mirror of
https://github.com/cline/cline.git
synced 2026-09-05 14:14:01 +08:00
Compare commits
66 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| dbc18a8fce | |||
| b77398390b | |||
| c20052de00 | |||
| 1dac507b63 | |||
| e5aa48c2b5 | |||
| 63efb4aa5d | |||
| 5fcf83b627 | |||
| 0b308d610e | |||
| 60d3048aa0 | |||
| aed3ac6597 | |||
| 86e2a3e7ce | |||
| 47856c70d2 | |||
| f1a84ddbde | |||
| ebcc927cc7 | |||
| 1e108d87e0 | |||
| 7b62d7786e | |||
| 042f5c9823 | |||
| 2f8a4525a4 | |||
| 20774a4187 | |||
| be5bda2740 | |||
| 261fd9036f | |||
| 2334d4d531 | |||
| 6390d854f7 | |||
| 7d5c56a55a | |||
| 0dc760ac03 | |||
| 8c1241c8cd | |||
| 17686ae3d9 | |||
| 97460d2952 | |||
| e629ed0ef6 | |||
| 96788e9127 | |||
| d7716a514d | |||
| 7aaa5966d6 | |||
| bb1d068139 | |||
| 450945ae0e | |||
| 191e9635bd | |||
| c13a7a80b3 | |||
| d4a4adfa5f | |||
| 557e20224e | |||
| 0e9a326a6a | |||
| f5ecb6db0c | |||
| 8f1405b881 | |||
| 8a9e03c8ff | |||
| edba02b45e | |||
| cc36c67fc9 | |||
| d77032bc8a | |||
| 608dde94b3 | |||
| 47ff7c1620 | |||
| 1b7f971c34 | |||
| 12eadd3378 | |||
| b3e0ef9ed7 | |||
| fb94d8d3d4 | |||
| d11bd15d60 | |||
| 31c48898a6 | |||
| 5c9901d68a | |||
| 45b79dc3d7 | |||
| 26b6c7bdb6 | |||
| d0678a2ad1 | |||
| 09276ebf43 | |||
| af8b51b189 | |||
| 3e6b3f252b | |||
| f019c365a6 | |||
| f6fe843cfb | |||
| 01f26c21ae | |||
| 031c2f5b05 | |||
| c999e269db | |||
| 032c1bf792 |
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": minor
|
||||
---
|
||||
|
||||
show slash command autocompletion in the cli
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
fix: Fetch remote config values from the cache
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Replace current diff edit tools with Apply Patch tool for GPT-5+ models
|
||||
Executable
+51
@@ -0,0 +1,51 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
# Only run in Claude Code remote environments
|
||||
if [ "${CLAUDE_CODE_REMOTE:-}" != "true" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
cd "$CLAUDE_PROJECT_DIR"
|
||||
|
||||
echo "=== Claude Code for Web Setup ==="
|
||||
echo ""
|
||||
|
||||
# Install latest gh CLI tool
|
||||
echo "Installing GitHub CLI..."
|
||||
GH_VERSION=$(curl -s https://api.github.com/repos/cli/cli/releases/latest | grep '"tag_name"' | cut -d'"' -f4 | sed 's/^v//')
|
||||
curl -sL "https://github.com/cli/cli/releases/download/v${GH_VERSION}/gh_${GH_VERSION}_linux_amd64.tar.gz" -o /tmp/gh.tar.gz
|
||||
tar -xzf /tmp/gh.tar.gz -C /tmp
|
||||
sudo mv "/tmp/gh_${GH_VERSION}_linux_amd64/bin/gh" /usr/local/bin/gh
|
||||
rm -rf /tmp/gh.tar.gz /tmp/gh_${GH_VERSION}_linux_amd64
|
||||
echo "Installed gh version: $(gh --version | head -1)"
|
||||
echo ""
|
||||
|
||||
# Check if GITHUB_TOKEN is set and configure gh
|
||||
if [ -n "${GITHUB_TOKEN:-}" ]; then
|
||||
echo "GITHUB_TOKEN is configured - gh CLI is ready to use"
|
||||
echo ""
|
||||
echo "You can use gh commands directly, for example:"
|
||||
echo " gh issue list --repo cline/cline --limit 5"
|
||||
echo " gh pr list --repo cline/cline --state open"
|
||||
echo " gh issue view 123 --repo cline/cline"
|
||||
echo ""
|
||||
else
|
||||
echo "GITHUB_TOKEN is not set - gh CLI will have limited functionality"
|
||||
echo ""
|
||||
echo "To enable full GitHub API access:"
|
||||
echo "1. Create a Fine-grained Personal Access Token at https://github.com/settings/tokens?type=beta"
|
||||
echo "2. Add it as GITHUB_TOKEN in your Claude Code environment settings"
|
||||
echo ""
|
||||
fi
|
||||
|
||||
# Install project dependencies
|
||||
echo "Installing dependencies..."
|
||||
npm run install:all
|
||||
|
||||
# Generate gRPC/protobuf types (required for TypeScript)
|
||||
echo "Generating proto types..."
|
||||
npm run protos
|
||||
|
||||
echo ""
|
||||
echo "Session setup complete!"
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"hooks": {
|
||||
"SessionStart": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "$CLAUDE_PROJECT_DIR/.claude/hooks/claude-code-for-web-setup.sh"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
/docs/
|
||||
/.github/ @saoudrizwan @garoth @sjf
|
||||
/README.md @saoudrizwan @nickbaumann98
|
||||
/src/core/storage/ @celestial-vault
|
||||
/src/core/storage/ @celestial-vault @abeatrix
|
||||
|
||||
@@ -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.
|
||||
@@ -36,6 +36,8 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.inputs.tag }}
|
||||
fetch-depth: 0
|
||||
fetch-tags: true
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
@@ -116,22 +118,31 @@ jobs:
|
||||
echo "Successfully published release version ${{ steps.get_version.outputs.version }} to VS Code Marketplace and Open VSX Registry"
|
||||
fi
|
||||
|
||||
# - name: Get Changelog Entry
|
||||
# id: changelog
|
||||
# uses: mindsers/changelog-reader-action@v2
|
||||
# with:
|
||||
# # This expects a standard Keep a Changelog format
|
||||
# # "latest" means it will read whichever is the most recent version
|
||||
# # set in "## [1.2.3] - 2025-01-28" style
|
||||
# version: latest
|
||||
- name: Get Previous Tag
|
||||
id: prev_tag
|
||||
run: |
|
||||
CURRENT_TAG="${{ steps.validate_tag.outputs.tag }}"
|
||||
PREV_TAG=$(git describe --tags --abbrev=0 "$CURRENT_TAG^" 2>/dev/null || echo "")
|
||||
echo "prev_tag=$PREV_TAG" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Get Changelog Entry
|
||||
id: changelog
|
||||
run: |
|
||||
# Get content between first ## [ and second ## [
|
||||
CONTENT=$(awk '/^## \[/{if(found) exit; found=1; next} found{print}' CHANGELOG.md)
|
||||
echo "content<<EOF" >> $GITHUB_OUTPUT
|
||||
echo "$CONTENT" >> $GITHUB_OUTPUT
|
||||
echo "EOF" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
tag_name: ${{ steps.validate_tag.outputs.tag }}
|
||||
files: "*.vsix"
|
||||
# body: ${{ steps.changelog.outputs.content }}
|
||||
generate_release_notes: true
|
||||
body: |
|
||||
${{ steps.changelog.outputs.content }}
|
||||
|
||||
**Full Changelog**: https://github.com/${{ github.repository }}/compare/${{ steps.prev_tag.outputs.prev_tag }}...${{ steps.validate_tag.outputs.tag }}
|
||||
prerelease: ${{ github.event.inputs.release-type == 'pre-release' }}
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -8,12 +8,14 @@ tmp
|
||||
|
||||
.DS_Store
|
||||
.idea
|
||||
.husky/_/
|
||||
|
||||
pnpm-lock.yaml
|
||||
|
||||
.clineignore
|
||||
.venv
|
||||
.actrc
|
||||
CLAUDE.local.md
|
||||
|
||||
webview-ui/src/**/*.js
|
||||
webview-ui/src/**/*.js.map
|
||||
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
.gitignore
|
||||
@@ -1,5 +1,37 @@
|
||||
# Changelog
|
||||
|
||||
## [3.46.1]
|
||||
|
||||
### Fixed
|
||||
|
||||
- Remove GLM 4.6 from free models
|
||||
|
||||
|
||||
## [3.46.0]
|
||||
|
||||
### Added
|
||||
|
||||
- Added GLM 4.7 model
|
||||
- Enhanced background terminal execution with command tracking, log file output, zombie process prevention (10-minute timeout), and clickable log paths in UI
|
||||
- Apply Patch tool for GPT-5+ models (replacing current diff edit tools)
|
||||
|
||||
### Fixed
|
||||
|
||||
- Duplicate error messages during streaming for Diff Edit tool when Parallel Tool Calling is not enabled
|
||||
- Banner carousel styling and dismiss functionality
|
||||
- Typos in Gemini system prompt overrides
|
||||
- Model picker favorites ordering, star toggle, and keyboard navigation for OpenRouter and Vercel AI Gateway providers
|
||||
- Fetch remote config values from the cache
|
||||
|
||||
### Refactored
|
||||
|
||||
- Anthropic handler to use metadata for reasoning support
|
||||
- Bedrock provider to use metadata for reasoning support
|
||||
|
||||
## [3.45.1]
|
||||
|
||||
- Fixed MCP settings race condition where toggling auto-approve or changing timeout settings would cause the UI to flash and revert
|
||||
|
||||
## [3.45.0]
|
||||
|
||||
- Added Gemini 3 Flash Preview model
|
||||
|
||||
@@ -14,8 +14,9 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
port int
|
||||
verbose bool
|
||||
port int
|
||||
verbose bool
|
||||
workspaces []string
|
||||
)
|
||||
|
||||
func main() {
|
||||
@@ -28,6 +29,7 @@ func main() {
|
||||
|
||||
rootCmd.Flags().IntVarP(&port, "port", "p", 51052, "port to listen on")
|
||||
rootCmd.Flags().BoolVarP(&verbose, "verbose", "v", false, "verbose logging")
|
||||
rootCmd.Flags().StringSliceVar(&workspaces, "workspace", nil, "workspace paths")
|
||||
|
||||
if err := rootCmd.Execute(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
@@ -39,7 +41,7 @@ func runServer(cmd *cobra.Command, args []string) error {
|
||||
ctx := cmd.Context()
|
||||
|
||||
// Create gRPC hostbridge service
|
||||
service := hostbridge.NewGrpcServer(port, verbose)
|
||||
service := hostbridge.NewGrpcServer(port, verbose, workspaces)
|
||||
|
||||
// Handle graceful shutdown
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
|
||||
+69
-24
@@ -6,6 +6,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/huh"
|
||||
@@ -25,12 +26,13 @@ var (
|
||||
outputFormat string
|
||||
|
||||
// Task creation flags (for root command)
|
||||
images []string
|
||||
files []string
|
||||
mode string
|
||||
settings []string
|
||||
yolo bool
|
||||
oneshot bool
|
||||
images []string
|
||||
files []string
|
||||
mode string
|
||||
settings []string
|
||||
yolo bool
|
||||
oneshot bool
|
||||
workspaces []string
|
||||
)
|
||||
|
||||
func main() {
|
||||
@@ -70,12 +72,23 @@ see the manual page: man cline`,
|
||||
|
||||
var instanceAddress string
|
||||
|
||||
// Validate workspace paths exist
|
||||
if err := common.ValidateDirsExist(workspaces); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Build the full workspace list: cwd first, then additional workspaces
|
||||
allWorkspaces, err := buildWorkspaceList(workspaces)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to build workspace list: %w", err)
|
||||
}
|
||||
|
||||
// If --address flag not provided, start instance BEFORE getting prompt
|
||||
if !cmd.Flags().Changed("address") {
|
||||
if global.Config.Verbose {
|
||||
fmt.Println("Starting new Cline instance...")
|
||||
}
|
||||
instance, err := global.Clients.StartNewInstance(ctx)
|
||||
instance, err := global.Clients.StartNewInstance(ctx, allWorkspaces...)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to start new instance: %w", err)
|
||||
}
|
||||
@@ -131,8 +144,8 @@ see the manual page: man cline`,
|
||||
|
||||
// If no prompt from args or stdin, show interactive input
|
||||
if prompt == "" {
|
||||
// Pass the mode flag to banner so it shows correct mode
|
||||
prompt, err = promptForInitialTask(ctx, instanceAddress, mode)
|
||||
// Pass the mode flag and workspaces to banner so it shows correct info
|
||||
prompt, err = promptForInitialTask(ctx, instanceAddress, mode, allWorkspaces)
|
||||
if err != nil {
|
||||
// Check if user cancelled - exit cleanly without error
|
||||
if err == huh.ErrUserAborted {
|
||||
@@ -152,13 +165,14 @@ see the manual page: man cline`,
|
||||
}
|
||||
|
||||
return cli.CreateAndFollowTask(ctx, prompt, cli.TaskOptions{
|
||||
Images: images,
|
||||
Files: files,
|
||||
Mode: mode,
|
||||
Settings: settings,
|
||||
Yolo: yolo,
|
||||
Address: instanceAddress,
|
||||
Verbose: verbose,
|
||||
Images: images,
|
||||
Files: files,
|
||||
Mode: mode,
|
||||
Settings: settings,
|
||||
Yolo: yolo,
|
||||
Address: instanceAddress,
|
||||
Verbose: verbose,
|
||||
Workspaces: allWorkspaces,
|
||||
})
|
||||
},
|
||||
}
|
||||
@@ -175,6 +189,7 @@ see the manual page: man cline`,
|
||||
rootCmd.Flags().BoolVarP(&yolo, "yolo", "y", false, "enable yolo mode (non-interactive)")
|
||||
rootCmd.Flags().BoolVar(&yolo, "no-interactive", false, "enable yolo mode (non-interactive)")
|
||||
rootCmd.Flags().BoolVarP(&oneshot, "oneshot", "o", false, "full autonomous mode")
|
||||
rootCmd.Flags().StringSliceVarP(&workspaces, "workspace", "w", nil, "additional workspace paths (can be specified multiple times)")
|
||||
|
||||
rootCmd.AddCommand(cli.NewTaskCommand())
|
||||
rootCmd.AddCommand(cli.NewInstanceCommand())
|
||||
@@ -189,9 +204,9 @@ see the manual page: man cline`,
|
||||
}
|
||||
}
|
||||
|
||||
func promptForInitialTask(ctx context.Context, instanceAddress, modeFlag string) (string, error) {
|
||||
func promptForInitialTask(ctx context.Context, instanceAddress, modeFlag string, workspaces []string) (string, error) {
|
||||
// Show session banner before the initial input
|
||||
showSessionBanner(ctx, instanceAddress, modeFlag)
|
||||
showSessionBanner(ctx, instanceAddress, modeFlag, workspaces)
|
||||
|
||||
var prompt string
|
||||
|
||||
@@ -233,7 +248,7 @@ func promptForInitialTask(ctx context.Context, instanceAddress, modeFlag string)
|
||||
}
|
||||
|
||||
// showSessionBanner displays session info before initial prompt
|
||||
func showSessionBanner(ctx context.Context, instanceAddress, modeFlag string) {
|
||||
func showSessionBanner(ctx context.Context, instanceAddress, modeFlag string, workspaces []string) {
|
||||
bannerInfo := display.BannerInfo{
|
||||
Version: global.CliVersion,
|
||||
Mode: modeFlag, // Use the mode from command flag, not state
|
||||
@@ -244,10 +259,7 @@ func showSessionBanner(ctx context.Context, instanceAddress, modeFlag string) {
|
||||
bannerInfo.Mode = "plan"
|
||||
}
|
||||
|
||||
// Get current working directory (this is what Cline will use)
|
||||
if cwd, err := os.Getwd(); err == nil {
|
||||
bannerInfo.Workdir = cwd
|
||||
}
|
||||
bannerInfo.Workdirs = workspaces
|
||||
|
||||
// Get provider/model using auth functions (same logic as auth menu)
|
||||
manager, err := cli.NewTaskManagerForAddress(ctx, instanceAddress)
|
||||
@@ -345,4 +357,37 @@ func getContentFromStdinAndArgs(args []string) (string, error) {
|
||||
}
|
||||
|
||||
return content.String(), nil
|
||||
}
|
||||
}
|
||||
|
||||
// buildWorkspaceList builds the full workspace list with cwd as the first entry
|
||||
func buildWorkspaceList(additionalWorkspaces []string) ([]string, error) {
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get current working directory: %w", err)
|
||||
}
|
||||
|
||||
// Start with cwd
|
||||
workspaces := []string{cwd}
|
||||
|
||||
// Add additional workspaces, avoiding duplicates
|
||||
for _, ws := range additionalWorkspaces {
|
||||
// Normalize the path
|
||||
absPath, err := common.AbsPath(ws)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to resolve workspace path %s: %w", ws, err)
|
||||
}
|
||||
|
||||
// Skip if it's the same as cwd
|
||||
if absPath == cwd {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check for duplicates
|
||||
isDuplicate := slices.Contains(workspaces, absPath)
|
||||
if !isDuplicate {
|
||||
workspaces = append(workspaces, absPath)
|
||||
}
|
||||
}
|
||||
|
||||
return workspaces, nil
|
||||
}
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
module github.com/cline/cli
|
||||
|
||||
go 1.23.0
|
||||
go 1.24.0
|
||||
|
||||
require (
|
||||
github.com/atotto/clipboard v0.1.4
|
||||
|
||||
@@ -70,6 +70,10 @@ When using the instant task syntax **cline "prompt"** the following options are
|
||||
|
||||
: Starting mode. Options: **act** (default), **plan**
|
||||
|
||||
**-w**, **\--workspace** *path*
|
||||
|
||||
: Additional workspace paths. Can be specified multiple times to include multiple directories. The current working directory is always included as the first workspace. Example: cline -w /path/to/other/project "refactor shared code"
|
||||
|
||||
# GLOBAL OPTIONS
|
||||
|
||||
These options apply to all subcommands:
|
||||
|
||||
@@ -47,7 +47,7 @@ func GetProviderConfigurations(ctx context.Context, manager *task.Manager) (*Pro
|
||||
}
|
||||
|
||||
// Parse state_json as map[string]interface{}
|
||||
var stateData map[string]interface{}
|
||||
var stateData map[string]any
|
||||
if err := json.Unmarshal([]byte(stateJSON), &stateData); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse state JSON: %w", err)
|
||||
}
|
||||
@@ -57,7 +57,7 @@ func GetProviderConfigurations(ctx context.Context, manager *task.Manager) (*Pro
|
||||
}
|
||||
|
||||
// Extract apiConfiguration object from state
|
||||
apiConfig, ok := stateData["apiConfiguration"].(map[string]interface{})
|
||||
apiConfig, ok := stateData["apiConfiguration"].(map[string]any)
|
||||
if !ok {
|
||||
if global.Config.Verbose {
|
||||
fmt.Println("[DEBUG] No apiConfiguration found in state")
|
||||
@@ -128,11 +128,11 @@ func (r *ProviderListResult) GetAllReadyProviders() []*ProviderDisplay {
|
||||
modelID := getProviderSpecificModelID(r.apiConfig, "plan", provider)
|
||||
|
||||
// Determine if credentials exist
|
||||
hasCreds := checkAPIKeyExists(r.apiConfig, provider)
|
||||
hasCreds := checkCredentialsExists(r.apiConfig, provider)
|
||||
|
||||
// Determine readiness: OCA uses auth state presence; others need creds and model
|
||||
if provider == cline.ApiProvider_OCA {
|
||||
state, _ := GetLatestOCAState(context.Background(), 2 *time.Second)
|
||||
state, _ := GetLatestOCAState(context.Background(), 2*time.Second)
|
||||
if state == nil || state.User == nil {
|
||||
continue
|
||||
}
|
||||
@@ -156,7 +156,7 @@ func (r *ProviderListResult) GetAllReadyProviders() []*ProviderDisplay {
|
||||
Mode: "Ready",
|
||||
Provider: provider,
|
||||
ModelID: modelID,
|
||||
HasAPIKey: checkAPIKeyExists(r.apiConfig, provider),
|
||||
HasAPIKey: checkCredentialsExists(r.apiConfig, provider),
|
||||
BaseURL: baseURL,
|
||||
})
|
||||
seenProviders[provider] = true
|
||||
@@ -192,7 +192,7 @@ func extractProviderFromState(stateData map[string]interface{}, mode string) *Pr
|
||||
modelID := getProviderSpecificModelID(stateData, mode, provider)
|
||||
|
||||
// Check if API key exists
|
||||
hasAPIKey := checkAPIKeyExists(stateData, provider)
|
||||
hasCredentials := checkCredentialsExists(stateData, provider)
|
||||
|
||||
// Get base URL for Ollama (can be shown publicly)
|
||||
baseURL := ""
|
||||
@@ -206,7 +206,7 @@ func extractProviderFromState(stateData map[string]interface{}, mode string) *Pr
|
||||
Mode: capitalizeMode(mode),
|
||||
Provider: provider,
|
||||
ModelID: modelID,
|
||||
HasAPIKey: hasAPIKey,
|
||||
HasAPIKey: hasCredentials,
|
||||
BaseURL: baseURL,
|
||||
}
|
||||
}
|
||||
@@ -215,7 +215,7 @@ func extractProviderFromState(stateData map[string]interface{}, mode string) *Pr
|
||||
// Returns (provider, ok) where ok is false if the provider is unknown
|
||||
func mapProviderStringToEnum(providerStr string) (cline.ApiProvider, bool) {
|
||||
normalizedStr := strings.ToLower(providerStr)
|
||||
|
||||
|
||||
// Map string values to enum values
|
||||
switch normalizedStr {
|
||||
case "anthropic":
|
||||
@@ -303,23 +303,27 @@ func getProviderSpecificModelID(stateData map[string]interface{}, mode string, p
|
||||
return modelID
|
||||
}
|
||||
|
||||
// checkAPIKeyExists checks if API key field exists in state (never retrieve actual key)
|
||||
func checkAPIKeyExists(stateData map[string]interface{}, provider cline.ApiProvider) bool {
|
||||
// checkCredentialsExists checks if API key field exists in state (never retrieve actual key)
|
||||
func checkCredentialsExists(stateData map[string]interface{}, provider cline.ApiProvider) bool {
|
||||
// Get field mapping from centralized function
|
||||
fields, err := GetProviderFields(provider)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
keyField := fields.APIKeyField
|
||||
|
||||
// Check if the key exists and is not empty
|
||||
if value, ok := stateData[keyField]; ok {
|
||||
if value, ok := stateData[fields.APIKeyField]; ok {
|
||||
if str, ok := value.(string); ok && str != "" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
if value, ok := stateData[fields.UseProfileField]; ok {
|
||||
if hasProfileField, ok := value.(bool); ok && hasProfileField {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -438,13 +442,13 @@ func DetectAllConfiguredProviders(ctx context.Context, manager *task.Manager) ([
|
||||
stateJSON := state.StateJson
|
||||
|
||||
// Parse state_json as map[string]interface{}
|
||||
var stateData map[string]interface{}
|
||||
var stateData map[string]any
|
||||
if err := json.Unmarshal([]byte(stateJSON), &stateData); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse state JSON: %w", err)
|
||||
}
|
||||
|
||||
// Extract apiConfiguration object from state
|
||||
apiConfig, ok := stateData["apiConfiguration"].(map[string]interface{})
|
||||
apiConfig, ok := stateData["apiConfiguration"].(map[string]any)
|
||||
if !ok {
|
||||
verboseLog("[DEBUG] No apiConfiguration found in state")
|
||||
verboseLog("[DEBUG] Available keys in stateData: %v", getMapKeys(stateData))
|
||||
@@ -469,36 +473,38 @@ func DetectAllConfiguredProviders(ctx context.Context, manager *task.Manager) ([
|
||||
|
||||
// Check each BYO provider for API key presence
|
||||
providersToCheck := []struct {
|
||||
provider cline.ApiProvider
|
||||
keyField string
|
||||
provider cline.ApiProvider
|
||||
keyFields []string
|
||||
}{
|
||||
{cline.ApiProvider_ANTHROPIC, "apiKey"},
|
||||
{cline.ApiProvider_OPENAI, "openAiApiKey"},
|
||||
{cline.ApiProvider_OPENAI_NATIVE, "openAiNativeApiKey"},
|
||||
{cline.ApiProvider_OPENROUTER, "openRouterApiKey"},
|
||||
{cline.ApiProvider_XAI, "xaiApiKey"},
|
||||
{cline.ApiProvider_BEDROCK, "awsAccessKey"},
|
||||
{cline.ApiProvider_GEMINI, "geminiApiKey"},
|
||||
{cline.ApiProvider_OLLAMA, "ollamaBaseUrl"}, // Ollama uses baseUrl instead of API key
|
||||
{cline.ApiProvider_CEREBRAS, "cerebrasApiKey"},
|
||||
{cline.ApiProvider_HICAP, "hicapApiKey"},
|
||||
{cline.ApiProvider_NOUSRESEARCH, "nousResearchApiKey"},
|
||||
{cline.ApiProvider_ANTHROPIC, []string{"apiKey"}},
|
||||
{cline.ApiProvider_OPENAI, []string{"openAiApiKey"}},
|
||||
{cline.ApiProvider_OPENAI_NATIVE, []string{"openAiNativeApiKey"}},
|
||||
{cline.ApiProvider_OPENROUTER, []string{"openRouterApiKey"}},
|
||||
{cline.ApiProvider_XAI, []string{"xaiApiKey"}},
|
||||
{cline.ApiProvider_BEDROCK, []string{"awsAccessKey", "awsUseProfile"}},
|
||||
{cline.ApiProvider_GEMINI, []string{"geminiApiKey"}},
|
||||
{cline.ApiProvider_OLLAMA, []string{"ollamaBaseUrl"}}, // Ollama uses baseUrl instead of API key
|
||||
{cline.ApiProvider_CEREBRAS, []string{"cerebrasApiKey"}},
|
||||
{cline.ApiProvider_HICAP, []string{"hicapApiKey"}},
|
||||
{cline.ApiProvider_NOUSRESEARCH, []string{"nousResearchApiKey"}},
|
||||
}
|
||||
|
||||
for _, providerCheck := range providersToCheck {
|
||||
verboseLog("[DEBUG] Checking for %s key: %s", GetProviderDisplayName(providerCheck.provider), providerCheck.keyField)
|
||||
if value, ok := apiConfig[providerCheck.keyField]; ok {
|
||||
verboseLog("[DEBUG] Found key, value type: %T, is empty: %v", value, value == "")
|
||||
if str, ok := value.(string); ok && str != "" {
|
||||
configuredProviders = append(configuredProviders, providerCheck.provider)
|
||||
verboseLog("[DEBUG] ✓ Provider %s is configured", GetProviderDisplayName(providerCheck.provider))
|
||||
verboseLog("[DEBUG] Checking for %s key: %s", GetProviderDisplayName(providerCheck.provider), providerCheck.keyFields)
|
||||
for _, keyField := range providerCheck.keyFields {
|
||||
if value, ok := apiConfig[keyField]; ok {
|
||||
verboseLog("[DEBUG] Found key, value type: %T, is empty: %v", value, value == "")
|
||||
if str, ok := value.(string); ok && str != "" {
|
||||
configuredProviders = append(configuredProviders, providerCheck.provider)
|
||||
verboseLog("[DEBUG] ✓ Provider %s is configured", GetProviderDisplayName(providerCheck.provider))
|
||||
break
|
||||
}
|
||||
} else {
|
||||
verboseLog("[DEBUG] Key %s not found", keyField)
|
||||
}
|
||||
} else {
|
||||
verboseLog("[DEBUG] Key %s not found", providerCheck.keyField)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
verboseLog("[DEBUG] Total configured providers: %d", len(configuredProviders))
|
||||
for _, p := range configuredProviders {
|
||||
verboseLog("[DEBUG] - %s", GetProviderDisplayName(p))
|
||||
|
||||
@@ -54,6 +54,7 @@ type ProviderFields struct {
|
||||
// Provider-specific additional model ID fields
|
||||
PlanModeProviderSpecificModelIDField string // e.g., "planModeOpenRouterModelId"
|
||||
ActModeProviderSpecificModelIDField string // e.g., "actModeOpenRouterModelId"
|
||||
UseProfileField string // e.g., "awsUseProfile" (for bedrock) (optional, empty if not applicable)
|
||||
}
|
||||
|
||||
// GetProviderFields returns the field mapping for a given provider
|
||||
@@ -96,6 +97,7 @@ func GetProviderFields(provider cline.ApiProvider) (ProviderFields, error) {
|
||||
|
||||
case cline.ApiProvider_BEDROCK:
|
||||
return ProviderFields{
|
||||
UseProfileField: "awsUseProfile",
|
||||
APIKeyField: "awsAccessKey",
|
||||
PlanModeModelIDField: "planModeApiModelId",
|
||||
ActModeModelIDField: "actModeApiModelId",
|
||||
|
||||
@@ -15,23 +15,23 @@ import (
|
||||
// BedrockConfig holds all AWS Bedrock-specific configuration fields
|
||||
type BedrockConfig struct {
|
||||
// Profile authentication fields
|
||||
UseProfile bool // Always true for successful config
|
||||
Profile string // Optional: AWS profile name (empty = default)
|
||||
Region string // Required: AWS region
|
||||
Endpoint string // Optional: Custom VPC endpoint URL
|
||||
|
||||
UseProfile bool // Always true for successful config
|
||||
Profile string // Optional: AWS profile name (empty = default)
|
||||
Region string // Required: AWS region
|
||||
Endpoint string // Optional: Custom VPC endpoint URL
|
||||
|
||||
// Optional features
|
||||
UseCrossRegionInference bool // Optional: Enable cross-region inference
|
||||
UseGlobalInference bool // Optional: Use global inference endpoint
|
||||
UsePromptCache bool // Optional: Enable prompt caching
|
||||
|
||||
UseCrossRegionInference bool // Optional: Enable cross-region inference
|
||||
UseGlobalInference bool // Optional: Use global inference endpoint
|
||||
UsePromptCache bool // Optional: Enable prompt caching
|
||||
|
||||
// Authentication method (always "profile")
|
||||
Authentication string // Always set to "profile"
|
||||
|
||||
Authentication string // Always set to "profile"
|
||||
|
||||
// Legacy fields (no longer used in profile-only flow)
|
||||
AccessKey string // No longer used
|
||||
SecretKey string // No longer used
|
||||
SessionToken string // No longer used
|
||||
AccessKey string // No longer used
|
||||
SecretKey string // No longer used
|
||||
SessionToken string // No longer used
|
||||
}
|
||||
|
||||
// PromptForBedrockConfig displays a profile-first authentication form for Bedrock configuration
|
||||
@@ -130,7 +130,12 @@ func ApplyBedrockConfig(ctx context.Context, manager *task.Manager, config *Bedr
|
||||
// Build the API configuration with all Bedrock fields
|
||||
apiConfig := &cline.ModelsApiConfiguration{}
|
||||
|
||||
// Set model ID fields
|
||||
// Set provider for both Plan and Act modes
|
||||
bedrockProvider := cline.ApiProvider_BEDROCK
|
||||
apiConfig.PlanModeApiProvider = &bedrockProvider
|
||||
apiConfig.ActModeApiProvider = &bedrockProvider
|
||||
|
||||
// Set model ID field - this is the primary model ID used by Cline Core
|
||||
apiConfig.PlanModeApiModelId = proto.String(modelID)
|
||||
apiConfig.ActModeApiModelId = proto.String(modelID)
|
||||
apiConfig.PlanModeAwsBedrockCustomModelBaseId = proto.String(modelID)
|
||||
@@ -166,6 +171,8 @@ func ApplyBedrockConfig(ctx context.Context, manager *task.Manager, config *Bedr
|
||||
|
||||
// Build field mask including all fields we're setting (excluding access keys)
|
||||
fieldPaths := []string{
|
||||
"planModeApiProvider",
|
||||
"actModeApiProvider",
|
||||
"planModeApiModelId",
|
||||
"actModeApiModelId",
|
||||
"planModeAwsBedrockCustomModelBaseId",
|
||||
|
||||
+18
-130
@@ -1,22 +1,19 @@
|
||||
package display
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/cline/cli/pkg/common"
|
||||
)
|
||||
|
||||
// BannerInfo contains information to display in the session banner
|
||||
type BannerInfo struct {
|
||||
Version string
|
||||
Provider string
|
||||
ModelID string
|
||||
Workdir string
|
||||
Mode string
|
||||
Version string
|
||||
Provider string
|
||||
ModelID string
|
||||
Workdirs []string // workspace directories
|
||||
Mode string
|
||||
}
|
||||
|
||||
// RenderSessionBanner renders a nice banner showing version, model, and workspace info
|
||||
@@ -81,131 +78,22 @@ func RenderSessionBanner(info BannerInfo) string {
|
||||
|
||||
// Model line - dim gray
|
||||
if info.Provider != "" && info.ModelID != "" {
|
||||
lines = append(lines, dimStyle.Render(info.Provider+"/"+shortenPath(info.ModelID, 30)))
|
||||
lines = append(lines, dimStyle.Render(info.Provider+"/"+common.ShortenPath(info.ModelID, 30)))
|
||||
}
|
||||
|
||||
// Workspace line - dim gray
|
||||
if info.Workdir != "" {
|
||||
lines = append(lines, dimStyle.Render(shortenPath(info.Workdir, 45)))
|
||||
for _, wd := range info.Workdirs {
|
||||
lines = append(lines, dimStyle.Render(common.ShortenPath(wd, 45)))
|
||||
}
|
||||
|
||||
// Checkpoint warning for multi-root workspaces
|
||||
if len(info.Workdirs) > 1 {
|
||||
warningStyle := lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("3")). // Yellow warning color
|
||||
Italic(true)
|
||||
lines = append(lines, "")
|
||||
lines = append(lines, warningStyle.Render("⚠ Checkpoints disabled for multi-root workspaces"))
|
||||
}
|
||||
|
||||
content := lipgloss.JoinVertical(lipgloss.Left, lines...)
|
||||
return boxStyle.Render(content)
|
||||
}
|
||||
|
||||
// shortenPath shortens a filesystem path to fit within maxLen
|
||||
func shortenPath(path string, maxLen int) string {
|
||||
// Try to replace home directory with ~ (cross-platform)
|
||||
if homeDir, err := os.UserHomeDir(); err == nil {
|
||||
if strings.HasPrefix(path, homeDir) {
|
||||
shortened := "~" + path[len(homeDir):]
|
||||
// Always use ~ version if we can
|
||||
path = shortened
|
||||
}
|
||||
}
|
||||
|
||||
if len(path) <= maxLen {
|
||||
return path
|
||||
}
|
||||
|
||||
// If still too long, show last few path components
|
||||
if len(path) > maxLen {
|
||||
parts := strings.Split(path, string(filepath.Separator))
|
||||
if len(parts) > 2 {
|
||||
// Show last 2-3 components
|
||||
lastParts := parts[len(parts)-2:]
|
||||
shortened := "..." + string(filepath.Separator) + strings.Join(lastParts, string(filepath.Separator))
|
||||
if len(shortened) <= maxLen {
|
||||
return shortened
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Last resort: truncate with ellipsis
|
||||
if len(path) > maxLen {
|
||||
return "..." + path[len(path)-maxLen+3:]
|
||||
}
|
||||
|
||||
return path
|
||||
}
|
||||
|
||||
// ExtractBannerInfoFromState extracts banner info from state JSON
|
||||
func ExtractBannerInfoFromState(stateJSON, version string) (BannerInfo, error) {
|
||||
var state map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(stateJSON), &state); err != nil {
|
||||
return BannerInfo{}, fmt.Errorf("failed to parse state JSON: %w", err)
|
||||
}
|
||||
|
||||
info := BannerInfo{
|
||||
Version: version,
|
||||
}
|
||||
|
||||
// Extract mode
|
||||
if mode, ok := state["mode"].(string); ok {
|
||||
info.Mode = mode
|
||||
}
|
||||
|
||||
// Extract workspace roots
|
||||
if workspaceRoots, ok := state["workspaceRoots"].([]interface{}); ok && len(workspaceRoots) > 0 {
|
||||
if root, ok := workspaceRoots[0].(map[string]interface{}); ok {
|
||||
if path, ok := root["path"].(string); ok {
|
||||
info.Workdir = path
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Extract API configuration to get provider/model
|
||||
if apiConfig, ok := state["apiConfiguration"].(map[string]interface{}); ok {
|
||||
// Try common keys for provider and model (both camelCase and lowercase variants)
|
||||
providerKeys := []string{"apiProvider", "api_provider"}
|
||||
modelKeys := []string{"apiModelId", "api_model_id"}
|
||||
|
||||
// Try to extract provider
|
||||
for _, key := range providerKeys {
|
||||
if provider, ok := apiConfig[key].(string); ok && provider != "" {
|
||||
info.Provider = provider
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Try to extract model ID
|
||||
for _, key := range modelKeys {
|
||||
if modelID, ok := apiConfig[key].(string); ok && modelID != "" {
|
||||
info.ModelID = shortenModelID(modelID)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return info, nil
|
||||
}
|
||||
|
||||
// shortenModelID shortens long model IDs for display
|
||||
func shortenModelID(modelID string) string {
|
||||
// Remove date suffixes only if they're at the end (e.g., -20241022)
|
||||
// Check if the model ID ends with -YYYYMMDD pattern
|
||||
if len(modelID) > 9 {
|
||||
suffix := modelID[len(modelID)-9:] // Last 9 chars: -20241022
|
||||
if suffix[0] == '-' &&
|
||||
(strings.HasPrefix(suffix[1:], "202") || strings.HasPrefix(suffix[1:], "201")) {
|
||||
// Verify all remaining chars are digits
|
||||
allDigits := true
|
||||
for _, c := range suffix[1:] {
|
||||
if c < '0' || c > '9' {
|
||||
allDigits = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if allDigits {
|
||||
return modelID[:len(modelID)-9]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If still too long, show first 40 chars
|
||||
if len(modelID) > 40 {
|
||||
return modelID[:37] + "..."
|
||||
}
|
||||
|
||||
return modelID
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ func (c *ClineClients) Initialize(ctx context.Context) error {
|
||||
}
|
||||
|
||||
// StartNewInstance starts a new Cline instance and waits for cline-core to self-register
|
||||
func (c *ClineClients) StartNewInstance(ctx context.Context) (*common.CoreInstanceInfo, error) {
|
||||
func (c *ClineClients) StartNewInstance(ctx context.Context, workspaces ...string) (*common.CoreInstanceInfo, error) {
|
||||
// Find available ports
|
||||
corePort, hostPort, err := common.FindAvailablePortPair()
|
||||
if err != nil {
|
||||
@@ -48,7 +48,7 @@ func (c *ClineClients) StartNewInstance(ctx context.Context) (*common.CoreInstan
|
||||
}
|
||||
|
||||
// Start cline-host first
|
||||
hostCmd, err := startClineHost(hostPort, corePort)
|
||||
hostCmd, err := startClineHost(hostPort, workspaces)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to start cline-host: %w", err)
|
||||
}
|
||||
@@ -120,7 +120,7 @@ func (c *ClineClients) StartNewInstance(ctx context.Context) (*common.CoreInstan
|
||||
}
|
||||
|
||||
// StartNewInstanceAtPort starts a new Cline instance at the specified port and waits for self-registration
|
||||
func (c *ClineClients) StartNewInstanceAtPort(ctx context.Context, corePort int) (*common.CoreInstanceInfo, error) {
|
||||
func (c *ClineClients) StartNewInstanceAtPort(ctx context.Context, corePort int, workspaces ...string) (*common.CoreInstanceInfo, error) {
|
||||
// Find available host port (core port + 1000)
|
||||
hostPort := corePort + 1000
|
||||
coreAddress := fmt.Sprintf("localhost:%d", corePort)
|
||||
@@ -135,7 +135,7 @@ func (c *ClineClients) StartNewInstanceAtPort(ctx context.Context, corePort int)
|
||||
}
|
||||
|
||||
// Start cline-host first
|
||||
hostCmd, err := startClineHost(hostPort, corePort)
|
||||
hostCmd, err := startClineHost(hostPort, workspaces)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to start cline-host: %w", err)
|
||||
}
|
||||
@@ -242,7 +242,7 @@ func (c *ClineClients) EnsureInstanceAtAddress(ctx context.Context, address stri
|
||||
return fmt.Errorf("cannot start remote instance at %s", normalized)
|
||||
}
|
||||
|
||||
func startClineHost(hostPort, corePort int) (*exec.Cmd, error) {
|
||||
func startClineHost(hostPort int, workspaces []string) (*exec.Cmd, error) {
|
||||
if Config.Verbose {
|
||||
fmt.Printf("Starting cline-host on port %d\n", hostPort)
|
||||
}
|
||||
@@ -255,10 +255,18 @@ func startClineHost(hostPort, corePort int) (*exec.Cmd, error) {
|
||||
binDir := path.Dir(execPath)
|
||||
clineHostPath := path.Join(binDir, "cline-host")
|
||||
|
||||
// Start the cline-host process
|
||||
cmd := exec.Command(clineHostPath,
|
||||
// Build command arguments
|
||||
args := []string{
|
||||
"--verbose",
|
||||
"--port", fmt.Sprintf("%d", hostPort))
|
||||
"--port", fmt.Sprintf("%d", hostPort),
|
||||
}
|
||||
|
||||
for _, ws := range workspaces {
|
||||
args = append(args, "--workspace", ws)
|
||||
}
|
||||
|
||||
// Start the cline-host process
|
||||
cmd := exec.Command(clineHostPath, args...)
|
||||
|
||||
// Create logs directory in ~/.cline/logs
|
||||
logsDir := path.Join(Config.ConfigPath, "logs")
|
||||
@@ -333,7 +341,7 @@ func KillInstanceByAddress(ctx context.Context, registry *ClientRegistry, addres
|
||||
if Config.Verbose {
|
||||
fmt.Printf("Waiting for instance to clean up registry entry...\n")
|
||||
}
|
||||
for i := 0; i < 5; i++ {
|
||||
for range 5 {
|
||||
time.Sleep(1 * time.Second)
|
||||
if !registry.HasInstanceAtAddress(address) {
|
||||
if Config.Verbose {
|
||||
@@ -408,15 +416,15 @@ func startClineCore(corePort, hostPort int) (*exec.Cmd, error) {
|
||||
// This handles the case where we're running from cli/bin/cline
|
||||
devClineCorePath := path.Join(binDir, "..", "..", "dist-standalone", "cline-core.js")
|
||||
devInstallDir := path.Join(binDir, "..", "..", "dist-standalone")
|
||||
|
||||
|
||||
if Config.Verbose {
|
||||
fmt.Printf("Primary location not found, trying development path: %s\n", devClineCorePath)
|
||||
}
|
||||
|
||||
|
||||
if _, err := os.Stat(devClineCorePath); os.IsNotExist(err) {
|
||||
return nil, fmt.Errorf("cline-core.js not found at '%s' or '%s'. Please ensure you're running from the correct location or reinstall with 'npm install -g cline'", clineCorePath, devClineCorePath)
|
||||
}
|
||||
|
||||
|
||||
finalClineCorePath = devClineCorePath
|
||||
finalInstallDir = devInstallDir
|
||||
if Config.Verbose {
|
||||
@@ -475,7 +483,7 @@ func startClineCore(corePort, hostPort int) (*exec.Cmd, error) {
|
||||
realNodeModules := path.Join(finalInstallDir, "node_modules")
|
||||
fakeNodeModules := path.Join(finalInstallDir, "fake_node_modules")
|
||||
nodePath := fmt.Sprintf("%s%c%s", realNodeModules, os.PathListSeparator, fakeNodeModules)
|
||||
|
||||
|
||||
env = append(env,
|
||||
fmt.Sprintf("NODE_PATH=%s", nodePath),
|
||||
// These control gRPC debug logging
|
||||
@@ -484,7 +492,7 @@ func startClineCore(corePort, hostPort int) (*exec.Cmd, error) {
|
||||
"NODE_ENV=development",
|
||||
)
|
||||
cmd.Env = env
|
||||
|
||||
|
||||
if Config.Verbose {
|
||||
fmt.Printf("NODE_PATH set to: %s\n", nodePath)
|
||||
}
|
||||
|
||||
+8
-7
@@ -20,13 +20,14 @@ import (
|
||||
|
||||
// TaskOptions contains options for creating a task
|
||||
type TaskOptions struct {
|
||||
Images []string
|
||||
Files []string
|
||||
Mode string
|
||||
Settings []string
|
||||
Yolo bool
|
||||
Address string
|
||||
Verbose bool
|
||||
Images []string
|
||||
Files []string
|
||||
Mode string
|
||||
Settings []string
|
||||
Yolo bool
|
||||
Address string
|
||||
Verbose bool
|
||||
Workspaces []string
|
||||
}
|
||||
|
||||
func NewTaskCommand() *cobra.Command {
|
||||
|
||||
@@ -3,15 +3,16 @@ package types
|
||||
// HistoryItem represents a task history item from taskHistory.json
|
||||
// This struct matches the JSON format stored on disk
|
||||
type HistoryItem struct {
|
||||
Id string `json:"id"`
|
||||
Ulid string `json:"ulid,omitempty"`
|
||||
Ts int64 `json:"ts"`
|
||||
Task string `json:"task"`
|
||||
TokensIn int32 `json:"tokensIn"`
|
||||
TokensOut int32 `json:"tokensOut"`
|
||||
CacheWrites int32 `json:"cacheWrites,omitempty"`
|
||||
CacheReads int32 `json:"cacheReads,omitempty"`
|
||||
TotalCost float64 `json:"totalCost"`
|
||||
Size int64 `json:"size,omitempty"`
|
||||
IsFavorited bool `json:"isFavorited,omitempty"`
|
||||
Id string `json:"id"`
|
||||
Ulid string `json:"ulid,omitempty"`
|
||||
Ts int64 `json:"ts"`
|
||||
Task string `json:"task"`
|
||||
TokensIn int32 `json:"tokensIn"`
|
||||
TokensOut int32 `json:"tokensOut"`
|
||||
CacheWrites int32 `json:"cacheWrites,omitempty"`
|
||||
CacheReads int32 `json:"cacheReads,omitempty"`
|
||||
TotalCost float64 `json:"totalCost"`
|
||||
Size int64 `json:"size,omitempty"`
|
||||
IsFavorited bool `json:"isFavorited,omitempty"`
|
||||
WorkspacePaths []string `json:"workspacePaths,omitempty"`
|
||||
}
|
||||
|
||||
@@ -4,7 +4,9 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -183,3 +185,72 @@ DEBUGGING STEPS:
|
||||
For additional help, visit: https://github.com/cline/cline/issues
|
||||
`, maxRetries, lastErr, GetNodeVersion())
|
||||
}
|
||||
|
||||
// validateDirsExist validates that all workspace paths exist on the filesystem
|
||||
func ValidateDirsExist(paths []string) error {
|
||||
for _, p := range paths {
|
||||
info, err := os.Stat(p)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return fmt.Errorf("path does not exist: %s", p)
|
||||
}
|
||||
return fmt.Errorf("failed to access path %s: %w", p, err)
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return fmt.Errorf("path is not a directory: %s", p)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// absPath returns the absolute path, resolving symlinks
|
||||
func AbsPath(path string) (string, error) {
|
||||
// First get absolute path
|
||||
abs, err := filepath.Abs(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
// Then resolve any symlinks
|
||||
resolved, err := filepath.EvalSymlinks(abs)
|
||||
if err != nil {
|
||||
// If symlink resolution fails, return the absolute path
|
||||
return abs, nil
|
||||
}
|
||||
return resolved, nil
|
||||
}
|
||||
|
||||
// shortenPath shortens a filesystem path to fit within maxLen
|
||||
func ShortenPath(path string, maxLen int) string {
|
||||
// Try to replace home directory with ~ (cross-platform)
|
||||
if homeDir, err := os.UserHomeDir(); err == nil {
|
||||
if strings.HasPrefix(path, homeDir) {
|
||||
shortened := "~" + path[len(homeDir):]
|
||||
// Always use ~ version if we can
|
||||
path = shortened
|
||||
}
|
||||
}
|
||||
|
||||
if len(path) <= maxLen {
|
||||
return path
|
||||
}
|
||||
|
||||
// If still too long, show last few path components
|
||||
if len(path) > maxLen {
|
||||
parts := strings.Split(path, string(filepath.Separator))
|
||||
if len(parts) > 2 {
|
||||
// Show last 2-3 components
|
||||
lastParts := parts[len(parts)-2:]
|
||||
shortened := "..." + string(filepath.Separator) + strings.Join(lastParts, string(filepath.Separator))
|
||||
if len(shortened) <= maxLen {
|
||||
return shortened
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Last resort: truncate with ellipsis
|
||||
if len(path) > maxLen {
|
||||
return "..." + path[len(path)-maxLen+3:]
|
||||
}
|
||||
|
||||
return path
|
||||
}
|
||||
|
||||
@@ -16,15 +16,17 @@ import (
|
||||
type GrpcServer struct {
|
||||
port int
|
||||
verbose bool
|
||||
workspaces []string
|
||||
server *grpc.Server
|
||||
shutdownCh chan struct{}
|
||||
}
|
||||
|
||||
// NewGrpcServer creates a new GrpcServer
|
||||
func NewGrpcServer(port int, verbose bool) *GrpcServer {
|
||||
func NewGrpcServer(port int, verbose bool, workspaces []string) *GrpcServer {
|
||||
return &GrpcServer{
|
||||
port: port,
|
||||
verbose: verbose,
|
||||
workspaces: workspaces,
|
||||
shutdownCh: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
@@ -50,7 +52,7 @@ func (s *GrpcServer) Start(ctx context.Context) error {
|
||||
grpc_health_v1.RegisterHealthServer(s.server, healthServer)
|
||||
|
||||
// Register services
|
||||
workspaceService := NewSimpleWorkspaceService(s.verbose)
|
||||
workspaceService := NewSimpleWorkspaceService(s.verbose, s.workspaces)
|
||||
host.RegisterWorkspaceServiceServer(s.server, workspaceService)
|
||||
|
||||
windowService := NewWindowService(s.verbose)
|
||||
|
||||
@@ -12,13 +12,15 @@ import (
|
||||
// SimpleWorkspaceService implements a basic workspace service without complex dependencies
|
||||
type SimpleWorkspaceService struct {
|
||||
host.UnimplementedWorkspaceServiceServer
|
||||
verbose bool
|
||||
verbose bool
|
||||
workspaces []string
|
||||
}
|
||||
|
||||
// NewSimpleWorkspaceService creates a new SimpleWorkspaceService
|
||||
func NewSimpleWorkspaceService(verbose bool) *SimpleWorkspaceService {
|
||||
func NewSimpleWorkspaceService(verbose bool, workspaces []string) *SimpleWorkspaceService {
|
||||
return &SimpleWorkspaceService{
|
||||
verbose: verbose,
|
||||
verbose: verbose,
|
||||
workspaces: workspaces,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,14 +30,24 @@ func (s *SimpleWorkspaceService) GetWorkspacePaths(ctx context.Context, req *hos
|
||||
log.Printf("GetWorkspacePaths called")
|
||||
}
|
||||
|
||||
// Get current working directory as the workspace
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
paths := []string{}
|
||||
|
||||
if len(s.workspaces) == 0 {
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
paths = append(paths, cwd)
|
||||
} else {
|
||||
paths = s.workspaces
|
||||
}
|
||||
|
||||
if s.verbose {
|
||||
log.Printf("Returning configured workspaces: %v", paths)
|
||||
}
|
||||
|
||||
return &host.GetWorkspacePathsResponse{
|
||||
Paths: []string{cwd},
|
||||
Paths: paths,
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -95,6 +95,12 @@ INSTANT TASK OPTIONS
|
||||
-m, --mode mode
|
||||
Starting mode. Options: act (default), plan
|
||||
|
||||
-w, --workspace path
|
||||
Additional workspace paths. Can be specified multiple times to
|
||||
include multiple directories. The current working directory is
|
||||
always included as the first workspace. Example: cline -w
|
||||
/path/to/other/project "refactor shared code"
|
||||
|
||||
GLOBAL OPTIONS
|
||||
These options apply to all subcommands:
|
||||
|
||||
|
||||
@@ -13,46 +13,6 @@ Cline is your AI assistant that can:
|
||||
- Automate repetitive tasks
|
||||
- Integrate with external tools
|
||||
|
||||
## First Steps
|
||||
|
||||
1. **Start a Task**
|
||||
|
||||
- Type your request in the chat
|
||||
- Example: "Create a new React component called Header"
|
||||
|
||||
2. **Provide Context**
|
||||
|
||||
- Use @ mentions to add files, folders, or URLs
|
||||
- Example: "@file:src/components/App.tsx"
|
||||
|
||||
3. **Review Changes**
|
||||
- Cline will show diffs before making changes
|
||||
- You can edit or reject changes
|
||||
|
||||
## Key Features
|
||||
|
||||
1. **File Editing**
|
||||
|
||||
- Create new files
|
||||
- Modify existing code
|
||||
- Search and replace across files
|
||||
|
||||
2. **Terminal Commands**
|
||||
|
||||
- Run npm commands
|
||||
- Start development servers
|
||||
- Install dependencies
|
||||
|
||||
3. **Code Analysis**
|
||||
|
||||
- Find and fix errors
|
||||
- Refactor code
|
||||
- Add documentation
|
||||
|
||||
4. **Browser Integration**
|
||||
- Test web pages
|
||||
- Capture screenshots
|
||||
- Inspect console logs
|
||||
|
||||
## Available Tools
|
||||
|
||||
@@ -84,6 +44,7 @@ Cline has access to the following tools for various tasks:
|
||||
- `ask_followup_question`: Ask user for clarification
|
||||
- `attempt_completion`: Present final results
|
||||
|
||||
|
||||
Each tool has specific parameters and usage patterns. Here are some examples:
|
||||
|
||||
- Create a new file (write_to_file):
|
||||
|
||||
@@ -1,59 +1,104 @@
|
||||
The Auto Approve menu lets you set fine-grained permissions on what you allow Cline to do in an automated way.
|
||||
---
|
||||
title: "Auto Approve"
|
||||
sidebarTitle: "Auto Approve"
|
||||
description: "Let Cline take specific actions without asking for approval every time."
|
||||
---
|
||||
|
||||
Auto Approve lets you decide which actions Cline can take without prompting you each time. It keeps you out of approval popups during routine work, while still letting you keep tight control over high-risk actions.
|
||||
|
||||
If you find yourself repeatedly clicking approve for the same safe operations, Auto Approve is the setting that fixes that. The goal is fewer interruptions without losing the ability to review changes when it matters.
|
||||
|
||||
<Frame>
|
||||
<img src="https://storage.googleapis.com/cline_public_images/docs/assets/auto-approve.png" alt="Auto Approve" />
|
||||
<video
|
||||
style={{ width: "100%" }}
|
||||
src="https://storage.googleapis.com/cline_public_images/autoapprove.mp4"
|
||||
autoPlay
|
||||
controls
|
||||
playsInline
|
||||
/>
|
||||
</Frame>
|
||||
|
||||
## How it works
|
||||
|
||||
By default, Cline will ask for your permission before calling any tool, including reading or writing files.
|
||||
Auto Approve is evaluated per tool call. When Cline is about to read a file, edit a file, run a command, or use the browser, Cline checks your Auto Approve settings for that category.
|
||||
|
||||
If you want to allow Cline to do something without asking, you can set the Auto Approve permission for that tool.
|
||||
A few details matter in practice:
|
||||
|
||||
## Permission Options
|
||||
- **Workspace vs outside your workspace**: “Read all files” and “Edit all files” only extend the base toggle. If the base toggle is off, the “all files” option does nothing.
|
||||
|
||||
- **Read project files**
|
||||
- **Terminal commands**: Cline treats terminal commands as either safe or requiring approval. “Execute safe commands” covers the first category. “Execute all commands” extends this to commands flagged as requiring approval.
|
||||
|
||||
- Allows Cline to read files within your current workspace without asking
|
||||
- **Read all files**
|
||||
- Extends read permission to files outside your workspace (system files, config files, etc.)
|
||||
- **Notifications**: If enabled, Cline sends OS-level notifications when approval is required, and when an auto-approved terminal command has been running for 30 seconds and may need attention.
|
||||
|
||||
- **Edit project files**
|
||||
<Note>
|
||||
[YOLO mode](/features/yolo-mode) bypasses these granular approvals.
|
||||
</Note>
|
||||
|
||||
- Allows Cline to modify files within your current workspace without confirmation
|
||||
- **Edit all files**
|
||||
- Extends modification permission to files outside your workspace
|
||||
## Permissions
|
||||
|
||||
- **Execute safe commands**
|
||||
These labels match what you see in the Auto Approve menu.
|
||||
|
||||
- Allows execution of terminal commands that the model deems non-destructive
|
||||
- **Execute all commands**
|
||||
- Permits execution of any terminal command without asking
|
||||
| Setting | What it allows | Notes |
|
||||
|--------|-----------------|------|
|
||||
| Read project files | Read files, list files, search in your workspace | Good default for most tasks |
|
||||
| Read all files | Read files outside your workspace | Requires “Read project files” |
|
||||
| Edit project files | Create and edit files in your workspace | Consider using checkpoints |
|
||||
| Edit all files | Edit files outside your workspace | Requires “Edit project files” |
|
||||
| Execute safe commands | Run terminal commands marked safe | Can still run long |
|
||||
| Execute all commands | Run commands marked as requiring approval | Requires “Execute safe commands” |
|
||||
| Use the browser | Allows use of the browser tool for web fetching and searching | Proxy issues can apply |
|
||||
| Use MCP servers | Use MCP tools and access MCP resources | Some servers also have per-tool auto-approve |
|
||||
| Enable notifications | Notifies you about long-running auto-approved commands | Helpful for terminal work |
|
||||
|
||||
- **Use the browser**
|
||||
<Warning>
|
||||
“Read all files” and “Edit all files” only matter if their base toggle is enabled. They extend access outside your workspace.
|
||||
</Warning>
|
||||
|
||||
- Allows Cline to use the browser tool to fetch web content
|
||||
<Card title="Networking & proxies" icon="globe" href="/troubleshooting/networking-and-proxies">
|
||||
If browser-based tools fail in corporate networks, this page covers the common fixes.
|
||||
</Card>
|
||||
|
||||
- **Use MCP servers**
|
||||
## Safe vs approval-required command examples
|
||||
|
||||
- Permits connection to and usage of MCP servers for extended functionality
|
||||
Cline does not use a fixed allowlist of safe or unsafe commands. The model marks each command with a `requires_approval` flag based on the command and its arguments, and Auto Approve uses that flag.
|
||||
|
||||
- **Maximum requests**
|
||||
- Sets the number of consecutive automated actions Cline can take before requiring your input
|
||||
These are examples, not guarantees.
|
||||
|
||||
## Best Practices
|
||||
### Commonly treated as safe
|
||||
|
||||
Personally, I like to keep auto-editing disabled because it gives me a chance to review changes every step of the way.
|
||||
| Example | Why it is usually safe |
|
||||
|--------|-------------------------|
|
||||
| `npm run build` | Build output, no direct file deletions |
|
||||
| `npm test` | Runs tests |
|
||||
| `git status` | Read-only |
|
||||
| `ls -la` | Read-only |
|
||||
| `cat package.json` | Read-only |
|
||||
|
||||
For most serious development workflows, I recommend starting with:
|
||||
### Commonly requires approval
|
||||
|
||||
- Auto-approving read access to project files
|
||||
- Setting a reasonable maximum request limit (10-20)
|
||||
| Example | Why it often needs approval |
|
||||
|--------|------------------------------|
|
||||
| `npm install <pkg>` | Modifies dependencies and lockfiles |
|
||||
| `rm -rf <path>` | Deletes files |
|
||||
| `mv <a> <b>` | Moves files (can overwrite) |
|
||||
| `sed -i ...` | In-place file edits |
|
||||
| `curl https://...` | Downloads and executes remote code |
|
||||
|
||||
This gives Cline enough freedom to explore your codebase without constant interruptions, while still requiring permission for edits or potentially destructive actions.
|
||||
<Note>
|
||||
Whether a command is treated as safe depends on the exact command, flags, and the current task. When in doubt, keep command auto-approval off and approve commands manually.
|
||||
</Note>
|
||||
|
||||
As you build more trust in Cline's capabilities with your specific projects, you can gradually increase the permissions to match your comfort level.
|
||||
## Enable notifications
|
||||
|
||||
Remember that you can always adjust these settings as your needs change - tighten permissions for critical production work, or loosen them when prototyping and exploring.
|
||||
Auto-approved actions can run for a while, especially long terminal commands. If you enable notifications, Cline can notify you when an auto-approved command has been running for a while and may need attention.
|
||||
|
||||
You can even use the quick "star" actions to quickly toggle your auto-approved selections on and off as you go.
|
||||
## Recommendations
|
||||
|
||||
A good default setup is:
|
||||
|
||||
- Enable **Read project files**
|
||||
- Leave **Edit project files**, **Execute safe commands**, **Use the browser**, and **Use MCP servers** off until you have a specific reason to enable them
|
||||
|
||||
If you enable edits, use [Checkpoints](/features/checkpoints) so you can roll back quickly.
|
||||
|
||||
If you’re working in a sensitive environment (production credentials, personal files, corporate devices), keep external file access and command execution locked down and approve actions manually as you go.
|
||||
|
||||
@@ -243,16 +243,18 @@ Both limitations are restored when you return to a single-folder workspace.
|
||||
|
||||
- Break large tasks into workspace-specific operations when possible
|
||||
- Use [Plan mode](/features/plan-and-act) to let Cline understand structure first
|
||||
- Use VSCode's `files.exclude` setting to hide generated folders from the file explorer and search:
|
||||
- Add a `.clineignore` file to reduce noise, speed up scanning, and keep Cline focused on source code:
|
||||
|
||||
```json
|
||||
// settings.json
|
||||
"files.exclude": {
|
||||
"**/node_modules": true,
|
||||
"**/dist": true,
|
||||
"**/build": true,
|
||||
"**/.git": true
|
||||
}
|
||||
```text
|
||||
# Dependencies
|
||||
**/node_modules/
|
||||
|
||||
# Build outputs
|
||||
**/dist/
|
||||
**/build/
|
||||
|
||||
# VCS metadata
|
||||
**/.git/
|
||||
```
|
||||
|
||||
This reduces noise in Cline's file listings and helps it focus on your actual source code rather than generated files or dependencies.
|
||||
For more patterns and gotchas, see the [.clineignore File Guide](/prompting/prompt-engineering-guide#clineignore-file-guide).
|
||||
|
||||
Generated
+2310
-1422
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -13,7 +13,7 @@
|
||||
"license": "ISC",
|
||||
"description": "",
|
||||
"dependencies": {
|
||||
"mintlify": "^4.2.23"
|
||||
"mintlify": "^4.2.249"
|
||||
},
|
||||
"overrides": {
|
||||
"tar-fs": "^3.1.1",
|
||||
|
||||
@@ -6,12 +6,17 @@ description: "Complete guide to resolving terminal integration issues in Cline"
|
||||
|
||||
This guide helps you resolve terminal integration issues in Cline. Terminal integration is crucial for Cline to execute commands and read their output, enabling it to understand errors, test results, and command responses.
|
||||
|
||||
<Tip>
|
||||
If you're experiencing terminal issues, try switching to a simpler shell like `bash` in the Cline settings, under "Terminal Settings"
|
||||
## Try This First: Background Execution Mode
|
||||
|
||||
This resolves most terminal integration problems.
|
||||
The simplest fix for most terminal issues is switching to **Background Execution Mode**:
|
||||
|
||||
</Tip>
|
||||
1. Click **Settings** (top right of Cline chat)
|
||||
2. Go to **Terminal Settings**
|
||||
3. Set **Terminal Execution Mode** → **Background Exec**
|
||||
|
||||
This runs commands in a background process instead of VSCode's terminal, bypassing most integration issues. The guide below is for users who specifically need VSCode's integrated terminal.
|
||||
|
||||
---
|
||||
|
||||
## Quick Diagnosis Flowchart
|
||||
|
||||
|
||||
@@ -4,7 +4,21 @@ sidebarTitle: "Terminal Quick Fixes"
|
||||
description: "Quick solutions for common terminal issues"
|
||||
---
|
||||
|
||||
**Here is a list of common fixes, starting with the most applicable:**
|
||||
## Try This First: Background Execution Mode
|
||||
|
||||
The simplest fix for most terminal issues is switching to **Background Execution Mode**:
|
||||
|
||||
1. Click **Settings** (top right of Cline chat)
|
||||
2. Go to **Terminal Settings**
|
||||
3. Set **Terminal Execution Mode** → **Background Exec**
|
||||
|
||||
This runs commands in a background process instead of VSCode's terminal, bypassing most integration issues.
|
||||
|
||||
---
|
||||
|
||||
## Other Fixes
|
||||
|
||||
If you need VSCode's integrated terminal, try these:
|
||||
|
||||
- **Switch to bash** (solves most instances)
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
streamlit>=1.28.0
|
||||
streamlit==1.43.2
|
||||
plotly>=5.17.0
|
||||
pandas>=2.0.0
|
||||
numpy>=1.24.0
|
||||
|
||||
Generated
+119
-32
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "claude-dev",
|
||||
"version": "3.45.0",
|
||||
"version": "3.46.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "claude-dev",
|
||||
"version": "3.45.0",
|
||||
"version": "3.46.1",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.37.0",
|
||||
@@ -20,7 +20,7 @@
|
||||
"@grpc/grpc-js": "^1.9.15",
|
||||
"@grpc/reflection": "^1.0.4",
|
||||
"@mistralai/mistralai": "^1.5.0",
|
||||
"@modelcontextprotocol/sdk": "^1.11.1",
|
||||
"@modelcontextprotocol/sdk": "^1.25.1",
|
||||
"@opentelemetry/api": "^1.9.0",
|
||||
"@opentelemetry/core": "^2.1.0",
|
||||
"@opentelemetry/exporter-logs-otlp-grpc": "^0.56.0",
|
||||
@@ -2685,6 +2685,18 @@
|
||||
"@grpc/grpc-js": "^1.8.21"
|
||||
}
|
||||
},
|
||||
"node_modules/@hono/node-server": {
|
||||
"version": "1.19.7",
|
||||
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.7.tgz",
|
||||
"integrity": "sha512-vUcD0uauS7EU2caukW8z5lJKtoGMokxNbJtBiwHgpqxEXokaHCBkQUmCHhjFB1VUTWdqj25QoMkMKzgjq+uhrw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18.14.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"hono": "^4"
|
||||
}
|
||||
},
|
||||
"node_modules/@inquirer/external-editor": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@inquirer/external-editor/-/external-editor-1.0.3.tgz",
|
||||
@@ -3217,12 +3229,13 @@
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/@modelcontextprotocol/sdk": {
|
||||
"version": "1.22.0",
|
||||
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.22.0.tgz",
|
||||
"integrity": "sha512-VUpl106XVTCpDmTBil2ehgJZjhyLY2QZikzF8NvTXtLRF1CvO5iEE2UNZdVIUer35vFOwMKYeUGbjJtvPWan3g==",
|
||||
"version": "1.25.1",
|
||||
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.25.1.tgz",
|
||||
"integrity": "sha512-yO28oVFFC7EBoiKdAn+VqRm+plcfv4v0xp6osG/VsCB0NlPZWi87ajbCZZ8f/RvOFLEu7//rSRmuZZ7lMoe3gQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@hono/node-server": "^1.19.7",
|
||||
"ajv": "^8.17.1",
|
||||
"ajv-formats": "^3.0.1",
|
||||
"content-type": "^1.0.5",
|
||||
@@ -3232,20 +3245,26 @@
|
||||
"eventsource-parser": "^3.0.0",
|
||||
"express": "^5.0.1",
|
||||
"express-rate-limit": "^7.5.0",
|
||||
"jose": "^6.1.1",
|
||||
"json-schema-typed": "^8.0.2",
|
||||
"pkce-challenge": "^5.0.0",
|
||||
"raw-body": "^3.0.0",
|
||||
"zod": "^3.23.8",
|
||||
"zod-to-json-schema": "^3.24.1"
|
||||
"zod": "^3.25 || ^4.0",
|
||||
"zod-to-json-schema": "^3.25.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@cfworker/json-schema": "^4.1.1"
|
||||
"@cfworker/json-schema": "^4.1.1",
|
||||
"zod": "^3.25 || ^4.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@cfworker/json-schema": {
|
||||
"optional": true
|
||||
},
|
||||
"zod": {
|
||||
"optional": false
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -6429,6 +6448,60 @@
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": {
|
||||
"version": "1.5.0",
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/wasi-threads": "1.1.0",
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": {
|
||||
"version": "1.5.0",
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
|
||||
"version": "1.1.0",
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": {
|
||||
"version": "1.0.5",
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/core": "^1.5.0",
|
||||
"@emnapi/runtime": "^1.5.0",
|
||||
"@tybys/wasm-util": "^0.10.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": {
|
||||
"version": "0.10.1",
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"inBundle": true,
|
||||
"license": "0BSD",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
|
||||
"version": "4.1.14",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.14.tgz",
|
||||
@@ -11351,6 +11424,16 @@
|
||||
"he": "bin/he"
|
||||
}
|
||||
},
|
||||
"node_modules/hono": {
|
||||
"version": "4.11.1",
|
||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.11.1.tgz",
|
||||
"integrity": "sha512-KsFcH0xxHes0J4zaQgWbYwmz3UPOOskdqZmItstUG93+Wk1ePBLkLGwbP9zlmh1BFUiL8Qp+Xfu9P7feJWpGNg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=16.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/hosted-git-info": {
|
||||
"version": "2.8.9",
|
||||
"dev": true,
|
||||
@@ -12337,6 +12420,15 @@
|
||||
"jiti": "lib/jiti-cli.mjs"
|
||||
}
|
||||
},
|
||||
"node_modules/jose": {
|
||||
"version": "6.1.3",
|
||||
"resolved": "https://registry.npmjs.org/jose/-/jose-6.1.3.tgz",
|
||||
"integrity": "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/panva"
|
||||
}
|
||||
},
|
||||
"node_modules/js-tokens": {
|
||||
"version": "4.0.0",
|
||||
"dev": true,
|
||||
@@ -12397,6 +12489,12 @@
|
||||
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/json-schema-typed": {
|
||||
"version": "8.0.2",
|
||||
"resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz",
|
||||
"integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/json5": {
|
||||
"version": "2.2.3",
|
||||
"dev": true,
|
||||
@@ -12419,10 +12517,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/jsonwebtoken": {
|
||||
"version": "9.0.2",
|
||||
"version": "9.0.3",
|
||||
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
|
||||
"integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"jws": "^3.2.2",
|
||||
"jws": "^4.0.1",
|
||||
"lodash.includes": "^4.3.0",
|
||||
"lodash.isboolean": "^3.0.3",
|
||||
"lodash.isinteger": "^4.0.4",
|
||||
@@ -12438,23 +12538,6 @@
|
||||
"npm": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/jsonwebtoken/node_modules/jwa": {
|
||||
"version": "1.4.2",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"buffer-equal-constant-time": "^1.0.1",
|
||||
"ecdsa-sig-formatter": "1.0.11",
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/jsonwebtoken/node_modules/jws": {
|
||||
"version": "3.2.2",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"jwa": "^1.4.1",
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/jszip": {
|
||||
"version": "3.10.1",
|
||||
"license": "(MIT OR GPL-3.0-or-later)",
|
||||
@@ -12482,10 +12565,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/jws": {
|
||||
"version": "4.0.0",
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
|
||||
"integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"jwa": "^2.0.0",
|
||||
"jwa": "^2.0.1",
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
@@ -18979,10 +19064,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/zod-to-json-schema": {
|
||||
"version": "3.24.4",
|
||||
"version": "3.25.0",
|
||||
"resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.0.tgz",
|
||||
"integrity": "sha512-HvWtU2UG41LALjajJrML6uQejQhNJx+JBO9IflpSja4R03iNWfKXrj6W2h7ljuLyc1nKS+9yDyL/9tD1U/yBnQ==",
|
||||
"license": "ISC",
|
||||
"peerDependencies": {
|
||||
"zod": "^3.24.1"
|
||||
"zod": "^3.25 || ^4"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -2,7 +2,7 @@
|
||||
"name": "claude-dev",
|
||||
"displayName": "Cline",
|
||||
"description": "Autonomous coding agent right in your IDE, capable of creating/editing files, running commands, using the browser, and more with your permission every step of the way.",
|
||||
"version": "3.45.0",
|
||||
"version": "3.46.1",
|
||||
"icon": "assets/icons/icon.png",
|
||||
"engines": {
|
||||
"vscode": "^1.84.0"
|
||||
@@ -463,7 +463,7 @@
|
||||
"@grpc/grpc-js": "^1.9.15",
|
||||
"@grpc/reflection": "^1.0.4",
|
||||
"@mistralai/mistralai": "^1.5.0",
|
||||
"@modelcontextprotocol/sdk": "^1.11.1",
|
||||
"@modelcontextprotocol/sdk": "^1.25.1",
|
||||
"@opentelemetry/api": "^1.9.0",
|
||||
"@opentelemetry/core": "^2.1.0",
|
||||
"@opentelemetry/exporter-logs-otlp-grpc": "^0.56.0",
|
||||
|
||||
@@ -369,6 +369,7 @@ message UpdateSettingsRequest {
|
||||
optional OnboardingModelGroup onboarding_models = 33;
|
||||
optional bool cline_web_tools_enabled = 34;
|
||||
optional bool enable_parallel_tool_calling = 35;
|
||||
optional bool background_edit_enabled = 36;
|
||||
}
|
||||
|
||||
message UpdateTerminalConnectionTimeoutRequest {
|
||||
|
||||
@@ -55,8 +55,10 @@ enum Setting {
|
||||
}
|
||||
message GetTelemetrySettingsResponse {
|
||||
Setting is_enabled = 1;
|
||||
optional string error_level = 2;
|
||||
}
|
||||
|
||||
message TelemetrySettingsEvent {
|
||||
Setting is_enabled = 1;
|
||||
optional string error_level = 2;
|
||||
}
|
||||
|
||||
@@ -77,6 +77,7 @@ function createMockService<T extends grpc.UntypedServiceImplementation>(serviceN
|
||||
case "getTelemetrySettings":
|
||||
callback(null, {
|
||||
isEnabled: 2, // Setting.DISABLED
|
||||
errorLevel: "all",
|
||||
})
|
||||
return
|
||||
|
||||
|
||||
@@ -56,87 +56,61 @@ export class AnthropicHandler implements ApiHandler {
|
||||
|
||||
// Tools are available only when native tools are enabled.
|
||||
const nativeToolsOn = tools?.length && tools?.length > 0
|
||||
const reasoningOn = !!(
|
||||
(modelId.includes("3-7") || modelId.includes("4-") || modelId.includes("4-5")) &&
|
||||
budget_tokens !== 0
|
||||
)
|
||||
const reasoningOn = (model.info.supportsReasoning ?? false) && budget_tokens !== 0
|
||||
|
||||
switch (modelId) {
|
||||
// 'latest' alias does not support cache_control
|
||||
case "claude-haiku-4-5@20251001":
|
||||
case "claude-sonnet-4-5@20250929":
|
||||
case "claude-sonnet-4@20250514":
|
||||
case "claude-opus-4-5@20251101":
|
||||
case "claude-opus-4-1@20250805":
|
||||
case "claude-opus-4@20250514":
|
||||
case "claude-haiku-4-5-20251001":
|
||||
case "claude-sonnet-4-5-20250929:1m":
|
||||
case "claude-sonnet-4-5-20250929":
|
||||
case "claude-sonnet-4-20250514":
|
||||
case "claude-3-7-sonnet-20250219":
|
||||
case "claude-3-5-sonnet-20241022":
|
||||
case "claude-3-5-haiku-20241022":
|
||||
case "claude-opus-4-5-20251101":
|
||||
case "claude-opus-4-20250514":
|
||||
case "claude-opus-4-1-20250805":
|
||||
case "claude-3-opus-20240229":
|
||||
case "claude-3-haiku-20240307": {
|
||||
const anthropicMessages = sanitizeAnthropicMessages(messages, true)
|
||||
if (model.info.supportsPromptCache) {
|
||||
const anthropicMessages = sanitizeAnthropicMessages(messages, true)
|
||||
|
||||
stream = await client.messages.create(
|
||||
{
|
||||
model: modelId,
|
||||
thinking: reasoningOn ? { type: "enabled", budget_tokens: budget_tokens } : undefined,
|
||||
max_tokens: model.info.maxTokens || 8192,
|
||||
// "Thinking isn’t compatible with temperature, top_p, or top_k modifications as well as forced tool use."
|
||||
// (https://docs.anthropic.com/en/docs/build-with-claude/extended-thinking#important-considerations-when-using-extended-thinking)
|
||||
temperature: reasoningOn ? undefined : 0,
|
||||
system: [
|
||||
{
|
||||
text: systemPrompt,
|
||||
type: "text",
|
||||
cache_control: { type: "ephemeral" },
|
||||
},
|
||||
], // setting cache breakpoint for system prompt so new tasks can reuse it
|
||||
messages: anthropicMessages,
|
||||
// tools, // cache breakpoints go from tools > system > messages, and since tools dont change, we can just set the breakpoint at the end of system (this avoids having to set a breakpoint at the end of tools which by itself does not meet min requirements for haiku caching)
|
||||
stream: true,
|
||||
tools: nativeToolsOn ? tools : undefined,
|
||||
// tool_choice options:
|
||||
// - none: disables tool use, even if tools are provided. Claude will not call any tools.
|
||||
// - auto: allows Claude to decide whether to call any provided tools or not. This is the default value when tools are provided.
|
||||
// - any: tells Claude that it must use one of the provided tools, but doesn’t force a particular tool.
|
||||
// NOTE: Forcing tool use when tools are provided will result in error when thinking is also enabled.
|
||||
tool_choice: nativeToolsOn && !reasoningOn ? { type: "any" } : undefined,
|
||||
},
|
||||
(() => {
|
||||
// 1m context window beta header
|
||||
if (enable1mContextWindow) {
|
||||
return {
|
||||
headers: {
|
||||
"anthropic-beta": "context-1m-2025-08-07",
|
||||
},
|
||||
}
|
||||
} else {
|
||||
return undefined
|
||||
}
|
||||
})(),
|
||||
)
|
||||
break
|
||||
}
|
||||
default: {
|
||||
stream = await client.messages.create({
|
||||
stream = await client.messages.create(
|
||||
{
|
||||
model: modelId,
|
||||
thinking: reasoningOn ? { type: "enabled", budget_tokens: budget_tokens } : undefined,
|
||||
max_tokens: model.info.maxTokens || 8192,
|
||||
temperature: 0,
|
||||
system: [{ text: systemPrompt, type: "text" }],
|
||||
messages: sanitizeAnthropicMessages(messages, false),
|
||||
tools: nativeToolsOn ? tools : undefined,
|
||||
tool_choice: { type: "auto" },
|
||||
// "Thinking isn’t compatible with temperature, top_p, or top_k modifications as well as forced tool use."
|
||||
// (https://docs.anthropic.com/en/docs/build-with-claude/extended-thinking#important-considerations-when-using-extended-thinking)
|
||||
temperature: reasoningOn ? undefined : 0,
|
||||
system: [
|
||||
{
|
||||
text: systemPrompt,
|
||||
type: "text",
|
||||
cache_control: { type: "ephemeral" },
|
||||
},
|
||||
], // setting cache breakpoint for system prompt so new tasks can reuse it
|
||||
messages: anthropicMessages,
|
||||
// tools, // cache breakpoints go from tools > system > messages, and since tools dont change, we can just set the breakpoint at the end of system (this avoids having to set a breakpoint at the end of tools which by itself does not meet min requirements for haiku caching)
|
||||
stream: true,
|
||||
})
|
||||
break
|
||||
}
|
||||
tools: nativeToolsOn ? tools : undefined,
|
||||
// tool_choice options:
|
||||
// - none: disables tool use, even if tools are provided. Claude will not call any tools.
|
||||
// - auto: allows Claude to decide whether to call any provided tools or not. This is the default value when tools are provided.
|
||||
// - any: tells Claude that it must use one of the provided tools, but doesn’t force a particular tool.
|
||||
// NOTE: Forcing tool use when tools are provided will result in error when thinking is also enabled.
|
||||
tool_choice: nativeToolsOn && !reasoningOn ? { type: "any" } : undefined,
|
||||
},
|
||||
(() => {
|
||||
// 1m context window beta header
|
||||
if (enable1mContextWindow) {
|
||||
return {
|
||||
headers: {
|
||||
"anthropic-beta": "context-1m-2025-08-07",
|
||||
},
|
||||
}
|
||||
} else {
|
||||
return undefined
|
||||
}
|
||||
})(),
|
||||
)
|
||||
} else {
|
||||
stream = await client.messages.create({
|
||||
model: modelId,
|
||||
max_tokens: model.info.maxTokens || 8192,
|
||||
temperature: 0,
|
||||
system: [{ text: systemPrompt, type: "text" }],
|
||||
messages: sanitizeAnthropicMessages(messages, false),
|
||||
tools: nativeToolsOn ? tools : undefined,
|
||||
tool_choice: { type: "auto" },
|
||||
stream: true,
|
||||
})
|
||||
}
|
||||
|
||||
const lastStartedToolCall = { id: "", name: "", arguments: "" }
|
||||
|
||||
@@ -755,10 +755,7 @@ export class AwsBedrockHandler implements ApiHandler {
|
||||
// For Anthropic models with thinking enabled, temperature must be 1
|
||||
if (modelType === "anthropic") {
|
||||
const budget_tokens = this.options.thinkingBudgetTokens || 0
|
||||
const baseModelId =
|
||||
(this.options.awsBedrockCustomSelected ? this.options.awsBedrockCustomModelBaseId : this.getModel().id) ||
|
||||
this.getModel().id
|
||||
const reasoningOn = this.shouldEnableReasoning(baseModelId, budget_tokens)
|
||||
const reasoningOn = modelInfo.supportsReasoning && budget_tokens > 0
|
||||
|
||||
return {
|
||||
maxTokens: modelInfo.maxTokens || 8192,
|
||||
@@ -772,20 +769,6 @@ export class AwsBedrockHandler implements ApiHandler {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if reasoning should be enabled for Claude models
|
||||
*/
|
||||
private shouldEnableReasoning(baseModelId: string, budgetTokens: number): boolean {
|
||||
return (
|
||||
(baseModelId.includes("3-7") ||
|
||||
baseModelId.includes("sonnet-4") ||
|
||||
baseModelId.includes("opus-4") ||
|
||||
baseModelId.includes("haiku-4-5") ||
|
||||
baseModelId.includes("sonnet-4-5")) &&
|
||||
budgetTokens !== 0
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a message using Anthropic Claude models through AWS Bedrock Converse API
|
||||
* Implements support for Anthropic Claude models using the unified Converse API
|
||||
@@ -815,10 +798,7 @@ export class AwsBedrockHandler implements ApiHandler {
|
||||
|
||||
// Get thinking configuration
|
||||
const budget_tokens = this.options.thinkingBudgetTokens || 0
|
||||
const baseModelId =
|
||||
(this.options.awsBedrockCustomSelected ? this.options.awsBedrockCustomModelBaseId : this.getModel().id) ||
|
||||
this.getModel().id
|
||||
const reasoningOn = this.shouldEnableReasoning(baseModelId, budget_tokens)
|
||||
const reasoningOn = model.info.supportsReasoning && budget_tokens > 0
|
||||
|
||||
// Prepare request for Anthropic model using Converse API
|
||||
const command = new ConverseStreamCommand({
|
||||
|
||||
@@ -878,12 +878,6 @@ export class SapAiCoreHandler implements ApiHandler {
|
||||
stream: any,
|
||||
_model: { id: SapAiCoreModelId; info: ModelInfo },
|
||||
): AsyncGenerator<any, void, unknown> {
|
||||
function toStrictJson(str: string): string {
|
||||
// Wrap it in parentheses so JS will treat it as an expression
|
||||
const obj = new Function("return " + str)()
|
||||
return JSON.stringify(obj)
|
||||
}
|
||||
|
||||
const _usage = { input_tokens: 0, output_tokens: 0 }
|
||||
|
||||
try {
|
||||
@@ -898,7 +892,7 @@ export class SapAiCoreHandler implements ApiHandler {
|
||||
|
||||
try {
|
||||
// Parse the incoming JSON data from the stream
|
||||
const data = JSON.parse(toStrictJson(jsonData))
|
||||
const data = JSON.parse(jsonData)
|
||||
|
||||
// Handle metadata (token usage)
|
||||
if (data.metadata?.usage) {
|
||||
|
||||
@@ -954,6 +954,7 @@ export class Controller {
|
||||
subagentsEnabled,
|
||||
nativeToolCallSetting: this.stateManager.getGlobalStateKey("nativeToolCallEnabled"),
|
||||
enableParallelToolCalling: this.stateManager.getGlobalSettingsKey("enableParallelToolCalling"),
|
||||
backgroundEditEnabled: this.stateManager.getGlobalSettingsKey("backgroundEditEnabled"),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -320,6 +320,10 @@ export async function updateSettings(controller: Controller, request: UpdateSett
|
||||
}
|
||||
}
|
||||
|
||||
if (request.backgroundEditEnabled !== undefined) {
|
||||
controller.stateManager.setGlobalState("backgroundEditEnabled", !!request.backgroundEditEnabled)
|
||||
}
|
||||
|
||||
if (request.autoCondenseThreshold !== undefined) {
|
||||
const threshold = Math.min(1, Math.max(0, request.autoCondenseThreshold)) // Clamp to 0-1 range
|
||||
controller.stateManager.setGlobalState("autoCondenseThreshold", threshold)
|
||||
|
||||
@@ -8,6 +8,10 @@
|
||||
* @returns true if hooks are enabled and supported on this platform, false otherwise
|
||||
*/
|
||||
export function getHooksEnabledSafe(userSetting: boolean | undefined): boolean {
|
||||
// Handle legacy object format: {user: boolean, featureFlag: boolean}, which
|
||||
// can occur if the migration hasn't run yet or if reading from an old state.
|
||||
const booleanValue = Boolean((userSetting as any)?.user ?? userSetting)
|
||||
|
||||
// Force hooks to false on Windows (not yet supported)
|
||||
return process.platform === "win32" ? false : (userSetting ?? false)
|
||||
return process.platform === "win32" ? false : booleanValue
|
||||
}
|
||||
|
||||
@@ -24,14 +24,14 @@ Plan Mode is for deep analysis and strategic planning before implementation. You
|
||||
|
||||
### Phase 1: Silent Investigation
|
||||
|
||||
Perform comprehensive research to build complete understanding of the codebase. Work silently - execute targetted searcg commands and read files without explaining what you're doing. Only ask questions when truly necessary for planning. You must strongly incrporate key words and principles from the user's input into your targetted search patterns and strategy.
|
||||
Perform comprehensive research to build complete understanding of the codebase. Work silently - execute targeted search commands and read files without explaining what you're doing. Only ask questions when truly necessary for planning. You must strongly incorporate key words and principles from the user's input into your targeted search patterns and strategy.
|
||||
|
||||
**Research Activities:**
|
||||
- Use read_file, search_files, and list_code_definition_names extensively to understand architecture, patterns, and conventions
|
||||
- Execute targetted terminal commands to search and gather information about structure and dependencies.
|
||||
- Execute targeted terminal commands to search and gather information about structure and dependencies.
|
||||
- Identify technical constraints, existing patterns, and potential risks
|
||||
- Ask targeted clarifying questions only when they will directly influence your implementation approach
|
||||
- Ensure complete converage- before presenting a plan, you should identify all related functions, classes, calls, and methods that are involved or affected by the proposed changes.
|
||||
- Ensure complete coverage - before presenting a plan, you should identify all related functions, classes, calls, and methods that are involved or affected by the proposed changes.
|
||||
|
||||
### Phase 2: Plan Presentation
|
||||
|
||||
@@ -66,7 +66,7 @@ Engage with the user to discuss the plan, answer questions, and incorporate feed
|
||||
|
||||
### Phase 4: Transition to Implementation
|
||||
|
||||
Once the plan is finalized and approved, you MUST direct the user to switch to ACT MODE. In Act Mode, you'll execute the plan step-by-step as outlined. If you not specifically ask the user to switch to ACT MODE, you will not be able to implemnent the planned changes.
|
||||
Once the plan is finalized and approved, you MUST direct the user to switch to ACT MODE. In Act Mode, you'll execute the plan step-by-step as outlined. If you not specifically ask the user to switch to ACT MODE, you will not be able to implement the planned changes.
|
||||
|
||||
## Act Mode Workflow
|
||||
|
||||
@@ -215,7 +215,7 @@ RULES
|
||||
|
||||
- The current working directory is `/test/project` - this is the directory where all the tools will be executed from.
|
||||
- When executing terminal commands, new terminals always open in the workspace directory. Use relative paths or chain commands with proper shell operators (e.g., `cd path && command` to change directory and run a command together).
|
||||
- Whean searching, prefer the search_files tool over using grep in the terminal. If you are directly instruted to use grep, ensure your search patterns are targetted and not too vague to prevent extremely large outputs.
|
||||
- When searching, prefer the search_files tool over using grep in the terminal. If you are directly instructed to use grep, ensure your search patterns are targeted and not too vague to prevent extremely large outputs.
|
||||
- When using replace_in_file, pay careful attention to the EDITING FILES section above. The most common errors are:
|
||||
- Not matching content exactly (every character, space, and newline must match)
|
||||
- Using incomplete lines in SEARCH blocks (always include complete lines from start to end)
|
||||
|
||||
+5
-5
@@ -24,14 +24,14 @@ Plan Mode is for deep analysis and strategic planning before implementation. You
|
||||
|
||||
### Phase 1: Silent Investigation
|
||||
|
||||
Perform comprehensive research to build complete understanding of the codebase. Work silently - execute targetted searcg commands and read files without explaining what you're doing. Only ask questions when truly necessary for planning. You must strongly incrporate key words and principles from the user's input into your targetted search patterns and strategy.
|
||||
Perform comprehensive research to build complete understanding of the codebase. Work silently - execute targeted search commands and read files without explaining what you're doing. Only ask questions when truly necessary for planning. You must strongly incorporate key words and principles from the user's input into your targeted search patterns and strategy.
|
||||
|
||||
**Research Activities:**
|
||||
- Use read_file, search_files, and list_code_definition_names extensively to understand architecture, patterns, and conventions
|
||||
- Execute targetted terminal commands to search and gather information about structure and dependencies.
|
||||
- Execute targeted terminal commands to search and gather information about structure and dependencies.
|
||||
- Identify technical constraints, existing patterns, and potential risks
|
||||
- Ask targeted clarifying questions only when they will directly influence your implementation approach
|
||||
- Ensure complete converage- before presenting a plan, you should identify all related functions, classes, calls, and methods that are involved or affected by the proposed changes.
|
||||
- Ensure complete coverage - before presenting a plan, you should identify all related functions, classes, calls, and methods that are involved or affected by the proposed changes.
|
||||
|
||||
### Phase 2: Plan Presentation
|
||||
|
||||
@@ -66,7 +66,7 @@ Engage with the user to discuss the plan, answer questions, and incorporate feed
|
||||
|
||||
### Phase 4: Transition to Implementation
|
||||
|
||||
Once the plan is finalized and approved, you MUST direct the user to switch to ACT MODE. In Act Mode, you'll execute the plan step-by-step as outlined. If you not specifically ask the user to switch to ACT MODE, you will not be able to implemnent the planned changes.
|
||||
Once the plan is finalized and approved, you MUST direct the user to switch to ACT MODE. In Act Mode, you'll execute the plan step-by-step as outlined. If you not specifically ask the user to switch to ACT MODE, you will not be able to implement the planned changes.
|
||||
|
||||
## Act Mode Workflow
|
||||
|
||||
@@ -213,7 +213,7 @@ RULES
|
||||
|
||||
- The current working directory is `/test/project` - this is the directory where all the tools will be executed from.
|
||||
- When executing terminal commands, new terminals always open in the workspace directory. Use relative paths or chain commands with proper shell operators (e.g., `cd path && command` to change directory and run a command together).
|
||||
- Whean searching, prefer the search_files tool over using grep in the terminal. If you are directly instruted to use grep, ensure your search patterns are targetted and not too vague to prevent extremely large outputs.
|
||||
- When searching, prefer the search_files tool over using grep in the terminal. If you are directly instructed to use grep, ensure your search patterns are targeted and not too vague to prevent extremely large outputs.
|
||||
- When using replace_in_file, pay careful attention to the EDITING FILES section above. The most common errors are:
|
||||
- Not matching content exactly (every character, space, and newline must match)
|
||||
- Using incomplete lines in SEARCH blocks (always include complete lines from start to end)
|
||||
|
||||
+5
-5
@@ -24,14 +24,14 @@ Plan Mode is for deep analysis and strategic planning before implementation. You
|
||||
|
||||
### Phase 1: Silent Investigation
|
||||
|
||||
Perform comprehensive research to build complete understanding of the codebase. Work silently - execute targetted searcg commands and read files without explaining what you're doing. Only ask questions when truly necessary for planning. You must strongly incrporate key words and principles from the user's input into your targetted search patterns and strategy.
|
||||
Perform comprehensive research to build complete understanding of the codebase. Work silently - execute targeted search commands and read files without explaining what you're doing. Only ask questions when truly necessary for planning. You must strongly incorporate key words and principles from the user's input into your targeted search patterns and strategy.
|
||||
|
||||
**Research Activities:**
|
||||
- Use read_file, search_files, and list_code_definition_names extensively to understand architecture, patterns, and conventions
|
||||
- Execute targetted terminal commands to search and gather information about structure and dependencies.
|
||||
- Execute targeted terminal commands to search and gather information about structure and dependencies.
|
||||
- Identify technical constraints, existing patterns, and potential risks
|
||||
- Ask targeted clarifying questions only when they will directly influence your implementation approach
|
||||
- Ensure complete converage- before presenting a plan, you should identify all related functions, classes, calls, and methods that are involved or affected by the proposed changes.
|
||||
- Ensure complete coverage - before presenting a plan, you should identify all related functions, classes, calls, and methods that are involved or affected by the proposed changes.
|
||||
|
||||
### Phase 2: Plan Presentation
|
||||
|
||||
@@ -66,7 +66,7 @@ Engage with the user to discuss the plan, answer questions, and incorporate feed
|
||||
|
||||
### Phase 4: Transition to Implementation
|
||||
|
||||
Once the plan is finalized and approved, you MUST direct the user to switch to ACT MODE. In Act Mode, you'll execute the plan step-by-step as outlined. If you not specifically ask the user to switch to ACT MODE, you will not be able to implemnent the planned changes.
|
||||
Once the plan is finalized and approved, you MUST direct the user to switch to ACT MODE. In Act Mode, you'll execute the plan step-by-step as outlined. If you not specifically ask the user to switch to ACT MODE, you will not be able to implement the planned changes.
|
||||
|
||||
## Act Mode Workflow
|
||||
|
||||
@@ -193,7 +193,7 @@ RULES
|
||||
|
||||
- The current working directory is `/test/project` - this is the directory where all the tools will be executed from.
|
||||
- When executing terminal commands, new terminals always open in the workspace directory. Use relative paths or chain commands with proper shell operators (e.g., `cd path && command` to change directory and run a command together).
|
||||
- Whean searching, prefer the search_files tool over using grep in the terminal. If you are directly instruted to use grep, ensure your search patterns are targetted and not too vague to prevent extremely large outputs.
|
||||
- When searching, prefer the search_files tool over using grep in the terminal. If you are directly instructed to use grep, ensure your search patterns are targeted and not too vague to prevent extremely large outputs.
|
||||
- When using replace_in_file, pay careful attention to the EDITING FILES section above. The most common errors are:
|
||||
- Not matching content exactly (every character, space, and newline must match)
|
||||
- Using incomplete lines in SEARCH blocks (always include complete lines from start to end)
|
||||
|
||||
@@ -24,14 +24,14 @@ Plan Mode is for deep analysis and strategic planning before implementation. You
|
||||
|
||||
### Phase 1: Silent Investigation
|
||||
|
||||
Perform comprehensive research to build complete understanding of the codebase. Work silently - execute targetted searcg commands and read files without explaining what you're doing. Only ask questions when truly necessary for planning. You must strongly incrporate key words and principles from the user's input into your targetted search patterns and strategy.
|
||||
Perform comprehensive research to build complete understanding of the codebase. Work silently - execute targeted search commands and read files without explaining what you're doing. Only ask questions when truly necessary for planning. You must strongly incorporate key words and principles from the user's input into your targeted search patterns and strategy.
|
||||
|
||||
**Research Activities:**
|
||||
- Use read_file, search_files, and list_code_definition_names extensively to understand architecture, patterns, and conventions
|
||||
- Execute targetted terminal commands to search and gather information about structure and dependencies.
|
||||
- Execute targeted terminal commands to search and gather information about structure and dependencies.
|
||||
- Identify technical constraints, existing patterns, and potential risks
|
||||
- Ask targeted clarifying questions only when they will directly influence your implementation approach
|
||||
- Ensure complete converage- before presenting a plan, you should identify all related functions, classes, calls, and methods that are involved or affected by the proposed changes.
|
||||
- Ensure complete coverage - before presenting a plan, you should identify all related functions, classes, calls, and methods that are involved or affected by the proposed changes.
|
||||
|
||||
### Phase 2: Plan Presentation
|
||||
|
||||
@@ -66,7 +66,7 @@ Engage with the user to discuss the plan, answer questions, and incorporate feed
|
||||
|
||||
### Phase 4: Transition to Implementation
|
||||
|
||||
Once the plan is finalized and approved, you MUST direct the user to switch to ACT MODE. In Act Mode, you'll execute the plan step-by-step as outlined. If you not specifically ask the user to switch to ACT MODE, you will not be able to implemnent the planned changes.
|
||||
Once the plan is finalized and approved, you MUST direct the user to switch to ACT MODE. In Act Mode, you'll execute the plan step-by-step as outlined. If you not specifically ask the user to switch to ACT MODE, you will not be able to implement the planned changes.
|
||||
|
||||
## Act Mode Workflow
|
||||
|
||||
@@ -215,7 +215,7 @@ RULES
|
||||
|
||||
- The current working directory is `/test/project` - this is the directory where all the tools will be executed from.
|
||||
- When executing terminal commands, new terminals always open in the workspace directory. Use relative paths or chain commands with proper shell operators (e.g., `cd path && command` to change directory and run a command together).
|
||||
- Whean searching, prefer the search_files tool over using grep in the terminal. If you are directly instruted to use grep, ensure your search patterns are targetted and not too vague to prevent extremely large outputs.
|
||||
- When searching, prefer the search_files tool over using grep in the terminal. If you are directly instructed to use grep, ensure your search patterns are targeted and not too vague to prevent extremely large outputs.
|
||||
- When using replace_in_file, pay careful attention to the EDITING FILES section above. The most common errors are:
|
||||
- Not matching content exactly (every character, space, and newline must match)
|
||||
- Using incomplete lines in SEARCH blocks (always include complete lines from start to end)
|
||||
|
||||
@@ -129,7 +129,7 @@ const GEMINI_3_RULES_TEMPLATE = (_context: SystemPromptContext) => `RULES
|
||||
|
||||
- The current working directory is \`{{CWD}}\` - this is the directory where all the tools will be executed from.
|
||||
- When executing terminal commands, new terminals always open in the workspace directory. Use relative paths or chain commands with proper shell operators (e.g., \`cd path && command\` to change directory and run a command together).
|
||||
- Whean searching, prefer the search_files tool over using grep in the terminal. If you are directly instruted to use grep, ensure your search patterns are targetted and not too vague to prevent extremely large outputs.
|
||||
- When searching, prefer the search_files tool over using grep in the terminal. If you are directly instructed to use grep, ensure your search patterns are targeted and not too vague to prevent extremely large outputs.
|
||||
- When using replace_in_file, pay careful attention to the EDITING FILES section above. The most common errors are:
|
||||
- Not matching content exactly (every character, space, and newline must match)
|
||||
- Using incomplete lines in SEARCH blocks (always include complete lines from start to end)
|
||||
@@ -157,13 +157,13 @@ Plan Mode is for deep analysis and strategic planning before implementation. You
|
||||
|
||||
### Phase 1: Silent Investigation
|
||||
|
||||
Perform comprehensive research to build complete understanding of the codebase. Work silently - execute targetted searcg commands and read files without explaining what you're doing. Only ask questions when truly necessary for planning. You must strongly incrporate key words and principles from the user's input into your targetted search patterns and strategy.
|
||||
Perform comprehensive research to build complete understanding of the codebase. Work silently - execute targeted search commands and read files without explaining what you're doing. Only ask questions when truly necessary for planning. You must strongly incorporate key words and principles from the user's input into your targeted search patterns and strategy.
|
||||
|
||||
**Research Activities:**
|
||||
- Use read_file, search_files, and list_code_definition_names extensively to understand architecture, patterns, and conventions
|
||||
- Execute targetted terminal commands to search and gather information about structure and dependencies.
|
||||
- Execute targeted terminal commands to search and gather information about structure and dependencies.
|
||||
- Identify technical constraints, existing patterns, and potential risks${context.yoloModeToggled !== true ? "\n- Ask targeted clarifying questions only when they will directly influence your implementation approach" : ""}
|
||||
- Ensure complete converage- before presenting a plan, you should identify all related functions, classes, calls, and methods that are involved or affected by the proposed changes.
|
||||
- Ensure complete coverage - before presenting a plan, you should identify all related functions, classes, calls, and methods that are involved or affected by the proposed changes.
|
||||
|
||||
### Phase 2: Plan Presentation
|
||||
|
||||
@@ -198,7 +198,7 @@ Engage with the user to discuss the plan, answer questions, and incorporate feed
|
||||
|
||||
### Phase 4: Transition to Implementation
|
||||
|
||||
Once the plan is finalized and approved, you MUST direct the user to switch to ACT MODE. In Act Mode, you'll execute the plan step-by-step as outlined. If you not specifically ask the user to switch to ACT MODE, you will not be able to implemnent the planned changes.
|
||||
Once the plan is finalized and approved, you MUST direct the user to switch to ACT MODE. In Act Mode, you'll execute the plan step-by-step as outlined. If you not specifically ask the user to switch to ACT MODE, you will not be able to implement the planned changes.
|
||||
|
||||
## Act Mode Workflow
|
||||
|
||||
|
||||
@@ -322,6 +322,8 @@ export async function readGlobalStateFromDisk(context: ExtensionContext): Promis
|
||||
const openTelemetryLogMaxQueueSize =
|
||||
context.globalState.get<GlobalStateAndSettings["openTelemetryLogMaxQueueSize"]>("openTelemetryLogMaxQueueSize")
|
||||
const subagentsEnabled = context.globalState.get<GlobalStateAndSettings["subagentsEnabled"]>("subagentsEnabled")
|
||||
const backgroundEditEnabled =
|
||||
context.globalState.get<GlobalStateAndSettings["backgroundEditEnabled"]>("backgroundEditEnabled")
|
||||
|
||||
// Get mode-related configurations
|
||||
const mode = context.globalState.get<GlobalStateAndSettings["mode"]>("mode")
|
||||
@@ -682,6 +684,7 @@ export async function readGlobalStateFromDisk(context: ExtensionContext): Promis
|
||||
qwenCodeOauthPath,
|
||||
customPrompt,
|
||||
autoCondenseThreshold: autoCondenseThreshold || 0.75, // default to 0.75 if not set
|
||||
backgroundEditEnabled: backgroundEditEnabled ?? false,
|
||||
// Hooks require explicit user opt-in and are only supported on macOS/Linux
|
||||
hooksEnabled: getHooksEnabledSafe(hooksEnabled),
|
||||
subagentsEnabled: subagentsEnabled ?? false,
|
||||
|
||||
+28
-22
@@ -69,12 +69,16 @@ import Mutex from "p-mutex"
|
||||
import pWaitFor from "p-wait-for"
|
||||
import * as path from "path"
|
||||
import { ulid } from "ulid"
|
||||
import * as vscode from "vscode"
|
||||
import type { SystemPromptContext } from "@/core/prompts/system-prompt"
|
||||
import { getSystemPrompt } from "@/core/prompts/system-prompt"
|
||||
import { HostProvider } from "@/hosts/host-provider"
|
||||
import { CommandExecutorCallbacks, StandaloneTerminalManager } from "@/integrations/terminal"
|
||||
import { CommandExecutor, FullCommandExecutorConfig } from "@/integrations/terminal/CommandExecutor"
|
||||
import { FileEditProvider } from "@/integrations/editor/FileEditProvider"
|
||||
import {
|
||||
CommandExecutor,
|
||||
CommandExecutorCallbacks,
|
||||
FullCommandExecutorConfig,
|
||||
StandaloneTerminalManager,
|
||||
} from "@/integrations/terminal"
|
||||
import { ClineError, ClineErrorType, ErrorService } from "@/services/error"
|
||||
import { telemetryService } from "@/services/telemetry"
|
||||
import {
|
||||
@@ -272,7 +276,6 @@ export class Task {
|
||||
this.cancelTask = cancelTask
|
||||
this.clineIgnoreController = new ClineIgnoreController(cwd)
|
||||
this.taskLockAcquired = taskLockAcquired
|
||||
|
||||
// Determine terminal execution mode and create appropriate terminal manager
|
||||
this.terminalExecutionMode = vscodeTerminalExecutionMode || "vscodeTerminal"
|
||||
|
||||
@@ -296,12 +299,16 @@ export class Task {
|
||||
this.urlContentFetcher = new UrlContentFetcher(controller.context)
|
||||
this.browserSession = new BrowserSession(stateManager)
|
||||
this.contextManager = new ContextManager()
|
||||
this.diffViewProvider = HostProvider.get().createDiffViewProvider()
|
||||
this.streamHandler = new StreamResponseHandler()
|
||||
this.cwd = cwd
|
||||
this.stateManager = stateManager
|
||||
this.workspaceManager = workspaceManager
|
||||
|
||||
// DiffViewProvider opens Diff Editor during edits while FileEditProvider performs
|
||||
// edits in the background without stealing user's editor's focus.
|
||||
const backgroundEditEnabled = this.stateManager.getGlobalSettingsKey("backgroundEditEnabled")
|
||||
this.diffViewProvider = backgroundEditEnabled ? new FileEditProvider() : HostProvider.get().createDiffViewProvider()
|
||||
|
||||
// Set up MCP notification callback for real-time notifications
|
||||
this.mcpHub.setNotificationCallback(async (serverName: string, _level: string, message: string) => {
|
||||
// Display notification in chat immediately
|
||||
@@ -1600,21 +1607,6 @@ export class Task {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrates the disableBrowserTool setting from VSCode configuration to browserSettings
|
||||
*/
|
||||
private async migrateDisableBrowserToolSetting(): Promise<void> {
|
||||
const config = vscode.workspace.getConfiguration("cline")
|
||||
const disableBrowserTool = config.get<boolean>("disableBrowserTool")
|
||||
|
||||
if (disableBrowserTool !== undefined) {
|
||||
const browserSettings = this.stateManager.getGlobalSettingsKey("browserSettings")
|
||||
browserSettings.disableToolUse = disableBrowserTool
|
||||
// Remove from VSCode configuration
|
||||
await config.update("disableBrowserTool", undefined, true)
|
||||
}
|
||||
}
|
||||
|
||||
private getCurrentProviderInfo(): ApiProviderInfo {
|
||||
const model = this.api.getModel()
|
||||
const apiConfig = this.stateManager.getApiConfiguration()
|
||||
@@ -1705,7 +1697,6 @@ export class Task {
|
||||
|
||||
const providerInfo = this.getCurrentProviderInfo()
|
||||
const ide = (await HostProvider.env.getHostVersion({})).platform || "Unknown"
|
||||
await this.migrateDisableBrowserToolSetting()
|
||||
const browserSettings = this.stateManager.getGlobalSettingsKey("browserSettings")
|
||||
const disableBrowserTool = browserSettings.disableToolUse ?? false
|
||||
// cline browser tool uses image recognition for navigation (requires model image support).
|
||||
@@ -2142,6 +2133,16 @@ export class Task {
|
||||
}
|
||||
|
||||
if (this.taskState.consecutiveMistakeCount >= this.stateManager.getGlobalSettingsKey("maxConsecutiveMistakes")) {
|
||||
// In yolo mode, don't wait for user input - fail the task
|
||||
if (this.stateManager.getGlobalSettingsKey("yoloModeToggled")) {
|
||||
const errorMessage =
|
||||
`[YOLO MODE] Task failed: Too many consecutive mistakes (${this.taskState.consecutiveMistakeCount}). ` +
|
||||
`The model may not be capable enough for this task. Consider using a more capable model.`
|
||||
await this.say("error", errorMessage)
|
||||
// End the task loop with failure
|
||||
return true // didEndLoop = true, signals task completion/failure
|
||||
}
|
||||
|
||||
const autoApprovalSettings = this.stateManager.getGlobalSettingsKey("autoApprovalSettings")
|
||||
if (autoApprovalSettings.enableNotifications) {
|
||||
showSystemNotification({
|
||||
@@ -2215,7 +2216,12 @@ export class Task {
|
||||
|
||||
// Now, if it's the first request AND checkpoints are enabled AND tracker was successfully initialized,
|
||||
// then say "checkpoint_created" and perform the commit.
|
||||
if (isFirstRequest && this.stateManager.getGlobalSettingsKey("enableCheckpointsSetting") && this.checkpointManager) {
|
||||
if (
|
||||
isFirstRequest &&
|
||||
this.stateManager.getGlobalSettingsKey("enableCheckpointsSetting") &&
|
||||
this.checkpointManager &&
|
||||
!this.taskState.checkpointManagerErrorMessage
|
||||
) {
|
||||
await this.say("checkpoint_created") // Now this is conditional
|
||||
const lastCheckpointMessageIndex = findLastIndex(
|
||||
this.messageStateHandler.getClineMessages(),
|
||||
|
||||
@@ -43,10 +43,6 @@ export class ApplyPatchHandler implements IFullyManagedTool {
|
||||
private config?: TaskConfig
|
||||
private pathResolver?: PathResolver
|
||||
private providerOps?: FileProviderOperations
|
||||
private partialPreviewState?: {
|
||||
originalFiles: Record<string, string>
|
||||
currentPreviewPath?: string
|
||||
}
|
||||
|
||||
constructor(private validator: ToolValidator) {}
|
||||
|
||||
@@ -85,19 +81,11 @@ export class ApplyPatchHandler implements IFullyManagedTool {
|
||||
}
|
||||
}
|
||||
|
||||
private ensurePartialPreviewState(): { originalFiles: Record<string, string>; currentPreviewPath?: string } {
|
||||
if (!this.partialPreviewState) {
|
||||
this.partialPreviewState = { originalFiles: {} }
|
||||
}
|
||||
return this.partialPreviewState
|
||||
}
|
||||
|
||||
private async previewPatchStream(rawInput: string, uiHelpers: StronglyTypedUIHelpers): Promise<void> {
|
||||
const config = uiHelpers.getConfig()
|
||||
const provider = config.services.diffViewProvider
|
||||
this.initializeHelpers(config)
|
||||
|
||||
const state = this.ensurePartialPreviewState()
|
||||
const lines = this.stripBashWrapper(rawInput.split("\n"))
|
||||
|
||||
// Extract the first operation path and type
|
||||
@@ -211,12 +199,6 @@ export class ApplyPatchHandler implements IFullyManagedTool {
|
||||
if (stream.content === undefined) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await provider.update(stream.content, false)
|
||||
} catch {
|
||||
// Ignore streaming errors
|
||||
}
|
||||
}
|
||||
|
||||
async execute(config: TaskConfig, block: ToolUse): Promise<ToolResponse> {
|
||||
@@ -238,7 +220,6 @@ export class ApplyPatchHandler implements IFullyManagedTool {
|
||||
// Ignore reset errors
|
||||
}
|
||||
}
|
||||
this.partialPreviewState = undefined
|
||||
|
||||
try {
|
||||
const lines = this.preprocessLines(rawInput)
|
||||
|
||||
@@ -40,6 +40,19 @@ export class AskFollowupQuestionToolHandler implements IToolHandler, IPartialBlo
|
||||
}
|
||||
config.taskState.consecutiveMistakeCount = 0
|
||||
|
||||
// In yolo mode, don't wait for user input - instruct AI to use tools instead
|
||||
if (config.yoloModeToggled) {
|
||||
// Log the question that was asked but auto-respond
|
||||
await config.callbacks.say(
|
||||
"info",
|
||||
`[YOLO MODE] Auto-responding to question: "${question.substring(0, 100)}${question.length > 100 ? "..." : ""}"`,
|
||||
)
|
||||
|
||||
return formatResponse.toolResult(
|
||||
`[YOLO MODE: User input is not available in non-interactive mode. You must use available tools (read_file, list_files, search_files, etc.) to gather the information you need instead of asking the user. Proceed with using tools to find the answer to your question: "${question}"]`,
|
||||
)
|
||||
}
|
||||
|
||||
// Show notification if enabled
|
||||
if (config.autoApprovalSettings.enableNotifications) {
|
||||
showSystemNotification({
|
||||
|
||||
@@ -415,6 +415,11 @@ export class WriteToFileToolHandler implements IFullyManagedTool {
|
||||
!block.partial, // Pass the partial flag correctly
|
||||
)
|
||||
} catch (error) {
|
||||
// As we set the didAlreadyUseTool flag when the tool has failed once, we don't want to add the error message to the
|
||||
// userMessages array again on each new streaming chunk received.
|
||||
if (!config.enableParallelToolCalling && config.taskState.didAlreadyUseTool) {
|
||||
return
|
||||
}
|
||||
// Full original behavior - comprehensive error handling even for partial blocks
|
||||
await config.callbacks.say("diff_error", relPath)
|
||||
|
||||
|
||||
Vendored
+2
-2
@@ -228,7 +228,7 @@ function createAuthSucceededHtml(redirectUri?: string): string {
|
||||
<title>Cline - Authentication Success</title>
|
||||
${redirect}
|
||||
<style>
|
||||
@import url('https://fonts.googleapis.com/css2?family=Azeret+Mono:wght@300;400;700&display=swap');
|
||||
@import url('https://fonts.googleapis.com/css2?family=Azeret:wght@300;400;700&display=swap');
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
@@ -237,7 +237,7 @@ function createAuthSucceededHtml(redirectUri?: string): string {
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Azeret Mono', monospace;
|
||||
font-family: 'Azeret', sans-serif;
|
||||
background-color: #ffffff;
|
||||
color: #333333;
|
||||
height: 100vh;
|
||||
|
||||
+6
-2
@@ -1,11 +1,15 @@
|
||||
import * as vscode from "vscode"
|
||||
import { ErrorSettings } from "@/services/error"
|
||||
import { EmptyRequest } from "@/shared/proto/index.cline"
|
||||
import { GetTelemetrySettingsResponse, Setting } from "@/shared/proto/index.host"
|
||||
|
||||
export async function getTelemetrySettings(_: EmptyRequest): Promise<GetTelemetrySettingsResponse> {
|
||||
const config = vscode.workspace.getConfiguration("telemetry")
|
||||
const errorLevel = config?.get<ErrorSettings["level"]>("telemetryLevel") || "all"
|
||||
|
||||
if (vscode.env.isTelemetryEnabled) {
|
||||
return { isEnabled: Setting.ENABLED }
|
||||
return { isEnabled: Setting.ENABLED, errorLevel }
|
||||
} else {
|
||||
return { isEnabled: Setting.DISABLED }
|
||||
return { isEnabled: Setting.DISABLED, errorLevel }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,12 +3,16 @@ import { EventEmitter } from "events"
|
||||
import * as vscode from "vscode"
|
||||
import { stripAnsi } from "@/hosts/vscode/terminal/ansiUtils"
|
||||
import { getLatestTerminalOutput } from "@/hosts/vscode/terminal/get-latest-output"
|
||||
import {
|
||||
isCompilingOutput,
|
||||
MAX_FULL_OUTPUT_SIZE,
|
||||
MAX_UNRETRIEVED_LINES,
|
||||
PROCESS_HOT_TIMEOUT_COMPILING,
|
||||
PROCESS_HOT_TIMEOUT_NORMAL,
|
||||
TRUNCATE_KEEP_LINES,
|
||||
} from "@/integrations/terminal/constants"
|
||||
import type { ITerminalProcess, TerminalProcessEvents } from "@/integrations/terminal/types"
|
||||
|
||||
// how long to wait after a process outputs anything before we consider it "cool" again
|
||||
const PROCESS_HOT_TIMEOUT_NORMAL = 2_000
|
||||
const PROCESS_HOT_TIMEOUT_COMPILING = 15_000
|
||||
|
||||
/**
|
||||
* VscodeTerminalProcess - Manages command execution in VSCode's integrated terminal.
|
||||
*
|
||||
@@ -156,24 +160,7 @@ export class VscodeTerminalProcess extends EventEmitter<TerminalProcessEvents> i
|
||||
clearTimeout(this.hotTimer)
|
||||
}
|
||||
// these markers indicate the command is some kind of local dev server recompiling the app, which we want to wait for output of before sending request to cline
|
||||
const compilingMarkers = ["compiling", "building", "bundling", "transpiling", "generating", "starting"]
|
||||
const markerNullifiers = [
|
||||
"compiled",
|
||||
"success",
|
||||
"finish",
|
||||
"complete",
|
||||
"succeed",
|
||||
"done",
|
||||
"end",
|
||||
"stop",
|
||||
"exit",
|
||||
"terminate",
|
||||
"error",
|
||||
"fail",
|
||||
]
|
||||
const isCompiling =
|
||||
compilingMarkers.some((marker) => data.toLowerCase().includes(marker.toLowerCase())) &&
|
||||
!markerNullifiers.some((nullifier) => data.toLowerCase().includes(nullifier.toLowerCase()))
|
||||
const isCompiling = isCompilingOutput(data)
|
||||
this.hotTimer = setTimeout(
|
||||
() => {
|
||||
this.isHot = false
|
||||
@@ -189,6 +176,15 @@ export class VscodeTerminalProcess extends EventEmitter<TerminalProcessEvents> i
|
||||
}
|
||||
|
||||
this.fullOutput += data
|
||||
|
||||
// Cap fullOutput at MAX_FULL_OUTPUT_SIZE to prevent memory exhaustion
|
||||
if (this.fullOutput.length > MAX_FULL_OUTPUT_SIZE) {
|
||||
// Keep last half of max size
|
||||
this.fullOutput = this.fullOutput.slice(-MAX_FULL_OUTPUT_SIZE / 2)
|
||||
// Reset lastRetrievedIndex since we truncated the beginning
|
||||
this.lastRetrievedIndex = 0
|
||||
}
|
||||
|
||||
if (this.isListening) {
|
||||
this.emitIfEol(data)
|
||||
this.lastRetrievedIndex = this.fullOutput.length - this.buffer.length
|
||||
@@ -200,18 +196,18 @@ export class VscodeTerminalProcess extends EventEmitter<TerminalProcessEvents> i
|
||||
// the command process is finished, let's check the output to see if we need to use the terminal capture fallback
|
||||
if (!this.fullOutput.trim()) {
|
||||
// No output captured via shell integration, trying fallback
|
||||
telemetryService.captureTerminalOutputFailure(TerminalOutputFailureReason.TIMEOUT)
|
||||
telemetryService.captureTerminalOutputFailure(TerminalOutputFailureReason.TIMEOUT, "vscode")
|
||||
await returnCurrentTerminalContents()
|
||||
// Check if fallback worked
|
||||
const terminalSnapshot = await getLatestTerminalOutput()
|
||||
if (terminalSnapshot && terminalSnapshot.trim()) {
|
||||
telemetryService.captureTerminalExecution(true, "clipboard")
|
||||
telemetryService.captureTerminalExecution(true, "vscode", "clipboard")
|
||||
} else {
|
||||
telemetryService.captureTerminalExecution(false, "none")
|
||||
telemetryService.captureTerminalExecution(false, "vscode", "none")
|
||||
}
|
||||
} else {
|
||||
// Shell integration worked
|
||||
telemetryService.captureTerminalExecution(true, "shell_integration")
|
||||
telemetryService.captureTerminalExecution(true, "vscode", "shell_integration")
|
||||
}
|
||||
|
||||
// for now we don't want this delaying requests since we don't send diagnostics automatically anymore (previous: "even though the command is finished, we still want to consider it 'hot' in case so that api request stalls to let diagnostics catch up")
|
||||
@@ -225,7 +221,7 @@ export class VscodeTerminalProcess extends EventEmitter<TerminalProcessEvents> i
|
||||
this.emit("continue")
|
||||
} else {
|
||||
// no shell integration detected, we'll fallback to running the command and capturing the terminal's output after some time
|
||||
telemetryService.captureTerminalOutputFailure(TerminalOutputFailureReason.NO_SHELL_INTEGRATION)
|
||||
telemetryService.captureTerminalOutputFailure(TerminalOutputFailureReason.NO_SHELL_INTEGRATION, "vscode")
|
||||
terminal.sendText(command, true)
|
||||
|
||||
// wait 3 seconds for the command to run
|
||||
@@ -236,9 +232,9 @@ export class VscodeTerminalProcess extends EventEmitter<TerminalProcessEvents> i
|
||||
// Check if clipboard fallback worked
|
||||
const terminalSnapshot = await getLatestTerminalOutput()
|
||||
if (terminalSnapshot && terminalSnapshot.trim()) {
|
||||
telemetryService.captureTerminalExecution(true, "clipboard")
|
||||
telemetryService.captureTerminalExecution(true, "vscode", "clipboard")
|
||||
} else {
|
||||
telemetryService.captureTerminalExecution(false, "none")
|
||||
telemetryService.captureTerminalExecution(false, "vscode", "none")
|
||||
}
|
||||
// For terminals without shell integration, we can't know when the command completes
|
||||
// So we'll just emit the continue event after a delay
|
||||
@@ -285,9 +281,24 @@ export class VscodeTerminalProcess extends EventEmitter<TerminalProcessEvents> i
|
||||
this.emit("continue")
|
||||
}
|
||||
|
||||
/**
|
||||
* Get output that hasn't been retrieved yet.
|
||||
* Truncates if output is too large to prevent context window overflow.
|
||||
* @returns The unretrieved output (truncated if necessary)
|
||||
*/
|
||||
getUnretrievedOutput(): string {
|
||||
const unretrieved = this.fullOutput.slice(this.lastRetrievedIndex)
|
||||
this.lastRetrievedIndex = this.fullOutput.length
|
||||
|
||||
// Truncate if too many lines to prevent context overflow
|
||||
const lines = unretrieved.split("\n")
|
||||
if (lines.length > MAX_UNRETRIEVED_LINES) {
|
||||
const first = lines.slice(0, TRUNCATE_KEEP_LINES)
|
||||
const last = lines.slice(-TRUNCATE_KEEP_LINES)
|
||||
const skipped = lines.length - first.length - last.length
|
||||
return this.removeLastLineArtifacts([...first, `\n... (${skipped} lines truncated) ...\n`, ...last].join("\n"))
|
||||
}
|
||||
|
||||
return this.removeLastLineArtifacts(unretrieved)
|
||||
}
|
||||
|
||||
|
||||
@@ -21,25 +21,14 @@ import { telemetryService } from "@services/telemetry"
|
||||
import { ClineToolResponseContent } from "@shared/messages"
|
||||
import { orchestrateCommandExecution } from "./CommandOrchestrator"
|
||||
import { StandaloneTerminalManager } from "./standalone/StandaloneTerminalManager"
|
||||
import {
|
||||
ActiveBackgroundCommand,
|
||||
import type {
|
||||
CommandExecutorCallbacks,
|
||||
CommandExecutorConfig,
|
||||
ITerminalManager,
|
||||
ShellIntegrationWarningTracker,
|
||||
TerminalProcessResultPromise,
|
||||
} from "./types"
|
||||
|
||||
// Re-export types for convenience
|
||||
export type { CommandExecutorCallbacks, CommandExecutorConfig, FullCommandExecutorConfig } from "./types"
|
||||
|
||||
/**
|
||||
* Tracker for shell integration warnings to determine when to show background terminal suggestion
|
||||
*/
|
||||
interface ShellIntegrationWarningTracker {
|
||||
timestamps: number[]
|
||||
lastSuggestionShown?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* CommandExecutor - Unified command executor for all terminal modes.
|
||||
*
|
||||
@@ -55,19 +44,18 @@ export class CommandExecutor {
|
||||
private standaloneManager: StandaloneTerminalManager
|
||||
private callbacks: CommandExecutorCallbacks
|
||||
|
||||
// Track the currently executing foreground process for cancellation
|
||||
private currentProcess: TerminalProcessResultPromise | null = null
|
||||
|
||||
// Flag to track if the current command was cancelled externally
|
||||
private wasCancelledExternally = false
|
||||
|
||||
// Track shell integration warnings to determine when to show background terminal suggestion
|
||||
private shellIntegrationWarningTracker: ShellIntegrationWarningTracker = {
|
||||
timestamps: [],
|
||||
lastSuggestionShown: undefined,
|
||||
}
|
||||
|
||||
// Track active background command for cancellation (standalone mode only)
|
||||
private activeBackgroundCommand?: {
|
||||
process: TerminalProcessResultPromise & { terminate?: () => void }
|
||||
command: string
|
||||
outputLines: string[]
|
||||
}
|
||||
|
||||
constructor(config: CommandExecutorConfig, callbacks: CommandExecutorCallbacks) {
|
||||
this.cwd = config.cwd
|
||||
this.taskId = config.taskId
|
||||
@@ -76,16 +64,27 @@ export class CommandExecutor {
|
||||
this.terminalManager = config.terminalManager
|
||||
this.callbacks = callbacks
|
||||
|
||||
// Always create StandaloneTerminalManager for subagents (even in VSCode mode)
|
||||
this.standaloneManager = new StandaloneTerminalManager()
|
||||
// When in backgroundExec mode, the terminalManager is already a StandaloneTerminalManager
|
||||
// created by Task. We should reuse it so that Task.getEnvironmentDetails() can see
|
||||
// the terminals and processes we create (for isHot logic, busy terminals, etc.)
|
||||
if (config.terminalExecutionMode === "backgroundExec" && config.terminalManager instanceof StandaloneTerminalManager) {
|
||||
// Reuse the same instance that Task is using
|
||||
this.standaloneManager = config.terminalManager
|
||||
Logger.info(`[CommandExecutor] Reusing Task's StandaloneTerminalManager for backgroundExec mode`)
|
||||
} else {
|
||||
// Create new StandaloneTerminalManager for subagents (even in VSCode mode)
|
||||
// This ensures subagents run in hidden terminals, not cluttering the user's VSCode terminal
|
||||
this.standaloneManager = new StandaloneTerminalManager()
|
||||
Logger.info(`[CommandExecutor] Created new StandaloneTerminalManager for subagents`)
|
||||
|
||||
// Copy settings from the provided terminalManager to ensure consistency
|
||||
if ("shellIntegrationTimeout" in config.terminalManager) {
|
||||
const tm = config.terminalManager as any
|
||||
this.standaloneManager.setShellIntegrationTimeout(tm.shellIntegrationTimeout || 4000)
|
||||
this.standaloneManager.setTerminalReuseEnabled(tm.terminalReuseEnabled ?? true)
|
||||
this.standaloneManager.setTerminalOutputLineLimit(tm.terminalOutputLineLimit || 500)
|
||||
this.standaloneManager.setSubagentTerminalOutputLineLimit(tm.subagentTerminalOutputLineLimit || 2000)
|
||||
// Copy settings from the provided terminalManager to ensure consistency
|
||||
if ("shellIntegrationTimeout" in config.terminalManager) {
|
||||
const tm = config.terminalManager as any
|
||||
this.standaloneManager.setShellIntegrationTimeout(tm.shellIntegrationTimeout || 4000)
|
||||
this.standaloneManager.setTerminalReuseEnabled(tm.terminalReuseEnabled ?? true)
|
||||
this.standaloneManager.setTerminalOutputLineLimit(tm.terminalOutputLineLimit || 500)
|
||||
this.standaloneManager.setSubagentTerminalOutputLineLimit(tm.subagentTerminalOutputLineLimit || 2000)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,7 +119,6 @@ export class CommandExecutor {
|
||||
// Subagents always use standalone manager (hidden terminal)
|
||||
const useStandalone = isSubagent || this.terminalExecutionMode === "backgroundExec"
|
||||
const manager = useStandalone ? this.standaloneManager : this.terminalManager
|
||||
|
||||
Logger.info(`Executing command in ${useStandalone ? "standalone" : "VSCode"} terminal: ${command}`)
|
||||
|
||||
// Get terminal and run command
|
||||
@@ -128,146 +126,111 @@ export class CommandExecutor {
|
||||
terminalInfo.terminal.show()
|
||||
const process = manager.runCommand(terminalInfo, command)
|
||||
|
||||
// Track background command for standalone mode (enables cancellation)
|
||||
if (useStandalone) {
|
||||
this.activeBackgroundCommand = {
|
||||
process: process as any,
|
||||
command,
|
||||
outputLines: [],
|
||||
}
|
||||
// Reset cancellation flag and track the current process
|
||||
this.wasCancelledExternally = false
|
||||
this.currentProcess = process
|
||||
const clearCurrentProcess = () => {
|
||||
this.currentProcess = null
|
||||
}
|
||||
process.once("completed", clearCurrentProcess)
|
||||
process.once("error", clearCurrentProcess)
|
||||
|
||||
// Use shared orchestration logic
|
||||
// The StandaloneTerminalManager handles background command tracking internally
|
||||
const result = await orchestrateCommandExecution(process, manager, this.callbacks, {
|
||||
command,
|
||||
timeoutSeconds,
|
||||
onOutputLine: useStandalone
|
||||
? (line) => {
|
||||
if (this.activeBackgroundCommand) {
|
||||
this.activeBackgroundCommand.outputLines.push(line)
|
||||
}
|
||||
// When "Proceed While Running" is triggered, track the command in the manager
|
||||
// Returns the log file path so the orchestrator can send it to the UI
|
||||
// existingOutput contains all output lines captured so far
|
||||
onProceedWhileRunning: useStandalone
|
||||
? (existingOutput: string[]) => {
|
||||
const backgroundCmd = this.standaloneManager.trackBackgroundCommand(process, command, existingOutput)
|
||||
return { logFilePath: backgroundCmd.logFilePath }
|
||||
}
|
||||
: undefined,
|
||||
showShellIntegrationSuggestion: this.shouldShowBackgroundTerminalSuggestion(),
|
||||
terminalType: useStandalone ? "standalone" : "vscode",
|
||||
})
|
||||
|
||||
// Clear background command tracking if completed
|
||||
if (result.completed && useStandalone) {
|
||||
this.activeBackgroundCommand = undefined
|
||||
}
|
||||
|
||||
// Capture subagent telemetry
|
||||
if (isSubagent && subAgentStartTime > 0) {
|
||||
const durationMs = Math.round(performance.now() - subAgentStartTime)
|
||||
telemetryService.captureSubagentExecution(this.ulid, durationMs, result.outputLines.length, result.completed)
|
||||
}
|
||||
|
||||
// If the command was cancelled externally (via cancel button), return a clear cancellation message
|
||||
// This ensures the AI agent knows the command was cancelled by the user
|
||||
if (this.wasCancelledExternally) {
|
||||
const outputSoFar =
|
||||
result.outputLines.length > 0
|
||||
? `\nOutput captured before cancellation:\n${manager.processOutput(result.outputLines)}`
|
||||
: ""
|
||||
return [true, `Command was cancelled by the user.${outputSoFar}`]
|
||||
}
|
||||
|
||||
return [result.userRejected, result.result]
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel the currently running background command.
|
||||
* Only works in standalone/backgroundExec mode.
|
||||
* Cancel all running commands (both foreground and background).
|
||||
*
|
||||
* @returns true if a command was cancelled, false otherwise
|
||||
* This method cancels:
|
||||
* 1. All detached background commands (those that were "proceeded while running")
|
||||
* 2. The current foreground process (if one is actively running)
|
||||
*
|
||||
* @returns true if any commands were cancelled, false otherwise
|
||||
*/
|
||||
async cancelBackgroundCommand(): Promise<boolean> {
|
||||
if (!this.activeBackgroundCommand) {
|
||||
return false
|
||||
let cancelled = false
|
||||
|
||||
// 1. Cancel all detached background commands
|
||||
const runningCommands = this.standaloneManager.getRunningBackgroundCommands()
|
||||
for (const cmd of runningCommands) {
|
||||
if (this.standaloneManager.cancelBackgroundCommand(cmd.id)) {
|
||||
cancelled = true
|
||||
Logger.info(`Cancelled background command: ${cmd.command}`)
|
||||
}
|
||||
}
|
||||
|
||||
const { process, command, outputLines } = this.activeBackgroundCommand
|
||||
this.activeBackgroundCommand = undefined
|
||||
this.callbacks.updateBackgroundCommandState(false)
|
||||
// 2. Cancel the current foreground process (if any)
|
||||
if (this.currentProcess && typeof (this.currentProcess as any).terminate === "function") {
|
||||
// Set flag so execute() knows the command was cancelled externally
|
||||
this.wasCancelledExternally = true
|
||||
;(this.currentProcess as any).terminate()
|
||||
this.currentProcess = null
|
||||
cancelled = true
|
||||
Logger.info("Cancelled foreground command")
|
||||
}
|
||||
|
||||
try {
|
||||
// Try to terminate the process if the method exists
|
||||
if (typeof process.terminate === "function") {
|
||||
try {
|
||||
await process.terminate()
|
||||
Logger.info(`Terminated background command: ${command}`)
|
||||
} catch (error) {
|
||||
Logger.error(`Error terminating background command: ${command}`, error)
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure any pending operations complete
|
||||
if (typeof process.continue === "function") {
|
||||
try {
|
||||
process.continue()
|
||||
} catch (error) {
|
||||
Logger.error(`Error continuing background command: ${command}`, error)
|
||||
}
|
||||
}
|
||||
|
||||
// Mark the command message as completed in the UI
|
||||
const clineMessages = this.callbacks.getClineMessages()
|
||||
const lastCommandIndex = this.findLastIndex(clineMessages, (m) => m.ask === "command" || m.say === "command")
|
||||
if (lastCommandIndex !== -1) {
|
||||
await this.callbacks.updateClineMessage(lastCommandIndex, {
|
||||
commandCompleted: true,
|
||||
})
|
||||
}
|
||||
|
||||
// Process the captured output to include in the cancellation message
|
||||
const processedOutput = this.standaloneManager.processOutput(outputLines, undefined, false)
|
||||
|
||||
// Add cancellation information to the API conversation history
|
||||
let cancellationMessage = `Command "${command}" was cancelled by the user.`
|
||||
if (processedOutput.length > 0) {
|
||||
cancellationMessage += `\n\nOutput captured before cancellation:\n${processedOutput}`
|
||||
}
|
||||
|
||||
this.callbacks.addToUserMessageContent({
|
||||
type: "text",
|
||||
text: cancellationMessage,
|
||||
})
|
||||
|
||||
return true
|
||||
} catch (error) {
|
||||
Logger.error("Error in cancelBackgroundCommand", error)
|
||||
return false
|
||||
} finally {
|
||||
// 3. Update UI state and notify user
|
||||
if (cancelled) {
|
||||
this.callbacks.updateBackgroundCommandState(false)
|
||||
try {
|
||||
await this.callbacks.say("command_output", "Command execution has been cancelled.")
|
||||
await this.callbacks.say("command_output", "Command(s) cancelled by user.")
|
||||
} catch (error) {
|
||||
Logger.error("Failed to send cancellation notification", error)
|
||||
}
|
||||
}
|
||||
|
||||
return cancelled
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if there's an active background command
|
||||
* Check if there are any active background commands.
|
||||
* Delegates to StandaloneTerminalManager.
|
||||
*/
|
||||
hasActiveBackgroundCommand(): boolean {
|
||||
return !!this.activeBackgroundCommand
|
||||
return this.standaloneManager.hasActiveBackgroundCommands()
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the active background command info (for external access)
|
||||
*/
|
||||
getActiveBackgroundCommand(): ActiveBackgroundCommand | undefined {
|
||||
return this.activeBackgroundCommand
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a summary of background commands for environment details
|
||||
* Get a summary of background commands for environment details.
|
||||
* Delegates to StandaloneTerminalManager which tracks multiple commands.
|
||||
*/
|
||||
getBackgroundCommandSummary(): string | undefined {
|
||||
if (!this.activeBackgroundCommand) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const { command, outputLines } = this.activeBackgroundCommand
|
||||
const recentOutput = outputLines.slice(-10).join("\n")
|
||||
|
||||
let summary = "# Background Commands\n"
|
||||
summary += `## Running: \`${command}\`\n`
|
||||
if (recentOutput) {
|
||||
summary += `### Recent Output\n${recentOutput}`
|
||||
}
|
||||
|
||||
return summary
|
||||
const summary = this.standaloneManager.getBackgroundCommandsSummary()
|
||||
return summary || undefined
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -304,16 +267,4 @@ export class CommandExecutor {
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to find last index matching a predicate
|
||||
*/
|
||||
private findLastIndex<T>(array: T[], predicate: (item: T) => boolean): number {
|
||||
for (let i = array.length - 1; i >= 0; i--) {
|
||||
if (predicate(array[i])) {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,19 @@ import { processFilesIntoText } from "@integrations/misc/extract-text"
|
||||
import { Logger } from "@services/logging/Logger"
|
||||
import { TerminalHangStage, TerminalUserInterventionAction, telemetryService } from "@services/telemetry"
|
||||
import { COMMAND_CANCEL_TOKEN } from "@shared/ExtensionMessage"
|
||||
import * as fs from "fs"
|
||||
import * as os from "os"
|
||||
import * as path from "path"
|
||||
import {
|
||||
BUFFER_STUCK_TIMEOUT_MS,
|
||||
CHUNK_BYTE_SIZE,
|
||||
CHUNK_DEBOUNCE_MS,
|
||||
CHUNK_LINE_COUNT,
|
||||
COMPLETION_TIMEOUT_MS,
|
||||
MAX_BYTES_BEFORE_FILE,
|
||||
MAX_LINES_BEFORE_FILE,
|
||||
SUMMARY_LINES_TO_KEEP,
|
||||
} from "./constants"
|
||||
import type {
|
||||
CommandExecutorCallbacks,
|
||||
ITerminalManager,
|
||||
@@ -27,16 +40,6 @@ import type {
|
||||
TerminalProcessResultPromise,
|
||||
} from "./types"
|
||||
|
||||
// Chunked terminal output buffering constants
|
||||
export const CHUNK_LINE_COUNT = 20
|
||||
export const CHUNK_BYTE_SIZE = 2048 // 2KB
|
||||
export const CHUNK_DEBOUNCE_MS = 100
|
||||
export const BUFFER_STUCK_TIMEOUT_MS = 6000 // 6 seconds
|
||||
export const COMPLETION_TIMEOUT_MS = 6000 // 6 seconds
|
||||
|
||||
// Re-export types for convenience
|
||||
export type { OrchestrationOptions, OrchestrationResult } from "./types"
|
||||
|
||||
/**
|
||||
* Orchestrate command execution with shared logic for buffering, user interaction, and result formatting.
|
||||
*
|
||||
@@ -52,7 +55,13 @@ export async function orchestrateCommandExecution(
|
||||
callbacks: CommandExecutorCallbacks,
|
||||
options: OrchestrationOptions,
|
||||
): Promise<OrchestrationResult> {
|
||||
const { command, timeoutSeconds, onOutputLine, showShellIntegrationSuggestion } = options
|
||||
const {
|
||||
timeoutSeconds,
|
||||
onOutputLine,
|
||||
showShellIntegrationSuggestion,
|
||||
onProceedWhileRunning,
|
||||
terminalType = "vscode",
|
||||
} = options
|
||||
|
||||
// Track command execution state
|
||||
callbacks.updateBackgroundCommandState(true)
|
||||
@@ -79,6 +88,7 @@ export async function orchestrateCommandExecution(
|
||||
let userFeedback: { text?: string; images?: string[]; files?: string[] } | undefined
|
||||
let didContinue = false
|
||||
let didCancelViaUi = false
|
||||
let backgroundTrackingResult: OrchestrationResult | null = null // Set when background tracking returns early
|
||||
|
||||
// Chunked terminal output buffering
|
||||
let outputBuffer: string[] = []
|
||||
@@ -104,7 +114,7 @@ export async function orchestrateCommandExecution(
|
||||
if (!didContinue) {
|
||||
// Start timer to detect if buffer gets stuck
|
||||
bufferStuckTimer = setTimeout(() => {
|
||||
telemetryService.captureTerminalHang(TerminalHangStage.BUFFER_STUCK)
|
||||
telemetryService.captureTerminalHang(TerminalHangStage.BUFFER_STUCK, terminalType)
|
||||
bufferStuckTimer = null
|
||||
}, BUFFER_STUCK_TIMEOUT_MS)
|
||||
|
||||
@@ -115,22 +125,70 @@ export async function orchestrateCommandExecution(
|
||||
|
||||
if (response === "yesButtonClicked") {
|
||||
// Track when user clicks "Proceed While Running"
|
||||
telemetryService.captureTerminalUserIntervention(TerminalUserInterventionAction.PROCESS_WHILE_RUNNING)
|
||||
telemetryService.captureTerminalUserIntervention(
|
||||
TerminalUserInterventionAction.PROCESS_WHILE_RUNNING,
|
||||
terminalType,
|
||||
)
|
||||
// Proceed while running - but still capture user feedback if provided
|
||||
if (text || (images && images.length > 0) || (files && files.length > 0)) {
|
||||
userFeedback = { text, images, files }
|
||||
}
|
||||
didContinue = true
|
||||
|
||||
// Notify caller to start background command tracking
|
||||
// Pass existing output lines so they can be written to the log file
|
||||
// and send log file path to UI if tracking was started
|
||||
if (onProceedWhileRunning) {
|
||||
const trackingResult = onProceedWhileRunning(outputLines)
|
||||
|
||||
// Clear timers first
|
||||
if (chunkTimer) {
|
||||
clearTimeout(chunkTimer)
|
||||
chunkTimer = null
|
||||
}
|
||||
if (completionTimer) {
|
||||
clearTimeout(completionTimer)
|
||||
completionTimer = null
|
||||
}
|
||||
|
||||
// Set early return result BEFORE resuming the process
|
||||
// This prevents the orchestrator's listener from processing new lines
|
||||
const result = terminalManager.processOutput(outputLines)
|
||||
const logMsg = trackingResult?.logFilePath ? `Log file: ${trackingResult.logFilePath}\n` : ""
|
||||
const outputMsg = result.length > 0 ? `Output so far:\n${result}` : ""
|
||||
|
||||
backgroundTrackingResult = {
|
||||
userRejected: false,
|
||||
result: `Command is running in the background. You can proceed with other tasks.\n${logMsg}${outputMsg}`,
|
||||
completed: false,
|
||||
outputLines,
|
||||
}
|
||||
|
||||
// Send log file message to UI BEFORE resuming the process
|
||||
// This ensures the message appears before any new output lines
|
||||
if (trackingResult?.logFilePath) {
|
||||
await callbacks.say("command_output", `\n📋 Output is being logged to: ${trackingResult.logFilePath}`)
|
||||
}
|
||||
|
||||
// Now resume the process - any new lines will be handled by the background tracker
|
||||
process.continue()
|
||||
return
|
||||
}
|
||||
|
||||
process.continue()
|
||||
} else if (response === "noButtonClicked" && text === COMMAND_CANCEL_TOKEN) {
|
||||
telemetryService.captureTerminalUserIntervention(TerminalUserInterventionAction.CANCELLED)
|
||||
telemetryService.captureTerminalUserIntervention(TerminalUserInterventionAction.CANCELLED, terminalType)
|
||||
// Set flags BEFORE resuming the process to prevent new lines from being processed
|
||||
didCancelViaUi = true
|
||||
userFeedback = undefined
|
||||
didContinue = true
|
||||
process.continue()
|
||||
outputBuffer = []
|
||||
outputBufferSize = 0
|
||||
// Send cancellation message BEFORE resuming the process
|
||||
// This ensures the message appears before any new output lines
|
||||
await callbacks.say("command_output", "Command cancelled")
|
||||
// Now resume the process
|
||||
process.continue()
|
||||
} else {
|
||||
userFeedback = { text, images, files }
|
||||
didContinue = true
|
||||
@@ -162,31 +220,134 @@ export async function orchestrateCommandExecution(
|
||||
chunkTimer = setTimeout(async () => await flushBuffer(), CHUNK_DEBOUNCE_MS)
|
||||
}
|
||||
|
||||
// Large output file-based logging state
|
||||
let isWritingToFile = false
|
||||
let largeOutputLogPath: string | null = null
|
||||
let largeOutputLogStream: fs.WriteStream | null = null
|
||||
let totalOutputBytes = 0
|
||||
let totalLineCount = 0
|
||||
let firstLines: string[] = [] // Keep first N lines for summary
|
||||
let lastLines: string[] = [] // Keep last N lines for summary (circular buffer)
|
||||
|
||||
/**
|
||||
* Switch to file-based logging when output is too large.
|
||||
* This protects against memory exhaustion from commands with huge output.
|
||||
*/
|
||||
const switchToFileBased = async () => {
|
||||
if (isWritingToFile) return
|
||||
|
||||
isWritingToFile = true
|
||||
|
||||
// FIRST: Flush any pending buffer to UI so the "writing to file" message appears at the end
|
||||
if (outputBuffer.length > 0) {
|
||||
const chunk = outputBuffer.join("\n")
|
||||
outputBuffer = []
|
||||
outputBufferSize = 0
|
||||
if (!didContinue) {
|
||||
// Use say() instead of ask() since we're transitioning to file mode
|
||||
await callbacks.say("command_output", chunk)
|
||||
}
|
||||
}
|
||||
|
||||
// Clear any pending flush timer
|
||||
if (chunkTimer) {
|
||||
clearTimeout(chunkTimer)
|
||||
chunkTimer = null
|
||||
}
|
||||
|
||||
// Set up file logging
|
||||
largeOutputLogPath = path.join(os.tmpdir(), `cline-large-output-${Date.now()}.log`)
|
||||
largeOutputLogStream = fs.createWriteStream(largeOutputLogPath, { flags: "a" })
|
||||
|
||||
// Write all existing lines to file in a single batch to reduce I/O overhead
|
||||
if (outputLines.length > 0) {
|
||||
largeOutputLogStream.write(outputLines.join("\n") + "\n")
|
||||
}
|
||||
|
||||
// Keep first N lines for summary
|
||||
firstLines = outputLines.slice(0, SUMMARY_LINES_TO_KEEP)
|
||||
|
||||
// Keep last N lines for summary (will be updated as more lines come in)
|
||||
lastLines = outputLines.slice(-SUMMARY_LINES_TO_KEEP)
|
||||
|
||||
// FINALLY: Notify user (now this will appear at the end after all buffered output)
|
||||
await callbacks.say(
|
||||
"command_output",
|
||||
`\n📋 Output is large (${outputLines.length} lines, ${Math.round(totalOutputBytes / 1024)}KB). Writing to: ${largeOutputLogPath}`,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up file-based logging resources.
|
||||
*/
|
||||
const cleanupFileBased = () => {
|
||||
if (largeOutputLogStream) {
|
||||
largeOutputLogStream.end()
|
||||
largeOutputLogStream = null
|
||||
}
|
||||
}
|
||||
|
||||
const outputLines: string[] = []
|
||||
process.on("line", async (line: string) => {
|
||||
if (didCancelViaUi) {
|
||||
return
|
||||
}
|
||||
outputLines.push(line)
|
||||
|
||||
// If background tracking is active, don't process lines here
|
||||
// The background tracker's listener will handle them
|
||||
if (backgroundTrackingResult) {
|
||||
return
|
||||
}
|
||||
|
||||
const lineBytes = Buffer.byteLength(line, "utf8")
|
||||
totalOutputBytes += lineBytes
|
||||
totalLineCount++
|
||||
|
||||
// Check if we should switch to file-based logging
|
||||
if (!isWritingToFile && (outputLines.length >= MAX_LINES_BEFORE_FILE || totalOutputBytes >= MAX_BYTES_BEFORE_FILE)) {
|
||||
await switchToFileBased()
|
||||
}
|
||||
|
||||
if (isWritingToFile) {
|
||||
// Write to file instead of keeping in memory
|
||||
if (largeOutputLogStream) {
|
||||
largeOutputLogStream.write(line + "\n")
|
||||
}
|
||||
|
||||
// Update last lines circular buffer for summary
|
||||
lastLines.push(line)
|
||||
if (lastLines.length > SUMMARY_LINES_TO_KEEP) {
|
||||
lastLines.shift()
|
||||
}
|
||||
} else {
|
||||
// Normal behavior - keep in memory
|
||||
outputLines.push(line)
|
||||
}
|
||||
|
||||
// Notify caller about output line (for background command tracking)
|
||||
if (onOutputLine) {
|
||||
onOutputLine(line)
|
||||
}
|
||||
|
||||
// Apply buffered streaming
|
||||
// Apply buffered streaming (only if not in file mode or still showing initial output)
|
||||
if (!didContinue) {
|
||||
outputBuffer.push(line)
|
||||
outputBufferSize += Buffer.byteLength(line, "utf8")
|
||||
// Flush if buffer is large enough
|
||||
if (outputBuffer.length >= CHUNK_LINE_COUNT || outputBufferSize >= CHUNK_BYTE_SIZE) {
|
||||
await flushBuffer()
|
||||
} else {
|
||||
scheduleFlush()
|
||||
if (!isWritingToFile) {
|
||||
outputBuffer.push(line)
|
||||
outputBufferSize += lineBytes
|
||||
// Flush if buffer is large enough
|
||||
if (outputBuffer.length >= CHUNK_LINE_COUNT || outputBufferSize >= CHUNK_BYTE_SIZE) {
|
||||
await flushBuffer()
|
||||
} else {
|
||||
scheduleFlush()
|
||||
}
|
||||
}
|
||||
// When in file mode, we've already notified the user, so don't keep buffering
|
||||
} else {
|
||||
// After "Proceed While Running": stream output directly to UI
|
||||
await callbacks.say("command_output", line)
|
||||
// After "Proceed While Running" (without background tracking): stream output directly to UI
|
||||
// But throttle if we're in file mode to avoid flooding UI
|
||||
if (!isWritingToFile) {
|
||||
await callbacks.say("command_output", line)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -196,7 +357,7 @@ export async function orchestrateCommandExecution(
|
||||
// Start timer to detect if waiting for completion takes too long
|
||||
completionTimer = setTimeout(() => {
|
||||
if (!completed) {
|
||||
telemetryService.captureTerminalHang(TerminalHangStage.WAITING_FOR_COMPLETION)
|
||||
telemetryService.captureTerminalHang(TerminalHangStage.WAITING_FOR_COMPLETION, terminalType)
|
||||
completionTimer = null
|
||||
}
|
||||
}, COMPLETION_TIMEOUT_MS)
|
||||
@@ -241,9 +402,8 @@ export async function orchestrateCommandExecution(
|
||||
if (error.message === "COMMAND_TIMEOUT") {
|
||||
// Timeout triggers "Proceed While Running" behavior
|
||||
didContinue = true
|
||||
process.continue()
|
||||
|
||||
// Clear all our timers
|
||||
// Clear all our timers first
|
||||
if (chunkTimer) {
|
||||
clearTimeout(chunkTimer)
|
||||
chunkTimer = null
|
||||
@@ -253,6 +413,41 @@ export async function orchestrateCommandExecution(
|
||||
completionTimer = null
|
||||
}
|
||||
|
||||
// If background tracking is available (standalone mode only), use it
|
||||
// This writes output to a log file and detaches the command
|
||||
if (onProceedWhileRunning) {
|
||||
const trackingResult = onProceedWhileRunning(outputLines)
|
||||
|
||||
// Set early return result BEFORE resuming the process
|
||||
// This prevents the orchestrator's listener from processing new lines
|
||||
const result = terminalManager.processOutput(outputLines)
|
||||
const logMsg = trackingResult?.logFilePath ? `Log file: ${trackingResult.logFilePath}\n` : ""
|
||||
const outputMsg = result.length > 0 ? `Output so far:\n${result}` : ""
|
||||
|
||||
backgroundTrackingResult = {
|
||||
userRejected: false,
|
||||
result: `Command timed out after ${timeoutSeconds} seconds. Running in background.\n${logMsg}${outputMsg}`,
|
||||
completed: false,
|
||||
outputLines,
|
||||
}
|
||||
|
||||
// Send log file message to UI BEFORE resuming the process
|
||||
if (trackingResult?.logFilePath) {
|
||||
await callbacks.say(
|
||||
"command_output",
|
||||
`\n⏱️ Command timed out. Output is being logged to: ${trackingResult.logFilePath}`,
|
||||
)
|
||||
}
|
||||
|
||||
// Now resume the process - any new lines will be handled by the background tracker
|
||||
process.continue()
|
||||
return backgroundTrackingResult
|
||||
}
|
||||
|
||||
// VSCode terminal mode: no background tracking available
|
||||
// Just continue the process and return timeout result
|
||||
process.continue()
|
||||
|
||||
// Process any output we captured before timeout
|
||||
await setTimeoutPromise(50)
|
||||
const result = terminalManager.processOutput(outputLines)
|
||||
@@ -274,6 +469,12 @@ export async function orchestrateCommandExecution(
|
||||
}
|
||||
}
|
||||
|
||||
// Check if we returned early due to background tracking
|
||||
// This happens when user clicks "Proceed While Running" with background tracking enabled
|
||||
if (backgroundTrackingResult) {
|
||||
return backgroundTrackingResult
|
||||
}
|
||||
|
||||
// Clear timer if process completes normally
|
||||
if (completionTimer) {
|
||||
clearTimeout(completionTimer)
|
||||
@@ -283,7 +484,23 @@ export async function orchestrateCommandExecution(
|
||||
// Wait for a short delay to ensure all messages are sent to the webview
|
||||
await setTimeoutPromise(50)
|
||||
|
||||
const result = terminalManager.processOutput(outputLines)
|
||||
// Clean up file-based logging if active
|
||||
cleanupFileBased()
|
||||
|
||||
// Build result based on whether we used file-based logging
|
||||
let result: string
|
||||
let resultOutputLines: string[]
|
||||
|
||||
if (isWritingToFile) {
|
||||
// Build summary from first and last lines
|
||||
const skippedLines = totalLineCount - firstLines.length - lastLines.length
|
||||
const summaryLines = [...firstLines, `\n... (${skippedLines} lines written to ${largeOutputLogPath}) ...\n`, ...lastLines]
|
||||
result = terminalManager.processOutput(summaryLines)
|
||||
resultOutputLines = summaryLines
|
||||
} else {
|
||||
result = terminalManager.processOutput(outputLines)
|
||||
resultOutputLines = outputLines
|
||||
}
|
||||
|
||||
if (didCancelViaUi) {
|
||||
return {
|
||||
@@ -292,7 +509,8 @@ export async function orchestrateCommandExecution(
|
||||
`Command cancelled. ${result.length > 0 ? `\nOutput captured before cancellation:\n${result}` : ""}`,
|
||||
),
|
||||
completed: false,
|
||||
outputLines,
|
||||
outputLines: resultOutputLines,
|
||||
logFilePath: largeOutputLogPath || undefined,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -314,25 +532,30 @@ export async function orchestrateCommandExecution(
|
||||
fileContentString,
|
||||
),
|
||||
completed: false,
|
||||
outputLines,
|
||||
outputLines: resultOutputLines,
|
||||
logFilePath: largeOutputLogPath || undefined,
|
||||
}
|
||||
}
|
||||
|
||||
if (completed) {
|
||||
const logFileMsg = largeOutputLogPath ? `\nFull output saved to: ${largeOutputLogPath}` : ""
|
||||
return {
|
||||
userRejected: false,
|
||||
result: `Command executed.${result.length > 0 ? `\nOutput:\n${result}` : ""}`,
|
||||
result: `Command executed.${result.length > 0 ? `\nOutput:\n${result}` : ""}${logFileMsg}`,
|
||||
completed: true,
|
||||
outputLines,
|
||||
outputLines: resultOutputLines,
|
||||
logFilePath: largeOutputLogPath || undefined,
|
||||
}
|
||||
} else {
|
||||
const logFileMsg = largeOutputLogPath ? `\nFull output saved to: ${largeOutputLogPath}` : ""
|
||||
return {
|
||||
userRejected: false,
|
||||
result: `Command is still running in the user's terminal.${
|
||||
result.length > 0 ? `\nHere's the output so far:\n${result}` : ""
|
||||
}\n\nYou will be updated on the terminal status and new output in the future.`,
|
||||
}${logFileMsg}\n\nYou will be updated on the terminal status and new output in the future.`,
|
||||
completed: false,
|
||||
outputLines,
|
||||
outputLines: resultOutputLines,
|
||||
logFilePath: largeOutputLogPath || undefined,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* Terminal Constants
|
||||
*
|
||||
* Central location for all terminal-related constants.
|
||||
* This makes it easy to understand and tune terminal behavior.
|
||||
*/
|
||||
|
||||
// =============================================================================
|
||||
// Process "Hot" State Timeouts
|
||||
// =============================================================================
|
||||
// How long to wait after output before considering the process "cool"
|
||||
// This stalls API requests to let terminal output settle
|
||||
|
||||
/** Normal timeout after last output (2 seconds) */
|
||||
export const PROCESS_HOT_TIMEOUT_NORMAL = 2_000
|
||||
|
||||
/** Extended timeout for compilation/build commands (15 seconds) */
|
||||
export const PROCESS_HOT_TIMEOUT_COMPILING = 15_000
|
||||
|
||||
// =============================================================================
|
||||
// Output Buffering (CommandOrchestrator)
|
||||
// =============================================================================
|
||||
// Controls how output is chunked and sent to the UI
|
||||
|
||||
/** Lines to buffer before flushing to UI */
|
||||
export const CHUNK_LINE_COUNT = 20
|
||||
|
||||
/** Bytes to buffer before flushing to UI */
|
||||
export const CHUNK_BYTE_SIZE = 2048 // 2KB
|
||||
|
||||
/** Debounce time for buffer flush */
|
||||
export const CHUNK_DEBOUNCE_MS = 100
|
||||
|
||||
/** Timeout to detect stuck buffer */
|
||||
export const BUFFER_STUCK_TIMEOUT_MS = 6000 // 6 seconds
|
||||
|
||||
/** Timeout to detect stuck completion */
|
||||
export const COMPLETION_TIMEOUT_MS = 6000 // 6 seconds
|
||||
|
||||
// =============================================================================
|
||||
// Large Output Protection
|
||||
// =============================================================================
|
||||
// Prevents memory exhaustion and context window overflow
|
||||
|
||||
/** Switch to file-based logging after this many lines */
|
||||
export const MAX_LINES_BEFORE_FILE = 1000
|
||||
|
||||
/** Switch to file-based logging after this many bytes */
|
||||
export const MAX_BYTES_BEFORE_FILE = 512 * 1024 // 512KB
|
||||
|
||||
/** Lines to keep at start/end for summary when truncating */
|
||||
export const SUMMARY_LINES_TO_KEEP = 100
|
||||
|
||||
/** Maximum size for fullOutput storage (memory protection) */
|
||||
export const MAX_FULL_OUTPUT_SIZE = 1024 * 1024 // 1MB
|
||||
|
||||
/** Maximum lines to return from getUnretrievedOutput */
|
||||
export const MAX_UNRETRIEVED_LINES = 500
|
||||
|
||||
/** Lines to keep at start/end when truncating unretrieved output */
|
||||
export const TRUNCATE_KEEP_LINES = 100
|
||||
|
||||
// =============================================================================
|
||||
// Output Line Limits (processOutput)
|
||||
// =============================================================================
|
||||
// Controls truncation when returning output to AI
|
||||
|
||||
/** Default max lines for command output */
|
||||
export const DEFAULT_TERMINAL_OUTPUT_LINE_LIMIT = 500
|
||||
|
||||
/** Max lines for subagent commands (more context needed) */
|
||||
export const DEFAULT_SUBAGENT_TERMINAL_OUTPUT_LINE_LIMIT = 2000
|
||||
|
||||
// =============================================================================
|
||||
// Background Command Tracking
|
||||
// =============================================================================
|
||||
// Controls background command behavior for "Proceed While Running"
|
||||
|
||||
/** Hard timeout for background commands to prevent zombie processes (10 minutes) */
|
||||
export const BACKGROUND_COMMAND_TIMEOUT_MS = 10 * 60 * 1000
|
||||
|
||||
// =============================================================================
|
||||
// Compilation Detection Markers
|
||||
// =============================================================================
|
||||
// Used to detect if a command is compiling/building
|
||||
|
||||
/** Markers that indicate compilation is starting */
|
||||
export const COMPILING_MARKERS = ["compiling", "building", "bundling", "transpiling", "generating", "starting"]
|
||||
|
||||
/** Markers that indicate compilation is done (nullify extended timeout) */
|
||||
export const COMPILING_NULLIFIERS = [
|
||||
"compiled",
|
||||
"success",
|
||||
"finish",
|
||||
"complete",
|
||||
"succeed",
|
||||
"done",
|
||||
"end",
|
||||
"stop",
|
||||
"exit",
|
||||
"terminate",
|
||||
"error",
|
||||
"fail",
|
||||
]
|
||||
|
||||
/**
|
||||
* Check if terminal output indicates compilation/building.
|
||||
* Matches markers anywhere in the output.
|
||||
*/
|
||||
export function isCompilingOutput(data: string): boolean {
|
||||
const lowerData = data.toLowerCase()
|
||||
const hasMarker = COMPILING_MARKERS.some((marker) => lowerData.includes(marker.toLowerCase()))
|
||||
const hasNullifier = COMPILING_NULLIFIERS.some((nullifier) => lowerData.includes(nullifier.toLowerCase()))
|
||||
return hasMarker && !hasNullifier
|
||||
}
|
||||
@@ -23,17 +23,7 @@
|
||||
export { CommandExecutor } from "./CommandExecutor"
|
||||
|
||||
// Export command orchestrator (shared logic)
|
||||
export {
|
||||
BUFFER_STUCK_TIMEOUT_MS,
|
||||
CHUNK_BYTE_SIZE,
|
||||
CHUNK_DEBOUNCE_MS,
|
||||
CHUNK_LINE_COUNT,
|
||||
COMPLETION_TIMEOUT_MS,
|
||||
findLastIndex,
|
||||
orchestrateCommandExecution,
|
||||
} from "./CommandOrchestrator"
|
||||
|
||||
// Export terminal process interface
|
||||
export { findLastIndex, orchestrateCommandExecution } from "./CommandOrchestrator"
|
||||
|
||||
// Export standalone terminal implementations
|
||||
export { StandaloneTerminal } from "./standalone/StandaloneTerminal"
|
||||
|
||||
@@ -4,12 +4,29 @@
|
||||
* This class provides the same interface as VSCode's TerminalManager but works
|
||||
* in CLI and JetBrains environments by using subprocess management instead of
|
||||
* VSCode's terminal API.
|
||||
*
|
||||
* Also handles background command tracking for "Proceed While Running" functionality:
|
||||
* - Logs output to temp files for later retrieval
|
||||
* - Tracks command status (running, completed, error, timed_out)
|
||||
* - Implements 10-minute hard timeout to prevent zombie processes
|
||||
* - Provides summary for environment details
|
||||
*/
|
||||
|
||||
import type { ITerminalManager, TerminalInfo, TerminalProcessResultPromise } from "../types"
|
||||
import * as fs from "fs"
|
||||
import * as os from "os"
|
||||
import * as path from "path"
|
||||
import {
|
||||
BACKGROUND_COMMAND_TIMEOUT_MS,
|
||||
DEFAULT_SUBAGENT_TERMINAL_OUTPUT_LINE_LIMIT,
|
||||
DEFAULT_TERMINAL_OUTPUT_LINE_LIMIT,
|
||||
} from "../constants"
|
||||
import type { BackgroundCommand, ITerminalManager, TerminalInfo, TerminalProcessResultPromise } from "../types"
|
||||
import { StandaloneTerminalProcess } from "./StandaloneTerminalProcess"
|
||||
import { StandaloneTerminalRegistry } from "./StandaloneTerminalRegistry"
|
||||
|
||||
// Re-export BackgroundCommand for backwards compatibility
|
||||
export type { BackgroundCommand }
|
||||
|
||||
/**
|
||||
* Helper function to merge a process with a promise for the TerminalProcessResultPromise type.
|
||||
* This allows the returned object to be both awaitable and have event methods.
|
||||
@@ -63,14 +80,27 @@ export class StandaloneTerminalManager implements ITerminalManager {
|
||||
private terminalReuseEnabled: boolean = true
|
||||
|
||||
/** Maximum output lines to keep */
|
||||
private terminalOutputLineLimit: number = 500
|
||||
private terminalOutputLineLimit: number = DEFAULT_TERMINAL_OUTPUT_LINE_LIMIT
|
||||
|
||||
/** Maximum output lines for subagent commands */
|
||||
private subagentTerminalOutputLineLimit: number = 2000
|
||||
private subagentTerminalOutputLineLimit: number = DEFAULT_SUBAGENT_TERMINAL_OUTPUT_LINE_LIMIT
|
||||
|
||||
/** Default terminal profile */
|
||||
private defaultTerminalProfile: string = "default"
|
||||
|
||||
// =========================================================================
|
||||
// Background Command Tracking
|
||||
// =========================================================================
|
||||
|
||||
/** Map of background command ID to command info */
|
||||
private backgroundCommands: Map<string, BackgroundCommand> = new Map()
|
||||
|
||||
/** Map of background command ID to log file write stream */
|
||||
private logStreams: Map<string, fs.WriteStream> = new Map()
|
||||
|
||||
/** Map of background command ID to timeout handle */
|
||||
private backgroundTimeouts: Map<string, NodeJS.Timeout> = new Map()
|
||||
|
||||
/**
|
||||
* Run a command in the specified terminal.
|
||||
* @param terminalInfo The terminal to run the command in
|
||||
@@ -88,9 +118,8 @@ export class StandaloneTerminalManager implements ITerminalManager {
|
||||
terminalInfo.busy = false
|
||||
})
|
||||
|
||||
process.once("error", (error: Error) => {
|
||||
process.once("error", (_error: Error) => {
|
||||
terminalInfo.busy = false
|
||||
console.error(`[StandaloneTerminalManager] Command error on terminal ${terminalInfo.id}:`, error)
|
||||
})
|
||||
|
||||
// Create promise for the process
|
||||
@@ -138,7 +167,6 @@ export class StandaloneTerminalManager implements ITerminalManager {
|
||||
availableTerminal.terminal.shellIntegration.cwd.fsPath = cwd
|
||||
}
|
||||
this.terminalIds.add(availableTerminal.id)
|
||||
console.log(`[StandaloneTerminalManager] Reused terminal ${availableTerminal.id} with cd`)
|
||||
return availableTerminal
|
||||
}
|
||||
}
|
||||
@@ -158,10 +186,19 @@ export class StandaloneTerminalManager implements ITerminalManager {
|
||||
* @returns Array of terminal info with id and last command
|
||||
*/
|
||||
getTerminals(busy: boolean): { id: number; lastCommand: string }[] {
|
||||
return Array.from(this.terminalIds)
|
||||
const allTerminalIds = Array.from(this.terminalIds)
|
||||
|
||||
const terminals = allTerminalIds
|
||||
.map((id) => this.registry.getTerminal(id))
|
||||
.filter((t): t is TerminalInfo => t !== undefined && t.busy === busy)
|
||||
.filter((t): t is TerminalInfo => {
|
||||
if (t === undefined) {
|
||||
return false
|
||||
}
|
||||
return t.busy === busy
|
||||
})
|
||||
.map((t) => ({ id: t.id, lastCommand: t.lastCommand }))
|
||||
|
||||
return terminals
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -213,6 +250,9 @@ export class StandaloneTerminalManager implements ITerminalManager {
|
||||
* Dispose of all terminals and clean up resources.
|
||||
*/
|
||||
disposeAll(): void {
|
||||
// Dispose background commands first
|
||||
this.disposeBackgroundCommands()
|
||||
|
||||
// Terminate all processes
|
||||
for (const [_terminalId, process] of this.processes) {
|
||||
if (process && process.terminate) {
|
||||
@@ -369,4 +409,212 @@ export class StandaloneTerminalManager implements ITerminalManager {
|
||||
closeAllTerminals(): number {
|
||||
return this.closeTerminals(() => true, true)
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Background Command Tracking Methods
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Track a command that will continue running in the background.
|
||||
* Called when user clicks "Proceed While Running".
|
||||
* Creates a log file and pipes output to it.
|
||||
* Sets up a 10-minute hard timeout to prevent zombie processes.
|
||||
*
|
||||
* @param process The terminal process to track
|
||||
* @param command The command string being executed
|
||||
* @param existingOutput Output lines already captured before tracking started
|
||||
* @returns The background command info with log file path
|
||||
*/
|
||||
trackBackgroundCommand(
|
||||
process: TerminalProcessResultPromise,
|
||||
command: string,
|
||||
existingOutput: string[] = [],
|
||||
): BackgroundCommand {
|
||||
const id = `background-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
|
||||
const logFilePath = path.join(os.tmpdir(), `cline-${id}.log`)
|
||||
|
||||
const backgroundCommand: BackgroundCommand = {
|
||||
id,
|
||||
command,
|
||||
startTime: Date.now(),
|
||||
status: "running",
|
||||
logFilePath,
|
||||
lineCount: existingOutput.length,
|
||||
process,
|
||||
}
|
||||
|
||||
// Create write stream for log file
|
||||
const logStream = fs.createWriteStream(logFilePath, { flags: "a" })
|
||||
this.logStreams.set(id, logStream)
|
||||
|
||||
// Write existing output that was captured before tracking started
|
||||
if (existingOutput.length > 0) {
|
||||
logStream.write(existingOutput.join("\n") + "\n")
|
||||
}
|
||||
|
||||
// Pipe future process output to log file
|
||||
process.on("line", (line: string) => {
|
||||
backgroundCommand.lineCount++
|
||||
logStream.write(line + "\n")
|
||||
})
|
||||
|
||||
// Set up 10-minute hard timeout to prevent zombie processes
|
||||
const timeoutId = setTimeout(() => {
|
||||
if (backgroundCommand.status === "running") {
|
||||
backgroundCommand.status = "timed_out"
|
||||
logStream.write("\n[TIMEOUT] Process killed after 10 minutes\n")
|
||||
logStream.end()
|
||||
|
||||
// Terminate the process if it has a terminate method
|
||||
if (process && typeof (process as any).terminate === "function") {
|
||||
;(process as any).terminate()
|
||||
}
|
||||
}
|
||||
}, BACKGROUND_COMMAND_TIMEOUT_MS)
|
||||
this.backgroundTimeouts.set(id, timeoutId)
|
||||
|
||||
// Listen for completion - clear timeout
|
||||
process.on("completed", () => {
|
||||
// Guard: Skip if already handled by timeout
|
||||
if (backgroundCommand.status !== "running") {
|
||||
return
|
||||
}
|
||||
const timeout = this.backgroundTimeouts.get(id)
|
||||
if (timeout) {
|
||||
clearTimeout(timeout)
|
||||
this.backgroundTimeouts.delete(id)
|
||||
}
|
||||
backgroundCommand.status = "completed"
|
||||
logStream.end()
|
||||
})
|
||||
|
||||
// Listen for errors - clear timeout
|
||||
process.on("error", (error: Error) => {
|
||||
// Guard: Skip if already handled by timeout
|
||||
if (backgroundCommand.status !== "running") {
|
||||
return
|
||||
}
|
||||
const timeout = this.backgroundTimeouts.get(id)
|
||||
if (timeout) {
|
||||
clearTimeout(timeout)
|
||||
this.backgroundTimeouts.delete(id)
|
||||
}
|
||||
backgroundCommand.status = "error"
|
||||
// Try to extract exit code from error message if available
|
||||
const exitCodeMatch = error.message.match(/exit code (\d+)/)
|
||||
if (exitCodeMatch) {
|
||||
backgroundCommand.exitCode = parseInt(exitCodeMatch[1], 10)
|
||||
}
|
||||
logStream.end()
|
||||
})
|
||||
|
||||
this.backgroundCommands.set(id, backgroundCommand)
|
||||
return backgroundCommand
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific background command by ID.
|
||||
*/
|
||||
getBackgroundCommand(id: string): BackgroundCommand | undefined {
|
||||
return this.backgroundCommands.get(id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all tracked background commands.
|
||||
*/
|
||||
getAllBackgroundCommands(): BackgroundCommand[] {
|
||||
return Array.from(this.backgroundCommands.values())
|
||||
}
|
||||
|
||||
/**
|
||||
* Get only running background commands.
|
||||
*/
|
||||
getRunningBackgroundCommands(): BackgroundCommand[] {
|
||||
return this.getAllBackgroundCommands().filter((c) => c.status === "running")
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if there are any active background commands.
|
||||
*/
|
||||
hasActiveBackgroundCommands(): boolean {
|
||||
return this.getRunningBackgroundCommands().length > 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel/terminate a specific background command.
|
||||
* @param id The background command ID to cancel
|
||||
* @returns true if cancelled, false if not found or already completed
|
||||
*/
|
||||
cancelBackgroundCommand(id: string): boolean {
|
||||
const command = this.backgroundCommands.get(id)
|
||||
if (!command || command.status !== "running") {
|
||||
return false
|
||||
}
|
||||
|
||||
// Clear timeout
|
||||
const timeout = this.backgroundTimeouts.get(id)
|
||||
if (timeout) {
|
||||
clearTimeout(timeout)
|
||||
this.backgroundTimeouts.delete(id)
|
||||
}
|
||||
|
||||
// Close log stream
|
||||
const logStream = this.logStreams.get(id)
|
||||
if (logStream) {
|
||||
logStream.write("\n[CANCELLED] Command cancelled by user\n")
|
||||
logStream.end()
|
||||
this.logStreams.delete(id)
|
||||
}
|
||||
|
||||
// Terminate process
|
||||
if (command.process && typeof (command.process as any).terminate === "function") {
|
||||
;(command.process as any).terminate()
|
||||
}
|
||||
|
||||
command.status = "error"
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a summary string for environment details.
|
||||
* Shows running background commands with duration, line count, and log paths.
|
||||
*/
|
||||
getBackgroundCommandsSummary(): string {
|
||||
const running = this.getRunningBackgroundCommands()
|
||||
if (running.length === 0) {
|
||||
return ""
|
||||
}
|
||||
|
||||
const lines = [`# Background Commands (${running.length} running)`]
|
||||
for (const c of running) {
|
||||
const duration = Math.round((Date.now() - c.startTime) / 1000 / 60)
|
||||
lines.push(`- ${c.command} (running ${duration}m, ${c.lineCount} lines, log: ${c.logFilePath})`)
|
||||
}
|
||||
return lines.join("\n")
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up all background command resources.
|
||||
* Called when disposing the manager.
|
||||
*/
|
||||
disposeBackgroundCommands(): void {
|
||||
// Clear all timeouts
|
||||
for (const [_id, timeout] of this.backgroundTimeouts) {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
this.backgroundTimeouts.clear()
|
||||
|
||||
// Close all log streams
|
||||
for (const [_id, logStream] of this.logStreams) {
|
||||
try {
|
||||
logStream.end()
|
||||
} catch (_error) {
|
||||
// Ignore errors when closing log streams
|
||||
}
|
||||
}
|
||||
this.logStreams.clear()
|
||||
|
||||
// Clear command tracking
|
||||
this.backgroundCommands.clear()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,9 +8,19 @@
|
||||
* Implements ITerminalProcess interface for polymorphic usage with CommandExecutor.
|
||||
*/
|
||||
|
||||
import { telemetryService } from "@services/telemetry"
|
||||
import { ChildProcess, spawn } from "child_process"
|
||||
import { EventEmitter } from "events"
|
||||
import { terminateProcessTree } from "@/utils/process-termination"
|
||||
|
||||
import {
|
||||
isCompilingOutput,
|
||||
MAX_FULL_OUTPUT_SIZE,
|
||||
MAX_UNRETRIEVED_LINES,
|
||||
PROCESS_HOT_TIMEOUT_COMPILING,
|
||||
PROCESS_HOT_TIMEOUT_NORMAL,
|
||||
TRUNCATE_KEEP_LINES,
|
||||
} from "../constants"
|
||||
import type { ITerminal, ITerminalProcess, TerminalProcessEvents } from "../types"
|
||||
|
||||
/**
|
||||
@@ -67,8 +77,6 @@ export class StandaloneTerminalProcess extends EventEmitter<TerminalProcessEvent
|
||||
* @param command The command to execute
|
||||
*/
|
||||
async run(terminal: ITerminal, command: string): Promise<void> {
|
||||
console.log(`[StandaloneTerminal] Running command: ${command}`)
|
||||
|
||||
// Get shell and working directory from terminal
|
||||
const shell = (terminal as any)._shellPath || this.getDefaultShell()
|
||||
const cwd = (terminal as any)._cwd || process.cwd()
|
||||
@@ -104,8 +112,12 @@ export class StandaloneTerminalProcess extends EventEmitter<TerminalProcessEvent
|
||||
// Spawn the process with special handling for "cmd.exe"
|
||||
this.childProcess = spawn("cmd.exe", shellArgs, shellOptions)
|
||||
} else {
|
||||
// Spawn the process
|
||||
this.childProcess = spawn(shell, shellArgs, shellOptions)
|
||||
// Spawn the process with detached: true to create a process group
|
||||
// This allows us to kill the entire process tree when terminating
|
||||
this.childProcess = spawn(shell, shellArgs, {
|
||||
...shellOptions,
|
||||
detached: true,
|
||||
})
|
||||
}
|
||||
|
||||
// Track process state
|
||||
@@ -132,8 +144,7 @@ export class StandaloneTerminalProcess extends EventEmitter<TerminalProcessEvent
|
||||
})
|
||||
|
||||
// Handle process completion
|
||||
this.childProcess.on("close", (code: number | null, signal: NodeJS.Signals | null) => {
|
||||
console.log(`[StandaloneTerminal] Process closed with code ${code}, signal ${signal}`)
|
||||
this.childProcess.on("close", (code: number | null, _signal: NodeJS.Signals | null) => {
|
||||
this.exitCode = code
|
||||
this.isCompleted = true
|
||||
this.emitRemainingBuffer()
|
||||
@@ -144,13 +155,18 @@ export class StandaloneTerminalProcess extends EventEmitter<TerminalProcessEvent
|
||||
this.isHot = false
|
||||
}
|
||||
|
||||
// Track terminal execution telemetry
|
||||
const success = code === 0 || code === null
|
||||
telemetryService.captureTerminalExecution(success, "standalone", "child_process")
|
||||
|
||||
this.emit("completed")
|
||||
this.emit("continue")
|
||||
})
|
||||
|
||||
// Handle process errors
|
||||
this.childProcess.on("error", (error: Error) => {
|
||||
console.error(`[StandaloneTerminal] Process error:`, error)
|
||||
// Track terminal execution error telemetry
|
||||
telemetryService.captureTerminalExecution(false, "standalone", "child_process_error")
|
||||
this.emit("error", error)
|
||||
})
|
||||
|
||||
@@ -158,7 +174,6 @@ export class StandaloneTerminalProcess extends EventEmitter<TerminalProcessEvent
|
||||
;(terminal as any)._process = this.childProcess
|
||||
;(terminal as any)._processId = this.childProcess.pid
|
||||
} catch (error) {
|
||||
console.error(`[StandaloneTerminal] Failed to spawn process:`, error)
|
||||
this.emit("error", error)
|
||||
}
|
||||
}
|
||||
@@ -176,37 +191,25 @@ export class StandaloneTerminalProcess extends EventEmitter<TerminalProcessEvent
|
||||
}
|
||||
|
||||
// Check for compilation markers to adjust hot timeout
|
||||
const compilingMarkers = ["compiling", "building", "bundling", "transpiling", "generating", "starting"]
|
||||
const markerNullifiers = [
|
||||
"compiled",
|
||||
"success",
|
||||
"finish",
|
||||
"complete",
|
||||
"succeed",
|
||||
"done",
|
||||
"end",
|
||||
"stop",
|
||||
"exit",
|
||||
"terminate",
|
||||
"error",
|
||||
"fail",
|
||||
]
|
||||
|
||||
const isCompiling =
|
||||
compilingMarkers.some((marker) => data.toLowerCase().includes(marker.toLowerCase())) &&
|
||||
!markerNullifiers.some((nullifier) => data.toLowerCase().includes(nullifier.toLowerCase()))
|
||||
|
||||
const hotTimeout = isCompiling ? 15000 : 2000
|
||||
const isCompiling = isCompilingOutput(data)
|
||||
const hotTimeout = isCompiling ? PROCESS_HOT_TIMEOUT_COMPILING : PROCESS_HOT_TIMEOUT_NORMAL
|
||||
this.hotTimer = setTimeout(() => {
|
||||
this.isHot = false
|
||||
}, hotTimeout)
|
||||
|
||||
// Store full output
|
||||
// Store full output with size cap to prevent memory exhaustion
|
||||
this.fullOutput += data
|
||||
|
||||
// Cap fullOutput at MAX_FULL_OUTPUT_SIZE to prevent memory exhaustion
|
||||
if (this.fullOutput.length > MAX_FULL_OUTPUT_SIZE) {
|
||||
// Keep last half of max size
|
||||
this.fullOutput = this.fullOutput.slice(-MAX_FULL_OUTPUT_SIZE / 2)
|
||||
// Reset lastRetrievedIndex since we truncated the beginning
|
||||
this.lastRetrievedIndex = 0
|
||||
}
|
||||
|
||||
if (this.isListening) {
|
||||
this.emitLines(data)
|
||||
this.lastRetrievedIndex = this.fullOutput.length - this.buffer.length
|
||||
}
|
||||
}
|
||||
|
||||
@@ -240,22 +243,37 @@ export class StandaloneTerminalProcess extends EventEmitter<TerminalProcessEvent
|
||||
|
||||
/**
|
||||
* Continue execution without waiting for completion.
|
||||
* Stops event emission and resolves the promise.
|
||||
* Emits "continue" event but keeps emitting "line" events for background tracking.
|
||||
*
|
||||
* Note: We intentionally do NOT call removeAllListeners("line") or set isListening=false
|
||||
* because background command tracking needs to continue receiving output lines
|
||||
* after the user clicks "Proceed While Running".
|
||||
*/
|
||||
continue(): void {
|
||||
this.emitRemainingBuffer()
|
||||
this.isListening = false
|
||||
this.removeAllListeners("line")
|
||||
// Keep isListening = true so we continue emitting "line" events
|
||||
// This is needed for background command tracking to log output to file
|
||||
this.emit("continue")
|
||||
}
|
||||
|
||||
/**
|
||||
* Get output that hasn't been retrieved yet.
|
||||
* @returns The unretrieved output
|
||||
* Truncates if output is too large to prevent context window overflow.
|
||||
* @returns The unretrieved output (truncated if necessary)
|
||||
*/
|
||||
getUnretrievedOutput(): string {
|
||||
const unretrieved = this.fullOutput.slice(this.lastRetrievedIndex)
|
||||
this.lastRetrievedIndex = this.fullOutput.length
|
||||
|
||||
// Truncate if too many lines to prevent context overflow
|
||||
const lines = unretrieved.split("\n")
|
||||
if (lines.length > MAX_UNRETRIEVED_LINES) {
|
||||
const first = lines.slice(0, TRUNCATE_KEEP_LINES)
|
||||
const last = lines.slice(-TRUNCATE_KEEP_LINES)
|
||||
const skipped = lines.length - first.length - last.length
|
||||
return this.removeLastLineArtifacts([...first, `\n... (${skipped} lines truncated) ...\n`, ...last].join("\n"))
|
||||
}
|
||||
|
||||
return this.removeLastLineArtifacts(unretrieved)
|
||||
}
|
||||
|
||||
@@ -305,41 +323,29 @@ export class StandaloneTerminalProcess extends EventEmitter<TerminalProcessEvent
|
||||
}
|
||||
|
||||
/**
|
||||
* Terminate the process if it's still running.
|
||||
* Terminate the process and all its children.
|
||||
*
|
||||
* Uses terminateProcessTree utility which handles:
|
||||
* - Cross-platform process tree termination via tree-kill
|
||||
* - Graceful shutdown with SIGTERM
|
||||
* - SIGKILL fallback after 2 second timeout
|
||||
*/
|
||||
terminate(): void {
|
||||
async terminate(): Promise<void> {
|
||||
if (!this.childProcess || this.isCompleted) {
|
||||
console.log(`[StandaloneTerminal] Process already completed or doesn't exist, skipping termination`)
|
||||
return
|
||||
}
|
||||
|
||||
const pid = this.childProcess.pid
|
||||
console.log(`[StandaloneTerminal] Terminating process ${pid} with SIGTERM`)
|
||||
|
||||
try {
|
||||
if (!pid) {
|
||||
// Fallback: try to kill the process directly if PID is unavailable
|
||||
this.childProcess.kill("SIGTERM")
|
||||
|
||||
// Force kill after timeout if process doesn't exit gracefully
|
||||
setTimeout(() => {
|
||||
if (!this.isCompleted && this.childProcess) {
|
||||
console.log(`[StandaloneTerminal] Process ${pid} did not exit gracefully, force killing with SIGKILL`)
|
||||
try {
|
||||
this.childProcess.kill("SIGKILL")
|
||||
} catch (killError) {
|
||||
console.error(`[StandaloneTerminal] Failed to force kill process ${pid}:`, killError)
|
||||
}
|
||||
} else {
|
||||
console.log(`[StandaloneTerminal] Process ${pid} exited gracefully`)
|
||||
}
|
||||
}, 5000)
|
||||
} catch (error) {
|
||||
console.error(`[StandaloneTerminal] Failed to send SIGTERM to process ${pid}:`, error)
|
||||
// Try SIGKILL immediately if SIGTERM fails
|
||||
try {
|
||||
this.childProcess.kill("SIGKILL")
|
||||
} catch (killError) {
|
||||
console.error(`[StandaloneTerminal] Failed to send SIGKILL to process ${pid}:`, killError)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
await terminateProcessTree({
|
||||
pid,
|
||||
childProcess: this.childProcess,
|
||||
isCompleted: () => this.isCompleted,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,8 +62,10 @@ export interface ITerminalProcess extends EventEmitter<TerminalProcessEvents> {
|
||||
* Terminate the process if it's still running.
|
||||
* Only available for standalone processes (child_process).
|
||||
* VSCode terminal processes cannot be terminated via this interface.
|
||||
*
|
||||
* May be async to allow for graceful shutdown with SIGKILL fallback.
|
||||
*/
|
||||
terminate?(): void
|
||||
terminate?(): void | Promise<void>
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
@@ -243,12 +245,51 @@ export interface StandaloneTerminalOptions {
|
||||
shellPath?: string
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Background Command Types
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Represents a command running in the background after user clicked "Proceed While Running".
|
||||
* Used by StandaloneTerminalManager to track background commands.
|
||||
*/
|
||||
export interface BackgroundCommand {
|
||||
/** Unique identifier for the background command */
|
||||
id: string
|
||||
/** The command string being executed */
|
||||
command: string
|
||||
/** Timestamp when the command started */
|
||||
startTime: number
|
||||
/** Current status of the command */
|
||||
status: "running" | "completed" | "error" | "timed_out"
|
||||
/** Path to the log file where output is being written */
|
||||
logFilePath: string
|
||||
/** Number of lines written to the log file */
|
||||
lineCount: number
|
||||
/** Exit code if the command completed or errored */
|
||||
exitCode?: number
|
||||
/** The terminal process running the command */
|
||||
process: TerminalProcessResultPromise
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Command Executor Types
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Tracker for shell integration warnings to determine when to show background terminal suggestion.
|
||||
* Used internally by CommandExecutor to track warning frequency.
|
||||
*/
|
||||
export interface ShellIntegrationWarningTracker {
|
||||
/** Timestamps of recent shell integration warnings */
|
||||
timestamps: number[]
|
||||
/** Timestamp when the suggestion was last shown */
|
||||
lastSuggestionShown?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents an active background command that can be cancelled
|
||||
* @deprecated Use BackgroundCommand instead
|
||||
*/
|
||||
export interface ActiveBackgroundCommand {
|
||||
process: {
|
||||
@@ -327,6 +368,18 @@ export interface OrchestrationOptions {
|
||||
onOutputLine?: (line: string) => void
|
||||
/** Whether to show shell integration warning with suggestion */
|
||||
showShellIntegrationSuggestion?: boolean
|
||||
/**
|
||||
* Callback invoked when user clicks "Proceed While Running".
|
||||
* Used to start background command tracking in the terminal manager.
|
||||
* @param existingOutput The output lines captured so far (to write to log file)
|
||||
* @returns The log file path if tracking was started, undefined otherwise
|
||||
*/
|
||||
onProceedWhileRunning?: (existingOutput: string[]) => { logFilePath: string } | undefined
|
||||
/**
|
||||
* The type of terminal being used for telemetry tracking.
|
||||
* Defaults to "vscode" for backward compatibility.
|
||||
*/
|
||||
terminalType?: "vscode" | "standalone"
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -341,4 +394,6 @@ export interface OrchestrationResult {
|
||||
completed: boolean
|
||||
/** All output lines captured */
|
||||
outputLines: string[]
|
||||
/** Path to log file if output was too large and written to file */
|
||||
logFilePath?: string
|
||||
}
|
||||
|
||||
@@ -126,13 +126,13 @@ describe("BannerService", () => {
|
||||
})
|
||||
|
||||
describe("API Provider Rule Evaluation (Client-Side)", () => {
|
||||
it("should show banner when user has the required API provider configured", async () => {
|
||||
it("should show banner when user has selected the required API provider in act mode", async () => {
|
||||
const controllerWithOpenAI: Partial<Controller> = {
|
||||
stateManager: {
|
||||
getApiConfiguration: () => ({
|
||||
openAiApiKey: "sk-test-key",
|
||||
actModeApiProvider: "openai",
|
||||
}),
|
||||
getGlobalSettingsKey: () => undefined,
|
||||
getGlobalSettingsKey: (key: string) => (key === "mode" ? "act" : undefined),
|
||||
getGlobalStateKey: () => [],
|
||||
} as any,
|
||||
}
|
||||
@@ -164,19 +164,57 @@ describe("BannerService", () => {
|
||||
expect(banners[0].id).to.equal("bnr_openai")
|
||||
})
|
||||
|
||||
it("should NOT show banner when user doesn't have the required API provider", async () => {
|
||||
const controllerWithoutOpenAI: Partial<Controller> = {
|
||||
it("should show banner when user has selected the required API provider in plan mode", async () => {
|
||||
const controllerWithAnthropic: Partial<Controller> = {
|
||||
stateManager: {
|
||||
getApiConfiguration: () => ({
|
||||
apiKey: "sk-ant-test", // Has Anthropic key but not OpenAI
|
||||
planModeApiProvider: "anthropic",
|
||||
}),
|
||||
getGlobalSettingsKey: () => undefined,
|
||||
getGlobalSettingsKey: (key: string) => (key === "mode" ? "plan" : undefined),
|
||||
getGlobalStateKey: () => [],
|
||||
} as any,
|
||||
}
|
||||
// Reinitialize with new controller
|
||||
BannerService.reset()
|
||||
bannerService = BannerService.initialize(controllerWithoutOpenAI as Controller)
|
||||
bannerService = BannerService.initialize(controllerWithAnthropic as Controller)
|
||||
|
||||
const mockResponse = {
|
||||
data: {
|
||||
data: {
|
||||
items: [
|
||||
{
|
||||
id: "bnr_anthropic",
|
||||
titleMd: "Anthropic Users",
|
||||
bodyMd: "For Anthropic API",
|
||||
severity: "info" as const,
|
||||
placement: "top" as const,
|
||||
rulesJson: JSON.stringify({ providers: ["anthropic"] } as BannerRules),
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
axiosGetStub.resolves(mockResponse)
|
||||
const banners = await bannerService.fetchActiveBanners()
|
||||
|
||||
expect(banners).to.have.lengthOf(1)
|
||||
expect(banners[0].id).to.equal("bnr_anthropic")
|
||||
})
|
||||
|
||||
it("should NOT show banner when user has selected a different API provider", async () => {
|
||||
const controllerWithAnthropic: Partial<Controller> = {
|
||||
stateManager: {
|
||||
getApiConfiguration: () => ({
|
||||
actModeApiProvider: "anthropic",
|
||||
}),
|
||||
getGlobalSettingsKey: (key: string) => (key === "mode" ? "act" : undefined),
|
||||
getGlobalStateKey: () => [],
|
||||
} as any,
|
||||
}
|
||||
// Reinitialize with new controller
|
||||
BannerService.reset()
|
||||
bannerService = BannerService.initialize(controllerWithAnthropic as Controller)
|
||||
|
||||
const mockResponse = {
|
||||
data: {
|
||||
@@ -201,13 +239,13 @@ describe("BannerService", () => {
|
||||
expect(banners).to.have.lengthOf(0)
|
||||
})
|
||||
|
||||
it("should show banner if user has ANY of multiple specified providers", async () => {
|
||||
it("should show banner if user has selected ANY of multiple specified providers", async () => {
|
||||
const controllerWithAnthropic: Partial<Controller> = {
|
||||
stateManager: {
|
||||
getApiConfiguration: () => ({
|
||||
apiKey: "sk-ant-test", // Has Anthropic key
|
||||
actModeApiProvider: "anthropic",
|
||||
}),
|
||||
getGlobalSettingsKey: () => undefined,
|
||||
getGlobalSettingsKey: (key: string) => (key === "mode" ? "act" : undefined),
|
||||
getGlobalStateKey: () => [],
|
||||
} as any,
|
||||
}
|
||||
@@ -238,6 +276,41 @@ describe("BannerService", () => {
|
||||
expect(banners).to.have.lengthOf(1)
|
||||
expect(banners[0].id).to.equal("bnr_multi")
|
||||
})
|
||||
|
||||
it("should NOT show banner when no provider is selected", async () => {
|
||||
const controllerWithNoProvider: Partial<Controller> = {
|
||||
stateManager: {
|
||||
getApiConfiguration: () => ({}),
|
||||
getGlobalSettingsKey: (key: string) => (key === "mode" ? "act" : undefined),
|
||||
getGlobalStateKey: () => [],
|
||||
} as any,
|
||||
}
|
||||
// Reinitialize with new controller
|
||||
BannerService.reset()
|
||||
bannerService = BannerService.initialize(controllerWithNoProvider as Controller)
|
||||
|
||||
const mockResponse = {
|
||||
data: {
|
||||
data: {
|
||||
items: [
|
||||
{
|
||||
id: "bnr_openai",
|
||||
titleMd: "OpenAI Users",
|
||||
bodyMd: "For OpenAI API",
|
||||
severity: "info" as const,
|
||||
placement: "top" as const,
|
||||
rulesJson: JSON.stringify({ providers: ["openai"] } as BannerRules),
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
axiosGetStub.resolves(mockResponse)
|
||||
const banners = await bannerService.fetchActiveBanners()
|
||||
|
||||
expect(banners).to.have.lengthOf(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe("Invalid or No Banner Rules", () => {
|
||||
|
||||
@@ -173,51 +173,40 @@ export class BannerService {
|
||||
}
|
||||
|
||||
const apiConfiguration = this._controller.stateManager.getApiConfiguration()
|
||||
const hasAnyProvider = rules.providers.some((provider) => {
|
||||
const currentMode = this._controller.stateManager.getGlobalSettingsKey("mode")
|
||||
const selectedProvider =
|
||||
currentMode === "plan" ? apiConfiguration?.planModeApiProvider : apiConfiguration?.actModeApiProvider
|
||||
|
||||
if (!selectedProvider) {
|
||||
Logger.log(`BannerService: Banner ${banner.id} filtered by client - no provider selected for ${currentMode} mode`)
|
||||
return false
|
||||
}
|
||||
|
||||
const hasMatchingProvider = rules.providers.some((provider) => {
|
||||
// Normalize provider names for comparison
|
||||
switch (provider) {
|
||||
case "anthropic":
|
||||
case "claude-code":
|
||||
return !!apiConfiguration?.apiKey
|
||||
return selectedProvider === "anthropic"
|
||||
case "openai":
|
||||
case "openai-native":
|
||||
return !!apiConfiguration?.openAiApiKey || !!apiConfiguration?.openAiNativeApiKey
|
||||
case "openrouter":
|
||||
return !!apiConfiguration?.openRouterApiKey
|
||||
case "bedrock":
|
||||
return !!apiConfiguration?.awsAccessKey || !!apiConfiguration?.awsBedrockApiKey
|
||||
case "gemini":
|
||||
return !!apiConfiguration?.geminiApiKey
|
||||
case "deepseek":
|
||||
return !!apiConfiguration?.deepSeekApiKey
|
||||
return selectedProvider === "openai" || selectedProvider === "openai-native"
|
||||
case "qwen":
|
||||
case "qwen-code":
|
||||
return !!apiConfiguration?.qwenApiKey
|
||||
case "mistral":
|
||||
return !!apiConfiguration?.mistralApiKey
|
||||
case "ollama":
|
||||
return !!apiConfiguration?.ollamaApiKey
|
||||
case "xai":
|
||||
return !!apiConfiguration?.xaiApiKey
|
||||
case "cerebras":
|
||||
return !!apiConfiguration?.cerebrasApiKey
|
||||
case "groq":
|
||||
return !!apiConfiguration?.groqApiKey
|
||||
case "cline":
|
||||
return (
|
||||
apiConfiguration?.planModeApiProvider === "cline" || apiConfiguration?.actModeApiProvider === "cline"
|
||||
)
|
||||
return selectedProvider === "qwen"
|
||||
default:
|
||||
return false
|
||||
// For any other providers, do a direct string comparison
|
||||
return selectedProvider === provider
|
||||
}
|
||||
})
|
||||
|
||||
if (!hasAnyProvider) {
|
||||
if (!hasMatchingProvider) {
|
||||
Logger.log(
|
||||
`BannerService: Banner ${banner.id} filtered by client - user doesn't have any of these providers configured: ${rules.providers.join(", ")}`,
|
||||
`BannerService: Banner ${banner.id} filtered by client - selected provider '${selectedProvider}' doesn't match any of these required providers: ${rules.providers.join(", ")}`,
|
||||
)
|
||||
}
|
||||
|
||||
return hasAnyProvider
|
||||
return hasMatchingProvider
|
||||
} catch (error) {
|
||||
Logger.log(
|
||||
`BannerService: Error parsing provider rules for banner ${banner.id}: ${error instanceof Error ? error.message : String(error)}`,
|
||||
|
||||
@@ -11,7 +11,6 @@ import * as path from "path"
|
||||
// @ts-ignore
|
||||
import type { ConsoleMessage, ScreenshotOptions } from "puppeteer-core"
|
||||
import { Browser, connect, launch, Page, TimeoutError } from "puppeteer-core"
|
||||
import * as vscode from "vscode"
|
||||
import { StateManager } from "@/core/storage/StateManager"
|
||||
import { telemetryService } from "@/services/telemetry"
|
||||
import { discoverChromeInstances, isPortOpen, testBrowserConnection } from "./BrowserDiscovery"
|
||||
@@ -73,24 +72,9 @@ export class BrowserSession {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrates the chromeExecutablePath setting from VSCode configuration to browserSettings
|
||||
*/
|
||||
private async migrateChromeExecutablePathSetting(): Promise<void> {
|
||||
const config = vscode.workspace.getConfiguration("cline")
|
||||
const configPath = vscode.workspace.getConfiguration("cline").get<string>("chromeExecutablePath")
|
||||
|
||||
if (configPath !== undefined) {
|
||||
this.stateManager.getGlobalSettingsKey("browserSettings").chromeExecutablePath = configPath
|
||||
// Remove from VSCode configuration
|
||||
await config.update("chromeExecutablePath", undefined, true)
|
||||
}
|
||||
}
|
||||
|
||||
async getDetectedChromePath(): Promise<{ path: string; isBundled: boolean }> {
|
||||
// First check browserSettings (from UI, stored in global state)
|
||||
const browserSettings = this.stateManager.getGlobalSettingsKey("browserSettings")
|
||||
await this.migrateChromeExecutablePathSetting()
|
||||
if (browserSettings.chromeExecutablePath && (await fileExistsAtPath(browserSettings.chromeExecutablePath))) {
|
||||
return {
|
||||
path: browserSettings.chromeExecutablePath,
|
||||
|
||||
@@ -1,5 +1,20 @@
|
||||
import { ErrorSettings } from "./providers/IErrorProvider"
|
||||
|
||||
export { ClineError, ClineErrorType } from "./ClineError"
|
||||
export { type ErrorProviderConfig, ErrorProviderFactory, type ErrorProviderType } from "./ErrorProviderFactory"
|
||||
export { ErrorService } from "./ErrorService"
|
||||
export type { ErrorSettings, IErrorProvider } from "./providers/IErrorProvider"
|
||||
export { PostHogErrorProvider } from "./providers/PostHogErrorProvider"
|
||||
|
||||
export function getErrorLevelFromString(level: string | undefined): ErrorSettings["level"] {
|
||||
switch (level) {
|
||||
case "disabled":
|
||||
case "off":
|
||||
return "off"
|
||||
case "error":
|
||||
case "crash":
|
||||
return "error"
|
||||
default:
|
||||
return "all"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { PostHog } from "posthog-node"
|
||||
import * as vscode from "vscode"
|
||||
import { HostProvider } from "@/hosts/host-provider"
|
||||
import { getDistinctId } from "@/services/logging/distinctId"
|
||||
import { PostHogClientProvider } from "@/services/telemetry/providers/posthog/PostHogClientProvider"
|
||||
import { Setting } from "@/shared/proto/index.host"
|
||||
import * as pkg from "../../../../package.json"
|
||||
import { PostHogClientValidConfig } from "../../../shared/services/config/posthog-config"
|
||||
import { getErrorLevelFromString } from ".."
|
||||
import { ClineError } from "../ClineError"
|
||||
import type { ErrorSettings, IErrorProvider } from "./IErrorProvider"
|
||||
|
||||
@@ -53,13 +53,8 @@ export class PostHogErrorProvider implements IErrorProvider {
|
||||
this.errorSettings.hostEnabled = false
|
||||
}
|
||||
|
||||
// Check extension-specific telemetry setting
|
||||
const config = vscode.workspace.getConfiguration("cline")
|
||||
if (config.get("telemetrySetting") === "disabled") {
|
||||
this.errorSettings.enabled = false
|
||||
}
|
||||
this.errorSettings.level = getErrorLevelFromString(hostSettings.errorLevel)
|
||||
|
||||
this.errorSettings.level = await this.getErrorLevel()
|
||||
return this
|
||||
}
|
||||
|
||||
@@ -134,15 +129,6 @@ export class PostHogErrorProvider implements IErrorProvider {
|
||||
return { ...this.errorSettings }
|
||||
}
|
||||
|
||||
private async getErrorLevel(): Promise<ErrorSettings["level"]> {
|
||||
const hostSettings = await HostProvider.env.getTelemetrySettings({})
|
||||
if (hostSettings.isEnabled === Setting.DISABLED) {
|
||||
return "off"
|
||||
}
|
||||
const config = vscode.workspace.getConfiguration("telemetry")
|
||||
return config?.get<ErrorSettings["level"]>("telemetryLevel") || "all"
|
||||
}
|
||||
|
||||
private get distinctId(): string {
|
||||
return getDistinctId()
|
||||
}
|
||||
|
||||
+133
-8
@@ -55,6 +55,21 @@ export class McpHub {
|
||||
private fileWatchers: Map<string, FSWatcher> = new Map()
|
||||
connections: McpConnection[] = []
|
||||
isConnecting: boolean = false
|
||||
/**
|
||||
* Flag to skip file watcher processing when we're updating Cline-specific settings
|
||||
* (autoApprove, timeout) that don't require an MCP server restart.
|
||||
*
|
||||
* The file watcher has a 100ms stabilityThreshold before firing "change" events.
|
||||
* When we update settings, we set this flag to true, write the file, then clear
|
||||
* the flag after 300ms. This ensures the flag is still true when the delayed
|
||||
* file watcher event fires, so we can skip redundant processing.
|
||||
*
|
||||
* Timeline:
|
||||
* 0ms: flag = true, write file
|
||||
* ~100ms: file watcher fires "change" → sees flag=true → skips
|
||||
* 300ms: flag = false (ready for external file changes)
|
||||
*/
|
||||
private isUpdatingClineSettings: boolean = false
|
||||
|
||||
/**
|
||||
* Map of unique keys to each connected server names
|
||||
@@ -190,6 +205,11 @@ export class McpHub {
|
||||
})
|
||||
|
||||
this.settingsWatcher.on("change", async () => {
|
||||
// Skip processing if we're updating Cline-specific settings (autoApprove, timeout)
|
||||
if (this.isUpdatingClineSettings) {
|
||||
return
|
||||
}
|
||||
|
||||
const settings = await this.readAndValidateMcpSettingsFile()
|
||||
if (settings) {
|
||||
try {
|
||||
@@ -717,8 +737,8 @@ export class McpHub {
|
||||
} catch (error) {
|
||||
console.error(`Failed to connect to new MCP server ${name}:`, error)
|
||||
}
|
||||
} else if (!deepEqual(JSON.parse(currentConnection.server.config), config)) {
|
||||
// Existing server with changed config
|
||||
} else if (this.configsRequireRestart(JSON.parse(currentConnection.server.config), config)) {
|
||||
// Existing server with changed connection config (excludes Cline-specific settings)
|
||||
try {
|
||||
if (config.type === "stdio") {
|
||||
this.setupFileWatcher(name, config)
|
||||
@@ -729,8 +749,24 @@ export class McpHub {
|
||||
} catch (error) {
|
||||
console.error(`Failed to reconnect MCP server ${name}:`, error)
|
||||
}
|
||||
} else {
|
||||
// Only Cline-specific settings changed - update in-memory state without restart
|
||||
const autoApprove = config.autoApprove || []
|
||||
if (currentConnection.server.tools) {
|
||||
currentConnection.server.tools = currentConnection.server.tools.map((tool) => ({
|
||||
...tool,
|
||||
autoApprove: autoApprove.includes(tool.name),
|
||||
}))
|
||||
}
|
||||
// Also update Cline-specific settings in the stored config.
|
||||
// This handles the case where someone manually edits the MCP settings file -
|
||||
// the file watcher triggers this code path, and we need to sync the in-memory
|
||||
// config with the file without restarting the server.
|
||||
const currentConfig = JSON.parse(currentConnection.server.config)
|
||||
currentConfig.autoApprove = config.autoApprove
|
||||
currentConfig.timeout = config.timeout
|
||||
currentConnection.server.config = JSON.stringify(currentConfig)
|
||||
}
|
||||
// If server exists with same config, do nothing
|
||||
}
|
||||
|
||||
this.isConnecting = false
|
||||
@@ -742,12 +778,16 @@ export class McpHub {
|
||||
const currentNames = new Set(this.connections.map((conn) => conn.server.name))
|
||||
const newNames = new Set(Object.keys(newServers))
|
||||
|
||||
// Track if any connection-level changes occurred (excludes Cline-specific settings)
|
||||
let connectionChangesOccurred = false
|
||||
|
||||
// Delete removed servers
|
||||
for (const name of currentNames) {
|
||||
if (!newNames.has(name)) {
|
||||
await this.clearOAuthForConnection(name) // Clear OAuth data first
|
||||
await this.deleteConnection(name) // Then delete connection
|
||||
console.log(`Deleted MCP server: ${name}`)
|
||||
connectionChangesOccurred = true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -762,28 +802,80 @@ export class McpHub {
|
||||
this.setupFileWatcher(name, config)
|
||||
}
|
||||
await this.connectToServer(name, config, "internal")
|
||||
connectionChangesOccurred = true
|
||||
} catch (error) {
|
||||
console.error(`Failed to connect to new MCP server ${name}:`, error)
|
||||
}
|
||||
} else if (!deepEqual(JSON.parse(currentConnection.server.config), config)) {
|
||||
// Existing server with changed config
|
||||
} else if (this.configsRequireRestart(JSON.parse(currentConnection.server.config), config)) {
|
||||
// Existing server with changed connection config (excludes Cline-specific settings)
|
||||
try {
|
||||
// Set status to "connecting" and notify webview before restart (same pattern as restartConnection)
|
||||
currentConnection.server.status = "connecting"
|
||||
currentConnection.server.error = ""
|
||||
await this.notifyWebviewOfServerChanges()
|
||||
|
||||
if (config.type === "stdio") {
|
||||
this.setupFileWatcher(name, config)
|
||||
}
|
||||
await this.deleteConnection(name)
|
||||
await this.connectToServer(name, config, "internal")
|
||||
console.log(`Reconnected MCP server with updated config: ${name}`)
|
||||
connectionChangesOccurred = true
|
||||
} catch (error) {
|
||||
console.error(`Failed to reconnect MCP server ${name}:`, error)
|
||||
}
|
||||
} else {
|
||||
// Only Cline-specific settings changed - update in-memory state without restart
|
||||
// Don't set connectionChangesOccurred since the RPC already returned the updated state
|
||||
const autoApprove = config.autoApprove || []
|
||||
if (currentConnection.server.tools) {
|
||||
currentConnection.server.tools = currentConnection.server.tools.map((tool) => ({
|
||||
...tool,
|
||||
autoApprove: autoApprove.includes(tool.name),
|
||||
}))
|
||||
}
|
||||
// Also update Cline-specific settings in the stored config
|
||||
const currentConfig = JSON.parse(currentConnection.server.config)
|
||||
currentConfig.autoApprove = config.autoApprove
|
||||
currentConfig.timeout = config.timeout
|
||||
currentConnection.server.config = JSON.stringify(currentConfig)
|
||||
}
|
||||
// If server exists with same config, do nothing
|
||||
}
|
||||
await this.notifyWebviewOfServerChanges()
|
||||
|
||||
// Only notify webview if actual connection changes occurred.
|
||||
// For Cline-specific settings changes, the RPC response already updated the webview,
|
||||
// so we skip notification to avoid race conditions.
|
||||
if (connectionChangesOccurred) {
|
||||
await this.notifyWebviewOfServerChanges()
|
||||
}
|
||||
this.isConnecting = false
|
||||
}
|
||||
|
||||
/**
|
||||
* Compares two MCP server configs to determine if a restart is required.
|
||||
* Excludes Cline-specific settings since they don't affect the MCP server transport connection.
|
||||
*
|
||||
* ## Cline-specific settings (don't require restart):
|
||||
* - `autoApprove`: tool approval list (UI setting)
|
||||
* - `timeout`: request timeout (read at request time, not connection time)
|
||||
*
|
||||
* ## MCP SDK connection settings (require restart):
|
||||
* - `type`, `command`, `args`, `cwd`, `env`, `url`, `headers`, `disabled`
|
||||
*
|
||||
* ## Adding new Cline-specific settings:
|
||||
* When adding a new setting that doesn't require server restart:
|
||||
* 1. Add it to the destructuring below to exclude from comparison
|
||||
* 2. Add it to `isUpdatingClineSettings` flag usage in the update function
|
||||
* 3. Update in-memory state (e.g., `connection.server.config`) in the update function
|
||||
* 4. Update the schema in `src/services/mcp/schemas.ts` if needed
|
||||
*/
|
||||
private configsRequireRestart(oldConfig: McpServerConfig, newConfig: McpServerConfig): boolean {
|
||||
// Exclude Cline-specific settings from comparison (add new ones here)
|
||||
const { autoApprove: _oldAutoApprove, timeout: _oldTimeout, ...oldConnectionConfig } = oldConfig
|
||||
const { autoApprove: _newAutoApprove, timeout: _newTimeout, ...newConnectionConfig } = newConfig
|
||||
return !deepEqual(oldConnectionConfig, newConnectionConfig)
|
||||
}
|
||||
|
||||
private setupFileWatcher(name: string, config: Extract<McpServerConfig, { type: "stdio" }>) {
|
||||
const filePath = config.args?.find((arg: string) => arg.includes("build/index.js"))
|
||||
if (filePath) {
|
||||
@@ -940,6 +1032,11 @@ export class McpHub {
|
||||
const connection = this.connections.find((conn) => conn.server.name === serverName)
|
||||
if (connection) {
|
||||
connection.server.disabled = disabled
|
||||
// When enabling a server, set status to "connecting" so UI shows yellow indicator
|
||||
if (!disabled) {
|
||||
connection.server.status = "connecting"
|
||||
connection.server.error = ""
|
||||
}
|
||||
}
|
||||
|
||||
const serverOrder = Object.keys(config.mcpServers || {})
|
||||
@@ -1065,6 +1162,8 @@ export class McpHub {
|
||||
* @returns Array of updated MCP servers
|
||||
*/
|
||||
async toggleToolAutoApproveRPC(serverName: string, toolNames: string[], shouldAllow: boolean): Promise<McpServer[]> {
|
||||
// Set flag to prevent file watcher from triggering during our update
|
||||
this.isUpdatingClineSettings = true
|
||||
try {
|
||||
const settingsPath = await this.getMcpSettingsFilePath()
|
||||
const content = await fs.readFile(settingsPath, "utf-8")
|
||||
@@ -1106,10 +1205,18 @@ export class McpHub {
|
||||
} catch (error) {
|
||||
console.error("Failed to update autoApprove settings:", error)
|
||||
throw error // Re-throw to ensure the error is properly handled
|
||||
} finally {
|
||||
// Clear flag after a delay to ensure file watcher event has been processed
|
||||
// The file watcher has a 100ms stabilityThreshold, so we wait a bit longer
|
||||
setTimeout(() => {
|
||||
this.isUpdatingClineSettings = false
|
||||
}, 300)
|
||||
}
|
||||
}
|
||||
|
||||
async toggleToolAutoApprove(serverName: string, toolNames: string[], shouldAllow: boolean): Promise<void> {
|
||||
// Set flag to prevent file watcher from triggering during our update
|
||||
this.isUpdatingClineSettings = true
|
||||
try {
|
||||
const settingsPath = await this.getMcpSettingsFilePath()
|
||||
const content = await fs.readFile(settingsPath, "utf-8")
|
||||
@@ -1152,6 +1259,11 @@ export class McpHub {
|
||||
message: "Failed to update autoApprove settings",
|
||||
})
|
||||
throw error // Re-throw to ensure the error is properly handled
|
||||
} finally {
|
||||
// Clear flag after a delay to ensure file watcher event has been processed
|
||||
setTimeout(() => {
|
||||
this.isUpdatingClineSettings = false
|
||||
}, 300)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1248,6 +1360,8 @@ export class McpHub {
|
||||
}
|
||||
|
||||
public async updateServerTimeoutRPC(serverName: string, timeout: number): Promise<McpServer[]> {
|
||||
// Set flag to prevent file watcher from triggering during our update
|
||||
this.isUpdatingClineSettings = true
|
||||
try {
|
||||
// Validate timeout against schema
|
||||
const setConfigResult = BaseConfigSchema.shape.timeout.safeParse(timeout)
|
||||
@@ -1270,7 +1384,13 @@ export class McpHub {
|
||||
|
||||
await fs.writeFile(settingsPath, JSON.stringify(config, null, 2))
|
||||
|
||||
await this.updateServerConnectionsRPC(config.mcpServers)
|
||||
// Update in-memory config to reflect the new timeout
|
||||
const connection = this.connections.find((conn) => conn.server.name === serverName)
|
||||
if (connection) {
|
||||
const currentConfig = JSON.parse(connection.server.config)
|
||||
currentConfig.timeout = timeout
|
||||
connection.server.config = JSON.stringify(currentConfig)
|
||||
}
|
||||
|
||||
const serverOrder = Object.keys(config.mcpServers || {})
|
||||
return this.getSortedMcpServers(serverOrder)
|
||||
@@ -1284,6 +1404,11 @@ export class McpHub {
|
||||
message: `Failed to update server timeout: ${error instanceof Error ? error.message : String(error)}`,
|
||||
})
|
||||
throw error
|
||||
} finally {
|
||||
// Clear flag after a delay to ensure file watcher event has been processed
|
||||
setTimeout(() => {
|
||||
this.isUpdatingClineSettings = false
|
||||
}, 300)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,26 @@ import { TelemetryProviderFactory } from "./TelemetryProviderFactory"
|
||||
*/
|
||||
type TelemetryCategory = "checkpoints" | "browser" | "focus_chain" | "dictation" | "subagents" | "hooks"
|
||||
|
||||
/**
|
||||
* Terminal type for telemetry differentiation
|
||||
*/
|
||||
export type TerminalType = "vscode" | "standalone"
|
||||
|
||||
/**
|
||||
* VSCode-specific output capture methods
|
||||
*/
|
||||
export type VscodeOutputMethod = "shell_integration" | "clipboard" | "none"
|
||||
|
||||
/**
|
||||
* Standalone-specific output capture methods
|
||||
*/
|
||||
export type StandaloneOutputMethod = "child_process" | "child_process_error"
|
||||
|
||||
/**
|
||||
* Combined type for terminal output methods
|
||||
*/
|
||||
export type TerminalOutputMethod = VscodeOutputMethod | StandaloneOutputMethod
|
||||
|
||||
/**
|
||||
* Enum for terminal output failure reasons
|
||||
*/
|
||||
@@ -1594,15 +1614,28 @@ export class TelemetryService {
|
||||
// Terminal telemetry methods
|
||||
|
||||
/**
|
||||
* Records terminal command execution outcomes
|
||||
* Records terminal command execution outcomes for VSCode terminal
|
||||
* @param success Whether the command output was successfully captured
|
||||
* @param method The method used to capture output ("shell_integration" | "clipboard" | "none")
|
||||
* @param terminalType The type of terminal ("vscode")
|
||||
* @param method The VSCode-specific method used to capture output
|
||||
*/
|
||||
public captureTerminalExecution(success: boolean, method: "shell_integration" | "clipboard" | "none") {
|
||||
public captureTerminalExecution(success: boolean, terminalType: "vscode", method: VscodeOutputMethod): void
|
||||
/**
|
||||
* Records terminal command execution outcomes for standalone terminal
|
||||
* @param success Whether the command output was successfully captured
|
||||
* @param terminalType The type of terminal ("standalone")
|
||||
* @param method The standalone-specific method used to capture output
|
||||
*/
|
||||
public captureTerminalExecution(success: boolean, terminalType: "standalone", method: StandaloneOutputMethod): void
|
||||
/**
|
||||
* Implementation of captureTerminalExecution
|
||||
*/
|
||||
public captureTerminalExecution(success: boolean, terminalType: TerminalType, method: TerminalOutputMethod): void {
|
||||
this.capture({
|
||||
event: TelemetryService.EVENTS.TASK.TERMINAL_EXECUTION,
|
||||
properties: {
|
||||
success,
|
||||
terminalType,
|
||||
method,
|
||||
},
|
||||
})
|
||||
@@ -1611,12 +1644,14 @@ export class TelemetryService {
|
||||
/**
|
||||
* Records when terminal output capture fails
|
||||
* @param reason The reason for failure
|
||||
* @param terminalType The type of terminal (defaults to "vscode" for backward compatibility)
|
||||
*/
|
||||
public captureTerminalOutputFailure(reason: TerminalOutputFailureReason) {
|
||||
public captureTerminalOutputFailure(reason: TerminalOutputFailureReason, terminalType: TerminalType = "vscode") {
|
||||
this.capture({
|
||||
event: TelemetryService.EVENTS.TASK.TERMINAL_OUTPUT_FAILURE,
|
||||
properties: {
|
||||
reason,
|
||||
terminalType,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -1624,12 +1659,14 @@ export class TelemetryService {
|
||||
/**
|
||||
* Records when user has to intervene with terminal execution
|
||||
* @param action The user action
|
||||
* @param terminalType The type of terminal (defaults to "vscode" for backward compatibility)
|
||||
*/
|
||||
public captureTerminalUserIntervention(action: TerminalUserInterventionAction) {
|
||||
public captureTerminalUserIntervention(action: TerminalUserInterventionAction, terminalType: TerminalType = "vscode") {
|
||||
this.capture({
|
||||
event: TelemetryService.EVENTS.TASK.TERMINAL_USER_INTERVENTION,
|
||||
properties: {
|
||||
action,
|
||||
terminalType,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -1637,12 +1674,14 @@ export class TelemetryService {
|
||||
/**
|
||||
* Records when terminal execution hangs or gets stuck
|
||||
* @param stage Where the hang occurred
|
||||
* @param terminalType The type of terminal (defaults to "vscode" for backward compatibility)
|
||||
*/
|
||||
public captureTerminalHang(stage: TerminalHangStage) {
|
||||
public captureTerminalHang(stage: TerminalHangStage, terminalType: TerminalType = "vscode") {
|
||||
this.capture({
|
||||
event: TelemetryService.EVENTS.TASK.TERMINAL_HANG,
|
||||
properties: {
|
||||
stage,
|
||||
terminalType,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -8,8 +8,14 @@ export {
|
||||
TelemetryProviderFactory,
|
||||
type TelemetryProviderType,
|
||||
} from "./TelemetryProviderFactory"
|
||||
|
||||
// Export the enums for terminal telemetry
|
||||
// Export terminal type definitions for type-safe telemetry
|
||||
export type {
|
||||
StandaloneOutputMethod,
|
||||
TerminalOutputMethod,
|
||||
TerminalType,
|
||||
VscodeOutputMethod,
|
||||
} from "./TelemetryService"
|
||||
// Export the enums and types for terminal telemetry
|
||||
export {
|
||||
TerminalHangStage,
|
||||
TerminalOutputFailureReason,
|
||||
|
||||
@@ -2,8 +2,8 @@ import { Meter } from "@opentelemetry/api"
|
||||
import type { Logger as OTELLogger } from "@opentelemetry/api-logs"
|
||||
import { LoggerProvider } from "@opentelemetry/sdk-logs"
|
||||
import { MeterProvider } from "@opentelemetry/sdk-metrics"
|
||||
import * as vscode from "vscode"
|
||||
import { HostProvider } from "@/hosts/host-provider"
|
||||
import { getErrorLevelFromString } from "@/services/error"
|
||||
import { getDistinctId, setDistinctId } from "@/services/logging/distinctId"
|
||||
import { Setting } from "@/shared/proto/index.host"
|
||||
import type { ClineAccountUserInfo } from "../../../auth/AuthService"
|
||||
@@ -304,8 +304,7 @@ export class OpenTelemetryTelemetryProvider implements ITelemetryProvider {
|
||||
if (hostSettings.isEnabled === Setting.DISABLED) {
|
||||
return "off"
|
||||
}
|
||||
const config = vscode.workspace.getConfiguration("telemetry")
|
||||
return config?.get<TelemetrySettings["level"]>("telemetryLevel") || "all"
|
||||
return getErrorLevelFromString(hostSettings.errorLevel)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { PostHog } from "posthog-node"
|
||||
import * as vscode from "vscode"
|
||||
import { HostProvider } from "@/hosts/host-provider"
|
||||
import { getErrorLevelFromString } from "@/services/error"
|
||||
import { getDistinctId, setDistinctId } from "@/services/logging/distinctId"
|
||||
import { Setting } from "@/shared/proto/index.host"
|
||||
import { posthogConfig } from "../../../../shared/services/config/posthog-config"
|
||||
@@ -208,7 +208,6 @@ export class PostHogTelemetryProvider implements ITelemetryProvider {
|
||||
if (hostSettings.isEnabled === Setting.DISABLED) {
|
||||
return "off"
|
||||
}
|
||||
const config = vscode.workspace.getConfiguration("telemetry")
|
||||
return config?.get<TelemetrySettings["level"]>("telemetryLevel") || "all"
|
||||
return getErrorLevelFromString(hostSettings.errorLevel)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,6 +106,7 @@ export interface ExtensionState {
|
||||
subagentsEnabled?: boolean
|
||||
nativeToolCallSetting?: boolean
|
||||
enableParallelToolCalling?: boolean
|
||||
backgroundEditEnabled?: boolean
|
||||
}
|
||||
|
||||
export interface ClineMessage {
|
||||
|
||||
+40
-2
@@ -322,6 +322,7 @@ export const anthropicModels = {
|
||||
contextWindow: 200_000,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: true,
|
||||
supportsReasoning: true,
|
||||
inputPrice: 3.0,
|
||||
outputPrice: 15.0,
|
||||
cacheWritesPrice: 3.75,
|
||||
@@ -332,6 +333,7 @@ export const anthropicModels = {
|
||||
contextWindow: 1_000_000,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: true,
|
||||
supportsReasoning: true,
|
||||
inputPrice: 3.0,
|
||||
outputPrice: 15.0,
|
||||
cacheWritesPrice: 3.75,
|
||||
@@ -343,6 +345,7 @@ export const anthropicModels = {
|
||||
contextWindow: 200_000,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: true,
|
||||
supportsReasoning: true,
|
||||
inputPrice: 1,
|
||||
outputPrice: 5.0,
|
||||
cacheWritesPrice: 1.25,
|
||||
@@ -353,6 +356,7 @@ export const anthropicModels = {
|
||||
contextWindow: 200_000,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: true,
|
||||
supportsReasoning: true,
|
||||
inputPrice: 3.0,
|
||||
outputPrice: 15.0,
|
||||
cacheWritesPrice: 3.75,
|
||||
@@ -363,6 +367,7 @@ export const anthropicModels = {
|
||||
contextWindow: 1_000_000,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: true,
|
||||
supportsReasoning: true,
|
||||
inputPrice: 3.0,
|
||||
outputPrice: 15.0,
|
||||
cacheWritesPrice: 3.75,
|
||||
@@ -374,6 +379,7 @@ export const anthropicModels = {
|
||||
contextWindow: 200_000,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: true,
|
||||
supportsReasoning: true,
|
||||
inputPrice: 5.0,
|
||||
outputPrice: 25.0,
|
||||
cacheWritesPrice: 6.25,
|
||||
@@ -384,6 +390,7 @@ export const anthropicModels = {
|
||||
contextWindow: 200_000,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: true,
|
||||
supportsReasoning: true,
|
||||
inputPrice: 15.0,
|
||||
outputPrice: 75.0,
|
||||
cacheWritesPrice: 18.75,
|
||||
@@ -394,6 +401,7 @@ export const anthropicModels = {
|
||||
contextWindow: 200_000,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: true,
|
||||
supportsReasoning: true,
|
||||
inputPrice: 15.0,
|
||||
outputPrice: 75.0,
|
||||
cacheWritesPrice: 18.75,
|
||||
@@ -405,6 +413,7 @@ export const anthropicModels = {
|
||||
supportsImages: true,
|
||||
|
||||
supportsPromptCache: true,
|
||||
supportsReasoning: true,
|
||||
inputPrice: 3.0,
|
||||
outputPrice: 15.0,
|
||||
cacheWritesPrice: 3.75,
|
||||
@@ -519,6 +528,7 @@ export const bedrockModels = {
|
||||
contextWindow: 200_000,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: true,
|
||||
supportsReasoning: true,
|
||||
supportsGlobalEndpoint: true,
|
||||
inputPrice: 3.0,
|
||||
outputPrice: 15.0,
|
||||
@@ -530,6 +540,7 @@ export const bedrockModels = {
|
||||
contextWindow: 1_000_000,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: true,
|
||||
supportsReasoning: true,
|
||||
supportsGlobalEndpoint: true,
|
||||
inputPrice: 3.0,
|
||||
outputPrice: 15.0,
|
||||
@@ -542,6 +553,7 @@ export const bedrockModels = {
|
||||
contextWindow: 200_000,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: true,
|
||||
supportsReasoning: true,
|
||||
inputPrice: 1,
|
||||
outputPrice: 5.0,
|
||||
cacheWritesPrice: 1.25,
|
||||
@@ -552,6 +564,7 @@ export const bedrockModels = {
|
||||
contextWindow: 200_000,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: true,
|
||||
supportsReasoning: true,
|
||||
supportsGlobalEndpoint: true,
|
||||
inputPrice: 3.0,
|
||||
outputPrice: 15.0,
|
||||
@@ -563,6 +576,7 @@ export const bedrockModels = {
|
||||
contextWindow: 1_000_000,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: true,
|
||||
supportsReasoning: true,
|
||||
supportsGlobalEndpoint: true,
|
||||
inputPrice: 3.0,
|
||||
outputPrice: 15.0,
|
||||
@@ -575,6 +589,7 @@ export const bedrockModels = {
|
||||
contextWindow: 200_000,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: true,
|
||||
supportsReasoning: true,
|
||||
supportsGlobalEndpoint: true,
|
||||
inputPrice: 5.0,
|
||||
outputPrice: 25.0,
|
||||
@@ -586,6 +601,7 @@ export const bedrockModels = {
|
||||
contextWindow: 200_000,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: true,
|
||||
supportsReasoning: true,
|
||||
inputPrice: 15.0,
|
||||
outputPrice: 75.0,
|
||||
cacheWritesPrice: 18.75,
|
||||
@@ -596,6 +612,7 @@ export const bedrockModels = {
|
||||
contextWindow: 200_000,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: true,
|
||||
supportsReasoning: true,
|
||||
inputPrice: 15.0,
|
||||
outputPrice: 75.0,
|
||||
cacheWritesPrice: 18.75,
|
||||
@@ -661,6 +678,7 @@ export const bedrockModels = {
|
||||
supportsImages: true,
|
||||
|
||||
supportsPromptCache: true,
|
||||
supportsReasoning: true,
|
||||
inputPrice: 3.0,
|
||||
outputPrice: 15.0,
|
||||
cacheWritesPrice: 3.75,
|
||||
@@ -4041,13 +4059,23 @@ export const basetenDefaultModelId = "zai-org/GLM-4.6" satisfies BasetenModelId
|
||||
// https://docs.z.ai/guides/llm/glm-4.5
|
||||
// https://docs.z.ai/guides/overview/pricing
|
||||
export type internationalZAiModelId = keyof typeof internationalZAiModels
|
||||
export const internationalZAiDefaultModelId: internationalZAiModelId = "glm-4.5"
|
||||
export const internationalZAiDefaultModelId: internationalZAiModelId = "glm-4.7"
|
||||
export const internationalZAiModels = {
|
||||
"glm-4.7": {
|
||||
maxTokens: 131_000,
|
||||
contextWindow: 200_000,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: true,
|
||||
cacheReadsPrice: 0.11,
|
||||
inputPrice: 0.6,
|
||||
outputPrice: 2.2,
|
||||
},
|
||||
"glm-4.6": {
|
||||
maxTokens: 128_000,
|
||||
contextWindow: 200_000,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: true,
|
||||
cacheReadsPrice: 0.11,
|
||||
inputPrice: 0.6,
|
||||
outputPrice: 2.2,
|
||||
},
|
||||
@@ -4078,13 +4106,23 @@ export const internationalZAiModels = {
|
||||
} as const satisfies Record<string, ModelInfo>
|
||||
|
||||
export type mainlandZAiModelId = keyof typeof mainlandZAiModels
|
||||
export const mainlandZAiDefaultModelId: mainlandZAiModelId = "glm-4.5"
|
||||
export const mainlandZAiDefaultModelId: mainlandZAiModelId = "glm-4.7"
|
||||
export const mainlandZAiModels = {
|
||||
"glm-4.7": {
|
||||
maxTokens: 131_000,
|
||||
contextWindow: 200_000,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: true,
|
||||
cacheReadsPrice: 0.11,
|
||||
inputPrice: 0.6,
|
||||
outputPrice: 2.2,
|
||||
},
|
||||
"glm-4.6": {
|
||||
maxTokens: 128_000,
|
||||
contextWindow: 200_000,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: true,
|
||||
cacheReadsPrice: 0.11,
|
||||
inputPrice: 0.6,
|
||||
outputPrice: 2.2,
|
||||
},
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
/**
|
||||
* Action types that can be triggered from banner buttons/links
|
||||
* Frontend maps these to actual handlers
|
||||
*/
|
||||
export enum BannerActionType {
|
||||
/** Open external URL */
|
||||
Link = "link",
|
||||
/** Open API settings tab */
|
||||
ShowApiSettings = "show-api-settings",
|
||||
/** Open feature settings tab */
|
||||
ShowFeatureSettings = "show-feature-settings",
|
||||
/** Open account/login view */
|
||||
ShowAccount = "show-account",
|
||||
/** Set the active model */
|
||||
SetModel = "set-model",
|
||||
/** Trigger CLI installation flow */
|
||||
InstallCli = "install-cli",
|
||||
}
|
||||
|
||||
/**
|
||||
* Backend banner format returned from server API
|
||||
*/
|
||||
export interface BackendBanner {
|
||||
id: string
|
||||
titleMd: string
|
||||
bodyMd: string
|
||||
rulesJson: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Banner data structure for backend-to-frontend communication.
|
||||
* Backend constructs this JSON, frontend renders it via BannerCarousel.
|
||||
*/
|
||||
export interface BannerCardData {
|
||||
/** Unique identifier for the banner (used for dismissal tracking) */
|
||||
id: string
|
||||
|
||||
/** Banner title text */
|
||||
title: string
|
||||
|
||||
/** Banner description/body markdown text */
|
||||
description: string
|
||||
|
||||
/**
|
||||
* Icon ID from Lucide icon set (e.g., "lightbulb", "megaphone", "terminal")
|
||||
* LINK: https://lucide.dev/icons/
|
||||
* Optional - if omitted, no icon is shown
|
||||
*/
|
||||
icon?: string
|
||||
|
||||
/**
|
||||
* Optional footer action buttons
|
||||
* Rendered below the description as prominent buttons
|
||||
*/
|
||||
actions?: BannerAction[]
|
||||
|
||||
/**
|
||||
* Platform filter - only show on specified platforms
|
||||
* If undefined, show on all platforms
|
||||
*/
|
||||
platforms?: ("windows" | "mac" | "linux")[]
|
||||
|
||||
/** Only show to Cline users */
|
||||
isClineUserOnly?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Single action definition (button or link)
|
||||
*/
|
||||
export interface BannerAction {
|
||||
/** Button/link label text */
|
||||
title: string
|
||||
|
||||
/**
|
||||
* Action type - determines what happens on click
|
||||
* Defaults to "link" if omitted
|
||||
*/
|
||||
action?: BannerActionType
|
||||
|
||||
/**
|
||||
* Action argument - interpretation depends on action type:
|
||||
* - Link: URL to open
|
||||
* - SetModel: model ID (e.g., "anthropic/claude-opus-4.5")
|
||||
* - Others: generally unused
|
||||
*/
|
||||
arg?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* The list of predefined banner config rendered by the Welcome Section UI.
|
||||
* TODO: Backend would return a similar JSON structure in the future which we will replace this with.
|
||||
*/
|
||||
export const BANNER_DATA: BannerCardData[] = [
|
||||
// Info banner with inline link
|
||||
{
|
||||
id: "info-banner-v1",
|
||||
icon: "lightbulb",
|
||||
title: "Use Cline in Right Sidebar",
|
||||
description:
|
||||
"For the best experience, drag the Cline icon to your right sidebar. This keeps your file explorer and editor visible while you chat with Cline, making it easier to navigate your codebase and see changes in real-time. [See how →](https://docs.cline.bot/features/customization/opening-cline-in-sidebar)",
|
||||
},
|
||||
|
||||
// Announcement with conditional actions based on user auth state
|
||||
{
|
||||
id: "new-model-opus-4-5-cline-users",
|
||||
icon: "megaphone",
|
||||
title: "Claude Opus 4.5 Now Available",
|
||||
description: "State-of-the-art performance at 3x lower cost than Opus 4.1. Available now in the Cline provider.",
|
||||
actions: [
|
||||
{
|
||||
title: "Try Now",
|
||||
action: BannerActionType.SetModel,
|
||||
arg: "anthropic/claude-opus-4.5",
|
||||
},
|
||||
],
|
||||
isClineUserOnly: true, // Only Cline users see this
|
||||
},
|
||||
|
||||
{
|
||||
id: "new-model-opus-4-5-non-cline-users",
|
||||
icon: "megaphone",
|
||||
title: "Claude Opus 4.5 Now Available",
|
||||
description: "State-of-the-art performance at 3x lower cost than Opus 4.1. Available now in the Cline provider.",
|
||||
actions: [
|
||||
{
|
||||
title: "Get Started",
|
||||
action: BannerActionType.ShowAccount,
|
||||
},
|
||||
],
|
||||
isClineUserOnly: false, // Only non-Cline users see this
|
||||
},
|
||||
|
||||
// Platform-specific banner (macOS/Linux)
|
||||
{
|
||||
id: "cli-install-unix-v1",
|
||||
icon: "terminal",
|
||||
title: "CLI & Subagents Available",
|
||||
platforms: ["mac", "linux"] satisfies BannerCardData["platforms"],
|
||||
description:
|
||||
"Use Cline in your terminal and enable subagent capabilities. [Learn more](https://docs.cline.bot/cline-cli/overview)",
|
||||
actions: [
|
||||
{
|
||||
title: "Install",
|
||||
action: BannerActionType.InstallCli,
|
||||
},
|
||||
{
|
||||
title: "Enable Subagents",
|
||||
action: BannerActionType.ShowFeatureSettings,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// Platform-specific banner (Windows)
|
||||
{
|
||||
id: "cli-info-windows-v1",
|
||||
icon: "terminal",
|
||||
title: "Cline CLI Info",
|
||||
platforms: ["windows"] satisfies BannerCardData["platforms"],
|
||||
description:
|
||||
"Available for macOS and Linux. Coming soon to other platforms. [Learn more](https://docs.cline.bot/cline-cli/overview)",
|
||||
},
|
||||
]
|
||||
@@ -130,8 +130,10 @@ export interface Settings {
|
||||
hooksEnabled: boolean
|
||||
subagentsEnabled: boolean
|
||||
enableParallelToolCalling: boolean
|
||||
hicapModelId: string | undefined
|
||||
backgroundEditEnabled: boolean
|
||||
|
||||
// Model-specific settings
|
||||
hicapModelId: string | undefined
|
||||
// Plan mode configurations
|
||||
planModeApiProvider: ApiProvider
|
||||
planModeApiModelId: string | undefined
|
||||
|
||||
@@ -59,25 +59,25 @@ e2e("Views - can set up API keys and navigate to Settings from Chat", async ({ s
|
||||
|
||||
// Verify What's New Section is showing and starts with first banner,
|
||||
// and the navigation buttons work
|
||||
await expect(sidebar.locator(".fade-in-cards")).toBeVisible()
|
||||
await expect(sidebar.locator(".animate-fade-in")).toBeVisible()
|
||||
await expect(
|
||||
sidebar
|
||||
.locator("div")
|
||||
.filter({ hasText: /^1\/3$/ })
|
||||
.filter({ hasText: /^1 \/ 3$/ })
|
||||
.first(),
|
||||
).toBeVisible()
|
||||
await sidebar.getByRole("button", { name: "Next banner" }).click()
|
||||
await expect(
|
||||
sidebar
|
||||
.locator("div")
|
||||
.filter({ hasText: /^2\/3$/ })
|
||||
.filter({ hasText: /^2 \/ 3$/ })
|
||||
.first(),
|
||||
).toBeVisible()
|
||||
await sidebar.getByRole("button", { name: "Previous banner" }).click()
|
||||
await expect(
|
||||
sidebar
|
||||
.locator("div")
|
||||
.filter({ hasText: /^1\/3$/ })
|
||||
.filter({ hasText: /^1 \/ 3$/ })
|
||||
.first(),
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
+217
-345
@@ -1,407 +1,279 @@
|
||||
import { afterEach, beforeEach, describe, it } from "mocha";
|
||||
import "should";
|
||||
import * as sinon from "sinon";
|
||||
import { Controller } from "../core/controller";
|
||||
import { getAvailableSlashCommands } from "../core/controller/slash/getAvailableSlashCommands";
|
||||
import { EmptyRequest } from "../shared/proto/cline/common";
|
||||
import { BASE_SLASH_COMMANDS } from "../shared/slashCommands";
|
||||
import { afterEach, beforeEach, describe, it } from "mocha"
|
||||
import "should"
|
||||
import * as sinon from "sinon"
|
||||
import { Controller } from "../core/controller"
|
||||
import { getAvailableSlashCommands } from "../core/controller/slash/getAvailableSlashCommands"
|
||||
import { EmptyRequest } from "../shared/proto/cline/common"
|
||||
import { BASE_SLASH_COMMANDS } from "../shared/slashCommands"
|
||||
|
||||
/**
|
||||
* Unit tests for getAvailableSlashCommands RPC endpoint
|
||||
* Tests the slash command discovery and filtering functionality
|
||||
*/
|
||||
describe("getAvailableSlashCommands", () => {
|
||||
let mockController: Partial<Controller>;
|
||||
let mockStateManager: {
|
||||
getWorkspaceStateKey: sinon.SinonStub;
|
||||
getGlobalSettingsKey: sinon.SinonStub;
|
||||
getGlobalStateKey: sinon.SinonStub;
|
||||
getRemoteConfigSettings: sinon.SinonStub;
|
||||
};
|
||||
let mockController: Partial<Controller>
|
||||
let mockStateManager: {
|
||||
getWorkspaceStateKey: sinon.SinonStub
|
||||
getGlobalSettingsKey: sinon.SinonStub
|
||||
getGlobalStateKey: sinon.SinonStub
|
||||
getRemoteConfigSettings: sinon.SinonStub
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockStateManager = {
|
||||
getWorkspaceStateKey: sinon.stub(),
|
||||
getGlobalSettingsKey: sinon.stub(),
|
||||
getGlobalStateKey: sinon.stub(),
|
||||
getRemoteConfigSettings: sinon.stub(),
|
||||
};
|
||||
beforeEach(() => {
|
||||
mockStateManager = {
|
||||
getWorkspaceStateKey: sinon.stub(),
|
||||
getGlobalSettingsKey: sinon.stub(),
|
||||
getGlobalStateKey: sinon.stub(),
|
||||
getRemoteConfigSettings: sinon.stub(),
|
||||
}
|
||||
|
||||
// Default stubs return empty/null values
|
||||
mockStateManager.getWorkspaceStateKey.returns(null);
|
||||
mockStateManager.getGlobalSettingsKey.returns(null);
|
||||
mockStateManager.getGlobalStateKey.returns(null);
|
||||
mockStateManager.getRemoteConfigSettings.returns(null);
|
||||
// Default stubs return empty/null values
|
||||
mockStateManager.getWorkspaceStateKey.returns(null)
|
||||
mockStateManager.getGlobalSettingsKey.returns(null)
|
||||
mockStateManager.getGlobalStateKey.returns(null)
|
||||
mockStateManager.getRemoteConfigSettings.returns(null)
|
||||
|
||||
mockController = {
|
||||
stateManager: mockStateManager as any,
|
||||
};
|
||||
});
|
||||
mockController = {
|
||||
stateManager: mockStateManager as any,
|
||||
}
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
sinon.restore();
|
||||
});
|
||||
afterEach(() => {
|
||||
sinon.restore()
|
||||
})
|
||||
|
||||
describe("Base Slash Commands", () => {
|
||||
it("should return all base slash commands", async () => {
|
||||
const response = await getAvailableSlashCommands(
|
||||
mockController as Controller,
|
||||
EmptyRequest.create()
|
||||
);
|
||||
describe("Base Slash Commands", () => {
|
||||
it("should return all base slash commands", async () => {
|
||||
const response = await getAvailableSlashCommands(mockController as Controller, EmptyRequest.create())
|
||||
|
||||
// Should have at least all base commands
|
||||
response.commands.length.should.be.greaterThanOrEqual(
|
||||
BASE_SLASH_COMMANDS.length
|
||||
);
|
||||
// Should have at least all base commands
|
||||
response.commands.length.should.be.greaterThanOrEqual(BASE_SLASH_COMMANDS.length)
|
||||
|
||||
// Verify each base command is present
|
||||
for (const baseCmd of BASE_SLASH_COMMANDS) {
|
||||
const found = response.commands.find(
|
||||
(cmd) => cmd.name === baseCmd.name
|
||||
);
|
||||
found!.should.not.be.undefined();
|
||||
found!.description.should.equal(baseCmd.description);
|
||||
found!.section.should.equal("default");
|
||||
found!.cliCompatible.should.equal(baseCmd.cliCompatible ?? false);
|
||||
}
|
||||
});
|
||||
// Verify each base command is present
|
||||
for (const baseCmd of BASE_SLASH_COMMANDS) {
|
||||
const found = response.commands.find((cmd) => cmd.name === baseCmd.name)
|
||||
found!.should.not.be.undefined()
|
||||
found!.description.should.equal(baseCmd.description)
|
||||
found!.section.should.equal("default")
|
||||
found!.cliCompatible.should.equal(baseCmd.cliCompatible ?? false)
|
||||
}
|
||||
})
|
||||
|
||||
it("should mark base commands with section 'default'", async () => {
|
||||
const response = await getAvailableSlashCommands(
|
||||
mockController as Controller,
|
||||
EmptyRequest.create()
|
||||
);
|
||||
it("should mark base commands with section 'default'", async () => {
|
||||
const response = await getAvailableSlashCommands(mockController as Controller, EmptyRequest.create())
|
||||
|
||||
const baseCommandNames = BASE_SLASH_COMMANDS.map((cmd) => cmd.name);
|
||||
for (const cmd of response.commands) {
|
||||
if (baseCommandNames.includes(cmd.name)) {
|
||||
cmd.section.should.equal("default");
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
const baseCommandNames = BASE_SLASH_COMMANDS.map((cmd) => cmd.name)
|
||||
for (const cmd of response.commands) {
|
||||
if (baseCommandNames.includes(cmd.name)) {
|
||||
cmd.section.should.equal("default")
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe("Local Workflow Toggles", () => {
|
||||
it("should include enabled local workflows", async () => {
|
||||
mockStateManager.getWorkspaceStateKey
|
||||
.withArgs("workflowToggles")
|
||||
.returns({
|
||||
"/path/to/my-workflow.md": true,
|
||||
"/path/to/another-workflow.md": true,
|
||||
});
|
||||
describe("Local Workflow Toggles", () => {
|
||||
it("should include enabled local workflows", async () => {
|
||||
mockStateManager.getWorkspaceStateKey.withArgs("workflowToggles").returns({
|
||||
"/path/to/my-workflow.md": true,
|
||||
"/path/to/another-workflow.md": true,
|
||||
})
|
||||
|
||||
const response = await getAvailableSlashCommands(
|
||||
mockController as Controller,
|
||||
EmptyRequest.create()
|
||||
);
|
||||
const response = await getAvailableSlashCommands(mockController as Controller, EmptyRequest.create())
|
||||
|
||||
const myWorkflow = response.commands.find(
|
||||
(cmd) => cmd.name === "my-workflow.md"
|
||||
);
|
||||
myWorkflow!.should.not.be.undefined();
|
||||
myWorkflow!.section.should.equal("custom");
|
||||
myWorkflow!.cliCompatible.should.equal(true);
|
||||
const myWorkflow = response.commands.find((cmd) => cmd.name === "my-workflow.md")
|
||||
myWorkflow!.should.not.be.undefined()
|
||||
myWorkflow!.section.should.equal("custom")
|
||||
myWorkflow!.cliCompatible.should.equal(true)
|
||||
|
||||
const anotherWorkflow = response.commands.find(
|
||||
(cmd) => cmd.name === "another-workflow.md"
|
||||
);
|
||||
anotherWorkflow!.should.not.be.undefined();
|
||||
});
|
||||
const anotherWorkflow = response.commands.find((cmd) => cmd.name === "another-workflow.md")
|
||||
anotherWorkflow!.should.not.be.undefined()
|
||||
})
|
||||
|
||||
it("should exclude disabled local workflows", async () => {
|
||||
mockStateManager.getWorkspaceStateKey
|
||||
.withArgs("workflowToggles")
|
||||
.returns({
|
||||
"/path/to/enabled-workflow.md": true,
|
||||
"/path/to/disabled-workflow.md": false,
|
||||
});
|
||||
it("should exclude disabled local workflows", async () => {
|
||||
mockStateManager.getWorkspaceStateKey.withArgs("workflowToggles").returns({
|
||||
"/path/to/enabled-workflow.md": true,
|
||||
"/path/to/disabled-workflow.md": false,
|
||||
})
|
||||
|
||||
const response = await getAvailableSlashCommands(
|
||||
mockController as Controller,
|
||||
EmptyRequest.create()
|
||||
);
|
||||
const response = await getAvailableSlashCommands(mockController as Controller, EmptyRequest.create())
|
||||
|
||||
const enabled = response.commands.find(
|
||||
(cmd) => cmd.name === "enabled-workflow.md"
|
||||
);
|
||||
enabled!.should.not.be.undefined();
|
||||
const enabled = response.commands.find((cmd) => cmd.name === "enabled-workflow.md")
|
||||
enabled!.should.not.be.undefined()
|
||||
|
||||
const disabled = response.commands.find(
|
||||
(cmd) => cmd.name === "disabled-workflow.md"
|
||||
);
|
||||
(disabled === undefined).should.be.true();
|
||||
});
|
||||
const disabled = response.commands.find((cmd) => cmd.name === "disabled-workflow.md")
|
||||
;(disabled === undefined).should.be.true()
|
||||
})
|
||||
|
||||
it("should extract filename from full path", async () => {
|
||||
mockStateManager.getWorkspaceStateKey
|
||||
.withArgs("workflowToggles")
|
||||
.returns({
|
||||
"/Users/test/project/.clinerules/workflows/deep-analysis.md": true,
|
||||
});
|
||||
it("should extract filename from full path", async () => {
|
||||
mockStateManager.getWorkspaceStateKey.withArgs("workflowToggles").returns({
|
||||
"/Users/test/project/.clinerules/workflows/deep-analysis.md": true,
|
||||
})
|
||||
|
||||
const response = await getAvailableSlashCommands(
|
||||
mockController as Controller,
|
||||
EmptyRequest.create()
|
||||
);
|
||||
const response = await getAvailableSlashCommands(mockController as Controller, EmptyRequest.create())
|
||||
|
||||
const workflow = response.commands.find(
|
||||
(cmd) => cmd.name === "deep-analysis.md"
|
||||
);
|
||||
workflow!.should.not.be.undefined();
|
||||
});
|
||||
const workflow = response.commands.find((cmd) => cmd.name === "deep-analysis.md")
|
||||
workflow!.should.not.be.undefined()
|
||||
})
|
||||
|
||||
it("should handle Windows-style paths", async () => {
|
||||
mockStateManager.getWorkspaceStateKey
|
||||
.withArgs("workflowToggles")
|
||||
.returns({
|
||||
"C:\\Users\\test\\project\\.clinerules\\workflows\\windows-workflow.md":
|
||||
true,
|
||||
});
|
||||
it("should handle Windows-style paths", async () => {
|
||||
mockStateManager.getWorkspaceStateKey.withArgs("workflowToggles").returns({
|
||||
"C:\\Users\\test\\project\\.clinerules\\workflows\\windows-workflow.md": true,
|
||||
})
|
||||
|
||||
const response = await getAvailableSlashCommands(
|
||||
mockController as Controller,
|
||||
EmptyRequest.create()
|
||||
);
|
||||
const response = await getAvailableSlashCommands(mockController as Controller, EmptyRequest.create())
|
||||
|
||||
const workflow = response.commands.find(
|
||||
(cmd) => cmd.name === "windows-workflow.md"
|
||||
);
|
||||
workflow!.should.not.be.undefined();
|
||||
});
|
||||
});
|
||||
const workflow = response.commands.find((cmd) => cmd.name === "windows-workflow.md")
|
||||
workflow!.should.not.be.undefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe("Global Workflow Toggles", () => {
|
||||
it("should include enabled global workflows", async () => {
|
||||
mockStateManager.getGlobalSettingsKey
|
||||
.withArgs("globalWorkflowToggles")
|
||||
.returns({
|
||||
"/global/path/global-workflow.md": true,
|
||||
});
|
||||
describe("Global Workflow Toggles", () => {
|
||||
it("should include enabled global workflows", async () => {
|
||||
mockStateManager.getGlobalSettingsKey.withArgs("globalWorkflowToggles").returns({
|
||||
"/global/path/global-workflow.md": true,
|
||||
})
|
||||
|
||||
const response = await getAvailableSlashCommands(
|
||||
mockController as Controller,
|
||||
EmptyRequest.create()
|
||||
);
|
||||
const response = await getAvailableSlashCommands(mockController as Controller, EmptyRequest.create())
|
||||
|
||||
const workflow = response.commands.find(
|
||||
(cmd) => cmd.name === "global-workflow.md"
|
||||
);
|
||||
workflow!.should.not.be.undefined();
|
||||
workflow!.section.should.equal("custom");
|
||||
});
|
||||
const workflow = response.commands.find((cmd) => cmd.name === "global-workflow.md")
|
||||
workflow!.should.not.be.undefined()
|
||||
workflow!.section.should.equal("custom")
|
||||
})
|
||||
|
||||
it("should exclude disabled global workflows", async () => {
|
||||
mockStateManager.getGlobalSettingsKey
|
||||
.withArgs("globalWorkflowToggles")
|
||||
.returns({
|
||||
"/global/path/disabled-global.md": false,
|
||||
});
|
||||
it("should exclude disabled global workflows", async () => {
|
||||
mockStateManager.getGlobalSettingsKey.withArgs("globalWorkflowToggles").returns({
|
||||
"/global/path/disabled-global.md": false,
|
||||
})
|
||||
|
||||
const response = await getAvailableSlashCommands(
|
||||
mockController as Controller,
|
||||
EmptyRequest.create()
|
||||
);
|
||||
const response = await getAvailableSlashCommands(mockController as Controller, EmptyRequest.create())
|
||||
|
||||
const workflow = response.commands.find(
|
||||
(cmd) => cmd.name === "disabled-global.md"
|
||||
);
|
||||
(workflow === undefined).should.be.true();
|
||||
});
|
||||
});
|
||||
const workflow = response.commands.find((cmd) => cmd.name === "disabled-global.md")
|
||||
;(workflow === undefined).should.be.true()
|
||||
})
|
||||
})
|
||||
|
||||
describe("Workflow Deduplication", () => {
|
||||
it("should prefer local workflows over global workflows with same name", async () => {
|
||||
// Same filename in both local and global
|
||||
mockStateManager.getWorkspaceStateKey
|
||||
.withArgs("workflowToggles")
|
||||
.returns({
|
||||
"/local/path/shared-workflow.md": true,
|
||||
});
|
||||
mockStateManager.getGlobalSettingsKey
|
||||
.withArgs("globalWorkflowToggles")
|
||||
.returns({
|
||||
"/global/path/shared-workflow.md": true,
|
||||
});
|
||||
describe("Workflow Deduplication", () => {
|
||||
it("should prefer local workflows over global workflows with same name", async () => {
|
||||
// Same filename in both local and global
|
||||
mockStateManager.getWorkspaceStateKey.withArgs("workflowToggles").returns({
|
||||
"/local/path/shared-workflow.md": true,
|
||||
})
|
||||
mockStateManager.getGlobalSettingsKey.withArgs("globalWorkflowToggles").returns({
|
||||
"/global/path/shared-workflow.md": true,
|
||||
})
|
||||
|
||||
const response = await getAvailableSlashCommands(
|
||||
mockController as Controller,
|
||||
EmptyRequest.create()
|
||||
);
|
||||
const response = await getAvailableSlashCommands(mockController as Controller, EmptyRequest.create())
|
||||
|
||||
// Should only appear once
|
||||
const matches = response.commands.filter(
|
||||
(cmd) => cmd.name === "shared-workflow.md"
|
||||
);
|
||||
matches.length.should.equal(1);
|
||||
});
|
||||
// Should only appear once
|
||||
const matches = response.commands.filter((cmd) => cmd.name === "shared-workflow.md")
|
||||
matches.length.should.equal(1)
|
||||
})
|
||||
|
||||
it("should include global workflow if local with same name is disabled", async () => {
|
||||
mockStateManager.getWorkspaceStateKey
|
||||
.withArgs("workflowToggles")
|
||||
.returns({
|
||||
"/local/path/shared-workflow.md": false, // disabled locally
|
||||
});
|
||||
mockStateManager.getGlobalSettingsKey
|
||||
.withArgs("globalWorkflowToggles")
|
||||
.returns({
|
||||
"/global/path/shared-workflow.md": true, // enabled globally
|
||||
});
|
||||
it("should include global workflow if local with same name is disabled", async () => {
|
||||
mockStateManager.getWorkspaceStateKey.withArgs("workflowToggles").returns({
|
||||
"/local/path/shared-workflow.md": false, // disabled locally
|
||||
})
|
||||
mockStateManager.getGlobalSettingsKey.withArgs("globalWorkflowToggles").returns({
|
||||
"/global/path/shared-workflow.md": true, // enabled globally
|
||||
})
|
||||
|
||||
const response = await getAvailableSlashCommands(
|
||||
mockController as Controller,
|
||||
EmptyRequest.create()
|
||||
);
|
||||
const response = await getAvailableSlashCommands(mockController as Controller, EmptyRequest.create())
|
||||
|
||||
// Global should appear since local is disabled
|
||||
const workflow = response.commands.find(
|
||||
(cmd) => cmd.name === "shared-workflow.md"
|
||||
);
|
||||
workflow!.should.not.be.undefined();
|
||||
});
|
||||
});
|
||||
// Global should appear since local is disabled
|
||||
const workflow = response.commands.find((cmd) => cmd.name === "shared-workflow.md")
|
||||
workflow!.should.not.be.undefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe("Remote Workflows", () => {
|
||||
it("should include alwaysEnabled remote workflows", async () => {
|
||||
mockStateManager.getRemoteConfigSettings.returns({
|
||||
remoteGlobalWorkflows: [
|
||||
{ name: "always-on-workflow", alwaysEnabled: true },
|
||||
],
|
||||
});
|
||||
describe("Remote Workflows", () => {
|
||||
it("should include alwaysEnabled remote workflows", async () => {
|
||||
mockStateManager.getRemoteConfigSettings.returns({
|
||||
remoteGlobalWorkflows: [{ name: "always-on-workflow", alwaysEnabled: true }],
|
||||
})
|
||||
|
||||
const response = await getAvailableSlashCommands(
|
||||
mockController as Controller,
|
||||
EmptyRequest.create()
|
||||
);
|
||||
const response = await getAvailableSlashCommands(mockController as Controller, EmptyRequest.create())
|
||||
|
||||
const workflow = response.commands.find(
|
||||
(cmd) => cmd.name === "always-on-workflow"
|
||||
);
|
||||
workflow!.should.not.be.undefined();
|
||||
workflow!.section.should.equal("custom");
|
||||
});
|
||||
const workflow = response.commands.find((cmd) => cmd.name === "always-on-workflow")
|
||||
workflow!.should.not.be.undefined()
|
||||
workflow!.section.should.equal("custom")
|
||||
})
|
||||
|
||||
it("should include remote workflows enabled by toggle", async () => {
|
||||
mockStateManager.getRemoteConfigSettings.returns({
|
||||
remoteGlobalWorkflows: [
|
||||
{ name: "toggle-workflow", alwaysEnabled: false },
|
||||
],
|
||||
});
|
||||
mockStateManager.getGlobalStateKey
|
||||
.withArgs("remoteWorkflowToggles")
|
||||
.returns({
|
||||
"toggle-workflow": true, // not explicitly disabled
|
||||
});
|
||||
it("should include remote workflows enabled by toggle", async () => {
|
||||
mockStateManager.getRemoteConfigSettings.returns({
|
||||
remoteGlobalWorkflows: [{ name: "toggle-workflow", alwaysEnabled: false }],
|
||||
})
|
||||
mockStateManager.getGlobalStateKey.withArgs("remoteWorkflowToggles").returns({
|
||||
"toggle-workflow": true, // not explicitly disabled
|
||||
})
|
||||
|
||||
const response = await getAvailableSlashCommands(
|
||||
mockController as Controller,
|
||||
EmptyRequest.create()
|
||||
);
|
||||
const response = await getAvailableSlashCommands(mockController as Controller, EmptyRequest.create())
|
||||
|
||||
const workflow = response.commands.find(
|
||||
(cmd) => cmd.name === "toggle-workflow"
|
||||
);
|
||||
workflow!.should.not.be.undefined();
|
||||
});
|
||||
const workflow = response.commands.find((cmd) => cmd.name === "toggle-workflow")
|
||||
workflow!.should.not.be.undefined()
|
||||
})
|
||||
|
||||
it("should exclude remote workflows explicitly disabled by toggle", async () => {
|
||||
mockStateManager.getRemoteConfigSettings.returns({
|
||||
remoteGlobalWorkflows: [
|
||||
{ name: "disabled-remote", alwaysEnabled: false },
|
||||
],
|
||||
});
|
||||
mockStateManager.getGlobalStateKey
|
||||
.withArgs("remoteWorkflowToggles")
|
||||
.returns({
|
||||
"disabled-remote": false,
|
||||
});
|
||||
it("should exclude remote workflows explicitly disabled by toggle", async () => {
|
||||
mockStateManager.getRemoteConfigSettings.returns({
|
||||
remoteGlobalWorkflows: [{ name: "disabled-remote", alwaysEnabled: false }],
|
||||
})
|
||||
mockStateManager.getGlobalStateKey.withArgs("remoteWorkflowToggles").returns({
|
||||
"disabled-remote": false,
|
||||
})
|
||||
|
||||
const response = await getAvailableSlashCommands(
|
||||
mockController as Controller,
|
||||
EmptyRequest.create()
|
||||
);
|
||||
const response = await getAvailableSlashCommands(mockController as Controller, EmptyRequest.create())
|
||||
|
||||
const workflow = response.commands.find(
|
||||
(cmd) => cmd.name === "disabled-remote"
|
||||
);
|
||||
(workflow === undefined).should.be.true();
|
||||
});
|
||||
const workflow = response.commands.find((cmd) => cmd.name === "disabled-remote")
|
||||
;(workflow === undefined).should.be.true()
|
||||
})
|
||||
|
||||
it("should include remote workflows by default if not explicitly disabled", async () => {
|
||||
mockStateManager.getRemoteConfigSettings.returns({
|
||||
remoteGlobalWorkflows: [
|
||||
{ name: "default-enabled", alwaysEnabled: false },
|
||||
],
|
||||
});
|
||||
// No toggle entry for this workflow
|
||||
mockStateManager.getGlobalStateKey
|
||||
.withArgs("remoteWorkflowToggles")
|
||||
.returns({});
|
||||
it("should include remote workflows by default if not explicitly disabled", async () => {
|
||||
mockStateManager.getRemoteConfigSettings.returns({
|
||||
remoteGlobalWorkflows: [{ name: "default-enabled", alwaysEnabled: false }],
|
||||
})
|
||||
// No toggle entry for this workflow
|
||||
mockStateManager.getGlobalStateKey.withArgs("remoteWorkflowToggles").returns({})
|
||||
|
||||
const response = await getAvailableSlashCommands(
|
||||
mockController as Controller,
|
||||
EmptyRequest.create()
|
||||
);
|
||||
const response = await getAvailableSlashCommands(mockController as Controller, EmptyRequest.create())
|
||||
|
||||
const workflow = response.commands.find(
|
||||
(cmd) => cmd.name === "default-enabled"
|
||||
);
|
||||
workflow!.should.not.be.undefined();
|
||||
});
|
||||
});
|
||||
const workflow = response.commands.find((cmd) => cmd.name === "default-enabled")
|
||||
workflow!.should.not.be.undefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe("Edge Cases", () => {
|
||||
it("should handle null/undefined state values gracefully", async () => {
|
||||
mockStateManager.getWorkspaceStateKey.returns(null);
|
||||
mockStateManager.getGlobalSettingsKey.returns(undefined);
|
||||
mockStateManager.getGlobalStateKey.returns(null);
|
||||
mockStateManager.getRemoteConfigSettings.returns(null);
|
||||
describe("Edge Cases", () => {
|
||||
it("should handle null/undefined state values gracefully", async () => {
|
||||
mockStateManager.getWorkspaceStateKey.returns(null)
|
||||
mockStateManager.getGlobalSettingsKey.returns(undefined)
|
||||
mockStateManager.getGlobalStateKey.returns(null)
|
||||
mockStateManager.getRemoteConfigSettings.returns(null)
|
||||
|
||||
const response = await getAvailableSlashCommands(
|
||||
mockController as Controller,
|
||||
EmptyRequest.create()
|
||||
);
|
||||
const response = await getAvailableSlashCommands(mockController as Controller, EmptyRequest.create())
|
||||
|
||||
// Should still return base commands
|
||||
response.commands.length.should.be.greaterThanOrEqual(
|
||||
BASE_SLASH_COMMANDS.length
|
||||
);
|
||||
});
|
||||
// Should still return base commands
|
||||
response.commands.length.should.be.greaterThanOrEqual(BASE_SLASH_COMMANDS.length)
|
||||
})
|
||||
|
||||
it("should handle empty workflow toggle objects", async () => {
|
||||
mockStateManager.getWorkspaceStateKey
|
||||
.withArgs("workflowToggles")
|
||||
.returns({});
|
||||
mockStateManager.getGlobalSettingsKey
|
||||
.withArgs("globalWorkflowToggles")
|
||||
.returns({});
|
||||
mockStateManager.getGlobalStateKey
|
||||
.withArgs("remoteWorkflowToggles")
|
||||
.returns({});
|
||||
mockStateManager.getRemoteConfigSettings.returns({
|
||||
remoteGlobalWorkflows: [],
|
||||
});
|
||||
it("should handle empty workflow toggle objects", async () => {
|
||||
mockStateManager.getWorkspaceStateKey.withArgs("workflowToggles").returns({})
|
||||
mockStateManager.getGlobalSettingsKey.withArgs("globalWorkflowToggles").returns({})
|
||||
mockStateManager.getGlobalStateKey.withArgs("remoteWorkflowToggles").returns({})
|
||||
mockStateManager.getRemoteConfigSettings.returns({
|
||||
remoteGlobalWorkflows: [],
|
||||
})
|
||||
|
||||
const response = await getAvailableSlashCommands(
|
||||
mockController as Controller,
|
||||
EmptyRequest.create()
|
||||
);
|
||||
const response = await getAvailableSlashCommands(mockController as Controller, EmptyRequest.create())
|
||||
|
||||
// Should only have base commands
|
||||
response.commands.length.should.equal(BASE_SLASH_COMMANDS.length);
|
||||
});
|
||||
// Should only have base commands
|
||||
response.commands.length.should.equal(BASE_SLASH_COMMANDS.length)
|
||||
})
|
||||
|
||||
it("should handle remote config with no remoteGlobalWorkflows property", async () => {
|
||||
mockStateManager.getRemoteConfigSettings.returns({});
|
||||
it("should handle remote config with no remoteGlobalWorkflows property", async () => {
|
||||
mockStateManager.getRemoteConfigSettings.returns({})
|
||||
|
||||
const response = await getAvailableSlashCommands(
|
||||
mockController as Controller,
|
||||
EmptyRequest.create()
|
||||
);
|
||||
const response = await getAvailableSlashCommands(mockController as Controller, EmptyRequest.create())
|
||||
|
||||
// Should not throw, just return base commands
|
||||
response.commands.length.should.be.greaterThanOrEqual(
|
||||
BASE_SLASH_COMMANDS.length
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
// Should not throw, just return base commands
|
||||
response.commands.length.should.be.greaterThanOrEqual(BASE_SLASH_COMMANDS.length)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* Utility for graceful process termination with SIGKILL fallback.
|
||||
*
|
||||
* Handles cross-platform process tree termination:
|
||||
* - Sends SIGTERM first for graceful shutdown
|
||||
* - Waits for configurable timeout
|
||||
* - Falls back to SIGKILL if process doesn't exit
|
||||
*/
|
||||
|
||||
import { ChildProcess } from "child_process"
|
||||
import treeKill from "tree-kill"
|
||||
|
||||
export interface TerminateProcessTreeOptions {
|
||||
/** Process ID to terminate */
|
||||
pid: number
|
||||
/** Child process reference (for exit event listening) */
|
||||
childProcess?: ChildProcess | null
|
||||
/** Function to check if process has already completed */
|
||||
isCompleted: () => boolean
|
||||
/** Timeout in ms before escalating to SIGKILL (default: 2000) */
|
||||
gracefulTimeoutMs?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Terminates a process tree with graceful shutdown and SIGKILL fallback.
|
||||
*
|
||||
* Uses tree-kill to handle cross-platform process tree termination:
|
||||
* - On Windows: Uses taskkill /T /F (always force kills)
|
||||
* - On Unix: Sends signal to entire process tree
|
||||
*
|
||||
* @param options Termination options
|
||||
*/
|
||||
export async function terminateProcessTree(options: TerminateProcessTreeOptions): Promise<void> {
|
||||
const { pid, childProcess, isCompleted, gracefulTimeoutMs = 2000 } = options
|
||||
|
||||
// Send SIGTERM for graceful shutdown
|
||||
treeKill(pid, "SIGTERM")
|
||||
|
||||
// Wait for graceful shutdown or timeout
|
||||
const gracefulTimeout = new Promise<void>((resolve) => setTimeout(resolve, gracefulTimeoutMs))
|
||||
const processExit = new Promise<void>((resolve) => {
|
||||
if (childProcess) {
|
||||
childProcess.once("exit", () => resolve())
|
||||
} else {
|
||||
// No child process reference, just wait for timeout
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
|
||||
await Promise.race([processExit, gracefulTimeout])
|
||||
|
||||
// Force kill if still running
|
||||
if (!isCompleted()) {
|
||||
treeKill(pid, "SIGKILL")
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ const config: StorybookConfig = {
|
||||
// Define environment variables for Storybook
|
||||
config.define = {
|
||||
...config.define,
|
||||
"process.platform": JSON.stringify(process?.platform),
|
||||
"process.env": {
|
||||
...process.env,
|
||||
IS_DEV: JSON.stringify(true),
|
||||
|
||||
Generated
+64
-4
@@ -67,7 +67,7 @@
|
||||
"@vitest/coverage-v8": "^3.0.9",
|
||||
"globals": "^15.14.0",
|
||||
"jsdom": "^26.0.0",
|
||||
"storybook": "^9.1.6",
|
||||
"storybook": "^9.1.17",
|
||||
"tailwindcss": "^4.1.13",
|
||||
"typescript": "^5.7.3",
|
||||
"vite": "^7.1.11",
|
||||
@@ -6710,6 +6710,66 @@
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": {
|
||||
"version": "1.4.5",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/wasi-threads": "1.0.4",
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": {
|
||||
"version": "1.4.5",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
|
||||
"version": "1.0.4",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": {
|
||||
"version": "0.2.12",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/core": "^1.4.3",
|
||||
"@emnapi/runtime": "^1.4.3",
|
||||
"@tybys/wasm-util": "^0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": {
|
||||
"version": "0.10.0",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": {
|
||||
"version": "2.8.0",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "0BSD",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
|
||||
"version": "4.1.13",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.13.tgz",
|
||||
@@ -12915,9 +12975,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/storybook": {
|
||||
"version": "9.1.7",
|
||||
"resolved": "https://registry.npmjs.org/storybook/-/storybook-9.1.7.tgz",
|
||||
"integrity": "sha512-X8YSQMNuqV9DklQLZH6mLKpDn15Z5tuUUTAIYsiGqx5BwsjtXnv5K04fXgl3jqTZyUauzV/ii8KdT04NVLtMwQ==",
|
||||
"version": "9.1.17",
|
||||
"resolved": "https://registry.npmjs.org/storybook/-/storybook-9.1.17.tgz",
|
||||
"integrity": "sha512-kfr6kxQAjA96ADlH6FMALJwJ+eM80UqXy106yVHNgdsAP/CdzkkicglRAhZAvUycXK9AeadF6KZ00CWLtVMN4w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
|
||||
@@ -75,7 +75,7 @@
|
||||
"@vitest/coverage-v8": "^3.0.9",
|
||||
"globals": "^15.14.0",
|
||||
"jsdom": "^26.0.0",
|
||||
"storybook": "^9.1.6",
|
||||
"storybook": "^9.1.17",
|
||||
"tailwindcss": "^4.1.13",
|
||||
"typescript": "^5.7.3",
|
||||
"vite": "^7.1.11",
|
||||
|
||||
@@ -2,10 +2,10 @@ import { HeroUIProvider } from "@heroui/react"
|
||||
import { DEFAULT_AUTO_APPROVAL_SETTINGS } from "@shared/AutoApprovalSettings"
|
||||
import { type ApiConfiguration, bedrockModels } from "@shared/api"
|
||||
import { CLINE_ONBOARDING_MODELS } from "@shared/cline/onboarding"
|
||||
import type { ClineMessage } from "@shared/ExtensionMessage"
|
||||
import type { ClineMessage, ClineSayTool } from "@shared/ExtensionMessage"
|
||||
import type { HistoryItem } from "@shared/HistoryItem"
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite"
|
||||
import { useMemo } from "react"
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import { expect, userEvent, within } from "storybook/test"
|
||||
import { ExtensionStateContext, useExtensionState } from "@/context/ExtensionStateContext"
|
||||
import ChatView from "./components/chat/ChatView"
|
||||
@@ -72,7 +72,7 @@ The ChatView component is the main interface for interacting with Cline. It prov
|
||||
- Learning and exploration
|
||||
|
||||
**Note**: In Storybook, some features like file operations, command execution, and API calls are mocked for demonstration purposes.
|
||||
`,
|
||||
`,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -157,6 +157,21 @@ const createMessage = (
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const createSayToolMessage = (
|
||||
minutesAgo: number,
|
||||
sayTool: ClineSayTool,
|
||||
overrides: Partial<ClineMessage> = {},
|
||||
): ClineMessage => ({
|
||||
ts: Date.now() - minutesAgo * 60000,
|
||||
type: "say",
|
||||
say: "tool",
|
||||
text: JSON.stringify({
|
||||
operationIsLocatedInWorkspace: true,
|
||||
...sayTool,
|
||||
}),
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const createApiReqMessage = (minutesAgo: number, request: string, metrics: any = {}) =>
|
||||
createMessage(
|
||||
minutesAgo,
|
||||
@@ -235,6 +250,7 @@ const createMockState = (overrides: any = {}) => ({
|
||||
onboardingModels: undefined,
|
||||
openRouterModels: bedrockModels,
|
||||
showAnnouncement: false,
|
||||
backgroundEditEnabled: false,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
@@ -378,6 +394,19 @@ export const EmptyState: Story = {
|
||||
},
|
||||
}
|
||||
|
||||
export const ReturnUser: Story = {
|
||||
decorators: [
|
||||
createStoryDecorator({ clineMessages: [], taskHistory: mockTaskHistory, isNewUser: true, showAnnouncement: false }),
|
||||
],
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Shows the home screen populated with conversation history for returning users.",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export const ActiveConversation: Story = {
|
||||
decorators: [createStoryDecorator({ task: mockTaskHistory[0], currentTaskItem: mockTaskHistory[0] })],
|
||||
parameters: {
|
||||
@@ -764,3 +793,336 @@ export const ResumeCompletedTask = quickStory(
|
||||
"The previous task has been completed. Would you like to start a new task?",
|
||||
"Shows Start New Task option for resume completed task.",
|
||||
)
|
||||
|
||||
// Diff Edit Stories - New Format
|
||||
const createNewFormatMultiFileMessages = () => [
|
||||
createMessage(5, "say", "task", "Help me refactor the authentication module"),
|
||||
createMessage(4.7, "say", "text", "I'll help you refactor the authentication module. Let me make the necessary changes."),
|
||||
createSayToolMessage(4.3, {
|
||||
tool: "editedExistingFile",
|
||||
path: "src/auth/types.ts",
|
||||
content: `*** Begin Patch
|
||||
*** Add File: src/auth/types.ts
|
||||
+export interface User {
|
||||
+ id: string
|
||||
+ email: string
|
||||
+ role: 'admin' | 'user'
|
||||
+}
|
||||
+
|
||||
+export interface AuthState {
|
||||
+ user: User | null
|
||||
+ isAuthenticated: boolean
|
||||
+}
|
||||
|
||||
*** Update File: src/auth/login.ts
|
||||
@@
|
||||
-function login(email, password) {
|
||||
- return fetch('/api/login', {
|
||||
+function login(email: string, password: string): Promise<AuthState> {
|
||||
+ return fetch('/api/login', {
|
||||
method: 'POST',
|
||||
- body: { email, password }
|
||||
+ body: JSON.stringify({ email, password }),
|
||||
+ headers: { 'Content-Type': 'application/json' }
|
||||
})
|
||||
}
|
||||
@@
|
||||
-export default login
|
||||
+export { login }
|
||||
|
||||
*** Delete File: src/auth/old-utils.js
|
||||
-function deprecatedHelper() {
|
||||
- console.log('This is deprecated')
|
||||
-}
|
||||
-
|
||||
-module.exports = { deprecatedHelper }
|
||||
*** End Patch`,
|
||||
}),
|
||||
{ partial: false },
|
||||
]
|
||||
|
||||
export const DiffEditNewFormat: Story = {
|
||||
decorators: [createStoryDecorator({ backgroundEditEnabled: true, clineMessages: createNewFormatMultiFileMessages() })],
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Shows the new diff edit format with multiple file operations (Add, Update, Delete) displayed in an organized, expandable view.",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export const DiffEditNewFormatStreaming: Story = {
|
||||
decorators: [
|
||||
(Story) => {
|
||||
const [messages, setMessages] = useState<ClineMessage[]>([
|
||||
createMessage(5, "say", "task", "Add TypeScript types to the user module"),
|
||||
createMessage(4.7, "say", "text", "I'll add TypeScript types to improve type safety."),
|
||||
])
|
||||
const mockState = useMemo(() => createMockState({ backgroundEditEnabled: true, clineMessages: messages }), [messages])
|
||||
|
||||
useEffect(() => {
|
||||
// Simulate streaming: progressively add more content
|
||||
const partialPatch = `*** Begin Patch
|
||||
*** Update File: src/user/profile.ts
|
||||
@@
|
||||
-interface UserProfile {
|
||||
- name: string
|
||||
+interface UserProfile {
|
||||
+ id: string
|
||||
+ name: string`
|
||||
|
||||
const morePatch =
|
||||
partialPatch +
|
||||
`
|
||||
+ email: string
|
||||
+ createdAt: Date`
|
||||
|
||||
const completePatch =
|
||||
morePatch +
|
||||
`
|
||||
+}
|
||||
*** End Patch`
|
||||
|
||||
// Add initial partial message
|
||||
const timer1 = setTimeout(() => {
|
||||
setMessages((prev: ClineMessage[]) => [
|
||||
...prev,
|
||||
createSayToolMessage(
|
||||
4.3,
|
||||
{
|
||||
tool: "editedExistingFile",
|
||||
path: "src/user/profile.ts",
|
||||
content: partialPatch,
|
||||
},
|
||||
{ partial: true },
|
||||
),
|
||||
])
|
||||
}, 500)
|
||||
|
||||
// Add more content
|
||||
const timer2 = setTimeout(() => {
|
||||
setMessages((prev: ClineMessage[]) => {
|
||||
const updated = [...prev]
|
||||
updated[updated.length - 1] = createSayToolMessage(
|
||||
4.3,
|
||||
{
|
||||
tool: "editedExistingFile",
|
||||
path: "src/user/profile.ts",
|
||||
content: morePatch,
|
||||
},
|
||||
{ partial: true },
|
||||
)
|
||||
return updated
|
||||
})
|
||||
}, 1500)
|
||||
|
||||
// Complete the patch
|
||||
const timer3 = setTimeout(() => {
|
||||
setMessages((prev: ClineMessage[]) => {
|
||||
const updated = [...prev]
|
||||
updated[updated.length - 1] = createSayToolMessage(
|
||||
4.3,
|
||||
{
|
||||
tool: "editedExistingFile",
|
||||
path: "src/user/profile.ts",
|
||||
content: completePatch,
|
||||
},
|
||||
{ partial: false },
|
||||
)
|
||||
return updated
|
||||
})
|
||||
}, 2500)
|
||||
|
||||
return () => {
|
||||
clearTimeout(timer1)
|
||||
clearTimeout(timer2)
|
||||
clearTimeout(timer3)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<ExtensionStateProviderMock value={mockState}>
|
||||
<div className="w-full h-full flex justify-center items-center overflow-hidden">
|
||||
<div className={SIDEBAR_CLASS}>
|
||||
<Story />
|
||||
</div>
|
||||
</div>
|
||||
</ExtensionStateProviderMock>
|
||||
)
|
||||
},
|
||||
],
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Shows the new diff edit format while streaming (incomplete patch without End Patch marker).",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Diff Edit Stories - Replace Diff Edit Format
|
||||
const createReplaceDiffFormatPatchMessages = () => [
|
||||
createMessage(5, "say", "task", "Fix the validation logic in the form"),
|
||||
createMessage(4.7, "say", "text", "I'll fix the validation logic using the updated pattern."),
|
||||
createSayToolMessage(4.3, {
|
||||
tool: "editedExistingFile",
|
||||
path: "src/auth/types.ts",
|
||||
content: `------- SEARCH
|
||||
function validateEmail(email) {
|
||||
return email.includes('@')
|
||||
}
|
||||
=======
|
||||
function validateEmail(email: string): boolean {
|
||||
const emailRegex = /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/
|
||||
return emailRegex.test(email)
|
||||
}
|
||||
+++++++ REPLACE`,
|
||||
}),
|
||||
]
|
||||
|
||||
export const DiffEditReplaceDiffFormat: Story = {
|
||||
decorators: [createStoryDecorator({ backgroundEditEnabled: true, clineMessages: createReplaceDiffFormatPatchMessages() })],
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Shows the old SEARCH/REPLACE diff format (backward compatibility) with complete markers, automatically converted to the new format display.",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export const DiffEditReplaceDiffFormatStreaming: Story = {
|
||||
decorators: [
|
||||
(Story) => {
|
||||
const [messages, setMessages] = useState<ClineMessage[]>([
|
||||
createMessage(5, "say", "task", "Update error handling"),
|
||||
createMessage(4.7, "say", "text", "I'll improve the error handling in the API client."),
|
||||
])
|
||||
const mockState = useMemo(() => createMockState({ backgroundEditEnabled: true, clineMessages: messages }), [messages])
|
||||
|
||||
useEffect(() => {
|
||||
const completePatch = `------- SEARCH
|
||||
try {
|
||||
const response = await fetch(url)
|
||||
return response.json()
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
=======
|
||||
try {
|
||||
const response = await fetch(url)
|
||||
if (!response.ok) {
|
||||
throw new Error(\`HTTP error! status: \${response.status}\`)
|
||||
}
|
||||
return response.json()
|
||||
} catch (error) {
|
||||
console.error('API request failed:', error)
|
||||
throw error
|
||||
}
|
||||
+++++++ REPLACE`
|
||||
|
||||
const patchChunks = completePatch.split("\n")
|
||||
let currentIndex = 0
|
||||
|
||||
const intervalId = setInterval(() => {
|
||||
if (currentIndex >= patchChunks.length) {
|
||||
clearInterval(intervalId)
|
||||
return
|
||||
}
|
||||
|
||||
setMessages((prev: ClineMessage[]) => {
|
||||
const updated = [...prev]
|
||||
updated[updated.length - 1] = createSayToolMessage(
|
||||
4.3,
|
||||
{
|
||||
tool: "editedExistingFile",
|
||||
path: "src/auth/types.ts",
|
||||
content: patchChunks.slice(0, currentIndex + 1).join("\n"),
|
||||
},
|
||||
{ partial: currentIndex !== patchChunks.length - 1 },
|
||||
)
|
||||
return updated
|
||||
})
|
||||
|
||||
currentIndex++
|
||||
}, 500)
|
||||
|
||||
return () => clearInterval(intervalId)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<ExtensionStateProviderMock value={mockState}>
|
||||
<div className="w-full h-full flex justify-center items-center overflow-hidden">
|
||||
<div className={SIDEBAR_CLASS}>
|
||||
<Story />
|
||||
</div>
|
||||
</div>
|
||||
</ExtensionStateProviderMock>
|
||||
)
|
||||
},
|
||||
],
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Shows the old SEARCH/REPLACE diff format while streaming (incomplete, missing REPLACE marker), demonstrating graceful handling of partial content.",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Combined example showing both formats in one conversation
|
||||
const createMixedFormatMessages = () => [
|
||||
createMessage(5, "say", "task", "Refactor the entire authentication system"),
|
||||
createMessage(4.7, "say", "text", "I'll refactor the authentication system. Starting with the login function."),
|
||||
createSayToolMessage(4.5, {
|
||||
tool: "editedExistingFile",
|
||||
path: "src/auth/types.ts",
|
||||
content: `------- SEARCH
|
||||
function login(username, password) {
|
||||
return authenticateUser(username, password)
|
||||
}
|
||||
=======
|
||||
async function login(username: string, password: string): Promise<AuthResult> {
|
||||
return await authenticateUser(username, password)
|
||||
}
|
||||
+++++++ REPLACE`,
|
||||
}),
|
||||
createMessage(4.3, "say", "text", "Great! Now let me add the type definitions and update the authentication module."),
|
||||
createSayToolMessage(4.0, {
|
||||
tool: "editedExistingFile",
|
||||
path: "src/auth/types.ts",
|
||||
content: `*** Begin Patch
|
||||
*** Add File: src/auth/types.ts
|
||||
+export interface AuthResult {
|
||||
+ success: boolean
|
||||
+ token?: string
|
||||
+ error?: string
|
||||
+}
|
||||
+
|
||||
+export interface LoginCredentials {
|
||||
+ username: string
|
||||
+ password: string
|
||||
+}
|
||||
|
||||
*** Update File: src/auth/authenticate.ts
|
||||
@@
|
||||
-function authenticateUser(username, password) {
|
||||
+async function authenticateUser(username: string, password: string): Promise<AuthResult> {
|
||||
// Authentication logic
|
||||
+ return { success: true, token: 'mock-token' }
|
||||
}
|
||||
*** End Patch`,
|
||||
}),
|
||||
]
|
||||
|
||||
export const DiffEditMixedFormats: Story = {
|
||||
decorators: [createStoryDecorator({ clineMessages: createMixedFormatMessages() })],
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: "Shows a conversation using both search / replace and apply patch diff formats, demonstrating seamless backward compatibility and format detection.",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -37,6 +37,7 @@ import { cn } from "@/lib/utils"
|
||||
import { FileServiceClient, TaskServiceClient, UiServiceClient } from "@/services/grpc-client"
|
||||
import { findMatchingResourceOrTemplate, getMcpServerDisplayName } from "@/utils/mcp"
|
||||
import CodeAccordian, { cleanPathPrefix } from "../common/CodeAccordian"
|
||||
import { DiffEditRow } from "./DiffEditRow"
|
||||
import { ErrorBlockTitle } from "./ErrorBlockTitle"
|
||||
import ErrorRow from "./ErrorRow"
|
||||
import HookMessage from "./HookMessage"
|
||||
@@ -165,6 +166,44 @@ const CommandOutput = memo(
|
||||
return null
|
||||
}
|
||||
|
||||
// Check if output contains a log file path indicator
|
||||
const logFilePathMatch = output.match(/📋 Output is being logged to: ([^\n]+)/)
|
||||
const logFilePath = logFilePathMatch ? logFilePathMatch[1].trim() : null
|
||||
|
||||
// Render output with clickable log file path
|
||||
const renderOutput = () => {
|
||||
if (!logFilePath) {
|
||||
return <CodeBlock forceWrap={true} source={`${"```"}shell\n${output}\n${"```"}`} />
|
||||
}
|
||||
|
||||
// Split output into parts: before log path, log path line, after log path
|
||||
const logPathLineStart = output.indexOf("📋 Output is being logged to:")
|
||||
const logPathLineEnd = output.indexOf("\n", logPathLineStart)
|
||||
const beforeLogPath = output.substring(0, logPathLineStart)
|
||||
const afterLogPath = logPathLineEnd !== -1 ? output.substring(logPathLineEnd) : ""
|
||||
|
||||
// Extract just the filename from the full path for display
|
||||
const fileName = logFilePath.split("/").pop() || logFilePath
|
||||
|
||||
return (
|
||||
<>
|
||||
{beforeLogPath && <CodeBlock forceWrap={true} source={`${"```"}shell\n${beforeLogPath}\n${"```"}`} />}
|
||||
<div
|
||||
className="flex flex-wrap items-center gap-1.5 px-3 py-2 mx-2 my-1.5 rounded bg-banner-background cursor-pointer hover:brightness-110 transition-colors"
|
||||
onClick={() => {
|
||||
FileServiceClient.openFile(StringRequest.create({ value: logFilePath })).catch((err) =>
|
||||
console.error("Failed to open log file:", err),
|
||||
)
|
||||
}}
|
||||
title={`Click to open: ${logFilePath}`}>
|
||||
<span className="shrink-0">📋 Output is being logged to:</span>
|
||||
<span className="text-vscode-textLink-foreground underline break-all">{fileName}</span>
|
||||
</div>
|
||||
{afterLogPath && <CodeBlock forceWrap={true} source={`${"```"}shell\n${afterLogPath}\n${"```"}`} />}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
@@ -186,9 +225,7 @@ const CommandOutput = memo(
|
||||
scrollBehavior: "smooth",
|
||||
backgroundColor: TERMINAL_CODE_BLOCK_BG_COLOR,
|
||||
}}>
|
||||
<div style={{ backgroundColor: TERMINAL_CODE_BLOCK_BG_COLOR }}>
|
||||
<CodeBlock forceWrap={true} source={`${"```"}shell\n${output}\n${"```"}`} />
|
||||
</div>
|
||||
<div style={{ backgroundColor: TERMINAL_CODE_BLOCK_BG_COLOR }}>{renderOutput()}</div>
|
||||
</div>
|
||||
{/* Show notch only if there's more than 5 lines */}
|
||||
{lineCount > 5 && (
|
||||
@@ -276,7 +313,8 @@ export const ChatRowContent = memo(
|
||||
onSetQuote,
|
||||
onCancelCommand,
|
||||
}: ChatRowContentProps) => {
|
||||
const { mcpServers, mcpMarketplaceCatalog, onRelinquishControl, vscodeTerminalExecutionMode } = useExtensionState()
|
||||
const { backgroundEditEnabled, mcpServers, mcpMarketplaceCatalog, onRelinquishControl, vscodeTerminalExecutionMode } =
|
||||
useExtensionState()
|
||||
const [seeNewChangesDisabled, setSeeNewChangesDisabled] = useState(false)
|
||||
const [explainChangesDisabled, setExplainChangesDisabled] = useState(false)
|
||||
const [quoteButtonState, setQuoteButtonState] = useState<QuoteButtonState>({
|
||||
@@ -556,13 +594,17 @@ export const ChatRowContent = memo(
|
||||
toolIcon("sign-out", "yellow", -90, "This file is outside of your workspace")}
|
||||
<span style={{ fontWeight: "bold" }}>{editToolTitle}</span>
|
||||
</div>
|
||||
<CodeAccordian
|
||||
// isLoading={message.partial}
|
||||
code={tool.content}
|
||||
isExpanded={isExpanded}
|
||||
onToggleExpand={handleToggle}
|
||||
path={tool.path!}
|
||||
/>
|
||||
{backgroundEditEnabled && tool.path && tool.content ? (
|
||||
<DiffEditRow isLoading={message.partial} patch={tool.content} path={tool.path} />
|
||||
) : (
|
||||
<CodeAccordian
|
||||
// isLoading={message.partial}
|
||||
code={tool.content}
|
||||
isExpanded={isExpanded}
|
||||
onToggleExpand={handleToggle}
|
||||
path={tool.path!}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
case "fileDeleted":
|
||||
@@ -592,13 +634,17 @@ export const ChatRowContent = memo(
|
||||
toolIcon("sign-out", "yellow", -90, "This file is outside of your workspace")}
|
||||
<span style={{ fontWeight: "bold" }}>Cline wants to create a new file:</span>
|
||||
</div>
|
||||
<CodeAccordian
|
||||
code={tool.content!}
|
||||
isExpanded={isExpanded}
|
||||
isLoading={message.partial}
|
||||
onToggleExpand={handleToggle}
|
||||
path={tool.path!}
|
||||
/>
|
||||
{backgroundEditEnabled && tool.path && tool.content ? (
|
||||
<DiffEditRow patch={tool.content} path={tool.path} />
|
||||
) : (
|
||||
<CodeAccordian
|
||||
code={tool.content!}
|
||||
isExpanded={isExpanded}
|
||||
isLoading={message.partial}
|
||||
onToggleExpand={handleToggle}
|
||||
path={tool.path!}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
case "readFile":
|
||||
@@ -1119,7 +1165,7 @@ export const ChatRowContent = memo(
|
||||
? "Pending"
|
||||
: isCommandCompleted
|
||||
? "Completed"
|
||||
: "Not Executed"}
|
||||
: "Skipped"}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: "8px", flexShrink: 0 }}>
|
||||
@@ -1662,41 +1708,35 @@ export const ChatRowContent = memo(
|
||||
<>
|
||||
<div
|
||||
style={{
|
||||
...headerStyle,
|
||||
marginBottom: "10px",
|
||||
borderRadius: 6,
|
||||
border: `1px solid ${successColor}`,
|
||||
backgroundColor: "color-mix(in srgb, var(--vscode-charts-green) 8%, transparent)",
|
||||
padding: "10px 12px",
|
||||
}}>
|
||||
{icon}
|
||||
{title}
|
||||
{/* <TaskFeedbackButtons
|
||||
isFromHistory={
|
||||
!isLast ||
|
||||
lastModifiedMessage?.ask === "resume_completed_task" ||
|
||||
lastModifiedMessage?.ask === "resume_task"
|
||||
}
|
||||
messageTs={message.ts}
|
||||
<div
|
||||
style={{
|
||||
marginLeft: "auto",
|
||||
}}
|
||||
/> */}
|
||||
...headerStyle,
|
||||
marginBottom: "10px",
|
||||
}}>
|
||||
{icon}
|
||||
{title}
|
||||
</div>
|
||||
<WithCopyButton
|
||||
copyButtonStyle={{ bottom: 10, right: -8 }}
|
||||
onMouseUp={handleMouseUp}
|
||||
position="bottom-right"
|
||||
ref={contentRef}
|
||||
textToCopy={text}>
|
||||
<Markdown markdown={text} />
|
||||
{quoteButtonState.visible && (
|
||||
<QuoteButton
|
||||
left={quoteButtonState.left}
|
||||
onClick={handleQuoteClick}
|
||||
top={quoteButtonState.top}
|
||||
/>
|
||||
)}
|
||||
</WithCopyButton>
|
||||
</div>
|
||||
<WithCopyButton
|
||||
onMouseUp={handleMouseUp}
|
||||
position="bottom-right"
|
||||
ref={contentRef}
|
||||
style={{
|
||||
color: "var(--vscode-charts-green)",
|
||||
paddingTop: 10,
|
||||
}}
|
||||
textToCopy={text}>
|
||||
<Markdown markdown={text} />
|
||||
{quoteButtonState.visible && (
|
||||
<QuoteButton
|
||||
left={quoteButtonState.left}
|
||||
onClick={handleQuoteClick}
|
||||
top={quoteButtonState.top}
|
||||
/>
|
||||
)}
|
||||
</WithCopyButton>
|
||||
{message.partial !== true && hasChanges && (
|
||||
<div style={{ paddingTop: 17, display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
<SuccessButton
|
||||
@@ -1864,9 +1904,9 @@ export const ChatRowContent = memo(
|
||||
<div
|
||||
style={{
|
||||
padding: 8,
|
||||
backgroundColor: "rgba(0, 122, 204, 0.1)",
|
||||
backgroundColor: "color-mix(in srgb, var(--vscode-textLink-foreground) 10%, transparent)",
|
||||
borderRadius: 3,
|
||||
border: "1px solid rgba(0, 122, 204, 0.3)",
|
||||
border: "1px solid color-mix(in srgb, var(--vscode-textLink-foreground) 30%, transparent)",
|
||||
}}>
|
||||
<div
|
||||
style={{
|
||||
@@ -1967,41 +2007,46 @@ export const ChatRowContent = memo(
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
...headerStyle,
|
||||
marginBottom: "10px",
|
||||
borderRadius: 6,
|
||||
border: `1px solid ${successColor}`,
|
||||
backgroundColor: "color-mix(in srgb, var(--vscode-charts-green) 8%, transparent)",
|
||||
padding: "10px 12px",
|
||||
}}>
|
||||
{icon}
|
||||
{title}
|
||||
<TaskFeedbackButtons
|
||||
isFromHistory={
|
||||
!isLast ||
|
||||
lastModifiedMessage?.ask === "resume_completed_task" ||
|
||||
lastModifiedMessage?.ask === "resume_task"
|
||||
}
|
||||
messageTs={message.ts}
|
||||
<div
|
||||
style={{
|
||||
marginLeft: "auto",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<WithCopyButton
|
||||
onMouseUp={handleMouseUp}
|
||||
position="bottom-right"
|
||||
ref={contentRef}
|
||||
style={{
|
||||
color: "var(--vscode-charts-green)",
|
||||
paddingTop: 10,
|
||||
}}
|
||||
textToCopy={text}>
|
||||
<Markdown markdown={text} />
|
||||
{quoteButtonState.visible && (
|
||||
<QuoteButton
|
||||
left={quoteButtonState.left}
|
||||
onClick={handleQuoteClick}
|
||||
top={quoteButtonState.top}
|
||||
...headerStyle,
|
||||
marginBottom: "10px",
|
||||
}}>
|
||||
{icon}
|
||||
{title}
|
||||
<TaskFeedbackButtons
|
||||
isFromHistory={
|
||||
!isLast ||
|
||||
lastModifiedMessage?.ask === "resume_completed_task" ||
|
||||
lastModifiedMessage?.ask === "resume_task"
|
||||
}
|
||||
messageTs={message.ts}
|
||||
style={{
|
||||
marginLeft: "auto",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</WithCopyButton>
|
||||
</div>
|
||||
<WithCopyButton
|
||||
copyButtonStyle={{ bottom: 10, right: -8 }}
|
||||
onMouseUp={handleMouseUp}
|
||||
position="bottom-right"
|
||||
ref={contentRef}
|
||||
textToCopy={text}>
|
||||
<Markdown markdown={text} />
|
||||
{quoteButtonState.visible && (
|
||||
<QuoteButton
|
||||
left={quoteButtonState.left}
|
||||
onClick={handleQuoteClick}
|
||||
top={quoteButtonState.top}
|
||||
/>
|
||||
)}
|
||||
</WithCopyButton>
|
||||
</div>
|
||||
{message.partial !== true && hasChanges && (
|
||||
<div style={{ marginTop: 15, display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
<SuccessButton
|
||||
|
||||
@@ -0,0 +1,329 @@
|
||||
import { ChevronsDownUpIcon, FilePlus, FileText, FileX } from "lucide-react"
|
||||
import { memo, useEffect, useMemo, useRef, useState } from "react"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
interface Patch {
|
||||
action: string
|
||||
path: string
|
||||
lines: string[]
|
||||
additions: number
|
||||
deletions: number
|
||||
}
|
||||
|
||||
// Constants for format markers
|
||||
const MARKERS = {
|
||||
SEARCH_BLOCK: "------- SEARCH",
|
||||
SEARCH_SEPARATOR: "=======",
|
||||
REPLACE_BLOCK: "+++++++ REPLACE",
|
||||
NEW_BEGIN: "*** Begin Patch",
|
||||
NEW_END: "*** End Patch",
|
||||
FILE_PATTERN: /^\*\*\* (Add|Update|Delete) File: (.+)$/m,
|
||||
} as const
|
||||
|
||||
// Style mappings for actions
|
||||
const ACTION_STYLES = {
|
||||
Add: { icon: FilePlus, iconClass: "text-success", borderClass: "border-l-success" },
|
||||
Delete: { icon: FileX, iconClass: "text-error", borderClass: "border-l-error" },
|
||||
default: { icon: FileText, iconClass: "text-info", borderClass: "border-l-background" },
|
||||
} as const
|
||||
|
||||
// Style mappings for diff lines
|
||||
const LINE_STYLES = {
|
||||
"+": "bg-green-500/10 text-success border-l-1 border-green-500",
|
||||
"-": "bg-red-500/10 text-error border-l-1 border-red-500",
|
||||
default: "bg-editor-background text-editor-foreground",
|
||||
} as const
|
||||
|
||||
interface DiffEditRowProps {
|
||||
patch: string
|
||||
path: string
|
||||
isLoading?: boolean
|
||||
}
|
||||
|
||||
export const DiffEditRow = memo<DiffEditRowProps>(({ patch, path, isLoading }) => {
|
||||
const { parsedFiles, isStreaming } = useMemo(() => {
|
||||
const parsed = parsePatch(patch, path)
|
||||
return {
|
||||
parsedFiles: parsed.parsedFiles,
|
||||
isStreaming: isLoading || parsed.isStreaming,
|
||||
}
|
||||
}, [patch, path, isLoading])
|
||||
|
||||
if (!path) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4 border border-code-block-background/70 rounded-xs">
|
||||
{parsedFiles.map((file) => (
|
||||
<FileBlock file={file} isStreaming={isStreaming} key={file.path} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
const FileBlock = memo<{ file: Patch; isStreaming: boolean }>(
|
||||
({ file, isStreaming }) => {
|
||||
const [isExpanded, setIsExpanded] = useState(true)
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null)
|
||||
const shouldFollowRef = useRef(true)
|
||||
const isProgrammaticScrollRef = useRef(false)
|
||||
|
||||
// Auto-scroll to bottom during streaming
|
||||
useEffect(() => {
|
||||
const container = scrollContainerRef.current
|
||||
if (!isExpanded || !isStreaming || !shouldFollowRef.current || !container) {
|
||||
return
|
||||
}
|
||||
|
||||
isProgrammaticScrollRef.current = true
|
||||
container.scrollTop = container.scrollHeight - container.clientHeight
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
isProgrammaticScrollRef.current = false
|
||||
})
|
||||
}, [file.lines.length, isExpanded, isStreaming])
|
||||
|
||||
const handleScroll = () => {
|
||||
const container = scrollContainerRef.current
|
||||
if (!container || isProgrammaticScrollRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
const { scrollTop, scrollHeight, clientHeight } = container
|
||||
shouldFollowRef.current = Math.abs(scrollHeight - clientHeight - scrollTop) < 10
|
||||
}
|
||||
|
||||
const actionStyle = ACTION_STYLES[file.action as keyof typeof ACTION_STYLES] ?? ACTION_STYLES.default
|
||||
const ActionIcon = actionStyle.icon
|
||||
|
||||
return (
|
||||
<div className="p-1 bg-code rounded-xs border border-editor-group-border">
|
||||
<button
|
||||
className="w-full flex items-center gap-2 p-2 bg-code transition-colors rounded-t-xs justify-between cursor-pointer"
|
||||
onClick={() => setIsExpanded((prev) => !prev)}
|
||||
type="button">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={cn("flex items-center gap-2", actionStyle.borderClass)}>
|
||||
<ActionIcon className={cn("w-5 h-5", actionStyle.iconClass)} />
|
||||
<span className="font-medium">{file.path}</span>
|
||||
</div>
|
||||
</div>
|
||||
<DiffStats additions={file.additions} deletions={file.deletions} />
|
||||
</button>
|
||||
|
||||
{isExpanded && (
|
||||
<div
|
||||
className="border-t border-code-block-background max-h-72 overflow-y-auto"
|
||||
onScroll={handleScroll}
|
||||
ref={scrollContainerRef}>
|
||||
<div className="font-mono text-xs">
|
||||
{file.lines.map((line, idx) => (
|
||||
<DiffLine key={idx} line={line} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
(prev, next) =>
|
||||
prev.isStreaming === next.isStreaming &&
|
||||
prev.file.path === next.file.path &&
|
||||
prev.file.action === next.file.action &&
|
||||
prev.file.additions === next.file.additions &&
|
||||
prev.file.deletions === next.file.deletions &&
|
||||
prev.file.lines === next.file.lines, // Reference equality - parsing creates new arrays only when content changes
|
||||
)
|
||||
|
||||
const DiffStats = memo<{ additions: number; deletions: number }>(({ additions, deletions }) => (
|
||||
<div className="text-xs text-gray-500 flex">
|
||||
{additions > 0 && <span className="text-success">+{additions}</span>}
|
||||
{additions > 0 && deletions > 0 && <span className="mx-1">·</span>}
|
||||
{deletions > 0 && <span className="text-error">-{deletions}</span>}
|
||||
</div>
|
||||
))
|
||||
|
||||
const DiffLine = memo<{ line: string }>(({ line }) => {
|
||||
if (line.trim() === "@@") {
|
||||
return (
|
||||
<div className="inline-flex items-center px-3 py-1 text-xs font-mono bg-description/10 w-full text-description">
|
||||
<ChevronsDownUpIcon className="size-2 mr-2" />
|
||||
@@
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const firstChar = line[0] as "+" | "-" | undefined
|
||||
const style = LINE_STYLES[firstChar ?? "default"] ?? LINE_STYLES.default
|
||||
|
||||
return (
|
||||
<div className={cn("px-4 py-1 text-xs font-mono w-full", style)}>
|
||||
<span>{line}</span>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// Parsing Functions
|
||||
// ============================================================================
|
||||
|
||||
interface ParseResult {
|
||||
parsedFiles: Patch[]
|
||||
isStreaming: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Main parsing function that detects format and delegates to appropriate parser
|
||||
*/
|
||||
function parsePatch(patch: string, path: string): ParseResult {
|
||||
// Try old format first (------- SEARCH / ======= / +++++++ REPLACE)
|
||||
if (patch.includes(MARKERS.SEARCH_BLOCK)) {
|
||||
const result = parseSearchReplaceFormat(patch, path)
|
||||
if (result) {
|
||||
return {
|
||||
parsedFiles: [result],
|
||||
isStreaming: !patch.includes(MARKERS.REPLACE_BLOCK),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Try new format (*** Begin Patch / *** End Patch)
|
||||
if (patch.includes(MARKERS.NEW_BEGIN)) {
|
||||
const endIndex = patch.indexOf(MARKERS.NEW_END)
|
||||
const isComplete = endIndex !== -1
|
||||
|
||||
const beginIndex = patch.indexOf(MARKERS.NEW_BEGIN)
|
||||
const contentStart = beginIndex + MARKERS.NEW_BEGIN.length
|
||||
const contentEnd = isComplete ? endIndex : patch.length
|
||||
const patchContent = patch.substring(contentStart, contentEnd).trim()
|
||||
|
||||
const parsed = parseNewFormat(patchContent)
|
||||
if (parsed.length > 0) {
|
||||
return { parsedFiles: parsed, isStreaming: !isComplete }
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: treat entire patch as a new file addition
|
||||
if (path && patch) {
|
||||
const lines = patch.split("\n")
|
||||
return {
|
||||
parsedFiles: [
|
||||
{
|
||||
action: "Add",
|
||||
path,
|
||||
lines: lines.map((line) => `+ ${line}`),
|
||||
additions: lines.length,
|
||||
deletions: 0,
|
||||
},
|
||||
],
|
||||
isStreaming: true,
|
||||
}
|
||||
}
|
||||
|
||||
return { parsedFiles: [], isStreaming: true }
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse new format patches (*** Add/Update/Delete File: path)
|
||||
*/
|
||||
function parseNewFormat(content: string): Patch[] {
|
||||
const files: Patch[] = []
|
||||
const lines = content.split("\n")
|
||||
|
||||
let currentFile: Patch | null = null
|
||||
|
||||
for (const line of lines) {
|
||||
const fileMatch = line.match(/^\*\*\* (Add|Update|Delete) File: (.+)$/)
|
||||
|
||||
if (fileMatch) {
|
||||
if (currentFile) {
|
||||
files.push(currentFile)
|
||||
}
|
||||
currentFile = {
|
||||
action: fileMatch[1],
|
||||
path: fileMatch[2].trim(),
|
||||
lines: [],
|
||||
additions: 0,
|
||||
deletions: 0,
|
||||
}
|
||||
} else if (currentFile && line.trim()) {
|
||||
currentFile.lines.push(line)
|
||||
if (line[0] === "+") {
|
||||
currentFile.additions++
|
||||
} else if (line[0] === "-") {
|
||||
currentFile.deletions++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (currentFile) {
|
||||
files.push(currentFile)
|
||||
}
|
||||
|
||||
return files
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse SEARCH REPLACE diff format patches (------- SEARCH / ======= / +++++++ REPLACE)
|
||||
* Converts SEARCH block to deletions (-) and REPLACE block to additions (+)
|
||||
*/
|
||||
function parseSearchReplaceFormat(patch: string, path: string): Patch | undefined {
|
||||
const searchIndex = patch.indexOf(MARKERS.SEARCH_BLOCK)
|
||||
if (searchIndex === -1) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
// Extract file metadata if present
|
||||
const fileMatch = patch.match(MARKERS.FILE_PATTERN)
|
||||
|
||||
const result: Patch = {
|
||||
action: fileMatch?.[1] ?? "Update",
|
||||
path: fileMatch?.[2]?.trim() ?? path ?? "",
|
||||
lines: [],
|
||||
additions: 0,
|
||||
deletions: 0,
|
||||
}
|
||||
|
||||
// Extract content after SEARCH marker
|
||||
const afterSearch = patch.substring(searchIndex + 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()
|
||||
addLinesToPatch(result, searchContent, "-")
|
||||
return result
|
||||
}
|
||||
|
||||
// Extract SEARCH block (deletions)
|
||||
const searchContent = afterSearch.substring(0, separatorIndex).replace(/\r?\n$/, "")
|
||||
addLinesToPatch(result, searchContent, "-")
|
||||
|
||||
// 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()
|
||||
|
||||
addLinesToPatch(result, replaceContent, "+")
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to add lines to a patch with the specified prefix
|
||||
*/
|
||||
function addLinesToPatch(patch: Patch, content: string, prefix: "+" | "-"): void {
|
||||
const lines = content.split("\n")
|
||||
for (const line of lines) {
|
||||
patch.lines.push(`${prefix} ${line}`)
|
||||
if (prefix === "+") {
|
||||
patch.additions++
|
||||
} else {
|
||||
patch.deletions++
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { ModelInfo as ModelInfoType } from "@shared/api"
|
||||
import { ANTHROPIC_MAX_THINKING_BUDGET, ANTHROPIC_MIN_THINKING_BUDGET, ApiProvider } from "@shared/api"
|
||||
import { StringRequest } from "@shared/proto/cline/common"
|
||||
import { UpdateSettingsRequest } from "@shared/proto/cline/state"
|
||||
import { Mode } from "@shared/storage/types"
|
||||
import { ArrowLeftRight, Brain, Check, ChevronDownIcon, Search, Settings } from "lucide-react"
|
||||
@@ -150,6 +151,27 @@ interface ModelItem {
|
||||
info?: ModelInfoType
|
||||
}
|
||||
|
||||
// Star icon for favorites (only for openrouter/vercel-ai-gateway providers)
|
||||
const StarIcon = ({ isFavorite, onClick }: { isFavorite: boolean; onClick: (e: React.MouseEvent) => void }) => {
|
||||
return (
|
||||
<div
|
||||
onClick={onClick}
|
||||
style={{
|
||||
cursor: "pointer",
|
||||
color: isFavorite ? "var(--vscode-terminal-ansiYellow)" : "var(--vscode-descriptionForeground)",
|
||||
marginLeft: "8px",
|
||||
fontSize: "14px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
userSelect: "none",
|
||||
WebkitUserSelect: "none",
|
||||
}}>
|
||||
{isFavorite ? "★" : "☆"}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const ModelPickerModal: React.FC<ModelPickerModalProps> = ({ isOpen, onOpenChange, currentMode, children }) => {
|
||||
const {
|
||||
apiConfiguration,
|
||||
@@ -160,6 +182,7 @@ const ModelPickerModal: React.FC<ModelPickerModalProps> = ({ isOpen, onOpenChang
|
||||
showMcp,
|
||||
showHistory,
|
||||
showAccount,
|
||||
favoritedModelIds,
|
||||
} = useExtensionState()
|
||||
const { handleModeFieldChange, handleModeFieldsChange, handleFieldsChange } = useApiConfigurationHandlers()
|
||||
|
||||
@@ -169,11 +192,13 @@ const ModelPickerModal: React.FC<ModelPickerModalProps> = ({ isOpen, onOpenChang
|
||||
const [arrowPosition, setArrowPosition] = useState(0)
|
||||
const [isProviderExpanded, setIsProviderExpanded] = useState(false)
|
||||
const [providerDropdownPosition, setProviderDropdownPosition] = useState({ top: 0, left: 0, width: 0, maxHeight: 200 })
|
||||
const [selectedIndex, setSelectedIndex] = useState(-1) // For keyboard navigation
|
||||
const searchInputRef = useRef<HTMLInputElement>(null)
|
||||
const triggerRef = useRef<HTMLDivElement>(null)
|
||||
const modalRef = useRef<HTMLDivElement>(null)
|
||||
const providerRowRef = useRef<HTMLDivElement>(null)
|
||||
const providerDropdownRef = useRef<HTMLDivElement>(null)
|
||||
const itemRefs = useRef<(HTMLDivElement | null)[]>([]) // For scrollIntoView
|
||||
const { width: viewportWidth, height: viewportHeight } = useWindowSize()
|
||||
|
||||
// Get current provider from config - use activeEditMode when in split mode
|
||||
@@ -293,10 +318,20 @@ const ModelPickerModal: React.FC<ModelPickerModalProps> = ({ isOpen, onOpenChang
|
||||
models = models.filter((m) => !featuredIds.has(m.id))
|
||||
}
|
||||
|
||||
// For openrouter/vercel-ai-gateway (not cline): put favorites first
|
||||
if (!isCline && (selectedProvider === "openrouter" || selectedProvider === "vercel-ai-gateway")) {
|
||||
const favoriteSet = new Set(favoritedModelIds || [])
|
||||
const favoritedModels = models.filter((m) => favoriteSet.has(m.id))
|
||||
const nonFavoritedModels = models.filter((m) => !favoriteSet.has(m.id))
|
||||
// Sort non-favorited alphabetically by provider
|
||||
nonFavoritedModels.sort((a, b) => (a.provider || "").localeCompare(b.provider || ""))
|
||||
return [...favoritedModels, ...nonFavoritedModels]
|
||||
}
|
||||
|
||||
// Sort alphabetically by provider
|
||||
models = models.sort((a, b) => (a.provider || "").localeCompare(b.provider || ""))
|
||||
return models
|
||||
}, [searchQuery, matchesSearch, selectedModelId, selectedProvider, allModels])
|
||||
}, [searchQuery, matchesSearch, selectedModelId, selectedProvider, allModels, favoritedModelIds])
|
||||
|
||||
// Featured models for Cline provider (recommended + free)
|
||||
const featuredModels = useMemo(() => {
|
||||
@@ -396,13 +431,74 @@ const ModelPickerModal: React.FC<ModelPickerModalProps> = ({ isOpen, onOpenChang
|
||||
[onOpenChange, navigateToSettings],
|
||||
)
|
||||
|
||||
// Keyboard navigation handler
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
const totalItems = filteredModels.length + featuredModels.length
|
||||
if (totalItems === 0) return
|
||||
|
||||
switch (e.key) {
|
||||
case "ArrowDown":
|
||||
e.preventDefault()
|
||||
setSelectedIndex((prev) => (prev < totalItems - 1 ? prev + 1 : prev))
|
||||
break
|
||||
case "ArrowUp":
|
||||
e.preventDefault()
|
||||
setSelectedIndex((prev) => (prev > 0 ? prev - 1 : prev))
|
||||
break
|
||||
case "Enter":
|
||||
e.preventDefault()
|
||||
if (selectedIndex >= 0) {
|
||||
// Determine which list the index falls into
|
||||
if (selectedIndex < featuredModels.length) {
|
||||
const model = featuredModels[selectedIndex]
|
||||
handleSelectModel(model.id, openRouterModels[model.id])
|
||||
} else {
|
||||
const model = filteredModels[selectedIndex - featuredModels.length]
|
||||
handleSelectModel(model.id, model.info)
|
||||
}
|
||||
}
|
||||
break
|
||||
case "Escape":
|
||||
e.preventDefault()
|
||||
onOpenChange(false)
|
||||
break
|
||||
}
|
||||
},
|
||||
[filteredModels, featuredModels, selectedIndex, handleSelectModel, openRouterModels, onOpenChange],
|
||||
)
|
||||
|
||||
// Reset selectedIndex and clear refs when search/provider changes
|
||||
useEffect(() => {
|
||||
setSelectedIndex(-1)
|
||||
itemRefs.current = []
|
||||
}, [searchQuery, selectedProvider])
|
||||
|
||||
// Scroll selected item into view
|
||||
useEffect(() => {
|
||||
if (selectedIndex >= 0) {
|
||||
// Use requestAnimationFrame to ensure DOM is updated
|
||||
requestAnimationFrame(() => {
|
||||
const element = itemRefs.current[selectedIndex]
|
||||
if (element) {
|
||||
element.scrollIntoView({
|
||||
block: "nearest",
|
||||
behavior: "smooth",
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
}, [selectedIndex])
|
||||
|
||||
// Reset states when opening/closing
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setIsProviderExpanded(false)
|
||||
setSelectedIndex(-1)
|
||||
setTimeout(() => searchInputRef.current?.focus(), 100)
|
||||
} else {
|
||||
setSearchQuery("")
|
||||
setSelectedIndex(-1)
|
||||
}
|
||||
}, [isOpen])
|
||||
|
||||
@@ -504,6 +600,7 @@ const ModelPickerModal: React.FC<ModelPickerModalProps> = ({ isOpen, onOpenChang
|
||||
setSearchQuery(e.target.value)
|
||||
setIsProviderExpanded(false)
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={`Search ${allModels.length} models`}
|
||||
ref={searchInputRef as any}
|
||||
value={searchQuery}
|
||||
@@ -686,11 +783,13 @@ const ModelPickerModal: React.FC<ModelPickerModalProps> = ({ isOpen, onOpenChang
|
||||
|
||||
{/* For Cline: Show recommended models */}
|
||||
{isClineProvider &&
|
||||
featuredModels.map((model) => (
|
||||
featuredModels.map((model, index) => (
|
||||
<ModelItemContainer
|
||||
$isSelected={false}
|
||||
$isSelected={index === selectedIndex}
|
||||
key={model.id}
|
||||
onClick={() => handleSelectModel(model.id, openRouterModels[model.id])}>
|
||||
onClick={() => handleSelectModel(model.id, openRouterModels[model.id])}
|
||||
onMouseEnter={() => setSelectedIndex(index)}
|
||||
ref={(el) => (itemRefs.current[index] = el)}>
|
||||
<ModelInfoRow>
|
||||
<ModelName>{model.name}</ModelName>
|
||||
<ModelProvider>{model.provider}</ModelProvider>
|
||||
@@ -700,17 +799,37 @@ const ModelPickerModal: React.FC<ModelPickerModalProps> = ({ isOpen, onOpenChang
|
||||
))}
|
||||
|
||||
{/* All other models (for non-Cline always, for Cline only when searching) */}
|
||||
{filteredModels.map((model) => (
|
||||
<ModelItemContainer
|
||||
$isSelected={false}
|
||||
key={model.id}
|
||||
onClick={() => handleSelectModel(model.id, model.info)}>
|
||||
<ModelInfoRow>
|
||||
<ModelName>{model.name}</ModelName>
|
||||
<ModelProvider>{model.provider}</ModelProvider>
|
||||
</ModelInfoRow>
|
||||
</ModelItemContainer>
|
||||
))}
|
||||
{filteredModels.map((model, index) => {
|
||||
const globalIndex = featuredModels.length + index
|
||||
const isFavorite = (favoritedModelIds || []).includes(model.id)
|
||||
const showStar = selectedProvider === "openrouter" || selectedProvider === "vercel-ai-gateway"
|
||||
return (
|
||||
<ModelItemContainer
|
||||
$isSelected={globalIndex === selectedIndex}
|
||||
key={model.id}
|
||||
onClick={() => handleSelectModel(model.id, model.info)}
|
||||
onMouseEnter={() => setSelectedIndex(globalIndex)}
|
||||
ref={(el) => (itemRefs.current[globalIndex] = el)}>
|
||||
<ModelInfoRow>
|
||||
<ModelName>{model.name}</ModelName>
|
||||
<ModelProvider>{model.provider}</ModelProvider>
|
||||
</ModelInfoRow>
|
||||
{showStar && (
|
||||
<StarIcon
|
||||
isFavorite={isFavorite}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
StateServiceClient.toggleFavoriteModel(
|
||||
StringRequest.create({ value: model.id }),
|
||||
).catch((error: Error) =>
|
||||
console.error("Failed to toggle favorite model:", error),
|
||||
)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</ModelItemContainer>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Settings-only providers: show configured model info and help text */}
|
||||
{SETTINGS_ONLY_PROVIDERS.includes(selectedProvider) &&
|
||||
@@ -871,7 +990,7 @@ const ProviderRow = styled.div`
|
||||
`
|
||||
|
||||
const ProviderLabel = styled.span`
|
||||
font-size: 10px;
|
||||
font-size: 11px;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
`
|
||||
|
||||
@@ -925,6 +1044,8 @@ const ModelItemContainer = styled.div<{ $isSelected: boolean }>`
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 4px 10px;
|
||||
min-height: 28px;
|
||||
box-sizing: border-box;
|
||||
cursor: pointer;
|
||||
background: ${(props) => (props.$isSelected ? "var(--vscode-list-activeSelectionBackground)" : "transparent")};
|
||||
&:hover {
|
||||
|
||||
@@ -120,6 +120,14 @@ export const ActionButtons: React.FC<ActionButtonsProps> = ({
|
||||
behavior: "smooth",
|
||||
})
|
||||
disableAutoScrollRef.current = true
|
||||
// Virtual rendering may not have all items rendered when at bottom,
|
||||
// so scroll again after a delay to ensure we reach the true top
|
||||
setTimeout(() => {
|
||||
scrollBehavior.virtuosoRef.current?.scrollTo({
|
||||
top: 0,
|
||||
behavior: "smooth",
|
||||
})
|
||||
}, 300)
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { BANNER_DATA, BannerAction, BannerActionType, BannerCardData } from "@shared/cline/banner"
|
||||
import { EmptyRequest, Int64Request } from "@shared/proto/index.cline"
|
||||
import { VSCodeLink } from "@vscode/webview-ui-toolkit/react"
|
||||
import { Megaphone, Terminal } from "lucide-react"
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react"
|
||||
import BannerCarousel, { BannerData } from "@/components/common/BannerCarousel"
|
||||
import BannerCarousel from "@/components/common/BannerCarousel"
|
||||
import { CURRENT_CLI_BANNER_VERSION } from "@/components/common/CliInstallBanner"
|
||||
import { CURRENT_INFO_BANNER_VERSION } from "@/components/common/InfoBanner"
|
||||
import { CURRENT_MODEL_BANNER_VERSION } from "@/components/common/NewModelBanner"
|
||||
@@ -11,11 +10,11 @@ import HistoryPreview from "@/components/history/HistoryPreview"
|
||||
import { useApiConfigurationHandlers } from "@/components/settings/utils/useApiConfigurationHandlers"
|
||||
import HomeHeader from "@/components/welcome/HomeHeader"
|
||||
import { SuggestedTasks } from "@/components/welcome/SuggestedTasks"
|
||||
import { PLATFORM_CONFIG, PlatformType } from "@/config/platform.config"
|
||||
import { useClineAuth } from "@/context/ClineAuthContext"
|
||||
import { useExtensionState } from "@/context/ExtensionStateContext"
|
||||
import { AccountServiceClient, StateServiceClient } from "@/services/grpc-client"
|
||||
import { isMacOSOrLinux } from "@/utils/platformUtils"
|
||||
import { convertBannerData } from "@/utils/bannerUtils"
|
||||
import { getCurrentPlatform } from "@/utils/platformUtils"
|
||||
import { WelcomeSectionProps } from "../../types/chatTypes"
|
||||
|
||||
/**
|
||||
@@ -36,15 +35,6 @@ export const WelcomeSection: React.FC<WelcomeSectionProps> = ({
|
||||
const [hasShownWhatsNewModal, setHasShownWhatsNewModal] = useState(false)
|
||||
const [showWhatsNewModal, setShowWhatsNewModal] = useState(false)
|
||||
|
||||
const shouldShowInfoBanner = lastDismissedInfoBannerVersion < CURRENT_INFO_BANNER_VERSION
|
||||
const shouldShowNewModelBanner = lastDismissedModelBannerVersion < CURRENT_MODEL_BANNER_VERSION
|
||||
|
||||
// Show CLI banner if not dismissed and platform is VSCode (not JetBrains/standalone)
|
||||
const shouldShowCliBanner =
|
||||
isMacOSOrLinux() &&
|
||||
PLATFORM_CONFIG.type === PlatformType.VSCODE &&
|
||||
lastDismissedCliBannerVersion < CURRENT_CLI_BANNER_VERSION
|
||||
|
||||
const { clineUser } = useClineAuth()
|
||||
const { openRouterModels, setShowChatModelSelector, navigateToSettings, subagentsEnabled } = useExtensionState()
|
||||
const { handleFieldsChange } = useApiConfigurationHandlers()
|
||||
@@ -63,176 +53,143 @@ export const WelcomeSection: React.FC<WelcomeSectionProps> = ({
|
||||
hideAnnouncement()
|
||||
}, [hideAnnouncement])
|
||||
|
||||
// Build array of active banners for carousel
|
||||
const activeBanners = useMemo((): BannerData[] => {
|
||||
const banners: BannerData[] = []
|
||||
/**
|
||||
* Check if a banner has been dismissed based on its version
|
||||
*/
|
||||
const isBannerDismissed = useCallback(
|
||||
(bannerId: string): boolean => {
|
||||
if (bannerId.startsWith("info-banner")) {
|
||||
return (lastDismissedInfoBannerVersion ?? 0) >= CURRENT_INFO_BANNER_VERSION
|
||||
}
|
||||
if (bannerId.startsWith("new-model")) {
|
||||
return (lastDismissedModelBannerVersion ?? 0) >= CURRENT_MODEL_BANNER_VERSION
|
||||
}
|
||||
if (bannerId.startsWith("cli-")) {
|
||||
return (lastDismissedCliBannerVersion ?? 0) >= CURRENT_CLI_BANNER_VERSION
|
||||
}
|
||||
return false
|
||||
},
|
||||
[lastDismissedInfoBannerVersion, lastDismissedModelBannerVersion, lastDismissedCliBannerVersion],
|
||||
)
|
||||
|
||||
if (shouldShowInfoBanner) {
|
||||
banners.push({
|
||||
id: "info-banner",
|
||||
icon: <span>💡</span>,
|
||||
title: "Use Cline in Right Sidebar",
|
||||
description: (
|
||||
<>
|
||||
For the best experience, drag the Cline icon to your right sidebar. This keeps your file explorer and
|
||||
editor visible while you chat with Cline, making it easier to navigate your codebase and see changes in
|
||||
real-time.{" "}
|
||||
<VSCodeLink
|
||||
className="cursor-pointer"
|
||||
href="https://docs.cline.bot/features/customization/opening-cline-in-sidebar"
|
||||
style={{ display: "inline" }}>
|
||||
See how →
|
||||
</VSCodeLink>
|
||||
</>
|
||||
),
|
||||
onDismiss: () => {
|
||||
StateServiceClient.updateInfoBannerVersion({ value: CURRENT_INFO_BANNER_VERSION }).catch(console.error)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if (shouldShowNewModelBanner) {
|
||||
const setNewModel = () => {
|
||||
const modelId = "anthropic/claude-opus-4.5"
|
||||
handleFieldsChange({
|
||||
planModeOpenRouterModelId: modelId,
|
||||
actModeOpenRouterModelId: modelId,
|
||||
planModeOpenRouterModelInfo: openRouterModels[modelId],
|
||||
actModeOpenRouterModelInfo: openRouterModels[modelId],
|
||||
planModeApiProvider: "cline",
|
||||
actModeApiProvider: "cline",
|
||||
})
|
||||
setTimeout(() => setShowChatModelSelector(true), 10)
|
||||
/**
|
||||
* Banner configuration from backend
|
||||
* In production, this would come from an API/gRPC call
|
||||
* For now, using EXAMPLE_BANNER_DATA with version-based filtering
|
||||
*/
|
||||
const bannerConfig = useMemo((): BannerCardData[] => {
|
||||
// Filter banners based on version tracking and user status
|
||||
return BANNER_DATA.filter((banner) => {
|
||||
if (isBannerDismissed(banner.id)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const handleShowAccount = () => {
|
||||
AccountServiceClient.accountLoginClicked(EmptyRequest.create()).catch((err) =>
|
||||
console.error("Failed to get login URL:", err),
|
||||
)
|
||||
if (banner.isClineUserOnly !== undefined) {
|
||||
return banner.isClineUserOnly === !!clineUser
|
||||
}
|
||||
|
||||
banners.push({
|
||||
id: "new-model",
|
||||
icon: <Megaphone className="w-5 h-5" />,
|
||||
title: "Claude Opus 4.5 Now Available",
|
||||
description: "State-of-the-art performance at 3x lower cost than Opus 4.1. Available now in the Cline provider.",
|
||||
actions: [
|
||||
{
|
||||
label: clineUser ? "Try Now" : "Get Started",
|
||||
onClick: clineUser ? setNewModel : handleShowAccount,
|
||||
variant: "primary",
|
||||
},
|
||||
],
|
||||
onDismiss: () => {
|
||||
StateServiceClient.updateModelBannerVersion(
|
||||
Int64Request.create({ value: CURRENT_MODEL_BANNER_VERSION }),
|
||||
).catch(console.error)
|
||||
},
|
||||
})
|
||||
}
|
||||
if (banner.platforms && !banner.platforms.includes(getCurrentPlatform())) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (shouldShowCliBanner) {
|
||||
const handleInstallCli = async () => {
|
||||
try {
|
||||
await StateServiceClient.installClineCli(EmptyRequest.create())
|
||||
} catch (error) {
|
||||
console.error("Failed to initiate CLI installation:", error)
|
||||
return true
|
||||
})
|
||||
}, [isBannerDismissed, clineUser])
|
||||
|
||||
/**
|
||||
* Action handler - maps action types to actual implementations
|
||||
*/
|
||||
const handleBannerAction = useCallback(
|
||||
(action: BannerAction) => {
|
||||
switch (action.action) {
|
||||
case BannerActionType.Link:
|
||||
// Links are handled by VSCodeLink component
|
||||
break
|
||||
|
||||
case BannerActionType.SetModel: {
|
||||
const modelId = action.arg || "anthropic/claude-opus-4.5"
|
||||
handleFieldsChange({
|
||||
planModeOpenRouterModelId: modelId,
|
||||
actModeOpenRouterModelId: modelId,
|
||||
planModeOpenRouterModelInfo: openRouterModels[modelId],
|
||||
actModeOpenRouterModelInfo: openRouterModels[modelId],
|
||||
planModeApiProvider: "cline",
|
||||
actModeApiProvider: "cline",
|
||||
})
|
||||
setTimeout(() => setShowChatModelSelector(true), 10)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const handleEnableSubagents = () => {
|
||||
if (!subagentsEnabled) {
|
||||
navigateToSettings("features")
|
||||
}
|
||||
}
|
||||
|
||||
banners.push({
|
||||
id: "cli-install",
|
||||
icon: <Terminal className="w-5 h-5" />,
|
||||
title: isMacOSOrLinux() ? "CLI & Subagents Available" : "Cline CLI Info",
|
||||
description: isMacOSOrLinux() ? (
|
||||
<>
|
||||
Use Cline in your terminal and enable subagent capabilities.{" "}
|
||||
<VSCodeLink href="https://docs.cline.bot/cline-cli/overview" style={{ display: "inline" }}>
|
||||
Learn more
|
||||
</VSCodeLink>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Available for macOS and Linux. Coming soon to other platforms.{" "}
|
||||
<VSCodeLink href="https://docs.cline.bot/cline-cli/overview" style={{ display: "inline" }}>
|
||||
Learn more
|
||||
</VSCodeLink>
|
||||
</>
|
||||
),
|
||||
actions: isMacOSOrLinux()
|
||||
? [
|
||||
{ label: "Install", onClick: handleInstallCli, variant: "primary" },
|
||||
{
|
||||
label: "Enable Subagents",
|
||||
onClick: handleEnableSubagents,
|
||||
variant: "primary",
|
||||
disabled: subagentsEnabled,
|
||||
},
|
||||
]
|
||||
: [
|
||||
{ label: "Install CLI", onClick: handleInstallCli, variant: "primary" },
|
||||
{ label: "Subagents (Windows coming soon)", onClick: () => {}, variant: "secondary", disabled: true },
|
||||
],
|
||||
onDismiss: () => {
|
||||
StateServiceClient.updateCliBannerVersion(Int64Request.create({ value: CURRENT_CLI_BANNER_VERSION })).catch(
|
||||
console.error,
|
||||
case BannerActionType.ShowAccount:
|
||||
AccountServiceClient.accountLoginClicked(EmptyRequest.create()).catch((err) =>
|
||||
console.error("Failed to get login URL:", err),
|
||||
)
|
||||
},
|
||||
})
|
||||
}
|
||||
break
|
||||
|
||||
return banners
|
||||
}, [
|
||||
shouldShowInfoBanner,
|
||||
shouldShowNewModelBanner,
|
||||
shouldShowCliBanner,
|
||||
clineUser,
|
||||
openRouterModels,
|
||||
setShowChatModelSelector,
|
||||
handleFieldsChange,
|
||||
navigateToSettings,
|
||||
subagentsEnabled,
|
||||
])
|
||||
case BannerActionType.ShowApiSettings:
|
||||
navigateToSettings("api")
|
||||
break
|
||||
|
||||
case BannerActionType.ShowFeatureSettings:
|
||||
navigateToSettings("features")
|
||||
break
|
||||
|
||||
case BannerActionType.InstallCli:
|
||||
StateServiceClient.installClineCli(EmptyRequest.create()).catch((error) =>
|
||||
console.error("Failed to initiate CLI installation:", error),
|
||||
)
|
||||
break
|
||||
|
||||
default:
|
||||
console.warn("Unknown banner action:", action.action)
|
||||
}
|
||||
},
|
||||
[handleFieldsChange, openRouterModels, setShowChatModelSelector, navigateToSettings],
|
||||
)
|
||||
|
||||
/**
|
||||
* Dismissal handler - updates version tracking
|
||||
*/
|
||||
const handleBannerDismiss = useCallback((bannerId: string) => {
|
||||
// Map banner IDs to version updates
|
||||
if (bannerId.startsWith("info-banner")) {
|
||||
StateServiceClient.updateInfoBannerVersion({ value: CURRENT_INFO_BANNER_VERSION }).catch(console.error)
|
||||
} else if (bannerId.startsWith("new-model")) {
|
||||
StateServiceClient.updateModelBannerVersion(Int64Request.create({ value: CURRENT_MODEL_BANNER_VERSION })).catch(
|
||||
console.error,
|
||||
)
|
||||
} else if (bannerId.startsWith("cli-")) {
|
||||
StateServiceClient.updateCliBannerVersion(Int64Request.create({ value: CURRENT_CLI_BANNER_VERSION })).catch(
|
||||
console.error,
|
||||
)
|
||||
}
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* Build array of active banners for carousel
|
||||
*/
|
||||
const activeBanners = useMemo(() => {
|
||||
// Convert to BannerData format for carousel
|
||||
return bannerConfig.map((banner) =>
|
||||
convertBannerData(banner, {
|
||||
onAction: handleBannerAction,
|
||||
onDismiss: handleBannerDismiss,
|
||||
}),
|
||||
)
|
||||
}, [bannerConfig, clineUser, subagentsEnabled, handleBannerAction, handleBannerDismiss])
|
||||
|
||||
return (
|
||||
<div className="flex flex-col flex-1 w-full h-full p-0 m-0">
|
||||
<style>
|
||||
{`
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.98);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
.fade-in-cards {
|
||||
animation: fadeIn 0.4s ease-out forwards;
|
||||
}
|
||||
.fade-in-history {
|
||||
animation: fadeIn 0.4s ease-out forwards;
|
||||
opacity: 0;
|
||||
}
|
||||
`}
|
||||
</style>
|
||||
<WhatsNewModal onClose={handleCloseWhatsNewModal} open={showWhatsNewModal} version={version} />
|
||||
<div className="overflow-y-auto flex flex-col pb-2.5">
|
||||
<HomeHeader shouldShowQuickWins={shouldShowQuickWins} />
|
||||
{!showWhatsNewModal && (
|
||||
<>
|
||||
<div className="fade-in-cards">
|
||||
<div className="animate-fade-in">
|
||||
<BannerCarousel banners={activeBanners} />
|
||||
</div>
|
||||
{!shouldShowQuickWins && taskHistory.length > 0 && (
|
||||
<div className="fade-in-history">
|
||||
<div className="animate-fade-in opacity-0">
|
||||
<HistoryPreview showHistoryView={showHistoryView} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -213,6 +213,12 @@ export function getButtonConfig(message: ClineMessage | undefined, _mode: Mode =
|
||||
const isStreaming = message.partial === true
|
||||
const isError = message?.ask ? errorTypes.includes(message.ask) : false
|
||||
|
||||
// Special case: command_output should show "Proceed While Running" button even while streaming
|
||||
// This allows terminal output to stream while still showing the action button
|
||||
if (message.type === "ask" && message.ask === "command_output") {
|
||||
return BUTTON_CONFIGS.command_output
|
||||
}
|
||||
|
||||
// Handle partial/streaming messages first (most common during task execution)
|
||||
// This must be checked before any other conditions to ensure streaming state takes precedence
|
||||
if (isStreaming && !isError) {
|
||||
@@ -284,5 +290,11 @@ export function getButtonConfig(message: ClineMessage | undefined, _mode: Mode =
|
||||
return BUTTON_CONFIGS.api_req_active
|
||||
}
|
||||
|
||||
// Special case: command_output say messages should show "Proceed While Running" button
|
||||
// This allows terminal output to stream while still showing the action button
|
||||
if (message.type === "say" && message.say === "command_output") {
|
||||
return BUTTON_CONFIGS.command_output
|
||||
}
|
||||
|
||||
return BUTTON_CONFIGS.partial
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { ChevronLeft, ChevronRight, XIcon } from "lucide-react"
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react"
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||
import { useRemark } from "react-remark"
|
||||
import { Button } from "@/components/ui/button"
|
||||
|
||||
interface BannerAction {
|
||||
interface BannerActions {
|
||||
label: string
|
||||
onClick: () => void
|
||||
variant?: "primary" | "secondary"
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ export interface BannerData {
|
||||
icon?: React.ReactNode
|
||||
title: string
|
||||
description: string | React.ReactNode
|
||||
actions?: BannerAction[]
|
||||
actions?: BannerActions[]
|
||||
onDismiss?: () => void
|
||||
}
|
||||
|
||||
@@ -22,6 +22,56 @@ interface BannerCarouselProps {
|
||||
banners: BannerData[]
|
||||
}
|
||||
|
||||
interface BannerCardContentProps {
|
||||
banner: BannerData
|
||||
isActive: boolean
|
||||
isTransitioning: boolean
|
||||
showDismissButton: boolean
|
||||
}
|
||||
|
||||
const BannerCardContent: React.FC<BannerCardContentProps> = ({ banner, isActive, isTransitioning, showDismissButton }) => {
|
||||
const [markdownContent, setMarkdown] = useRemark()
|
||||
|
||||
useEffect(() => {
|
||||
setMarkdown(typeof banner.description === "string" ? banner.description : "")
|
||||
}, [banner.description, setMarkdown])
|
||||
|
||||
return (
|
||||
<div
|
||||
className="p-3"
|
||||
style={{
|
||||
gridArea: "stack",
|
||||
opacity: isActive && !isTransitioning ? 1 : 0,
|
||||
transition: "opacity 0.4s ease-in-out",
|
||||
pointerEvents: isActive ? "auto" : "none",
|
||||
}}>
|
||||
{/* Title with optional icon */}
|
||||
<h3
|
||||
className="font-semibold mb-2 flex items-center gap-2 text-base"
|
||||
style={{ paddingRight: showDismissButton ? "24px" : "0" }}>
|
||||
<span className="shrink-0">{banner.icon}</span>
|
||||
{banner.title}
|
||||
</h3>
|
||||
|
||||
{/* Description */}
|
||||
<div className="text-sm text-description leading-relaxed [&>*:last-child]:mb-0 [&_a]:hover:underline">
|
||||
{markdownContent}
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
{banner.actions?.length ? (
|
||||
<div className="flex flex-wrap gap-2 mt-3">
|
||||
{banner.actions.map((action) => (
|
||||
<Button disabled={action.disabled} key={action.label} onClick={action.onClick} size="sm">
|
||||
{action.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const BannerCarousel: React.FC<BannerCarouselProps> = ({ banners }) => {
|
||||
const [currentIndex, setCurrentIndex] = useState(0)
|
||||
const [isPaused, setIsPaused] = useState(false)
|
||||
@@ -29,7 +79,10 @@ export const BannerCarousel: React.FC<BannerCarouselProps> = ({ banners }) => {
|
||||
const autoPlayIntervalRef = useRef<NodeJS.Timeout | null>(null)
|
||||
|
||||
// Compute a safe index that's always within bounds
|
||||
const safeCurrentIndex = banners.length === 0 ? 0 : Math.min(currentIndex, banners.length - 1)
|
||||
const safeCurrentIndex = useMemo(
|
||||
() => (banners.length === 0 ? 0 : Math.min(currentIndex, banners.length - 1)),
|
||||
[currentIndex, banners.length],
|
||||
)
|
||||
|
||||
const transitionToIndex = useCallback((newIndex: number) => {
|
||||
setIsTransitioning(true)
|
||||
@@ -89,27 +142,24 @@ export const BannerCarousel: React.FC<BannerCarouselProps> = ({ banners }) => {
|
||||
return null
|
||||
}
|
||||
|
||||
const showDismissButton = safeCurrentIndex === banners.length - 1 && currentBanner.onDismiss
|
||||
|
||||
return (
|
||||
<div
|
||||
aria-label="Announcements"
|
||||
aria-live="polite"
|
||||
aria-roledescription="carousel"
|
||||
className="mx-4 mb-4 mt-9"
|
||||
className="mx-3 mb-3"
|
||||
onMouseEnter={() => setIsPaused(true)}
|
||||
onMouseLeave={() => setIsPaused(false)}
|
||||
role="region">
|
||||
{/* Card container with unified styling */}
|
||||
<div
|
||||
className="relative"
|
||||
style={{
|
||||
backgroundColor: "color-mix(in srgb, var(--vscode-toolbar-hoverBackground) 65%, transparent)",
|
||||
borderRadius: "4px",
|
||||
}}>
|
||||
{/* Card container */}
|
||||
<div className="relative bg-muted rounded-sm">
|
||||
{/* Dismiss button - only show on last card, dismisses ALL banners */}
|
||||
{safeCurrentIndex === banners.length - 1 && currentBanner.onDismiss && (
|
||||
{showDismissButton && (
|
||||
<Button
|
||||
aria-label="Dismiss all banners"
|
||||
className="absolute top-2 right-2 z-10"
|
||||
className="absolute top-2.5 right-2 z-10"
|
||||
data-testid="banner-dismiss-button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
@@ -122,86 +172,40 @@ export const BannerCarousel: React.FC<BannerCarouselProps> = ({ banners }) => {
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Card content with fixed height and fade transition */}
|
||||
<div
|
||||
className="px-4 pt-4 pb-3"
|
||||
style={{
|
||||
height: "144px",
|
||||
overflow: "hidden",
|
||||
opacity: isTransitioning ? 0 : 1,
|
||||
transition: "opacity 0.4s ease-in-out",
|
||||
}}>
|
||||
{/* Title with optional icon */}
|
||||
<h3
|
||||
className="font-semibold mb-3 flex items-center gap-2"
|
||||
style={{
|
||||
fontSize: "16px",
|
||||
paddingRight: safeCurrentIndex === banners.length - 1 && currentBanner.onDismiss ? "24px" : "0",
|
||||
}}>
|
||||
{currentBanner.icon}
|
||||
{currentBanner.title}
|
||||
</h3>
|
||||
{/* Card content - grid stack makes container size to tallest */}
|
||||
<div className="grid" style={{ gridTemplateAreas: "'stack'" }}>
|
||||
{banners.map((banner, idx) => {
|
||||
const isActive = idx === safeCurrentIndex
|
||||
const isLastBanner = idx === banners.length - 1
|
||||
const showDismiss = isLastBanner && banner.onDismiss
|
||||
|
||||
{/* Description */}
|
||||
<div className="text-base mb-4" style={{ color: "var(--vscode-descriptionForeground)" }}>
|
||||
{currentBanner.description}
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
{currentBanner.actions && currentBanner.actions.length > 0 && (
|
||||
<div className="flex gap-3 mt-4">
|
||||
{currentBanner.actions.map((action, idx) => (
|
||||
<Button
|
||||
disabled={action.disabled}
|
||||
key={idx}
|
||||
onClick={action.onClick}
|
||||
variant={action.variant === "secondary" ? "secondary" : "default"}>
|
||||
{action.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
return (
|
||||
<BannerCardContent
|
||||
banner={banner}
|
||||
isActive={isActive}
|
||||
isTransitioning={isTransitioning}
|
||||
key={banner.id}
|
||||
showDismissButton={!!showDismiss}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Navigation footer - only show if more than 1 banner */}
|
||||
{banners.length > 1 && (
|
||||
<div
|
||||
className="flex justify-between items-center px-4 py-1"
|
||||
style={{
|
||||
borderTop: "1px solid rgba(255, 255, 255, 0.1)",
|
||||
}}>
|
||||
<div className="flex justify-between items-center px-3 py-1.5 border-t border-description/15">
|
||||
{/* Page indicator */}
|
||||
<div className="text-base font-medium" style={{ color: "var(--vscode-descriptionForeground)" }}>
|
||||
{safeCurrentIndex + 1}/{banners.length}
|
||||
<div className="text-sm text-description">
|
||||
{safeCurrentIndex + 1} / {banners.length}
|
||||
</div>
|
||||
|
||||
{/* Navigation arrows */}
|
||||
<div className="flex -mr-3">
|
||||
<Button
|
||||
aria-label="Previous banner"
|
||||
onClick={handlePrevious}
|
||||
size="icon"
|
||||
style={{
|
||||
width: "40px",
|
||||
height: "40px",
|
||||
padding: "0",
|
||||
backgroundColor: "transparent",
|
||||
}}
|
||||
variant="icon">
|
||||
<ChevronLeft style={{ width: "18px", height: "18px" }} />
|
||||
<div className="flex gap-0.5">
|
||||
<Button aria-label="Previous banner" onClick={handlePrevious} size="icon" variant="icon">
|
||||
<ChevronLeft className="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
aria-label="Next banner"
|
||||
onClick={handleNext}
|
||||
size="icon"
|
||||
style={{
|
||||
width: "40px",
|
||||
height: "40px",
|
||||
padding: "0",
|
||||
backgroundColor: "transparent",
|
||||
}}
|
||||
variant="icon">
|
||||
<ChevronRight style={{ width: "18px", height: "18px" }} />
|
||||
<Button aria-label="Next banner" onClick={handleNext} size="icon" variant="icon">
|
||||
<ChevronRight className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -17,6 +17,7 @@ interface WithCopyButtonProps {
|
||||
onCopy?: () => string | undefined | null
|
||||
position?: "top-right" | "bottom-right"
|
||||
style?: React.CSSProperties
|
||||
copyButtonStyle?: React.CSSProperties
|
||||
className?: string
|
||||
onMouseUp?: (event: React.MouseEvent<HTMLDivElement>) => void
|
||||
ariaLabel?: string
|
||||
@@ -109,6 +110,7 @@ export const WithCopyButton = forwardRef<HTMLDivElement, WithCopyButtonProps>(
|
||||
onCopy,
|
||||
position = "top-right",
|
||||
style,
|
||||
copyButtonStyle,
|
||||
className,
|
||||
onMouseUp,
|
||||
ariaLabel, // Destructure ariaLabel
|
||||
@@ -120,7 +122,7 @@ export const WithCopyButton = forwardRef<HTMLDivElement, WithCopyButtonProps>(
|
||||
<ContentContainer className={className} onMouseUp={onMouseUp} ref={ref} style={style} {...props}>
|
||||
{children}
|
||||
{(textToCopy || onCopy) && (
|
||||
<ButtonContainer $position={position}>
|
||||
<ButtonContainer $position={position} style={copyButtonStyle}>
|
||||
<CopyButton
|
||||
ariaLabel={ariaLabel}
|
||||
onCopy={onCopy}
|
||||
|
||||
@@ -296,11 +296,11 @@ const StyledMarkdown = styled.div<{ compact?: boolean }>`
|
||||
}
|
||||
}
|
||||
|
||||
hr, ul {
|
||||
hr, ul, ol {
|
||||
margin: 13px 0;
|
||||
}
|
||||
|
||||
li > ul {
|
||||
li > ul, li > ol {
|
||||
margin: 4px 0; /* or 0 if you want them very tight */
|
||||
}
|
||||
|
||||
|
||||
@@ -89,11 +89,11 @@ export const WhatsNewModal: React.FC<WhatsNewModalProps> = ({ open, onClose, ver
|
||||
<li className="mb-2">
|
||||
<strong>Cline provider</strong> now runs on the Vercel AI Gateway for better latency and fewer errors.
|
||||
</li>
|
||||
<li className="mb-2">
|
||||
To celebrate, we're offering free <strong>GLM-4.6</strong> for a limited time!
|
||||
<li>
|
||||
<strong>GLM 4.7</strong> now available!
|
||||
<br />
|
||||
<AuthButton>
|
||||
<ModelButton label="Try GLM-4.6" modelId="z-ai/glm-4.6" />
|
||||
<ModelButton label="Try GLM 4.7" modelId="z-ai/glm-4.7" />
|
||||
</AuthButton>
|
||||
</li>
|
||||
<li>
|
||||
|
||||
@@ -146,7 +146,7 @@ const HistoryPreview = ({ showHistoryView }: HistoryPreviewProps) => {
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "flex-start",
|
||||
justifyContent: "center",
|
||||
}}>
|
||||
<VSCodeButton
|
||||
appearance="icon"
|
||||
|
||||
@@ -16,35 +16,6 @@ type HistoryViewProps = {
|
||||
|
||||
type SortOption = "newest" | "oldest" | "mostExpensive" | "mostTokens" | "mostRelevant"
|
||||
|
||||
// Tailwind-styled radio with custom icon support - works independently of VSCodeRadioGroup but looks the same
|
||||
// Used for workspace and favorites filters
|
||||
|
||||
interface CustomFilterRadioProps {
|
||||
checked: boolean
|
||||
onChange: () => void
|
||||
icon: string
|
||||
label: string
|
||||
}
|
||||
|
||||
const CustomFilterRadio = ({ checked, onChange, icon, label }: CustomFilterRadioProps) => {
|
||||
return (
|
||||
<div
|
||||
className="flex items-center cursor-pointer py-[0.3em] px-0 mr-[10px] text-(--vscode-font-size) select-none"
|
||||
onClick={onChange}>
|
||||
<div
|
||||
className={`w-[14px] h-[14px] rounded-full border border-(--vscode-checkbox-border) relative flex justify-center items-center mr-[6px] ${
|
||||
checked ? "bg-(--vscode-checkbox-background)" : "bg-transparent"
|
||||
}`}>
|
||||
{checked && <div className="w-[6px] h-[6px] rounded-full bg-(--vscode-checkbox-foreground)" />}
|
||||
</div>
|
||||
<span className="flex items-center gap-[3px]">
|
||||
<div className={`codicon codicon-${icon} text-(--vscode-button-background) text-base`} />
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const HistoryView = ({ onDone }: HistoryViewProps) => {
|
||||
const extensionStateContext = useExtensionState()
|
||||
const { taskHistory, onRelinquishControl, environment } = extensionStateContext
|
||||
@@ -363,24 +334,21 @@ const HistoryView = ({ onDone }: HistoryViewProps) => {
|
||||
<VSCodeRadio disabled={!searchQuery} style={{ opacity: searchQuery ? 1 : 0.5 }} value="mostRelevant">
|
||||
Most Relevant
|
||||
</VSCodeRadio>
|
||||
<CustomFilterRadio
|
||||
<VSCodeRadio
|
||||
checked={showCurrentWorkspaceOnly}
|
||||
icon="workspace"
|
||||
label="Workspace"
|
||||
onChange={() => setShowCurrentWorkspaceOnly(!showCurrentWorkspaceOnly)}
|
||||
/>
|
||||
<CustomFilterRadio
|
||||
checked={showFavoritesOnly}
|
||||
icon="star-full"
|
||||
label="Favorites"
|
||||
onChange={() => setShowFavoritesOnly(!showFavoritesOnly)}
|
||||
/>
|
||||
onClick={() => setShowCurrentWorkspaceOnly(!showCurrentWorkspaceOnly)}>
|
||||
<span className="flex items-center gap-[3px]">
|
||||
<span className="codicon codicon-folder text-(--vscode-button-background)" />
|
||||
Workspace
|
||||
</span>
|
||||
</VSCodeRadio>
|
||||
<VSCodeRadio checked={showFavoritesOnly} onClick={() => setShowFavoritesOnly(!showFavoritesOnly)}>
|
||||
<span className="flex items-center gap-[3px]">
|
||||
<span className="codicon codicon-star-full text-(--vscode-button-background)" />
|
||||
Favorites
|
||||
</span>
|
||||
</VSCodeRadio>
|
||||
</VSCodeRadioGroup>
|
||||
|
||||
<div className="flex justify-end gap-2.5">
|
||||
<VSCodeButton onClick={() => handleBatchHistorySelect(true)}>Select All</VSCodeButton>
|
||||
<VSCodeButton onClick={() => handleBatchHistorySelect(false)}>Select None</VSCodeButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ flexGrow: 1, overflowY: "auto", margin: 0 }}>
|
||||
@@ -676,6 +644,14 @@ const HistoryView = ({ onDone }: HistoryViewProps) => {
|
||||
padding: "10px 10px",
|
||||
borderTop: "1px solid var(--vscode-panel-border)",
|
||||
}}>
|
||||
<div className="flex gap-2.5 mb-2.5">
|
||||
<VSCodeButton appearance="secondary" onClick={() => handleBatchHistorySelect(true)} style={{ flex: 1 }}>
|
||||
Select All
|
||||
</VSCodeButton>
|
||||
<VSCodeButton appearance="secondary" onClick={() => handleBatchHistorySelect(false)} style={{ flex: 1 }}>
|
||||
Select None
|
||||
</VSCodeButton>
|
||||
</div>
|
||||
{selectedItems.length > 0 ? (
|
||||
<DangerButton
|
||||
aria-label="Delete selected items"
|
||||
|
||||
+13
-2
@@ -167,7 +167,11 @@ const ServerRow = ({
|
||||
|
||||
return (
|
||||
<div className="mb-2.5">
|
||||
<div className="flex bg-code-block-background p-2 gap-4 items-center" onClick={handleRowClick}>
|
||||
<div
|
||||
className={cn("flex bg-code-block-background p-2 gap-4 items-center", {
|
||||
"cursor-pointer": !server.error && isExpandable,
|
||||
})}
|
||||
onClick={handleRowClick}>
|
||||
{!server.error && isExpandable && (
|
||||
<span
|
||||
className={cn("mr-2 codicon", {
|
||||
@@ -207,7 +211,14 @@ const ServerRow = ({
|
||||
</Button>
|
||||
)}
|
||||
{/* Toggle Switch */}
|
||||
<Switch checked={!server.disabled} key={server.name} onClick={handleToggleMcpServer} />
|
||||
<Switch
|
||||
checked={!server.disabled}
|
||||
key={server.name}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleToggleMcpServer()
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className={cn("h-2 w-2 ml-0.5 rounded-full", {
|
||||
"bg-success": server.status === "connected",
|
||||
|
||||
@@ -85,11 +85,6 @@ export const freeModels = [
|
||||
description: "Open source model with solid performance",
|
||||
label: "FREE",
|
||||
},
|
||||
{
|
||||
id: "z-ai/glm-4.6",
|
||||
description: "Zhipu AI's latest agentic coding model in GLM series",
|
||||
label: "FREE",
|
||||
},
|
||||
{
|
||||
id: "kwaipilot/kat-coder-pro:free",
|
||||
description: "KwaiKAT's most advanced agentic coding model in the KAT-Coder series",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user