mirror of
https://github.com/cline/cline.git
synced 2026-09-12 09:14:50 +08:00
Compare commits
38
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a0c1f0ecab | ||
|
|
544bdf2fac | ||
|
|
c43cdbe940 | ||
|
|
f3692d09bc | ||
|
|
e76e61c67c | ||
|
|
4db1ad3bf4 | ||
|
|
bda5bf224f | ||
|
|
9a77cb4a3b | ||
|
|
80d53e700f | ||
|
|
16f5d3cd2e | ||
|
|
301c524458 | ||
|
|
94ccde51c0 | ||
|
|
4875a54dcd | ||
|
|
d8af586b31 | ||
|
|
83e86ddb9f | ||
|
|
5fd07256f0 | ||
|
|
1b1d880bf8 | ||
|
|
272cae33cf | ||
|
|
c721b34652 | ||
|
|
0eb2762ed2 | ||
|
|
b34056ed87 | ||
|
|
5f92b138e8 | ||
|
|
494ffac404 | ||
|
|
6e5afda275 | ||
|
|
04e4f25865 | ||
|
|
8cd50e7a31 | ||
|
|
dd4ac0167c | ||
|
|
55f7ce9b20 | ||
|
|
a80022795e | ||
|
|
669286bb8e | ||
|
|
e1f60ce8af | ||
|
|
f948e2bc7b | ||
|
|
329c269a8c | ||
|
|
a9617a1acb | ||
|
|
0a691cffbc | ||
|
|
aec21bb299 | ||
|
|
931279c845 | ||
|
|
841f3da57f |
@@ -1,7 +0,0 @@
|
||||
---
|
||||
"claude-dev": minor
|
||||
---
|
||||
Add Bedrock prompt caching support (optional).
|
||||
|
||||
This feature protected under checkbox because it is not yet rolled out to everyone, and if you will try to send cache headers, and its not enabled for you, you will get error.
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": minor
|
||||
---
|
||||
|
||||
improving search and replace edit failure behaviors
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": minor
|
||||
---
|
||||
|
||||
feat(extension): add access to history, mcp, and new task buttons in popout view
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": minor
|
||||
---
|
||||
|
||||
Support connecting to SSE servers
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Move the MCP Restart and Delete buttons and add an auto-approve all toggle
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": minor
|
||||
---
|
||||
|
||||
add coverage tests to github workflows
|
||||
@@ -0,0 +1,19 @@
|
||||
"""
|
||||
Coverage utility package for GitHub Actions workflows.
|
||||
This package handles extracting coverage percentages, comparing them, and generating PR comments.
|
||||
"""
|
||||
|
||||
# Import external dependencies
|
||||
import requests
|
||||
|
||||
# Import main function for CLI usage
|
||||
from .__main__ import main
|
||||
|
||||
# Import functions from extraction module
|
||||
from .extraction import extract_coverage, compare_coverage, run_coverage, set_verbose
|
||||
|
||||
# Import functions from github_api module
|
||||
from .github_api import generate_comment, post_comment, set_github_output
|
||||
|
||||
# Import functions from workflow module
|
||||
from .workflow import process_coverage_workflow
|
||||
@@ -0,0 +1,154 @@
|
||||
"""
|
||||
Main module.
|
||||
This module provides the CLI interface for the coverage utility script.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import argparse
|
||||
|
||||
from .extraction import extract_coverage, compare_coverage, run_coverage, set_verbose
|
||||
from .github_api import generate_comment, post_comment, set_github_output
|
||||
from .workflow import process_coverage_workflow
|
||||
from .util import log
|
||||
|
||||
def setup_verbose_mode(args):
|
||||
"""
|
||||
Set up verbose mode based on command line arguments.
|
||||
|
||||
Args:
|
||||
args: Parsed command line arguments
|
||||
"""
|
||||
if getattr(args, 'verbose', False):
|
||||
set_verbose(True)
|
||||
log("Verbose mode enabled")
|
||||
|
||||
def main():
|
||||
# Create parent parser with common arguments
|
||||
parent_parser = argparse.ArgumentParser(add_help=False)
|
||||
parent_parser.add_argument('-v', '--verbose', action='store_true', help='Enable verbose output')
|
||||
|
||||
# Create main parser that inherits common arguments
|
||||
parser = argparse.ArgumentParser(description='Coverage utility script for GitHub Actions workflows', parents=[parent_parser])
|
||||
subparsers = parser.add_subparsers(dest='command', help='Command to run')
|
||||
|
||||
# extract-coverage command - used directly in workflow
|
||||
extract_parser = subparsers.add_parser('extract-coverage', help='Extract coverage percentage from a file', parents=[parent_parser])
|
||||
extract_parser.add_argument('file_path', help='Path to the coverage report file')
|
||||
extract_parser.add_argument('--type', choices=['extension', 'webview'], default='extension',
|
||||
help='Type of coverage report')
|
||||
extract_parser.add_argument('--github-output', action='store_true', help='Output in GitHub Actions format')
|
||||
|
||||
# compare-coverage command - used by process-workflow
|
||||
compare_parser = subparsers.add_parser('compare-coverage', help='Compare coverage percentages', parents=[parent_parser])
|
||||
compare_parser.add_argument('base_cov', help='Base branch coverage percentage')
|
||||
compare_parser.add_argument('pr_cov', help='PR branch coverage percentage')
|
||||
compare_parser.add_argument('--output-prefix', default='', help='Prefix for GitHub Actions output variables')
|
||||
compare_parser.add_argument('--github-output', action='store_true', help='Output in GitHub Actions format')
|
||||
|
||||
# generate-comment command - used by process-workflow
|
||||
comment_parser = subparsers.add_parser('generate-comment', help='Generate PR comment with coverage comparison', parents=[parent_parser])
|
||||
comment_parser.add_argument('base_ext_cov', help='Base branch extension coverage')
|
||||
comment_parser.add_argument('pr_ext_cov', help='PR branch extension coverage')
|
||||
comment_parser.add_argument('ext_decreased', help='Whether extension coverage decreased (true/false)')
|
||||
comment_parser.add_argument('ext_diff', help='Extension coverage difference')
|
||||
comment_parser.add_argument('base_web_cov', help='Base branch webview coverage')
|
||||
comment_parser.add_argument('pr_web_cov', help='PR branch webview coverage')
|
||||
comment_parser.add_argument('web_decreased', help='Whether webview coverage decreased (true/false)')
|
||||
comment_parser.add_argument('web_diff', help='Webview coverage difference')
|
||||
|
||||
# post-comment command - used by process-workflow
|
||||
post_parser = subparsers.add_parser('post-comment', help='Post a comment to a GitHub PR', parents=[parent_parser])
|
||||
post_parser.add_argument('comment_path', help='Path to the file containing the comment text')
|
||||
post_parser.add_argument('pr_number', help='PR number')
|
||||
post_parser.add_argument('repo', help='Repository in the format "owner/repo"')
|
||||
post_parser.add_argument('--token', help='GitHub token')
|
||||
|
||||
# run-coverage command - used by process-workflow
|
||||
run_parser = subparsers.add_parser('run-coverage', help='Run a coverage command and extract the coverage percentage', parents=[parent_parser])
|
||||
run_parser.add_argument('coverage_cmd', help='Command to run')
|
||||
run_parser.add_argument('output_file', help='File to save the output to')
|
||||
run_parser.add_argument('--type', choices=['extension', 'webview'], default='extension',
|
||||
help='Type of coverage report')
|
||||
run_parser.add_argument('--github-output', action='store_true', help='Output in GitHub Actions format')
|
||||
|
||||
# process-workflow command - used directly in workflow
|
||||
workflow_parser = subparsers.add_parser('process-workflow', help='Process the entire coverage workflow', parents=[parent_parser])
|
||||
workflow_parser.add_argument('--base-branch', required=True, help='Base branch name')
|
||||
workflow_parser.add_argument('--pr-number', help='PR number')
|
||||
workflow_parser.add_argument('--repo', help='Repository in the format "owner/repo"')
|
||||
workflow_parser.add_argument('--token', help='GitHub token')
|
||||
|
||||
# set-github-output command - used by process-workflow
|
||||
output_parser = subparsers.add_parser('set-github-output', help='Set GitHub Actions output variable', parents=[parent_parser])
|
||||
output_parser.add_argument('name', help='Output variable name')
|
||||
output_parser.add_argument('value', help='Output variable value')
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# Set up verbose mode
|
||||
setup_verbose_mode(args)
|
||||
|
||||
if args.command == 'extract-coverage':
|
||||
log(f"Extracting coverage from file: {args.file_path} (type: {args.type})")
|
||||
coverage_pct = extract_coverage(args.file_path, args.type)
|
||||
if args.github_output:
|
||||
set_github_output(f"{args.type}_coverage", coverage_pct)
|
||||
else:
|
||||
log(f"Coverage: {coverage_pct}%")
|
||||
|
||||
elif args.command == 'compare-coverage':
|
||||
log(f"Comparing coverage: base={args.base_cov}%, PR={args.pr_cov}%")
|
||||
decreased, diff = compare_coverage(args.base_cov, args.pr_cov)
|
||||
if args.github_output:
|
||||
prefix = args.output_prefix
|
||||
set_github_output(f"{prefix}decreased", str(decreased).lower())
|
||||
set_github_output(f"{prefix}diff", diff)
|
||||
log(f"Coverage difference: {diff}%")
|
||||
log(f"Coverage decreased: {decreased}")
|
||||
else:
|
||||
log(f"decreased={str(decreased).lower()}")
|
||||
log(f"diff={diff}")
|
||||
|
||||
elif args.command == 'generate-comment':
|
||||
log("Generating coverage comparison comment")
|
||||
comment = generate_comment(
|
||||
args.base_ext_cov, args.pr_ext_cov, args.ext_decreased, args.ext_diff,
|
||||
args.base_web_cov, args.pr_web_cov, args.web_decreased, args.web_diff
|
||||
)
|
||||
# Output the comment to stdout
|
||||
log(comment)
|
||||
|
||||
elif args.command == 'post-comment':
|
||||
log(f"Posting comment from {args.comment_path} to PR #{args.pr_number} in {args.repo}")
|
||||
post_comment(args.comment_path, args.pr_number, args.repo, args.token)
|
||||
|
||||
elif args.command == 'run-coverage':
|
||||
log(f"Running coverage command: {args.coverage_cmd}")
|
||||
log(f"Output file: {args.output_file}")
|
||||
log(f"Coverage type: {args.type}")
|
||||
coverage_pct = run_coverage(args.coverage_cmd, args.output_file, args.type)
|
||||
if args.github_output:
|
||||
set_github_output(f"{args.type}_coverage", coverage_pct)
|
||||
else:
|
||||
log(f"Coverage: {coverage_pct}%")
|
||||
|
||||
elif args.command == 'process-workflow':
|
||||
log("Processing coverage workflow")
|
||||
log(f"Base branch: {args.base_branch}")
|
||||
if args.pr_number:
|
||||
log(f"PR number: {args.pr_number}")
|
||||
if args.repo:
|
||||
log(f"Repository: {args.repo}")
|
||||
process_coverage_workflow(args)
|
||||
|
||||
elif args.command == 'set-github-output':
|
||||
log(f"Setting GitHub output: {args.name}={args.value}")
|
||||
set_github_output(args.name, args.value)
|
||||
|
||||
else:
|
||||
log("No command specified")
|
||||
parser.print_help()
|
||||
sys.exit(1)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,265 @@
|
||||
"""
|
||||
Coverage extraction module.
|
||||
This module handles extracting coverage percentages from coverage report files.
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import shlex
|
||||
import subprocess
|
||||
import traceback
|
||||
from .util import log, file_exists, get_file_size, list_directory, is_safe_command, run_command
|
||||
|
||||
# Global verbose flag
|
||||
verbose = False
|
||||
|
||||
def set_verbose(value):
|
||||
"""Set the global verbose flag."""
|
||||
global verbose
|
||||
verbose = value
|
||||
|
||||
def print_debug_output(content, coverage_type):
|
||||
"""
|
||||
Print debug information about the coverage output.
|
||||
|
||||
Args:
|
||||
content: The content of the coverage file
|
||||
coverage_type: Type of coverage report (extension or webview)
|
||||
"""
|
||||
if not verbose:
|
||||
return
|
||||
|
||||
# Extract and print only the coverage summary section
|
||||
if coverage_type == "extension":
|
||||
# Look for the coverage summary section
|
||||
summary_match = re.search(r'=============================== Coverage summary ===============================\n(.*?)\n=+', content, re.DOTALL)
|
||||
if summary_match:
|
||||
sys.stdout.write("\n##[group]EXTENSION COVERAGE SUMMARY\n")
|
||||
sys.stdout.write("=============================== Coverage summary ===============================\n")
|
||||
sys.stdout.write(summary_match.group(1) + "\n")
|
||||
sys.stdout.write("================================================================================\n")
|
||||
sys.stdout.write("##[endgroup]\n")
|
||||
sys.stdout.flush()
|
||||
else:
|
||||
sys.stdout.write("\n##[warning]No coverage summary found in extension coverage file\n")
|
||||
sys.stdout.flush()
|
||||
else: # webview
|
||||
# Look for the coverage table - specifically the "All files" row
|
||||
table_match = re.search(r'% Coverage report from v8.*?-+\|.*?\n.*?\n(All files.*?)(?:\n[^\n]*\|)', content, re.DOTALL)
|
||||
if table_match:
|
||||
sys.stdout.write("\n##[group]WEBVIEW COVERAGE SUMMARY\n")
|
||||
sys.stdout.write("% Coverage report from v8\n")
|
||||
sys.stdout.write("-------------------|---------|----------|---------|---------|-------------------\n")
|
||||
sys.stdout.write("File | % Stmts | % Branch | % Funcs | % Lines | Uncovered Line #s \n")
|
||||
sys.stdout.write("-------------------|---------|----------|---------|---------|-------------------\n")
|
||||
sys.stdout.write(table_match.group(1) + "\n")
|
||||
sys.stdout.write("-------------------|---------|----------|---------|---------|-------------------\n")
|
||||
sys.stdout.write("##[endgroup]\n")
|
||||
sys.stdout.flush()
|
||||
else:
|
||||
sys.stdout.write("\n##[warning]No coverage table found in webview coverage file\n")
|
||||
sys.stdout.flush()
|
||||
|
||||
def extract_coverage(file_path, coverage_type="extension"):
|
||||
"""
|
||||
Extract coverage percentage from a coverage report file.
|
||||
|
||||
Args:
|
||||
file_path: Path to the coverage report file
|
||||
coverage_type: Type of coverage report (extension or webview)
|
||||
|
||||
Returns:
|
||||
Coverage percentage as a float
|
||||
"""
|
||||
|
||||
# Always print file path for debugging
|
||||
log(f"Checking coverage file: {file_path}")
|
||||
|
||||
# Check if file exists and get its size
|
||||
if not file_exists(file_path):
|
||||
sys.stdout.write(f"\n##[error]File {file_path} does not exist\n")
|
||||
sys.stdout.flush()
|
||||
log(f"Error: File {file_path} does not exist")
|
||||
|
||||
# Check if the directory exists
|
||||
dir_path = os.path.dirname(file_path)
|
||||
if not os.path.exists(dir_path):
|
||||
sys.stdout.write(f"\n##[error]Directory {dir_path} does not exist\n")
|
||||
sys.stdout.flush()
|
||||
log(f"Error: Directory {dir_path} does not exist")
|
||||
else:
|
||||
# List directory contents for debugging
|
||||
log(f"Directory {dir_path} exists, listing contents:")
|
||||
try:
|
||||
dir_contents = list_directory(dir_path)
|
||||
for name, size in dir_contents:
|
||||
log(f" {name} - {size}")
|
||||
sys.stdout.write(f" {name} - {size}\n")
|
||||
sys.stdout.flush()
|
||||
except Exception as e:
|
||||
log(f"Error listing directory: {e}")
|
||||
|
||||
return 0.0
|
||||
|
||||
file_size = get_file_size(file_path)
|
||||
log(f"File size: {file_size} bytes")
|
||||
sys.stdout.write(f"\n##[info]Coverage file {file_path} exists, size: {file_size} bytes\n")
|
||||
sys.stdout.flush()
|
||||
|
||||
if file_size == 0:
|
||||
sys.stdout.write(f"\n##[warning]File {file_path} is empty\n")
|
||||
sys.stdout.flush()
|
||||
log(f"Warning: File {file_path} is empty")
|
||||
return 0.0
|
||||
|
||||
# List directory contents for debugging
|
||||
dir_path = os.path.dirname(file_path)
|
||||
log(f"Directory contents of {dir_path}:")
|
||||
try:
|
||||
dir_contents = list_directory(dir_path)
|
||||
for name, size in dir_contents:
|
||||
log(f" {name} - {size}")
|
||||
except Exception as e:
|
||||
log(f"Error listing directory: {e}")
|
||||
|
||||
with open(file_path, 'r') as f:
|
||||
content = f.read()
|
||||
|
||||
# Print debug information if verbose
|
||||
print_debug_output(content, coverage_type)
|
||||
|
||||
# Extract coverage percentage based on coverage type
|
||||
if coverage_type == "extension":
|
||||
# Extract the percentage from the "Lines" row in the coverage summary
|
||||
# Pattern: Lines : xx.xx% ( xxxxxxx/xxxxxxx )
|
||||
lines_match = re.search(r'Lines\s*:\s*(\d+\.\d+)%', content)
|
||||
if lines_match:
|
||||
coverage_pct = float(lines_match.group(1))
|
||||
if verbose:
|
||||
sys.stdout.write(f"Pattern matched (Lines percentage): {coverage_pct}\n")
|
||||
sys.stdout.flush()
|
||||
return coverage_pct
|
||||
else:
|
||||
# No coverage data found, log full content for debugging
|
||||
log("No coverage data found. Full file content:")
|
||||
log("=== Full file content ===")
|
||||
log(content)
|
||||
log("=== End file content ===")
|
||||
else: # webview
|
||||
# Extract the percentage from the "% Lines" column in the "All files" row
|
||||
# Pattern: All files | xx.xx | xx.xx | xx.xx | xx.xx |
|
||||
all_files_match = re.search(r'All files\s+\|\s+\d+\.\d+\s+\|\s+\d+\.\d+\s+\|\s+\d+\.\d+\s+\|\s+(\d+\.\d+)', content)
|
||||
if all_files_match:
|
||||
coverage_pct = float(all_files_match.group(1))
|
||||
if verbose:
|
||||
sys.stdout.write(f"Pattern matched (All files % Lines): {coverage_pct}\n")
|
||||
sys.stdout.flush()
|
||||
return coverage_pct
|
||||
else:
|
||||
# No coverage data found, log full content for debugging
|
||||
log("No coverage data found. Full file content:")
|
||||
log("=== Full file content ===")
|
||||
log(content)
|
||||
log("=== End file content ===")
|
||||
|
||||
# If no match found, return 0.0
|
||||
return 0.0
|
||||
|
||||
def compare_coverage(base_cov, pr_cov):
|
||||
"""
|
||||
Compare coverage percentages between base and PR branches.
|
||||
|
||||
Args:
|
||||
base_cov: Base branch coverage percentage
|
||||
pr_cov: PR branch coverage percentage
|
||||
|
||||
Returns:
|
||||
Tuple of (decreased, diff)
|
||||
"""
|
||||
try:
|
||||
base_cov = float(base_cov)
|
||||
pr_cov = float(pr_cov)
|
||||
except ValueError:
|
||||
sys.stdout.write(f"Error: Invalid coverage values - base: {base_cov}, PR: {pr_cov}\n")
|
||||
sys.stdout.flush()
|
||||
return False, 0
|
||||
|
||||
diff = pr_cov - base_cov
|
||||
decreased = diff < 0
|
||||
|
||||
return decreased, abs(diff)
|
||||
|
||||
def run_coverage(command, output_file, coverage_type="extension"):
|
||||
"""
|
||||
Run a coverage command and extract the coverage percentage.
|
||||
|
||||
Args:
|
||||
command: Command to run
|
||||
output_file: File to save the output to
|
||||
coverage_type: Type of coverage report (extension or webview)
|
||||
|
||||
Returns:
|
||||
Coverage percentage as a float
|
||||
|
||||
Raises:
|
||||
SystemExit: If the output file is not created or is empty
|
||||
"""
|
||||
|
||||
try:
|
||||
# Run the command and capture output
|
||||
if not is_safe_command(command):
|
||||
error_msg = f"ERROR: Unsafe command detected: {command}"
|
||||
log(error_msg)
|
||||
sys.stdout.write(f"\n##[error]{error_msg}\n")
|
||||
sys.stdout.flush()
|
||||
sys.exit(1)
|
||||
|
||||
# Run command using safe execution from util
|
||||
returncode, stdout, stderr = run_command(command)
|
||||
|
||||
# Log command result
|
||||
log(f"Command exit code: {returncode}")
|
||||
log(f"Command stdout length: {len(stdout)} bytes")
|
||||
log(f"Command stderr length: {len(stderr)} bytes")
|
||||
|
||||
# Save output to file
|
||||
log(f"Saving command output to {output_file}")
|
||||
with open(output_file, 'w') as f:
|
||||
f.write(stdout)
|
||||
if stderr:
|
||||
f.write("\n\n=== STDERR ===\n")
|
||||
f.write(stderr)
|
||||
|
||||
# Verify file was created and has content
|
||||
if not file_exists(output_file):
|
||||
error_msg = f"ERROR: Output file {output_file} was not created"
|
||||
log(error_msg)
|
||||
sys.stdout.write(f"\n##[error]{error_msg}\n")
|
||||
sys.stdout.flush()
|
||||
sys.exit(1) # Exit with error code to fail the workflow
|
||||
|
||||
file_size = get_file_size(output_file)
|
||||
if file_size == 0:
|
||||
error_msg = f"ERROR: Output file {output_file} is empty"
|
||||
log(error_msg)
|
||||
sys.stdout.write(f"\n##[error]{error_msg}\n")
|
||||
sys.stdout.flush()
|
||||
sys.exit(1) # Exit with error code to fail the workflow
|
||||
|
||||
log(f"Output file size: {file_size} bytes")
|
||||
|
||||
# Extract coverage percentage
|
||||
coverage_pct = extract_coverage(output_file, coverage_type)
|
||||
|
||||
log(f"{coverage_type.capitalize()} coverage: {coverage_pct}%")
|
||||
return coverage_pct
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"Error running coverage command: {e}"
|
||||
log(error_msg)
|
||||
sys.stdout.write(f"\n##[error]{error_msg}\n")
|
||||
sys.stdout.flush()
|
||||
# Print stack trace for debugging
|
||||
log(traceback.format_exc())
|
||||
sys.exit(1) # Exit with error code to fail the workflow
|
||||
@@ -0,0 +1,177 @@
|
||||
"""
|
||||
GitHub API module.
|
||||
This module handles interactions with the GitHub API for posting comments to PRs.
|
||||
"""
|
||||
|
||||
import os
|
||||
import requests
|
||||
from .util import log, file_exists
|
||||
|
||||
def generate_comment(base_ext_cov, pr_ext_cov, ext_decreased, ext_diff,
|
||||
base_web_cov, pr_web_cov, web_decreased, web_diff):
|
||||
"""
|
||||
Generate a PR comment with coverage comparison.
|
||||
|
||||
Args:
|
||||
base_ext_cov: Base branch extension coverage
|
||||
pr_ext_cov: PR branch extension coverage
|
||||
ext_decreased: Whether extension coverage decreased
|
||||
ext_diff: Extension coverage difference
|
||||
base_web_cov: Base branch webview coverage
|
||||
pr_web_cov: PR branch webview coverage
|
||||
web_decreased: Whether webview coverage decreased
|
||||
web_diff: Webview coverage difference
|
||||
|
||||
Returns:
|
||||
Comment text
|
||||
"""
|
||||
from datetime import datetime
|
||||
|
||||
# Convert string inputs to appropriate types
|
||||
try:
|
||||
base_ext_cov = float(base_ext_cov)
|
||||
pr_ext_cov = float(pr_ext_cov)
|
||||
# Handle ext_decreased as either string or boolean
|
||||
if isinstance(ext_decreased, str):
|
||||
ext_decreased = ext_decreased.lower() == 'true'
|
||||
else:
|
||||
ext_decreased = bool(ext_decreased)
|
||||
ext_diff = float(ext_diff)
|
||||
base_web_cov = float(base_web_cov)
|
||||
pr_web_cov = float(pr_web_cov)
|
||||
# Handle web_decreased as either string or boolean
|
||||
if isinstance(web_decreased, str):
|
||||
web_decreased = web_decreased.lower() == 'true'
|
||||
else:
|
||||
web_decreased = bool(web_decreased)
|
||||
web_diff = float(web_diff)
|
||||
except ValueError as e:
|
||||
log(f"Error converting input values: {e}")
|
||||
return ""
|
||||
|
||||
# Add a unique identifier to find this comment later
|
||||
comment = '<!-- COVERAGE_REPORT -->\n'
|
||||
comment += '## Coverage Report\n\n'
|
||||
|
||||
# Extension coverage
|
||||
comment += '### Extension Coverage\n\n'
|
||||
comment += f'Base branch: {base_ext_cov:.0f}%\n\n'
|
||||
comment += f'PR branch: {pr_ext_cov:.0f}%\n\n'
|
||||
|
||||
if ext_decreased:
|
||||
comment += f'⚠️ **Warning: Coverage decreased by {ext_diff:.2f}%**\n\n'
|
||||
comment += 'Consider adding tests to cover your changes.\n\n'
|
||||
else:
|
||||
comment += '✅ Coverage increased or remained the same\n\n'
|
||||
|
||||
# Webview coverage
|
||||
comment += '### Webview Coverage\n\n'
|
||||
comment += f'Base branch: {base_web_cov:.0f}%\n\n'
|
||||
comment += f'PR branch: {pr_web_cov:.0f}%\n\n'
|
||||
|
||||
if web_decreased:
|
||||
comment += f'⚠️ **Warning: Coverage decreased by {web_diff:.2f}%**\n\n'
|
||||
comment += 'Consider adding tests to cover your changes.\n\n'
|
||||
else:
|
||||
comment += '✅ Coverage increased or remained the same\n\n'
|
||||
|
||||
# Overall assessment
|
||||
comment += '### Overall Assessment\n\n'
|
||||
if ext_decreased or web_decreased:
|
||||
comment += '⚠️ **Test coverage has decreased in this PR**\n\n'
|
||||
comment += 'Please consider adding tests to maintain or improve coverage.\n\n'
|
||||
else:
|
||||
comment += '✅ **Test coverage has been maintained or improved**\n\n'
|
||||
|
||||
# Add timestamp
|
||||
comment += f'\n\n<sub>Last updated: {datetime.now().isoformat()}</sub>'
|
||||
|
||||
return comment
|
||||
|
||||
def post_comment(comment_path, pr_number, repo, token=None):
|
||||
"""
|
||||
Post a comment to a GitHub PR.
|
||||
|
||||
Args:
|
||||
comment_path: Path to the file containing the comment text
|
||||
pr_number: PR number
|
||||
repo: Repository in the format "owner/repo"
|
||||
token: GitHub token
|
||||
"""
|
||||
if not file_exists(comment_path):
|
||||
log(f"Error: Comment file {comment_path} does not exist")
|
||||
return
|
||||
|
||||
with open(comment_path, 'r') as f:
|
||||
comment_body = f.read()
|
||||
|
||||
if not token:
|
||||
token = os.environ.get('GITHUB_TOKEN')
|
||||
if not token:
|
||||
log("Error: GitHub token not provided")
|
||||
return
|
||||
|
||||
# Find existing comment
|
||||
headers = {
|
||||
'Authorization': f'token {token}',
|
||||
'Accept': 'application/vnd.github.v3+json'
|
||||
}
|
||||
|
||||
# Get all comments
|
||||
comments_url = f'https://api.github.com/repos/{repo}/issues/{pr_number}/comments'
|
||||
log(f"Getting comments from: {comments_url}")
|
||||
response = requests.get(comments_url, headers=headers)
|
||||
|
||||
if response.status_code != 200:
|
||||
log(f"Error getting comments: {response.status_code} - {response.text}")
|
||||
return
|
||||
|
||||
comments = response.json()
|
||||
log(f"Found {len(comments)} existing comments")
|
||||
|
||||
# Find comment with our identifier
|
||||
comment_id = None
|
||||
for comment in comments:
|
||||
if '<!-- COVERAGE_REPORT -->' in comment['body']:
|
||||
comment_id = comment['id']
|
||||
log(f"Found existing coverage report comment with ID: {comment_id}")
|
||||
break
|
||||
|
||||
if comment_id:
|
||||
# Update existing comment
|
||||
update_url = f'https://api.github.com/repos/{repo}/issues/comments/{comment_id}'
|
||||
log(f"Updating existing comment at: {update_url}")
|
||||
response = requests.patch(update_url, headers=headers, json={'body': comment_body})
|
||||
|
||||
if response.status_code == 200:
|
||||
log(f"Successfully updated existing comment: {comment_id}")
|
||||
else:
|
||||
log(f"Error updating comment: {response.status_code} - {response.text}")
|
||||
else:
|
||||
# Create new comment
|
||||
log(f"Creating new comment at: {comments_url}")
|
||||
response = requests.post(comments_url, headers=headers, json={'body': comment_body})
|
||||
|
||||
if response.status_code == 201:
|
||||
log("Successfully created new comment")
|
||||
else:
|
||||
log(f"Error creating comment: {response.status_code} - {response.text}")
|
||||
|
||||
def set_github_output(name, value):
|
||||
"""
|
||||
Set GitHub Actions output variable.
|
||||
|
||||
Args:
|
||||
name: Output variable name
|
||||
value: Output variable value
|
||||
"""
|
||||
# Write to the GitHub output file if available
|
||||
if 'GITHUB_OUTPUT' in os.environ:
|
||||
with open(os.environ['GITHUB_OUTPUT'], 'a') as f:
|
||||
f.write(f"{name}={value}\n")
|
||||
else:
|
||||
# Fallback to the deprecated method for backward compatibility
|
||||
log(f"::set-output name={name}::{value}")
|
||||
|
||||
# Also print for human readability
|
||||
log(f"{name}: {value}")
|
||||
@@ -0,0 +1,245 @@
|
||||
"""
|
||||
Utility module.
|
||||
This module provides utility functions used across the coverage check scripts.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import re
|
||||
import shlex
|
||||
import subprocess
|
||||
import traceback
|
||||
from typing import List, Tuple, Dict, Any, Optional, Union
|
||||
|
||||
# List of allowed commands and their arguments
|
||||
ALLOWED_COMMANDS = {
|
||||
'xvfb-run': ['-a'],
|
||||
'npm': ['run', 'test:coverage', 'ci', 'install', '--no-save', '@vitest/coverage-v8', 'check-types', 'lint', 'format', 'compile'],
|
||||
'cd': ['webview-ui'],
|
||||
'python': ['-m', 'coverage_check'],
|
||||
'git': ['fetch', 'checkout', 'origin'],
|
||||
}
|
||||
|
||||
def is_safe_command(command: Union[str, List[str]]) -> bool:
|
||||
"""
|
||||
Check if a command is safe to execute.
|
||||
|
||||
Args:
|
||||
command: Command to check (string or list)
|
||||
|
||||
Returns:
|
||||
True if command is safe, False otherwise
|
||||
"""
|
||||
# Convert string command to list
|
||||
if isinstance(command, str):
|
||||
try:
|
||||
cmd_parts = shlex.split(command)
|
||||
except ValueError:
|
||||
return False
|
||||
else:
|
||||
cmd_parts = command
|
||||
|
||||
if not cmd_parts:
|
||||
return False
|
||||
|
||||
# Get base command
|
||||
base_cmd = os.path.basename(cmd_parts[0])
|
||||
|
||||
# Check if command is in allowed list
|
||||
if base_cmd not in ALLOWED_COMMANDS:
|
||||
return False
|
||||
|
||||
# For each argument, check for suspicious patterns
|
||||
for arg in cmd_parts[1:]:
|
||||
# Check for shell metacharacters
|
||||
if re.search(r'[;&|`$]', arg):
|
||||
return False
|
||||
# Check for path traversal
|
||||
if '..' in arg and not (base_cmd == 'npm' and arg.startswith('@')):
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def log(message: str) -> None:
|
||||
"""
|
||||
Write a message to stdout and flush.
|
||||
|
||||
Args:
|
||||
message: The message to write
|
||||
"""
|
||||
sys.stdout.write(f"{message}\n")
|
||||
sys.stdout.flush()
|
||||
|
||||
def file_exists(file_path: str) -> bool:
|
||||
"""
|
||||
Check if a file exists.
|
||||
|
||||
Args:
|
||||
file_path: Path to the file
|
||||
|
||||
Returns:
|
||||
True if the file exists, False otherwise
|
||||
"""
|
||||
return os.path.exists(file_path) and os.path.isfile(file_path)
|
||||
|
||||
def get_file_size(file_path: str) -> int:
|
||||
"""
|
||||
Get the size of a file in bytes.
|
||||
|
||||
Args:
|
||||
file_path: Path to the file
|
||||
|
||||
Returns:
|
||||
Size of the file in bytes, or 0 if the file doesn't exist
|
||||
"""
|
||||
if file_exists(file_path):
|
||||
return os.path.getsize(file_path)
|
||||
return 0
|
||||
|
||||
def list_directory(dir_path: str) -> List[Tuple[str, Union[int, str]]]:
|
||||
"""
|
||||
List the contents of a directory.
|
||||
|
||||
Args:
|
||||
dir_path: Path to the directory
|
||||
|
||||
Returns:
|
||||
List of (name, size) tuples for each file/directory in the directory
|
||||
"""
|
||||
if not os.path.exists(dir_path) or not os.path.isdir(dir_path):
|
||||
return []
|
||||
|
||||
contents = []
|
||||
for item in os.listdir(dir_path):
|
||||
item_path = os.path.join(dir_path, item)
|
||||
if os.path.isfile(item_path):
|
||||
contents.append((item, os.path.getsize(item_path)))
|
||||
else:
|
||||
contents.append((item, "DIR"))
|
||||
|
||||
return contents
|
||||
|
||||
def read_file_content(file_path: str, default: str = "") -> str:
|
||||
"""
|
||||
Read file content with error handling.
|
||||
|
||||
Args:
|
||||
file_path: Path to the file
|
||||
default: Default value to return if file cannot be read
|
||||
|
||||
Returns:
|
||||
File content or default value
|
||||
"""
|
||||
if not file_exists(file_path):
|
||||
log(f"File does not exist: {file_path}")
|
||||
return default
|
||||
|
||||
try:
|
||||
with open(file_path, 'r') as f:
|
||||
return f.read()
|
||||
except Exception as e:
|
||||
log(f"Error reading file {file_path}: {e}")
|
||||
return default
|
||||
|
||||
def write_file_content(file_path: str, content: str) -> bool:
|
||||
"""
|
||||
Write content to file with error handling.
|
||||
|
||||
Args:
|
||||
file_path: Path to the file
|
||||
content: Content to write
|
||||
|
||||
Returns:
|
||||
True if successful, False otherwise
|
||||
"""
|
||||
try:
|
||||
# Create directory if it doesn't exist
|
||||
os.makedirs(os.path.dirname(file_path), exist_ok=True)
|
||||
|
||||
with open(file_path, 'w') as f:
|
||||
f.write(content)
|
||||
return True
|
||||
except Exception as e:
|
||||
log(f"Error writing to file {file_path}: {e}")
|
||||
return False
|
||||
|
||||
def run_command(command: Union[str, List[str]], capture_output: bool = True) -> Tuple[int, str, str]:
|
||||
"""
|
||||
Run a command and return the result.
|
||||
|
||||
Args:
|
||||
command: Command to run (string or list)
|
||||
capture_output: Whether to capture stdout/stderr
|
||||
|
||||
Returns:
|
||||
Tuple of (returncode, stdout, stderr)
|
||||
"""
|
||||
if not is_safe_command(command):
|
||||
error_msg = f"Unsafe command detected: {command}"
|
||||
log(error_msg)
|
||||
return 1, "", error_msg
|
||||
|
||||
log(f"Running command: {command}")
|
||||
try:
|
||||
# Convert string command to list
|
||||
if isinstance(command, str):
|
||||
cmd_list = shlex.split(command)
|
||||
else:
|
||||
cmd_list = command
|
||||
|
||||
result = subprocess.run(
|
||||
cmd_list,
|
||||
shell=False, # Never use shell=True for security
|
||||
capture_output=capture_output,
|
||||
text=True
|
||||
)
|
||||
log(f"Command exit code: {result.returncode}")
|
||||
return result.returncode, result.stdout, result.stderr
|
||||
except Exception as e:
|
||||
log(f"Error running command: {e}")
|
||||
log(traceback.format_exc())
|
||||
return 1, "", str(e)
|
||||
|
||||
def find_pattern(content: str, pattern: str, group: int = 0,
|
||||
default: Optional[str] = None) -> Optional[str]:
|
||||
"""
|
||||
Find a pattern in content and return the specified group.
|
||||
|
||||
Args:
|
||||
content: Text content to search
|
||||
pattern: Regex pattern to search for
|
||||
group: Group number to return (default: 0 for entire match)
|
||||
default: Default value to return if pattern not found
|
||||
|
||||
Returns:
|
||||
Matched text or default value
|
||||
"""
|
||||
match = re.search(pattern, content, re.DOTALL)
|
||||
if match:
|
||||
return match.group(group)
|
||||
return default
|
||||
|
||||
def get_env_var(name: str, default: Optional[str] = None) -> Optional[str]:
|
||||
"""
|
||||
Get environment variable with default value.
|
||||
|
||||
Args:
|
||||
name: Environment variable name
|
||||
default: Default value if not set
|
||||
|
||||
Returns:
|
||||
Environment variable value or default
|
||||
"""
|
||||
return os.environ.get(name, default)
|
||||
|
||||
def format_exception(e: Exception) -> str:
|
||||
"""
|
||||
Format an exception with traceback for logging.
|
||||
|
||||
Args:
|
||||
e: Exception to format
|
||||
|
||||
Returns:
|
||||
Formatted exception string
|
||||
"""
|
||||
return f"{type(e).__name__}: {str(e)}\n{traceback.format_exc()}"
|
||||
@@ -0,0 +1,432 @@
|
||||
"""
|
||||
Workflow module.
|
||||
This module handles the main workflow logic for running coverage tests and processing results.
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import subprocess
|
||||
import traceback
|
||||
|
||||
from .extraction import run_coverage, compare_coverage, extract_coverage
|
||||
from .github_api import generate_comment, post_comment, set_github_output
|
||||
from .util import log, file_exists, get_file_size, list_directory, run_command
|
||||
|
||||
def is_valid_branch_name(branch_name: str) -> bool:
|
||||
"""
|
||||
Validate a git branch name.
|
||||
|
||||
Args:
|
||||
branch_name: Branch name to validate
|
||||
|
||||
Returns:
|
||||
True if valid, False otherwise
|
||||
"""
|
||||
# Check for common branch name patterns
|
||||
if not re.match(r'^[a-zA-Z0-9_\-./]+$', branch_name):
|
||||
return False
|
||||
|
||||
# Check for path traversal
|
||||
if '..' in branch_name:
|
||||
return False
|
||||
|
||||
# Check for shell metacharacters
|
||||
if re.search(r'[;&|`$]', branch_name):
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def checkout_branch(branch_name: str) -> None:
|
||||
"""
|
||||
Checkout a branch for testing.
|
||||
|
||||
Args:
|
||||
branch_name: Branch name to checkout
|
||||
|
||||
Raises:
|
||||
RuntimeError: If branch checkout fails
|
||||
ValueError: If branch name is invalid
|
||||
"""
|
||||
if not is_valid_branch_name(branch_name):
|
||||
raise ValueError(f"Invalid branch name: {branch_name}")
|
||||
|
||||
log(f"=== Checking out branch: {branch_name} ===")
|
||||
|
||||
# Fetch the branch
|
||||
returncode, stdout, stderr = run_command(['git', 'fetch', 'origin', branch_name])
|
||||
if returncode != 0:
|
||||
log(f"ERROR: Failed to fetch branch {branch_name}")
|
||||
log(f"Error details: {stderr}")
|
||||
raise RuntimeError(f"Git fetch failed: {stderr}")
|
||||
|
||||
# Checkout the branch
|
||||
returncode, stdout, stderr = run_command(['git', 'checkout', branch_name])
|
||||
if returncode != 0:
|
||||
log(f"ERROR: Failed to checkout branch {branch_name}")
|
||||
log(f"Error details: {stderr}")
|
||||
raise RuntimeError(f"Git checkout failed: {stderr}")
|
||||
|
||||
log(f"Successfully checked out branch: {branch_name}")
|
||||
|
||||
def extract_extension_coverage_from_file(file_path):
|
||||
"""Extract extension coverage from file when run_coverage returns 0."""
|
||||
if not file_exists(file_path):
|
||||
log(f"File {file_path} does not exist, cannot extract extension coverage")
|
||||
return 0.0
|
||||
|
||||
file_size = get_file_size(file_path)
|
||||
if file_size == 0:
|
||||
log(f"File {file_path} is empty, cannot extract extension coverage")
|
||||
return 0.0
|
||||
|
||||
log(f"Extension coverage is 0.0, trying to read from file directly: {file_path} (size: {file_size} bytes)")
|
||||
with open(file_path, 'r') as f:
|
||||
content = f.read()
|
||||
# Extract the percentage from the "Lines" row in the coverage summary
|
||||
# Pattern: Lines : xx.xx% ( xxxxxxx/xxxxxxx )
|
||||
lines_match = re.search(r'Lines\s*:\s*(\d+\.\d+)%', content)
|
||||
if lines_match:
|
||||
coverage = float(lines_match.group(1))
|
||||
log(f"Found extension coverage in file: {coverage}%")
|
||||
return coverage
|
||||
return 0.0
|
||||
|
||||
def extract_webview_coverage_from_file(file_path):
|
||||
"""Extract webview coverage from file when run_coverage returns 0."""
|
||||
if not file_exists(file_path):
|
||||
log(f"File {file_path} does not exist, cannot extract webview coverage")
|
||||
return 0.0
|
||||
|
||||
file_size = get_file_size(file_path)
|
||||
if file_size == 0:
|
||||
log(f"File {file_path} is empty, cannot extract webview coverage")
|
||||
return 0.0
|
||||
|
||||
log(f"Webview coverage is 0.0, trying to read from file directly: {file_path} (size: {file_size} bytes)")
|
||||
with open(file_path, 'r') as f:
|
||||
content = f.read()
|
||||
# Extract the percentage from the "% Lines" column in the "All files" row
|
||||
# Pattern: All files | xx.xx | xx.xx | xx.xx | xx.xx |
|
||||
all_files_match = re.search(r'All files\s+\|\s+\d+\.\d+\s+\|\s+\d+\.\d+\s+\|\s+\d+\.\d+\s+\|\s+(\d+\.\d+)', content)
|
||||
if all_files_match:
|
||||
coverage = float(all_files_match.group(1))
|
||||
log(f"Found webview coverage in file: {coverage}%")
|
||||
return coverage
|
||||
return 0.0
|
||||
|
||||
def run_extension_coverage(branch_name=None):
|
||||
"""Run extension coverage tests and extract results."""
|
||||
prefix = 'base_' if branch_name else ''
|
||||
file_path = f"{prefix}extension_coverage.txt"
|
||||
|
||||
# Run coverage tests
|
||||
ext_cov = run_coverage(
|
||||
["xvfb-run", "-a", "npm", "run", "test:coverage"],
|
||||
file_path,
|
||||
"extension"
|
||||
)
|
||||
|
||||
# If coverage is 0.0, try to extract from file directly
|
||||
if ext_cov == 0.0:
|
||||
ext_cov = extract_extension_coverage_from_file(file_path)
|
||||
|
||||
return ext_cov
|
||||
|
||||
def run_webview_coverage(branch_name=None):
|
||||
"""Run webview coverage tests and extract results."""
|
||||
prefix = 'base_' if branch_name else ''
|
||||
file_path = f"{prefix}webview_coverage.txt"
|
||||
|
||||
# Save current directory
|
||||
original_dir = os.getcwd()
|
||||
|
||||
try:
|
||||
# Change to webview-ui directory
|
||||
os.chdir('webview-ui')
|
||||
|
||||
# Install coverage dependency
|
||||
returncode, stdout, stderr = run_command(["npm", "install", "--no-save", "@vitest/coverage-v8"])
|
||||
if returncode != 0:
|
||||
log(f"Failed to install coverage dependency: {stderr}")
|
||||
return 0.0
|
||||
|
||||
# Run coverage tests from webview-ui directory
|
||||
web_cov = run_coverage(
|
||||
["npm", "run", "test:coverage"],
|
||||
os.path.join('..', file_path),
|
||||
"webview"
|
||||
)
|
||||
finally:
|
||||
# Always change back to original directory
|
||||
os.chdir(original_dir)
|
||||
|
||||
# If coverage is 0.0, try to extract from file directly
|
||||
if web_cov == 0.0:
|
||||
web_cov = extract_webview_coverage_from_file(file_path)
|
||||
|
||||
return web_cov
|
||||
|
||||
def run_branch_coverage(branch_name=None):
|
||||
"""
|
||||
Run coverage tests for a branch.
|
||||
|
||||
Args:
|
||||
branch_name: Name of the branch to checkout before running tests (optional)
|
||||
|
||||
Returns:
|
||||
Tuple of (extension_coverage, webview_coverage)
|
||||
"""
|
||||
# Checkout branch if specified
|
||||
if branch_name:
|
||||
checkout_branch(branch_name)
|
||||
|
||||
# Run coverage tests
|
||||
log(f"=== Running coverage tests{' for ' + branch_name if branch_name else ''} ===")
|
||||
|
||||
# Run extension and webview coverage
|
||||
ext_cov = run_extension_coverage(branch_name)
|
||||
web_cov = run_webview_coverage(branch_name)
|
||||
|
||||
return ext_cov, web_cov
|
||||
|
||||
def find_potential_coverage_files():
|
||||
"""Find potential coverage files in the current directory and webview-ui."""
|
||||
log("Searching for potential coverage files...")
|
||||
|
||||
# Find files in current directory
|
||||
current_dir_files = list_directory('.')
|
||||
for name, size in current_dir_files:
|
||||
if 'coverage' in name.lower() and size != "DIR":
|
||||
log(f"Found potential coverage file: {name} (size: {size} bytes)")
|
||||
|
||||
# Find files in webview-ui directory
|
||||
if os.path.exists('webview-ui') and os.path.isdir('webview-ui'):
|
||||
webview_files = list_directory('webview-ui')
|
||||
for name, size in webview_files:
|
||||
if 'coverage' in name.lower() and size != "DIR":
|
||||
log(f"Found potential webview coverage file: webview-ui/{name} (size: {size} bytes)")
|
||||
else:
|
||||
log("webview-ui directory not found")
|
||||
|
||||
def generate_warnings(base_ext_cov, pr_ext_cov, ext_decreased, ext_diff,
|
||||
base_web_cov, pr_web_cov, web_decreased, web_diff):
|
||||
"""Generate warnings for coverage decreases."""
|
||||
if not (ext_decreased or web_decreased):
|
||||
return []
|
||||
|
||||
warnings = [
|
||||
"Test coverage has decreased in this PR",
|
||||
f"Extension coverage: {base_ext_cov}% -> {pr_ext_cov}% (Diff: {ext_diff}%)",
|
||||
f"Webview coverage: {base_web_cov}% -> {pr_web_cov}% (Diff: {web_diff}%)"
|
||||
]
|
||||
|
||||
# Additional warning for significant decrease (more than 1%)
|
||||
if ext_decreased and ext_diff > 1.0:
|
||||
warnings.append(f"Extension coverage decreased by more than 1% ({ext_diff}%). Consider adding tests to cover your changes.")
|
||||
|
||||
if web_decreased and web_diff > 1.0:
|
||||
warnings.append(f"Webview coverage decreased by more than 1% ({web_diff}%). Consider adding tests to cover your changes.")
|
||||
|
||||
return warnings
|
||||
|
||||
def output_warnings(warnings):
|
||||
"""Output warnings to GitHub step summary and console."""
|
||||
if not warnings:
|
||||
return
|
||||
|
||||
# Get the GitHub step summary file path from environment variable
|
||||
github_step_summary = os.environ.get('GITHUB_STEP_SUMMARY')
|
||||
|
||||
# Write to GitHub step summary if available
|
||||
if github_step_summary:
|
||||
with open(github_step_summary, 'a') as f:
|
||||
f.write("## Coverage Warnings\n\n")
|
||||
for warning in warnings:
|
||||
f.write(f"⚠️ {warning}\n\n")
|
||||
|
||||
# Also output to console with ::warning:: syntax for backward compatibility
|
||||
for warning in warnings:
|
||||
log(f"::warning::{warning}")
|
||||
|
||||
def output_github_results(pr_ext_cov, pr_web_cov, base_ext_cov, base_web_cov,
|
||||
ext_decreased, ext_diff, web_decreased, web_diff):
|
||||
"""Output results for GitHub Actions."""
|
||||
set_github_output("pr_extension_coverage", pr_ext_cov)
|
||||
set_github_output("pr_webview_coverage", pr_web_cov)
|
||||
set_github_output("base_extension_coverage", base_ext_cov)
|
||||
set_github_output("base_webview_coverage", base_web_cov)
|
||||
set_github_output("extension_decreased", str(ext_decreased).lower())
|
||||
set_github_output("extension_diff", ext_diff)
|
||||
set_github_output("webview_decreased", str(web_decreased).lower())
|
||||
set_github_output("webview_diff", web_diff)
|
||||
|
||||
def extract_pr_coverage_from_artifacts():
|
||||
"""
|
||||
Extract PR branch coverage from artifact files.
|
||||
|
||||
Returns:
|
||||
Tuple of (extension_coverage, webview_coverage)
|
||||
|
||||
Raises:
|
||||
SystemExit: If the coverage files don't exist
|
||||
"""
|
||||
log("=== Extracting PR branch coverage from artifacts ===")
|
||||
|
||||
# Check if the coverage files exist
|
||||
ext_file_path = "extension_coverage.txt"
|
||||
web_file_path = "webview-ui/webview_coverage.txt"
|
||||
|
||||
# Extract extension coverage
|
||||
log(f"Extracting extension coverage from {ext_file_path}")
|
||||
if not file_exists(ext_file_path):
|
||||
error_msg = f"ERROR: PR extension coverage file {ext_file_path} not found"
|
||||
log(error_msg)
|
||||
|
||||
# List directory contents for debugging
|
||||
log("Current directory contents:")
|
||||
try:
|
||||
dir_contents = list_directory('.')
|
||||
for name, size in dir_contents:
|
||||
log(f" {name} - {size}\n")
|
||||
except Exception as e:
|
||||
log(f"Error listing directory: {e}")
|
||||
|
||||
sys.exit(1) # Exit with error code to fail the workflow
|
||||
|
||||
ext_cov = extract_extension_coverage_from_file(ext_file_path)
|
||||
log(f"PR extension coverage from artifact: {ext_cov}%")
|
||||
|
||||
# Extract webview coverage
|
||||
log(f"Extracting webview coverage from {web_file_path}")
|
||||
if not file_exists(web_file_path):
|
||||
error_msg = f"ERROR: PR webview coverage file {web_file_path} not found"
|
||||
log(error_msg)
|
||||
|
||||
# Check if the webview-ui directory exists
|
||||
if not os.path.exists('webview-ui'):
|
||||
log("ERROR: webview-ui directory not found")
|
||||
else:
|
||||
# List webview-ui directory contents for debugging
|
||||
log("webview-ui directory contents:")
|
||||
try:
|
||||
dir_contents = list_directory('webview-ui')
|
||||
for name, size in dir_contents:
|
||||
log(f" {name} - {size}")
|
||||
except Exception as e:
|
||||
log(f"Error listing directory: {e}")
|
||||
|
||||
sys.exit(1) # Exit with error code to fail the workflow
|
||||
|
||||
web_cov = extract_webview_coverage_from_file(web_file_path)
|
||||
log(f"PR webview coverage from artifact: {web_cov}%")
|
||||
|
||||
return ext_cov, web_cov
|
||||
|
||||
def process_coverage_workflow(args):
|
||||
"""
|
||||
Process the entire coverage workflow.
|
||||
|
||||
Args:
|
||||
args: Command line arguments
|
||||
"""
|
||||
# Initialize all variables at the start
|
||||
pr_ext_cov = 0.0
|
||||
pr_web_cov = 0.0
|
||||
base_ext_cov = 0.0
|
||||
base_web_cov = 0.0
|
||||
ext_decreased = False
|
||||
ext_diff = 0.0
|
||||
web_decreased = False
|
||||
web_diff = 0.0
|
||||
|
||||
try:
|
||||
# Validate branch name
|
||||
if not is_valid_branch_name(args.base_branch):
|
||||
raise ValueError(f"Invalid base branch name: {args.base_branch}")
|
||||
|
||||
# Check if we're running in GitHub Actions
|
||||
is_github_actions = 'GITHUB_ACTIONS' in os.environ
|
||||
if is_github_actions:
|
||||
log("Running in GitHub Actions environment")
|
||||
|
||||
# Extract PR branch coverage from artifacts (from test job)
|
||||
pr_ext_cov, pr_web_cov = extract_pr_coverage_from_artifacts()
|
||||
|
||||
# Verify PR coverage values
|
||||
if pr_ext_cov == 0.0:
|
||||
log("WARNING: PR extension coverage is 0.0, this may indicate an issue with the coverage report")
|
||||
find_potential_coverage_files()
|
||||
|
||||
if pr_web_cov == 0.0:
|
||||
log("WARNING: PR webview coverage is 0.0, this may indicate an issue with the coverage report")
|
||||
find_potential_coverage_files()
|
||||
|
||||
# Run base branch coverage
|
||||
log(f"=== Running base branch coverage for {args.base_branch} ===")
|
||||
base_ext_cov, base_web_cov = run_branch_coverage(args.base_branch)
|
||||
|
||||
# Verify base coverage values
|
||||
if base_ext_cov == 0.0:
|
||||
log("WARNING: Base extension coverage is 0.0, this may indicate an issue with the coverage report")
|
||||
|
||||
if base_web_cov == 0.0:
|
||||
log("WARNING: Base webview coverage is 0.0, this may indicate an issue with the coverage report")
|
||||
|
||||
# Compare coverage
|
||||
log("=== Comparing extension coverage ===")
|
||||
ext_decreased, ext_diff = compare_coverage(base_ext_cov, pr_ext_cov)
|
||||
|
||||
log("=== Comparing webview coverage ===")
|
||||
web_decreased, web_diff = compare_coverage(base_web_cov, pr_web_cov)
|
||||
|
||||
# Print summary of coverage values
|
||||
log("\n=== Coverage Summary ===")
|
||||
log(f"PR extension coverage: {pr_ext_cov}%")
|
||||
log(f"Base extension coverage: {base_ext_cov}%")
|
||||
log(f"Extension coverage change: {'+' if not ext_decreased else '-'}{ext_diff}%")
|
||||
log(f"PR webview coverage: {pr_web_cov}%")
|
||||
log(f"Base webview coverage: {base_web_cov}%")
|
||||
log(f"Webview coverage change: {'+' if not web_decreased else '-'}{web_diff}%")
|
||||
|
||||
# Generate and output warnings
|
||||
warnings = generate_warnings(
|
||||
base_ext_cov, pr_ext_cov, ext_decreased, ext_diff,
|
||||
base_web_cov, pr_web_cov, web_decreased, web_diff
|
||||
)
|
||||
output_warnings(warnings)
|
||||
|
||||
# Generate comment
|
||||
log("=== Generating comment ===")
|
||||
comment = generate_comment(
|
||||
base_ext_cov, pr_ext_cov, str(ext_decreased).lower(), ext_diff,
|
||||
base_web_cov, pr_web_cov, str(web_decreased).lower(), web_diff
|
||||
)
|
||||
|
||||
# Save comment to file
|
||||
with open("coverage_comment.md", "w") as f:
|
||||
f.write(comment)
|
||||
|
||||
# Post comment if PR number is provided
|
||||
if args.pr_number:
|
||||
log(f"=== Posting comment to PR #{args.pr_number} ===")
|
||||
post_comment("coverage_comment.md", args.pr_number, args.repo, args.token)
|
||||
|
||||
# Output results for GitHub Actions
|
||||
output_github_results(
|
||||
pr_ext_cov, pr_web_cov, base_ext_cov, base_web_cov,
|
||||
ext_decreased, ext_diff, web_decreased, web_diff
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
log(f"ERROR in process_coverage_workflow: {e}")
|
||||
traceback.print_exc()
|
||||
|
||||
# Try to output results even if there was an error
|
||||
try:
|
||||
output_github_results(
|
||||
pr_ext_cov, pr_web_cov, base_ext_cov, base_web_cov,
|
||||
ext_decreased, ext_diff, web_decreased, web_diff
|
||||
)
|
||||
except Exception as e2:
|
||||
log(f"ERROR outputting GitHub results: {e2}")
|
||||
@@ -22,7 +22,6 @@ Environment Variables:
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
CHANGELOG_PATH = os.environ.get("CHANGELOG_PATH", "CHANGELOG.md")
|
||||
VERSION = os.environ['VERSION']
|
||||
@@ -32,72 +31,49 @@ NEW_CONTENT = os.environ.get("NEW_CONTENT", "")
|
||||
def overwrite_changelog_section(changelog_text: str, new_content: str):
|
||||
# Find the section for the specified version
|
||||
version_pattern = f"## {VERSION}\n"
|
||||
bracketed_version_pattern = f"## [{VERSION}]\n"
|
||||
unformmatted_prev_version_pattern = f"## {PREV_VERSION}\n"
|
||||
prev_version_pattern = f"## [{PREV_VERSION}]\n"
|
||||
print(f"latest version: {VERSION}")
|
||||
print(f"prev_version: {PREV_VERSION}")
|
||||
|
||||
# Try both unbracketed and bracketed version patterns
|
||||
version_index = changelog_text.find(version_pattern)
|
||||
if version_index == -1:
|
||||
version_index = changelog_text.find(bracketed_version_pattern)
|
||||
if version_index == -1:
|
||||
# If version not found, add it at the top (after the first line)
|
||||
first_newline = changelog_text.find('\n')
|
||||
if first_newline == -1:
|
||||
# If no newline found, just prepend
|
||||
return f"## [{VERSION}]\n\n{changelog_text}"
|
||||
return f"{changelog_text[:first_newline + 1]}## [{VERSION}]\n\n{changelog_text[first_newline + 1:]}"
|
||||
else:
|
||||
# Using bracketed version
|
||||
version_pattern = bracketed_version_pattern
|
||||
|
||||
notes_start_index = version_index + len(version_pattern)
|
||||
notes_end_index = changelog_text.find(prev_version_pattern, notes_start_index) if PREV_VERSION and prev_version_pattern in changelog_text else len(changelog_text)
|
||||
notes_start_index = changelog_text.find(version_pattern) + len(version_pattern)
|
||||
notes_end_index = changelog_text.find(prev_version_pattern, notes_start_index) if PREV_VERSION and (prev_version_pattern in changelog_text or unformmatted_prev_version_pattern in changelog_text) else len(changelog_text)
|
||||
|
||||
if new_content:
|
||||
return changelog_text[:notes_start_index] + f"{new_content}\n" + changelog_text[notes_end_index:]
|
||||
else:
|
||||
changeset_lines = changelog_text[notes_start_index:notes_end_index].split("\n")
|
||||
# Ensure we have at least 2 lines before removing them
|
||||
if len(changeset_lines) < 2:
|
||||
print("Warning: Changeset content has fewer than 2 lines")
|
||||
parsed_lines = "\n".join(changeset_lines)
|
||||
else:
|
||||
# Remove the first two lines from the regular changeset format, ex: \n### Patch Changes
|
||||
parsed_lines = "\n".join(changeset_lines[2:])
|
||||
filtered_lines = []
|
||||
for line in changeset_lines:
|
||||
# If the previous line is a changeset format
|
||||
if len(filtered_lines) > 1 and filtered_lines[-1].startswith("### "):
|
||||
# Remove the last two lines from the filted_lines
|
||||
filtered_lines.pop()
|
||||
filtered_lines.pop()
|
||||
else:
|
||||
filtered_lines.append(line.strip())
|
||||
|
||||
# Prepend a new line to the first line of filtered_lines
|
||||
if filtered_lines:
|
||||
filtered_lines[0] = "\n" + filtered_lines[0]
|
||||
|
||||
# Print filted_lines wiht a "\n" at the end of each line
|
||||
for line in filtered_lines:
|
||||
print(line.strip())
|
||||
|
||||
parsed_lines = "\n".join(line for line in filtered_lines)
|
||||
updated_changelog = changelog_text[:notes_start_index] + parsed_lines + changelog_text[notes_end_index:]
|
||||
# Ensure version number is bracketed
|
||||
updated_changelog = updated_changelog.replace(f"## {VERSION}", f"## [{VERSION}]")
|
||||
return updated_changelog
|
||||
|
||||
try:
|
||||
print(f"Reading changelog from: {CHANGELOG_PATH}")
|
||||
with open(CHANGELOG_PATH, 'r') as f:
|
||||
changelog_content = f.read()
|
||||
with open(CHANGELOG_PATH, 'r') as f:
|
||||
changelog_content = f.read()
|
||||
|
||||
print(f"Changelog content length: {len(changelog_content)} characters")
|
||||
print("First 200 characters of changelog:")
|
||||
print(changelog_content[:200])
|
||||
print("----------------------------------------------------------------------------------")
|
||||
new_changelog = overwrite_changelog_section(changelog_content, NEW_CONTENT)
|
||||
# print("----------------------------------------------------------------------------------")
|
||||
# print(new_changelog)
|
||||
# print("----------------------------------------------------------------------------------")
|
||||
# Write back to CHANGELOG.md
|
||||
with open(CHANGELOG_PATH, 'w') as f:
|
||||
f.write(new_changelog)
|
||||
|
||||
new_changelog = overwrite_changelog_section(changelog_content, NEW_CONTENT)
|
||||
|
||||
print("New changelog content:")
|
||||
print("----------------------------------------------------------------------------------")
|
||||
print(new_changelog)
|
||||
print("----------------------------------------------------------------------------------")
|
||||
|
||||
print(f"Writing updated changelog back to: {CHANGELOG_PATH}")
|
||||
with open(CHANGELOG_PATH, 'w') as f:
|
||||
f.write(new_changelog)
|
||||
|
||||
print(f"{CHANGELOG_PATH} updated successfully!")
|
||||
|
||||
except FileNotFoundError:
|
||||
print(f"Error: Changelog file not found at {CHANGELOG_PATH}")
|
||||
sys.exit(1)
|
||||
except Exception as e:
|
||||
print(f"Error updating changelog: {str(e)}")
|
||||
print(f"Current working directory: {os.getcwd()}")
|
||||
sys.exit(1)
|
||||
print(f"{CHANGELOG_PATH} updated successfully!")
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Tests for coverage_check script.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
import subprocess
|
||||
import tempfile
|
||||
from unittest.mock import patch, MagicMock, call, mock_open
|
||||
|
||||
# Add parent directory to path so we can import coverage modules
|
||||
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
|
||||
from coverage_check import extract_coverage, compare_coverage, set_verbose, generate_comment, post_comment, set_github_output
|
||||
from coverage_check.util import log, file_exists, get_file_size, list_directory
|
||||
|
||||
|
||||
class TestCoverage(unittest.TestCase):
|
||||
# Class variables to store coverage files
|
||||
temp_dir = None
|
||||
extension_coverage_file = None
|
||||
webview_coverage_file = None
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
"""Set up test environment once for all tests."""
|
||||
# Create temporary directory for test files
|
||||
cls.temp_dir = tempfile.TemporaryDirectory()
|
||||
cls.extension_coverage_file = os.path.join(cls.temp_dir.name, 'extension_coverage.txt')
|
||||
cls.webview_coverage_file = os.path.join(cls.temp_dir.name, 'webview_coverage.txt')
|
||||
|
||||
# Run actual tests to generate coverage reports
|
||||
cls.generate_coverage_reports()
|
||||
|
||||
# Verify files exist and are not empty
|
||||
assert os.path.exists(cls.extension_coverage_file), \
|
||||
f"Extension coverage file {cls.extension_coverage_file} does not exist"
|
||||
assert os.path.getsize(cls.extension_coverage_file) > 0, \
|
||||
f"Extension coverage file {cls.extension_coverage_file} is empty"
|
||||
assert os.path.exists(cls.webview_coverage_file), \
|
||||
f"Webview coverage file {cls.webview_coverage_file} does not exist"
|
||||
assert os.path.getsize(cls.webview_coverage_file) > 0, \
|
||||
f"Webview coverage file {cls.webview_coverage_file} is empty"
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
"""Clean up test environment after all tests."""
|
||||
if cls.temp_dir:
|
||||
cls.temp_dir.cleanup()
|
||||
|
||||
@classmethod
|
||||
def generate_coverage_reports(cls):
|
||||
"""Generate real coverage reports by running tests."""
|
||||
log("Generating coverage reports (this may take a while)...")
|
||||
|
||||
# Run extension tests with coverage
|
||||
try:
|
||||
# Get absolute paths
|
||||
root_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), '../../..'))
|
||||
webview_dir = os.path.join(root_dir, 'webview-ui')
|
||||
|
||||
# Use xvfb-run on Linux
|
||||
if sys.platform.startswith('linux'):
|
||||
cmd = f"cd {root_dir} && xvfb-run -a npm run test:coverage > {cls.extension_coverage_file} 2>&1"
|
||||
else:
|
||||
cmd = f"cd {root_dir} && npm run test:coverage > {cls.extension_coverage_file} 2>&1"
|
||||
|
||||
log("Running extension tests...")
|
||||
log(f"Command: {cmd}")
|
||||
result = subprocess.run(cmd, shell=True, check=False, capture_output=True, text=True)
|
||||
log(f"Extension tests exit code: {result.returncode}")
|
||||
|
||||
# Run webview tests with coverage
|
||||
log("Running webview tests...")
|
||||
cmd = f"cd {webview_dir} && npm run test:coverage > {cls.webview_coverage_file} 2>&1"
|
||||
log(f"Command: {cmd}")
|
||||
result = subprocess.run(cmd, shell=True, check=False, capture_output=True, text=True)
|
||||
log(f"Webview tests exit code: {result.returncode}")
|
||||
|
||||
# Verify files were created
|
||||
if file_exists(cls.extension_coverage_file):
|
||||
ext_size = get_file_size(cls.extension_coverage_file)
|
||||
log(f"Extension coverage file created: {cls.extension_coverage_file} (size: {ext_size} bytes)")
|
||||
else:
|
||||
log(f"WARNING: Extension coverage file was not created: {cls.extension_coverage_file}")
|
||||
|
||||
if file_exists(cls.webview_coverage_file):
|
||||
web_size = get_file_size(cls.webview_coverage_file)
|
||||
log(f"Webview coverage file created: {cls.webview_coverage_file} (size: {web_size} bytes)")
|
||||
else:
|
||||
log(f"WARNING: Webview coverage file was not created: {cls.webview_coverage_file}")
|
||||
|
||||
log("Coverage reports generation completed.")
|
||||
except Exception as e:
|
||||
log(f"Error generating coverage reports: {e}")
|
||||
import traceback
|
||||
log(traceback.format_exc())
|
||||
|
||||
# Create empty files if tests fail
|
||||
log("Creating fallback coverage files...")
|
||||
with open(cls.extension_coverage_file, 'w') as f:
|
||||
f.write("No coverage data available")
|
||||
with open(cls.webview_coverage_file, 'w') as f:
|
||||
f.write("No coverage data available")
|
||||
|
||||
def test_extract_coverage(self):
|
||||
"""Test extract_coverage function with both extension and webview coverage."""
|
||||
# Check if verbose mode is enabled
|
||||
if '-v' in sys.argv or '--verbose' in sys.argv:
|
||||
set_verbose(True)
|
||||
|
||||
# Verify files exist before testing
|
||||
self.assertTrue(file_exists(self.extension_coverage_file),
|
||||
f"Extension coverage file does not exist: {self.extension_coverage_file}")
|
||||
self.assertTrue(file_exists(self.webview_coverage_file),
|
||||
f"Webview coverage file does not exist: {self.webview_coverage_file}")
|
||||
|
||||
# Log file sizes
|
||||
ext_size = get_file_size(self.extension_coverage_file)
|
||||
web_size = get_file_size(self.webview_coverage_file)
|
||||
log(f"Extension coverage file size: {ext_size} bytes")
|
||||
log(f"Webview coverage file size: {web_size} bytes")
|
||||
|
||||
# Test extension coverage
|
||||
log("Testing extension coverage extraction...")
|
||||
ext_coverage_pct = extract_coverage(self.extension_coverage_file, 'extension')
|
||||
|
||||
# Check that coverage percentage is a float
|
||||
self.assertIsInstance(ext_coverage_pct, float)
|
||||
|
||||
# Check that coverage percentage is between 0 and 100
|
||||
self.assertGreaterEqual(ext_coverage_pct, 0)
|
||||
self.assertLessEqual(ext_coverage_pct, 100)
|
||||
|
||||
# Log coverage percentage for debugging
|
||||
log(f"Extension coverage: {ext_coverage_pct}%")
|
||||
|
||||
# Test webview coverage
|
||||
log("Testing webview coverage extraction...")
|
||||
web_coverage_pct = extract_coverage(self.webview_coverage_file, 'webview')
|
||||
|
||||
# Convert to float if it's an integer
|
||||
if isinstance(web_coverage_pct, int):
|
||||
web_coverage_pct = float(web_coverage_pct)
|
||||
|
||||
# Check that coverage percentage is a float
|
||||
self.assertIsInstance(web_coverage_pct, float)
|
||||
|
||||
# Check that coverage percentage is between 0 and 100
|
||||
self.assertGreaterEqual(web_coverage_pct, 0)
|
||||
self.assertLessEqual(web_coverage_pct, 100)
|
||||
|
||||
# Log coverage percentage for debugging
|
||||
log(f"Webview coverage: {web_coverage_pct}%")
|
||||
|
||||
def test_compare_coverage(self):
|
||||
"""Test compare_coverage function."""
|
||||
# Test with coverage increase
|
||||
decreased, diff = compare_coverage(80, 90)
|
||||
self.assertFalse(decreased)
|
||||
self.assertEqual(diff, 10)
|
||||
|
||||
# Test with coverage decrease
|
||||
decreased, diff = compare_coverage(90, 80)
|
||||
self.assertTrue(decreased)
|
||||
self.assertEqual(diff, 10)
|
||||
|
||||
# Test with no change
|
||||
decreased, diff = compare_coverage(80, 80)
|
||||
self.assertFalse(decreased)
|
||||
self.assertEqual(diff, 0)
|
||||
|
||||
def test_generate_comment(self):
|
||||
"""Test generate_comment function."""
|
||||
comment = generate_comment(
|
||||
80, 90, 'false', 10,
|
||||
70, 75, 'false', 5
|
||||
)
|
||||
|
||||
# Check that comment contains expected sections
|
||||
self.assertIn('Coverage Report', comment)
|
||||
self.assertIn('Extension Coverage', comment)
|
||||
self.assertIn('Webview Coverage', comment)
|
||||
self.assertIn('Overall Assessment', comment)
|
||||
|
||||
# Check that comment contains coverage percentages
|
||||
self.assertIn('Base branch: 80%', comment)
|
||||
self.assertIn('PR branch: 90%', comment)
|
||||
self.assertIn('Base branch: 70%', comment)
|
||||
self.assertIn('PR branch: 75%', comment)
|
||||
|
||||
# Check that comment contains correct assessment
|
||||
self.assertIn('Coverage increased or remained the same', comment)
|
||||
self.assertIn('Test coverage has been maintained or improved', comment)
|
||||
|
||||
@patch('coverage_check.requests.get')
|
||||
@patch('coverage_check.requests.post')
|
||||
@patch('coverage_check.requests.patch')
|
||||
def test_post_comment_new(self, mock_patch, mock_post, mock_get):
|
||||
"""Test post_comment function when creating a new comment."""
|
||||
# Create a temporary comment file
|
||||
comment_file = os.path.join(self.temp_dir.name, 'comment.md')
|
||||
with open(comment_file, 'w') as f:
|
||||
f.write('<!-- COVERAGE_REPORT -->\nTest comment')
|
||||
|
||||
# Mock the API responses
|
||||
mock_get.return_value = MagicMock(status_code=200, json=lambda: [])
|
||||
mock_post.return_value = MagicMock(status_code=201)
|
||||
|
||||
# Test post_comment function
|
||||
post_comment(comment_file, '123', 'owner/repo', 'token')
|
||||
|
||||
# Check that the correct API calls were made
|
||||
mock_get.assert_called_once()
|
||||
mock_post.assert_called_once()
|
||||
mock_patch.assert_not_called()
|
||||
|
||||
@patch('coverage_check.requests.get')
|
||||
@patch('coverage_check.requests.post')
|
||||
@patch('coverage_check.requests.patch')
|
||||
def test_post_comment_update(self, mock_patch, mock_post, mock_get):
|
||||
"""Test post_comment function when updating an existing comment."""
|
||||
# Create a temporary comment file
|
||||
comment_file = os.path.join(self.temp_dir.name, 'comment.md')
|
||||
with open(comment_file, 'w') as f:
|
||||
f.write('<!-- COVERAGE_REPORT -->\nTest comment')
|
||||
|
||||
# Mock the API responses
|
||||
mock_get.return_value = MagicMock(
|
||||
status_code=200,
|
||||
json=lambda: [{'id': 456, 'body': '<!-- COVERAGE_REPORT -->\nOld comment'}]
|
||||
)
|
||||
mock_patch.return_value = MagicMock(status_code=200)
|
||||
|
||||
# Test post_comment function
|
||||
post_comment(comment_file, '123', 'owner/repo', 'token')
|
||||
|
||||
# Check that the correct API calls were made
|
||||
mock_get.assert_called_once()
|
||||
mock_patch.assert_called_once()
|
||||
mock_post.assert_not_called()
|
||||
|
||||
def test_set_github_output(self):
|
||||
"""Test set_github_output function."""
|
||||
# Capture stdout
|
||||
with patch('sys.stdout', new=MagicMock()) as mock_stdout:
|
||||
# Mock environment without GITHUB_OUTPUT
|
||||
with patch.dict('os.environ', {}, clear=True):
|
||||
set_github_output('test_name', 'test_value')
|
||||
|
||||
# Check that the correct output was printed to stdout
|
||||
mock_stdout.assert_has_calls([
|
||||
# GitHub Actions output format (deprecated method)
|
||||
call.write('::set-output name=test_name::test_value\n'),
|
||||
call.flush(),
|
||||
# Human readable format
|
||||
call.write('test_name: test_value\n'),
|
||||
call.flush()
|
||||
], any_order=False)
|
||||
|
||||
# Reset mock for next test
|
||||
mock_stdout.reset_mock()
|
||||
|
||||
# Test with GITHUB_OUTPUT environment variable
|
||||
with patch.dict('os.environ', {'GITHUB_OUTPUT': '/tmp/github_output'}), \
|
||||
patch('builtins.open', mock_open()) as mock_file:
|
||||
set_github_output('test_name', 'test_value')
|
||||
|
||||
# Check that file was written to
|
||||
mock_file.assert_called_once_with('/tmp/github_output', 'a')
|
||||
mock_file().write.assert_called_once_with('test_name=test_value\n')
|
||||
|
||||
# Check that human readable output was printed
|
||||
mock_stdout.assert_has_calls([
|
||||
call.write('test_name: test_value\n'),
|
||||
call.flush()
|
||||
], any_order=False)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,91 @@
|
||||
name: Changeset Converter
|
||||
run-name: Changeset Conversion
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [closed]
|
||||
|
||||
env:
|
||||
REPO_PATH: ${{ github.repository }}
|
||||
GIT_REF: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || 'main' }}
|
||||
NODE_VERSION: 20.18.1
|
||||
|
||||
jobs:
|
||||
# Job 1: Create version bump PR when changesets are merged to main
|
||||
changeset-pr-version-bump:
|
||||
if: >
|
||||
github.event_name == 'pull_request' &&
|
||||
github.event.pull_request.merged == true &&
|
||||
github.event.pull_request.base.ref == 'main' &&
|
||||
github.actor != 'github-actions'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Git Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: ${{ env.GIT_REF }}
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: "npm"
|
||||
|
||||
- name: Install Dependencies
|
||||
run: npm install changeset
|
||||
|
||||
# Check if there are any new changesets to process
|
||||
- name: Check for changesets
|
||||
id: check-changesets
|
||||
run: |
|
||||
NEW_CHANGESETS=$(find .changeset -name "*.md" ! -name "README.md" | wc -l | tr -d ' ')
|
||||
echo "Changesets diff with previous version: $NEW_CHANGESETS"
|
||||
echo "new_changesets=$NEW_CHANGESETS" >> $GITHUB_OUTPUT
|
||||
|
||||
# Create version bump PR using changesets/action if there are new changesets
|
||||
- name: Create Changeset Pull Request
|
||||
if: steps.check-changesets.outputs.new_changesets != '0'
|
||||
uses: changesets/action@v1
|
||||
with:
|
||||
commit: "changeset version bump"
|
||||
title: "Changeset version bump"
|
||||
version: npm run version-packages # This performs the changeset version bump
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# Get current and previous versions to edit changelog entry
|
||||
- name: Get version
|
||||
id: get_version
|
||||
run: |
|
||||
VERSION=$(git show HEAD:package.json | jq -r '.version')
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
PREV_VERSION=$(git show origin/main:package.json | jq -r '.version')
|
||||
echo "prev_version=$PREV_VERSION" >> $GITHUB_OUTPUT
|
||||
echo "version=$VERSION"
|
||||
echo "prev_version=$PREV_VERSION"
|
||||
|
||||
# Update CHANGELOG.md with proper format
|
||||
- name: Update Changelog Format
|
||||
env:
|
||||
VERSION: ${{ steps.get_version.outputs.version }}
|
||||
PREV_VERSION: ${{ steps.get_version.outputs.prev_version }}
|
||||
run: python .github/scripts/overwrite_changeset_changelog.py
|
||||
|
||||
# Commit and push changelog updates
|
||||
- name: Push Changelog updates to Pull Request
|
||||
run: |
|
||||
git config user.name "github-actions"
|
||||
git config user.email github-actions@github.com
|
||||
echo "Running git add and commit..."
|
||||
git add CHANGELOG.md
|
||||
git commit -m "Updating CHANGELOG.md format"
|
||||
git status
|
||||
echo "--------------------------------------------------------------------------------"
|
||||
echo "Pushing to remote..."
|
||||
echo "--------------------------------------------------------------------------------"
|
||||
CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD)
|
||||
git push origin $CURRENT_BRANCH
|
||||
@@ -1,117 +0,0 @@
|
||||
name: Check Changeset
|
||||
run-name: Check for Changeset in PR
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
types: [opened, synchronize, reopened, ready_for_review]
|
||||
|
||||
jobs:
|
||||
check-changeset:
|
||||
# Skip draft PRs and dependabot PRs
|
||||
if: github.event.pull_request.draft == false && github.actor != 'dependabot[bot]'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
|
||||
- name: Check for changeset
|
||||
id: check-changeset
|
||||
run: |
|
||||
# Debug info
|
||||
echo "Current directory: $(pwd)"
|
||||
echo "PR Base Ref: ${{ github.event.pull_request.base.ref }}"
|
||||
echo "PR Head Ref: ${{ github.event.pull_request.head.ref }}"
|
||||
echo "PR Head SHA: ${{ github.event.pull_request.head.sha }}"
|
||||
echo "Git status:"
|
||||
git status
|
||||
|
||||
# Get list of changed files
|
||||
git fetch origin ${{ github.event.pull_request.base.ref }}
|
||||
CHANGED_FILES=$(git diff --name-only origin/${{ github.event.pull_request.base.ref }} HEAD)
|
||||
echo "Changed files:"
|
||||
echo "$CHANGED_FILES"
|
||||
|
||||
# Check if any of the changed files are in docs/ or .github/
|
||||
echo "Checking if changes are docs-only..."
|
||||
DOCS_ONLY=true
|
||||
while IFS= read -r file; do
|
||||
if [[ ! "$file" =~ ^(docs/|.github/) ]]; then
|
||||
echo "Found non-docs change: $file"
|
||||
DOCS_ONLY=false
|
||||
break
|
||||
fi
|
||||
done <<< "$CHANGED_FILES"
|
||||
|
||||
# If changes are docs-only, skip changeset check
|
||||
if [ "$DOCS_ONLY" = true ]; then
|
||||
echo "All changes are in docs/ or .github/, skipping changeset check"
|
||||
exit 0
|
||||
else
|
||||
echo "Changes include non-docs files, checking for changeset..."
|
||||
fi
|
||||
|
||||
# Check if any changeset files are in the changed files
|
||||
echo "Checking for changeset files in changed files..."
|
||||
CHANGESET_IN_PR=false
|
||||
while IFS= read -r file; do
|
||||
if [[ "$file" =~ ^\.changeset/.*\.md$ && "$file" != ".changeset/README.md" && "$file" != ".changeset/config.json" ]]; then
|
||||
echo "Found changeset file in PR: $file"
|
||||
CHANGESET_IN_PR=true
|
||||
break
|
||||
fi
|
||||
done <<< "$CHANGED_FILES"
|
||||
|
||||
if [ "$CHANGESET_IN_PR" = false ]; then
|
||||
echo "No changeset files found in changed files. Changed files in .changeset/:"
|
||||
echo "$CHANGED_FILES" | grep "^\.changeset/" || true
|
||||
echo "::error::No changeset file found in PR changes. Please run 'npm run changeset' to create one."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Comment on PR
|
||||
if: failure()
|
||||
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7
|
||||
with:
|
||||
script: |
|
||||
const message = `This PR requires a changeset since it includes user-facing changes. Please:
|
||||
|
||||
1. Run \`npm run changeset\` locally
|
||||
2. Choose the appropriate version bump:
|
||||
- \`major\` for breaking changes (1.0.0 → 2.0.0)
|
||||
- \`minor\` for new features (1.0.0 → 1.1.0)
|
||||
- \`patch\` for bug fixes (1.0.0 → 1.0.1)
|
||||
3. Write a clear description of your changes
|
||||
4. Commit the generated changeset file
|
||||
|
||||
Note: Documentation-only changes do not require a changeset.`;
|
||||
|
||||
// Get existing comments
|
||||
const comments = await github.rest.issues.listComments({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number
|
||||
});
|
||||
|
||||
// Check if we already commented
|
||||
const botComment = comments.data.find(comment =>
|
||||
comment.user.login === 'github-actions[bot]' &&
|
||||
comment.body.includes('This PR requires a changeset')
|
||||
);
|
||||
|
||||
if (!botComment) {
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
body: message
|
||||
});
|
||||
}
|
||||
+123
-2
@@ -25,6 +25,17 @@ jobs:
|
||||
with:
|
||||
node-version: 20.15.1
|
||||
|
||||
# Setup Python for coverage script
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: "3.10"
|
||||
|
||||
- name: Install Python dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install requests
|
||||
|
||||
# Cache root dependencies - only reuse if package-lock.json exactly matches
|
||||
- name: Cache root dependencies
|
||||
uses: actions/cache@v4
|
||||
@@ -58,5 +69,115 @@ jobs:
|
||||
- name: Prettier / Format Check
|
||||
run: npm run format
|
||||
|
||||
- name: Extension Tests
|
||||
run: xvfb-run -a npm run test
|
||||
# Build the extension before running tests
|
||||
- name: Build Extension
|
||||
run: npm run compile
|
||||
|
||||
# Run extension tests with coverage
|
||||
- name: Extension Tests with Coverage
|
||||
id: extension_coverage
|
||||
continue-on-error: true
|
||||
run: |
|
||||
xvfb-run -a npm run test:coverage > extension_coverage.txt 2>&1 || true
|
||||
PYTHONPATH=.github/scripts python -m coverage_check extract-coverage extension_coverage.txt --type=extension --github-output --verbose
|
||||
|
||||
# Run webview tests with coverage
|
||||
- name: Webview Tests with Coverage
|
||||
id: webview_coverage
|
||||
continue-on-error: true
|
||||
run: |
|
||||
cd webview-ui
|
||||
# Ensure coverage dependency is installed
|
||||
npm install --no-save @vitest/coverage-v8
|
||||
npm run test:coverage > webview_coverage.txt 2>&1 || true
|
||||
cd ..
|
||||
PYTHONPATH=.github/scripts python -m coverage_check extract-coverage webview-ui/webview_coverage.txt --type=webview --github-output --verbose
|
||||
|
||||
# Save coverage reports as artifacts (workflow-scoped)
|
||||
- name: Save Coverage Reports
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: pr-coverage-reports
|
||||
path: |
|
||||
extension_coverage.txt
|
||||
webview-ui/webview_coverage.txt
|
||||
retention-period: workflow # Artifacts are automatically deleted when the workflow completes
|
||||
|
||||
coverage:
|
||||
needs: test
|
||||
runs-on: ubuntu-latest
|
||||
# Only run on PRs to main branch
|
||||
if: github.event_name == 'pull_request' && github.base_ref == 'main'
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0 # Fetch all history for accurate comparison
|
||||
|
||||
# Setup Python for coverage script
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: "3.10"
|
||||
|
||||
- name: Install Python dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install requests
|
||||
|
||||
- name: Setup Node.js environment
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20.15.1
|
||||
|
||||
# Cache root dependencies - only reuse if package-lock.json exactly matches
|
||||
- name: Cache root dependencies
|
||||
uses: actions/cache@v4
|
||||
id: root-cache
|
||||
with:
|
||||
path: node_modules
|
||||
key: ${{ runner.os }}-npm-${{ hashFiles('package-lock.json') }}
|
||||
|
||||
# Cache webview-ui dependencies - only reuse if package-lock.json exactly matches
|
||||
- name: Cache webview-ui dependencies
|
||||
uses: actions/cache@v4
|
||||
id: webview-cache
|
||||
with:
|
||||
path: webview-ui/node_modules
|
||||
key: ${{ runner.os }}-npm-webview-${{ hashFiles('webview-ui/package-lock.json') }}
|
||||
|
||||
- name: Install root dependencies
|
||||
if: steps.root-cache.outputs.cache-hit != 'true'
|
||||
run: npm ci
|
||||
|
||||
- name: Install webview-ui dependencies
|
||||
if: steps.webview-cache.outputs.cache-hit != 'true'
|
||||
run: cd webview-ui && npm ci
|
||||
|
||||
# Build the extension before running tests
|
||||
- name: Build Extension
|
||||
run: npm run compile
|
||||
|
||||
# Download coverage artifacts from test job
|
||||
- name: Download Coverage Reports
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: pr-coverage-reports
|
||||
path: . # Download to root directory to match expected paths
|
||||
|
||||
# Process coverage workflow
|
||||
- name: Process coverage workflow
|
||||
id: coverage
|
||||
run: |
|
||||
# Extract PR number from GITHUB_REF
|
||||
PR_NUMBER=$(echo "$GITHUB_REF" | sed -e 's/refs\/pull\///' -e 's/\/merge//')
|
||||
|
||||
# Run the coverage workflow from root directory
|
||||
PYTHONPATH=.github/scripts python -m coverage_check process-workflow \
|
||||
--base-branch ${{ github.base_ref }} \
|
||||
--pr-number $PR_NUMBER \
|
||||
--repo $GITHUB_REPOSITORY \
|
||||
--token ${{ secrets.GITHUB_TOKEN }} \
|
||||
--verbose
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
+8
-2
@@ -1,5 +1,4 @@
|
||||
out
|
||||
coverage
|
||||
dist
|
||||
node_modules
|
||||
tmp
|
||||
@@ -10,4 +9,11 @@ tmp
|
||||
|
||||
pnpm-lock.yaml
|
||||
|
||||
.clineignore
|
||||
.clineignore
|
||||
.venv
|
||||
.actrc
|
||||
|
||||
# Ignore coverage directories and files
|
||||
coverage
|
||||
# But don't ignore the coverage scripts in .github/scripts/
|
||||
!.github/scripts/coverage/
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
# Changelog
|
||||
|
||||
## [3.8.5]
|
||||
|
||||
- Add support for remote MCP Servers using SSE
|
||||
- Add gemini-2.5-pro-exp-03-25 to Vertex AI (thanks @arri-cc!)
|
||||
- Add access to history, mcp, and new task buttons in popout view
|
||||
- Add task feedback telemetry (thumbs up/down on task completion)
|
||||
- Add toggle disabled for remote servers
|
||||
- Move the MCP Restart and Delete buttons and add an auto-approve all toggle
|
||||
- Update Requestly UX for model selection (thanks @arafatkatze!)
|
||||
- Add escape for html content for gemini when running commands
|
||||
- Improve search and replace edit failure behaviors
|
||||
|
||||
## [3.8.4]
|
||||
|
||||
- Add Sambanova Deepseek-V3-0324
|
||||
|
||||
@@ -81,6 +81,7 @@ Cline has access to the following tools for various tasks:
|
||||
4. **Interaction Tools**
|
||||
- `ask_followup_question`: Ask user for clarification
|
||||
- `attempt_completion`: Present final results
|
||||
- `new_task`: Create a new task with preloaded context
|
||||
|
||||
Each tool has specific parameters and usage patterns. Here are some examples:
|
||||
|
||||
@@ -113,6 +114,21 @@ Each tool has specific parameters and usage patterns. Here are some examples:
|
||||
</execute_command>
|
||||
```
|
||||
|
||||
- Create a new task with context (new_task):
|
||||
```xml
|
||||
<new_task>
|
||||
<context>
|
||||
We've completed the backend API with these endpoints:
|
||||
- GET /api/tasks
|
||||
- POST /api/tasks
|
||||
- PUT /api/tasks/:id
|
||||
- DELETE /api/tasks/:id
|
||||
|
||||
Now we need to implement the React frontend.
|
||||
</context>
|
||||
</new_task>
|
||||
```
|
||||
|
||||
## Common Tasks
|
||||
|
||||
1. **Create a New Component**
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
# New Task Tool
|
||||
|
||||
The `new_task` tool allows Cline to create a new task with preloaded context. This is particularly useful for managing context window limitations and creating focused subtasks.
|
||||
|
||||
## Overview
|
||||
|
||||
When the context window is approaching capacity or when you want to create a focused subtask, Cline can suggest creating a new task with relevant context preloaded. This helps maintain continuity between tasks while keeping each task focused and efficient.
|
||||
|
||||
## How It Works
|
||||
|
||||
1. Cline uses the `new_task` tool to suggest creating a new task with specific context
|
||||
2. You'll see a preview of the context that will be preloaded into the new task
|
||||
3. You can choose to:
|
||||
- Click "Create New Task" to start a new task with the preloaded context in Plan Mode
|
||||
- Respond with a message to continue the current conversation (effectively declining the new task)
|
||||
|
||||
## Use Cases
|
||||
|
||||
### Context Window Management
|
||||
|
||||
As conversations get longer, the context window may approach capacity. Cline can proactively suggest creating a new task with relevant context to continue the work without losing important information.
|
||||
|
||||
```
|
||||
Cline: I notice our conversation is getting quite long. Would you like me to create a new task with the relevant context to continue our work on the authentication system?
|
||||
|
||||
<new_task>
|
||||
We've been working on implementing an authentication system for the web application. So far, we've:
|
||||
1. Set up the basic user model
|
||||
2. Implemented password hashing with bcrypt
|
||||
3. Created login and registration endpoints
|
||||
|
||||
Next steps:
|
||||
- Implement JWT token generation
|
||||
- Add token validation middleware
|
||||
- Set up refresh token functionality
|
||||
</new_task>
|
||||
```
|
||||
|
||||
### Breaking Down Complex Tasks
|
||||
|
||||
For complex projects, you can break down the work into focused subtasks, with each new task containing the relevant context from the previous work.
|
||||
|
||||
```
|
||||
Cline: Now that we've completed the backend API, let's focus on the frontend implementation. I'll create a new task for this:
|
||||
|
||||
<new_task>
|
||||
We've completed the backend API for our task management application with the following endpoints:
|
||||
- GET /api/tasks - List all tasks
|
||||
- POST /api/tasks - Create a new task
|
||||
- PUT /api/tasks/:id - Update a task
|
||||
- DELETE /api/tasks/:id - Delete a task
|
||||
|
||||
Now we need to implement the React frontend that will consume these endpoints.
|
||||
</new_task>
|
||||
```
|
||||
|
||||
### Maintaining Workflow Continuity
|
||||
|
||||
When you need to pause work on one aspect of a project and return to it later, Cline can help you create a new task with the necessary context to pick up where you left off.
|
||||
|
||||
```
|
||||
Cline: Let's save our progress on the database schema design so we can come back to it later:
|
||||
|
||||
<new_task>
|
||||
Database schema design for e-commerce application:
|
||||
- Users table: id, email, password_hash, name, created_at, updated_at
|
||||
- Products table: id, name, description, price, inventory_count, category_id
|
||||
- Categories table: id, name, parent_category_id
|
||||
- Orders table: id, user_id, status, total, created_at, updated_at
|
||||
- OrderItems table: id, order_id, product_id, quantity, price
|
||||
|
||||
We still need to design:
|
||||
- Payment processing tables
|
||||
- Shipping information tables
|
||||
- Review and rating tables
|
||||
</new_task>
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
- **Be Selective**: Only include relevant context in the new task to keep it focused
|
||||
- **Summarize**: Summarize previous work rather than copying everything verbatim
|
||||
- **Include Next Steps**: Clearly indicate what needs to be done in the new task
|
||||
- **Maintain Continuity**: Ensure the new task has enough context to continue the work without confusion
|
||||
|
||||
## Implementation Details
|
||||
|
||||
The `new_task` tool is implemented as part of Cline's tool system. When used, it presents a UI component with a preview of the context and a button to create the new task. The new task is created in Plan Mode, allowing you to review and refine the approach before switching to Act Mode for implementation.
|
||||
Generated
+67
-44
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "claude-dev",
|
||||
"version": "3.8.2",
|
||||
"version": "3.8.5",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "claude-dev",
|
||||
"version": "3.8.2",
|
||||
"version": "3.8.5",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/bedrock-sdk": "^0.12.4",
|
||||
@@ -23,10 +23,6 @@
|
||||
"@opentelemetry/sdk-node": "^0.39.1",
|
||||
"@opentelemetry/sdk-trace-node": "^1.30.1",
|
||||
"@opentelemetry/semantic-conventions": "^1.30.0",
|
||||
"@types/clone-deep": "^4.0.4",
|
||||
"@types/get-folder-size": "^3.0.4",
|
||||
"@types/pdf-parse": "^1.1.4",
|
||||
"@types/turndown": "^5.0.5",
|
||||
"@vscode/codicons": "^0.0.36",
|
||||
"axios": "^1.8.2",
|
||||
"cheerio": "^1.0.0",
|
||||
@@ -64,11 +60,15 @@
|
||||
"devDependencies": {
|
||||
"@changesets/cli": "^2.27.12",
|
||||
"@types/chai": "^5.0.1",
|
||||
"@types/clone-deep": "^4.0.4",
|
||||
"@types/diff": "^5.2.1",
|
||||
"@types/get-folder-size": "^3.0.4",
|
||||
"@types/mocha": "^10.0.7",
|
||||
"@types/node": "20.x",
|
||||
"@types/pdf-parse": "^1.1.4",
|
||||
"@types/should": "^11.2.0",
|
||||
"@types/sinon": "^17.0.4",
|
||||
"@types/turndown": "^5.0.5",
|
||||
"@types/vscode": "^1.84.0",
|
||||
"@typescript-eslint/eslint-plugin": "^7.14.1",
|
||||
"@typescript-eslint/parser": "^7.11.0",
|
||||
@@ -8581,7 +8581,9 @@
|
||||
"node_modules/@types/clone-deep": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/clone-deep/-/clone-deep-4.0.4.tgz",
|
||||
"integrity": "sha512-vXh6JuuaAha6sqEbJueYdh5zNBPPgG1OYumuz2UvLvriN6ABHDSW8ludREGWJb1MLIzbwZn4q4zUbUCerJTJfA=="
|
||||
"integrity": "sha512-vXh6JuuaAha6sqEbJueYdh5zNBPPgG1OYumuz2UvLvriN6ABHDSW8ludREGWJb1MLIzbwZn4q4zUbUCerJTJfA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/deep-eql": {
|
||||
"version": "4.0.2",
|
||||
@@ -8600,6 +8602,7 @@
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/get-folder-size/-/get-folder-size-3.0.4.tgz",
|
||||
"integrity": "sha512-tSf/k7Undx6jKRwpChR9tl+0ZPf0BVwkjBRtJ5qSnz6iWm2ZRYMAS2MktC2u7YaTAFHmxpL/LBxI85M7ioJCSg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
@@ -8642,6 +8645,7 @@
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/pdf-parse/-/pdf-parse-1.1.4.tgz",
|
||||
"integrity": "sha512-+gbBHbNCVGGYw1S9lAIIvrHW47UYOhMIFUsJcMkMrzy1Jf0vulBN3XQIjPgnoOXveMuHnF3b57fXROnY/Or7eg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/should": {
|
||||
@@ -8672,6 +8676,7 @@
|
||||
"version": "5.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/turndown/-/turndown-5.0.5.tgz",
|
||||
"integrity": "sha512-TL2IgGgc7B5j78rIccBtlYAnkuv8nUQqhQc+DSYV5j9Be9XOcm/SKOVRuA47xAVI3680Tk9B1d8flK2GWT2+4w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/uuid": {
|
||||
@@ -9340,50 +9345,75 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/bare-events": {
|
||||
"version": "2.4.2",
|
||||
"resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.4.2.tgz",
|
||||
"integrity": "sha512-qMKFd2qG/36aA4GwvKq8MxnPgCQAmBWmSyLWsJcbn8v03wvIPQ/hG1Ms8bPzndZxMDoHpxez5VOS+gC9Yi24/Q==",
|
||||
"version": "2.5.4",
|
||||
"resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.5.4.tgz",
|
||||
"integrity": "sha512-+gFfDkR8pj4/TrWCGUGWmJIkBwuxPS5F+a5yWjOHQt2hHvNZd5YLzadjmDUtFmMM4y429bnKLa8bYBMHcYdnQA==",
|
||||
"license": "Apache-2.0",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/bare-fs": {
|
||||
"version": "2.3.5",
|
||||
"resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-2.3.5.tgz",
|
||||
"integrity": "sha512-SlE9eTxifPDJrT6YgemQ1WGFleevzwY+XAP1Xqgl56HtcrisC2CHCZ2tq6dBpcH2TnNxwUEUGhweo+lrQtYuiw==",
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.0.2.tgz",
|
||||
"integrity": "sha512-S5mmkMesiduMqnz51Bfh0Et9EX0aTCJxhsI4bvzFFLs8Z1AV8RDHadfY5CyLwdoLHgXbNBEN1gQcbEtGwuvixw==",
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"bare-events": "^2.0.0",
|
||||
"bare-path": "^2.0.0",
|
||||
"bare-stream": "^2.0.0"
|
||||
"bare-events": "^2.5.4",
|
||||
"bare-path": "^3.0.0",
|
||||
"bare-stream": "^2.6.4"
|
||||
},
|
||||
"engines": {
|
||||
"bare": ">=1.16.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"bare-buffer": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"bare-buffer": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/bare-os": {
|
||||
"version": "2.4.4",
|
||||
"resolved": "https://registry.npmjs.org/bare-os/-/bare-os-2.4.4.tgz",
|
||||
"integrity": "sha512-z3UiI2yi1mK0sXeRdc4O1Kk8aOa/e+FNWZcTiPB/dfTWyLypuE99LibgRaQki914Jq//yAWylcAt+mknKdixRQ==",
|
||||
"version": "3.6.1",
|
||||
"resolved": "https://registry.npmjs.org/bare-os/-/bare-os-3.6.1.tgz",
|
||||
"integrity": "sha512-uaIjxokhFidJP+bmmvKSgiMzj2sV5GPHaZVAIktcxcpCyBFFWO+YlikVAdhmUo2vYFvFhOXIAlldqV29L8126g==",
|
||||
"license": "Apache-2.0",
|
||||
"optional": true
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"bare": ">=1.14.0"
|
||||
}
|
||||
},
|
||||
"node_modules/bare-path": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/bare-path/-/bare-path-2.1.3.tgz",
|
||||
"integrity": "sha512-lh/eITfU8hrj9Ru5quUp0Io1kJWIk1bTjzo7JH1P5dWmQ2EL4hFUlfI8FonAhSlgIfhn63p84CDY/x+PisgcXA==",
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.0.0.tgz",
|
||||
"integrity": "sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw==",
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"bare-os": "^2.1.0"
|
||||
"bare-os": "^3.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/bare-stream": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.3.0.tgz",
|
||||
"integrity": "sha512-pVRWciewGUeCyKEuRxwv06M079r+fRjAQjBEK2P6OYGrO43O+Z0LrPZZEjlc4mB6C2RpZ9AxJ1s7NLEtOHO6eA==",
|
||||
"version": "2.6.5",
|
||||
"resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.6.5.tgz",
|
||||
"integrity": "sha512-jSmxKJNJmHySi6hC42zlZnq00rga4jjxcgNZjY9N5WlOe/iOoGRtdwGsHzQv2RlH2KOYMwGUXhf2zXd32BA9RA==",
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"b4a": "^1.6.6",
|
||||
"streamx": "^2.20.0"
|
||||
"streamx": "^2.21.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"bare-buffer": "*",
|
||||
"bare-events": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"bare-buffer": {
|
||||
"optional": true
|
||||
},
|
||||
"bare-events": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/base64-js": {
|
||||
@@ -15171,12 +15201,6 @@
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/queue-tick": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/queue-tick/-/queue-tick-1.0.1.tgz",
|
||||
"integrity": "sha512-kJt5qhMxoszgU/62PLP1CJytzd2NKetjSRnyuj31fDd3Rlcz3fzlFdFLD1SItunPwyqEOkca6GbV612BWfaBag==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/randombytes": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz",
|
||||
@@ -16113,13 +16137,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/streamx": {
|
||||
"version": "2.20.1",
|
||||
"resolved": "https://registry.npmjs.org/streamx/-/streamx-2.20.1.tgz",
|
||||
"integrity": "sha512-uTa0mU6WUC65iUvzKH4X9hEdvSW7rbPxPtwfWiLMSj3qTdQbAiUboZTxauKfpFuGIGa1C2BYijZ7wgdUXICJhA==",
|
||||
"version": "2.22.0",
|
||||
"resolved": "https://registry.npmjs.org/streamx/-/streamx-2.22.0.tgz",
|
||||
"integrity": "sha512-sLh1evHOzBy/iWRiR6d1zRcLao4gGZr3C1kzNz4fopCOKJb6xD9ub8Mpi9Mr1R6id5o43S+d93fI48UC5uM9aw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fast-fifo": "^1.3.2",
|
||||
"queue-tick": "^1.0.1",
|
||||
"text-decoder": "^1.1.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
@@ -16382,17 +16405,17 @@
|
||||
}
|
||||
},
|
||||
"node_modules/tar-fs": {
|
||||
"version": "3.0.6",
|
||||
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.0.6.tgz",
|
||||
"integrity": "sha512-iokBDQQkUyeXhgPYaZxmczGPhnhXZ0CmrqI+MOb/WFGS9DW5wnfrLgtjUJBvz50vQ3qfRwJ62QVoCFu8mPVu5w==",
|
||||
"version": "3.0.8",
|
||||
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.0.8.tgz",
|
||||
"integrity": "sha512-ZoROL70jptorGAlgAYiLoBLItEKw/fUxg9BSYK/dF/GAGYFJOJJJMvjPAKDJraCXFwadD456FCuvLWgfhMsPwg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pump": "^3.0.0",
|
||||
"tar-stream": "^3.1.5"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"bare-fs": "^2.1.1",
|
||||
"bare-path": "^2.1.0"
|
||||
"bare-fs": "^4.0.1",
|
||||
"bare-path": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tar-stream": {
|
||||
|
||||
+9
-24
@@ -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.8.4",
|
||||
"version": "3.8.5",
|
||||
"icon": "assets/icons/icon.png",
|
||||
"engines": {
|
||||
"vscode": "^1.84.0"
|
||||
@@ -102,11 +102,6 @@
|
||||
"category": "Cline",
|
||||
"when": "cline.isDevMode"
|
||||
},
|
||||
{
|
||||
"command": "cline.openDocumentation",
|
||||
"title": "Documentation",
|
||||
"icon": "$(book)"
|
||||
},
|
||||
{
|
||||
"command": "cline.addToChat",
|
||||
"title": "Add to Cline",
|
||||
@@ -146,18 +141,13 @@
|
||||
"when": "view == claude-dev.SidebarProvider"
|
||||
},
|
||||
{
|
||||
"command": "cline.openDocumentation",
|
||||
"command": "cline.accountButtonClicked",
|
||||
"group": "navigation@5",
|
||||
"when": "view == claude-dev.SidebarProvider"
|
||||
},
|
||||
{
|
||||
"command": "cline.accountButtonClicked",
|
||||
"group": "navigation@6",
|
||||
"when": "view == claude-dev.SidebarProvider"
|
||||
},
|
||||
{
|
||||
"command": "cline.settingsButtonClicked",
|
||||
"group": "navigation@7",
|
||||
"group": "navigation@6",
|
||||
"when": "view == claude-dev.SidebarProvider"
|
||||
}
|
||||
],
|
||||
@@ -183,18 +173,13 @@
|
||||
"when": "activeWebviewPanelId == claude-dev.TabPanelProvider"
|
||||
},
|
||||
{
|
||||
"command": "cline.openDocumentation",
|
||||
"command": "cline.accountButtonClicked",
|
||||
"group": "navigation@5",
|
||||
"when": "activeWebviewPanelId == claude-dev.TabPanelProvider"
|
||||
},
|
||||
{
|
||||
"command": "cline.accountButtonClicked",
|
||||
"group": "navigation@6",
|
||||
"when": "activeWebviewPanelId == claude-dev.TabPanelProvider"
|
||||
},
|
||||
{
|
||||
"command": "cline.settingsButtonClicked",
|
||||
"group": "navigation@7",
|
||||
"group": "navigation@6",
|
||||
"when": "activeWebviewPanelId == claude-dev.TabPanelProvider"
|
||||
}
|
||||
],
|
||||
@@ -332,11 +317,15 @@
|
||||
"devDependencies": {
|
||||
"@changesets/cli": "^2.27.12",
|
||||
"@types/chai": "^5.0.1",
|
||||
"@types/clone-deep": "^4.0.4",
|
||||
"@types/diff": "^5.2.1",
|
||||
"@types/get-folder-size": "^3.0.4",
|
||||
"@types/mocha": "^10.0.7",
|
||||
"@types/node": "20.x",
|
||||
"@types/pdf-parse": "^1.1.4",
|
||||
"@types/should": "^11.2.0",
|
||||
"@types/sinon": "^17.0.4",
|
||||
"@types/turndown": "^5.0.5",
|
||||
"@types/vscode": "^1.84.0",
|
||||
"@typescript-eslint/eslint-plugin": "^7.14.1",
|
||||
"@typescript-eslint/parser": "^7.11.0",
|
||||
@@ -367,10 +356,6 @@
|
||||
"@opentelemetry/sdk-node": "^0.39.1",
|
||||
"@opentelemetry/sdk-trace-node": "^1.30.1",
|
||||
"@opentelemetry/semantic-conventions": "^1.30.0",
|
||||
"@types/clone-deep": "^4.0.4",
|
||||
"@types/get-folder-size": "^3.0.4",
|
||||
"@types/pdf-parse": "^1.1.4",
|
||||
"@types/turndown": "^5.0.5",
|
||||
"@vscode/codicons": "^0.0.36",
|
||||
"axios": "^1.8.2",
|
||||
"cheerio": "^1.0.0",
|
||||
|
||||
@@ -23,7 +23,7 @@ export class AnthropicHandler implements ApiHandler {
|
||||
let stream: AnthropicStream<Anthropic.RawMessageStreamEvent>
|
||||
const modelId = model.id
|
||||
|
||||
let budget_tokens = this.options.thinkingBudgetTokens || 0
|
||||
const budget_tokens = this.options.thinkingBudgetTokens || 0
|
||||
const reasoningOn = modelId.includes("3-7") && budget_tokens !== 0 ? true : false
|
||||
|
||||
switch (modelId) {
|
||||
|
||||
@@ -25,7 +25,7 @@ export class AwsBedrockHandler implements ApiHandler {
|
||||
@withRetry()
|
||||
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
|
||||
// cross region inference requires prefixing the model id with the region
|
||||
let modelId = await this.getModelId()
|
||||
const modelId = await this.getModelId()
|
||||
const model = this.getModel()
|
||||
|
||||
// Check if this is an Amazon Nova model
|
||||
@@ -40,7 +40,7 @@ export class AwsBedrockHandler implements ApiHandler {
|
||||
return
|
||||
}
|
||||
|
||||
let budget_tokens = this.options.thinkingBudgetTokens || 0
|
||||
const budget_tokens = this.options.thinkingBudgetTokens || 0
|
||||
const reasoningOn = modelId.includes("3-7") && budget_tokens !== 0 ? true : false
|
||||
|
||||
// Get model info and message indices for caching
|
||||
@@ -250,7 +250,7 @@ export class AwsBedrockHandler implements ApiHandler {
|
||||
*/
|
||||
async getModelId(): Promise<string> {
|
||||
if (this.options.awsUseCrossRegionInference) {
|
||||
let regionPrefix = this.getRegion().slice(0, 3)
|
||||
const regionPrefix = this.getRegion().slice(0, 3)
|
||||
switch (regionPrefix) {
|
||||
case "us-":
|
||||
return `us.${this.getModel().id}`
|
||||
|
||||
@@ -17,6 +17,11 @@ export class ClineHandler implements ApiHandler {
|
||||
this.client = new OpenAI({
|
||||
baseURL: "https://api.cline.bot/v1",
|
||||
apiKey: this.options.clineApiKey || "",
|
||||
defaultHeaders: {
|
||||
"HTTP-Referer": "https://cline.bot", // Optional, for including your app on cline.bot rankings.
|
||||
"X-Title": "Cline", // Optional. Shows in rankings on cline.bot.
|
||||
"X-Task-ID": this.options.taskId || "", // Include the task ID in the request headers
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ export class RequestyHandler implements ApiHandler {
|
||||
async *createMessage(systemPrompt: string, messages: Anthropic.Messages.MessageParam[]): ApiStream {
|
||||
const modelId = this.options.requestyModelId ?? ""
|
||||
|
||||
let openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [
|
||||
const openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [
|
||||
{ role: "system", content: systemPrompt },
|
||||
...convertToOpenAiMessages(messages),
|
||||
]
|
||||
|
||||
@@ -307,7 +307,7 @@ function parseToolCall(toolName: string, content: string): ToolCall | null {
|
||||
|
||||
// Parse nested XML elements
|
||||
const paramRegex = /<(\w+)>([\s\S]*?)<\/\1>/gs
|
||||
let match
|
||||
let match: RegExpExecArray | null
|
||||
|
||||
while ((match = paramRegex.exec(innerContent)) !== null) {
|
||||
const [, paramName, paramValue] = match
|
||||
|
||||
@@ -28,7 +28,7 @@ export function convertToOllamaMessages(anthropicMessages: Anthropic.Messages.Me
|
||||
)
|
||||
|
||||
// Process tool result messages FIRST since they must follow the tool use messages
|
||||
let toolResultImages: string[] = []
|
||||
const toolResultImages: string[] = []
|
||||
toolMessages.forEach((toolMessage) => {
|
||||
// The Anthropic SDK allows tool results to be a string or an array of text and image blocks, enabling rich and structured content. In contrast, the Ollama SDK only supports tool results as a single string, so we map the Anthropic tool result parts into one concatenated string to maintain compatibility.
|
||||
let content: string
|
||||
|
||||
@@ -38,7 +38,7 @@ export function convertToOpenAiMessages(
|
||||
)
|
||||
|
||||
// Process tool result messages FIRST since they must follow the tool use messages
|
||||
let toolResultImages: Anthropic.Messages.ImageBlockParam[] = []
|
||||
const toolResultImages: Anthropic.Messages.ImageBlockParam[] = []
|
||||
toolMessages.forEach((toolMessage) => {
|
||||
// The Anthropic SDK allows tool results to be a string or an array of text and image blocks, enabling rich and structured content. In contrast, the OpenAI SDK only supports tool results as a single string, so we map the Anthropic tool result parts into one concatenated string to maintain compatibility.
|
||||
let content: string
|
||||
@@ -127,7 +127,7 @@ export function convertToOpenAiMessages(
|
||||
}
|
||||
|
||||
// Process tool use messages
|
||||
let tool_calls: OpenAI.Chat.ChatCompletionMessageToolCall[] = toolMessages.map((toolMessage) => ({
|
||||
const tool_calls: OpenAI.Chat.ChatCompletionMessageToolCall[] = toolMessages.map((toolMessage) => ({
|
||||
id: toolMessage.id,
|
||||
type: "function",
|
||||
function: {
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
# Core Architecture
|
||||
|
||||
Extension entry point (extension.ts) -> webview -> controller -> task
|
||||
|
||||
```tree
|
||||
core/
|
||||
├── webview/ # Manages webview lifecycle
|
||||
├── controller/ # Handles webview messages and task management
|
||||
├── task/ # Executes API requests and tool operations
|
||||
└── ... # Additional components to help with context, parsing user/assistant messages, etc.
|
||||
```
|
||||
@@ -22,6 +22,7 @@ export const toolUseNames = [
|
||||
"ask_followup_question",
|
||||
"plan_mode_respond",
|
||||
"attempt_completion",
|
||||
"new_task",
|
||||
] as const
|
||||
|
||||
// Converts array of tool call names into a union type ("execute_command" | "read_file" | ...)
|
||||
@@ -48,6 +49,7 @@ export const toolParamNames = [
|
||||
"options",
|
||||
"response",
|
||||
"result",
|
||||
"context",
|
||||
] as const
|
||||
|
||||
export type ToolParamName = (typeof toolParamNames)[number]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { AssistantMessageContent, TextContent, ToolUse, ToolParamName, toolParamNames, toolUseNames, ToolUseName } from "."
|
||||
|
||||
export function parseAssistantMessage(assistantMessage: string) {
|
||||
let contentBlocks: AssistantMessageContent[] = []
|
||||
const contentBlocks: AssistantMessageContent[] = []
|
||||
let currentTextContent: TextContent | undefined = undefined
|
||||
let currentTextContentStartIndex = 0
|
||||
let currentToolUse: ToolUse | undefined = undefined
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Anthropic } from "@anthropic-ai/sdk"
|
||||
import { ClineApiReqInfo, ClineMessage } from "../../shared/ExtensionMessage"
|
||||
import { ApiHandler } from "../../api"
|
||||
import { OpenAiHandler } from "../../api/providers/openai"
|
||||
import { getContextWindowInfo } from "./context-window-utils"
|
||||
|
||||
export class ContextManager {
|
||||
getNewContextMessagesAndMetadata(
|
||||
@@ -19,25 +19,7 @@ export class ContextManager {
|
||||
if (previousRequest && previousRequest.text) {
|
||||
const { tokensIn, tokensOut, cacheWrites, cacheReads }: ClineApiReqInfo = JSON.parse(previousRequest.text)
|
||||
const totalTokens = (tokensIn || 0) + (tokensOut || 0) + (cacheWrites || 0) + (cacheReads || 0)
|
||||
let contextWindow = api.getModel().info.contextWindow || 128_000
|
||||
// FIXME: hack to get anyone using openai compatible with deepseek to have the proper context window instead of the default 128k. We need a way for the user to specify the context window for models they input through openai compatible
|
||||
if (api instanceof OpenAiHandler && api.getModel().id.toLowerCase().includes("deepseek")) {
|
||||
contextWindow = 64_000
|
||||
}
|
||||
let maxAllowedSize: number
|
||||
switch (contextWindow) {
|
||||
case 64_000: // deepseek models
|
||||
maxAllowedSize = contextWindow - 27_000
|
||||
break
|
||||
case 128_000: // most models
|
||||
maxAllowedSize = contextWindow - 30_000
|
||||
break
|
||||
case 200_000: // claude models
|
||||
maxAllowedSize = contextWindow - 40_000
|
||||
break
|
||||
default:
|
||||
maxAllowedSize = Math.max(contextWindow - 40_000, contextWindow * 0.8) // for deepseek, 80% of 64k meant only ~10k buffer which was too small and resulted in users getting context window errors.
|
||||
}
|
||||
const { contextWindow, maxAllowedSize } = getContextWindowInfo(api)
|
||||
|
||||
// This is the most reliable way to know when we're close to hitting the context window.
|
||||
if (totalTokens >= maxAllowedSize) {
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { ApiHandler } from "../../api"
|
||||
import { OpenAiHandler } from "../../api/providers/openai"
|
||||
|
||||
/**
|
||||
* Gets context window information for the given API handler
|
||||
*
|
||||
* @param api The API handler to get context window information for
|
||||
* @returns An object containing the raw context window size and the effective max allowed size
|
||||
*/
|
||||
export function getContextWindowInfo(api: ApiHandler) {
|
||||
let contextWindow = api.getModel().info.contextWindow || 128_000
|
||||
|
||||
// Handle special cases like DeepSeek
|
||||
if (api instanceof OpenAiHandler && api.getModel().id.toLowerCase().includes("deepseek")) {
|
||||
contextWindow = 64_000
|
||||
}
|
||||
|
||||
let maxAllowedSize: number
|
||||
switch (contextWindow) {
|
||||
case 64_000: // deepseek models
|
||||
maxAllowedSize = contextWindow - 27_000
|
||||
break
|
||||
case 128_000: // most models
|
||||
maxAllowedSize = contextWindow - 30_000
|
||||
break
|
||||
case 200_000: // claude models
|
||||
maxAllowedSize = contextWindow - 40_000
|
||||
break
|
||||
default:
|
||||
maxAllowedSize = Math.max(contextWindow - 40_000, contextWindow * 0.8)
|
||||
}
|
||||
|
||||
return { contextWindow, maxAllowedSize }
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -117,6 +117,91 @@ Otherwise, if you have not completed the task and do not need additional informa
|
||||
const prettyPatchLines = lines.slice(4)
|
||||
return prettyPatchLines.join("\n")
|
||||
},
|
||||
|
||||
taskResumption: (
|
||||
mode: "plan" | "act",
|
||||
agoText: string,
|
||||
cwd: string,
|
||||
wasRecent: boolean | 0 | undefined,
|
||||
responseText?: string,
|
||||
) => {
|
||||
return `[TASK RESUMPTION] ${
|
||||
mode === "plan"
|
||||
? `This task was interrupted ${agoText}. The conversation may have been incomplete. Be aware that the project state may have changed since then. The current working directory is now '${cwd.toPosix()}'.\n\nNote: If you previously attempted a tool use that the user did not provide a result for, you should assume the tool use was not successful. However you are in PLAN MODE, so rather than continuing the task, you must respond to the user's message.`
|
||||
: `This task was interrupted ${agoText}. It may or may not be complete, so please reassess the task context. Be aware that the project state may have changed since then. The current working directory is now '${cwd.toPosix()}'. If the task has not been completed, retry the last step before interruption and proceed with completing the task.\n\nNote: If you previously attempted a tool use that the user did not provide a result for, you should assume the tool use was not successful and assess whether you should retry. If the last tool was a browser_action, the browser has been closed and you must launch a new browser if needed.`
|
||||
}${
|
||||
wasRecent
|
||||
? "\n\nIMPORTANT: If the last tool use was a replace_in_file or write_to_file that was interrupted, the file was reverted back to its original state before the interrupted edit, and you do NOT need to re-read the file as you already have its up-to-date contents."
|
||||
: ""
|
||||
}${
|
||||
responseText
|
||||
? `\n\n${mode === "plan" ? "New message to respond to with plan_mode_respond tool (be sure to provide your response in the <response> parameter)" : "New instructions for task continuation"}:\n<user_message>\n${responseText}\n</user_message>`
|
||||
: mode === "plan"
|
||||
? "(The user did not provide a new message. Consider asking them how they'd like you to proceed, or to switch to Act mode to continue with the task.)"
|
||||
: ""
|
||||
}`
|
||||
},
|
||||
|
||||
planModeInstructions: () => {
|
||||
return `In this mode you should focus on information gathering, asking questions, and architecting a solution. Once you have a plan, use the plan_mode_respond tool to engage in a conversational back and forth with the user. Do not use the plan_mode_respond tool until you've gathered all the information you need e.g. with read_file or ask_followup_question.
|
||||
(Remember: If it seems the user wants you to use tools only available in Act Mode, you should ask the user to "toggle to Act mode" (use those words) - they will have to manually do this themselves with the Plan/Act toggle button below. You do not have the ability to switch to Act Mode yourself, and must wait for the user to do it themselves once they are satisfied with the plan. You also cannot present an option to toggle to Act mode, as this will be something you need to direct the user to do manually themselves.)`
|
||||
},
|
||||
|
||||
fileEditWithUserChanges: (
|
||||
relPath: string,
|
||||
userEdits: string,
|
||||
autoFormattingEdits: string | undefined,
|
||||
finalContent: string | undefined,
|
||||
newProblemsMessage: string | undefined,
|
||||
) =>
|
||||
`The user made the following updates to your content:\n\n${userEdits}\n\n` +
|
||||
(autoFormattingEdits
|
||||
? `The user's editor also applied the following auto-formatting to your content:\n\n${autoFormattingEdits}\n\n(Note: Pay close attention to changes such as single quotes being converted to double quotes, semicolons being removed or added, long lines being broken into multiple lines, adjusting indentation style, adding/removing trailing commas, etc. This will help you ensure future SEARCH/REPLACE operations to this file are accurate.)\n\n`
|
||||
: "") +
|
||||
`The updated content, which includes both your original modifications and the additional edits, has been successfully saved to ${relPath.toPosix()}. Here is the full, updated content of the file that was saved:\n\n` +
|
||||
`<final_file_content path="${relPath.toPosix()}">\n${finalContent}\n</final_file_content>\n\n` +
|
||||
`Please note:\n` +
|
||||
`1. You do not need to re-write the file with these changes, as they have already been applied.\n` +
|
||||
`2. Proceed with the task using this updated file content as the new baseline.\n` +
|
||||
`3. If the user's edits have addressed part of the task or changed the requirements, adjust your approach accordingly.` +
|
||||
`4. IMPORTANT: For any future changes to this file, use the final_file_content shown above as your reference. This content reflects the current state of the file, including both user edits and any auto-formatting (e.g., if you used single quotes but the formatter converted them to double quotes). Always base your SEARCH/REPLACE operations on this final version to ensure accuracy.\n` +
|
||||
`${newProblemsMessage}`,
|
||||
|
||||
fileEditWithoutUserChanges: (
|
||||
relPath: string,
|
||||
autoFormattingEdits: string | undefined,
|
||||
finalContent: string | undefined,
|
||||
newProblemsMessage: string | undefined,
|
||||
) =>
|
||||
`The content was successfully saved to ${relPath.toPosix()}.\n\n` +
|
||||
(autoFormattingEdits
|
||||
? `Along with your edits, the user's editor applied the following auto-formatting to your content:\n\n${autoFormattingEdits}\n\n(Note: Pay close attention to changes such as single quotes being converted to double quotes, semicolons being removed or added, long lines being broken into multiple lines, adjusting indentation style, adding/removing trailing commas, etc. This will help you ensure future SEARCH/REPLACE operations to this file are accurate.)\n\n`
|
||||
: "") +
|
||||
`Here is the full, updated content of the file that was saved:\n\n` +
|
||||
`<final_file_content path="${relPath.toPosix()}">\n${finalContent}\n</final_file_content>\n\n` +
|
||||
`IMPORTANT: For any future changes to this file, use the final_file_content shown above as your reference. This content reflects the current state of the file, including any auto-formatting (e.g., if you used single quotes but the formatter converted them to double quotes). Always base your SEARCH/REPLACE operations on this final version to ensure accuracy.\n\n` +
|
||||
`${newProblemsMessage}`,
|
||||
|
||||
diffError: (relPath: string, originalContent: string | undefined) =>
|
||||
`This is likely because the SEARCH block content doesn't match exactly with what's in the file, or if you used multiple SEARCH/REPLACE blocks they may not have been in the order they appear in the file.\n\n` +
|
||||
`The file was reverted to its original state:\n\n` +
|
||||
`<file_content path="${relPath.toPosix()}">\n${originalContent}\n</file_content>\n\n` +
|
||||
`Now that you have the latest state of the file, try the operation again with fewer/more precise SEARCH blocks.\n(If you run into this error 3 times in a row, you may use the write_to_file tool as a fallback. Keep in mind, the write_to_file fallback is far from ideal, as this means you'll be re-writing the entire contents of the file just to make a few edits, which takes time and money. So let's bias towards using replace_in_file as effectively as possible)`,
|
||||
|
||||
toolAlreadyUsed: (toolName: string) =>
|
||||
`Tool [${toolName}] was not executed because a tool has already been used in this message. Only one tool may be used per message. You must assess the first tool's result before proceeding to use the next tool.`,
|
||||
|
||||
clineIgnoreInstructions: (content: string) =>
|
||||
`# .clineignore\n\n(The following is provided by a root-level .clineignore file where the user has specified files and directories that should not be accessed. When using list_files, you'll notice a ${LOCK_TEXT_SYMBOL} next to files that are blocked. Attempting to access the file's contents e.g. through read_file will result in an error.)\n\n${content}\n.clineignore`,
|
||||
|
||||
clineRulesDirectoryInstructions: (cwd: string, content: string) =>
|
||||
`# .clinerules/\n\nThe following is provided by a root-level .clinerules/ directory where the user has specified instructions for this working directory (${cwd.toPosix()})\n\n${content}`,
|
||||
|
||||
clineRulesFileInstructions: (cwd: string, content: string) =>
|
||||
`# .clinerules\n\nThe following is provided by a root-level .clinerules file where the user has specified instructions for this working directory (${cwd.toPosix()})\n\n${content}`,
|
||||
|
||||
newTaskContext: (context: string) =>
|
||||
`Cline wants to start a new task with the following context:\n\n${context}\n\nClick "Start New Task" to start a new task with this context preloaded.`,
|
||||
}
|
||||
|
||||
// to avoid circular dependency
|
||||
|
||||
@@ -216,7 +216,7 @@ Usage:
|
||||
Description: Ask the user a question to gather additional information needed to complete the task. This tool should be used when you encounter ambiguities, need clarification, or require more details to proceed effectively. It allows for interactive problem-solving by enabling direct communication with the user. Use this tool judiciously to maintain a balance between gathering necessary information and avoiding excessive back-and-forth.
|
||||
Parameters:
|
||||
- question: (required) The question to ask the user. This should be a clear, specific question that addresses the information you need.
|
||||
- options: (optional) An array of 2-5 options for the user to choose from. Each option should be a string describing a possible answer. You may not always need to provide options, but it may be helpful in many cases where it can save the user from having to type out a response manually.
|
||||
- options: (optional) An array of 2-5 options for the user to choose from. Each option should be a string describing a possible answer. You may not always need to provide options, but it may be helpful in many cases where it can save the user from having to type out a response manually. IMPORTANT: NEVER include an option to toggle to Act mode, as this would be something you need to direct the user to do manually themselves if needed.
|
||||
Usage:
|
||||
<ask_followup_question>
|
||||
<question>Your question here</question>
|
||||
@@ -239,6 +239,23 @@ Your final result description here
|
||||
<command>Command to demonstrate result (optional)</command>
|
||||
</attempt_completion>
|
||||
|
||||
## new_task
|
||||
Description: Request to create a new task with preloaded context. The user will be presented with a preview of the context and can choose to create a new task or keep chatting in the current conversation.
|
||||
IMPORTANT NOTE: When creating a new task you must ALWAYS include context. You may use this tool proactively when the the context window usage is over 50%.
|
||||
|
||||
Parameters:
|
||||
- context: (required) The context to preload the new task with. This should include:
|
||||
* Comprehensively explain what has been accomplished in the current task - mention specific file names that are relevant
|
||||
* The specific next steps or focus for the new task - mention specific file names that are relevant
|
||||
* Any critical information needed to continue the work
|
||||
* Clear indication of how this new task relates to the overall workflow
|
||||
* This should be akin to a long handoff file, enough for a totally new developer to be able to pick up where you left off and know exactly what to do next and which files to look at.
|
||||
|
||||
Usage:
|
||||
<new_task>
|
||||
<context>context to preload new task with</context>
|
||||
</new_task>
|
||||
|
||||
## plan_mode_respond
|
||||
Description: Respond to the user's inquiry in an effort to plan a solution to the user's task. This tool should be used when you need to provide a response to a question or statement from the user about how you plan to accomplish the task. This tool is only available in PLAN MODE. The environment_details will specify the current mode, if it is not PLAN MODE then you should not use this tool. Depending on the user's message, you may ask questions to get clarification about the user's request, architect a solution to the task, and to brainstorm ideas with the user. For example, if the user's task is to create a website, you may start by asking some clarifying questions, then present a detailed plan for how you will accomplish the task given the context, and perhaps engage in a back and forth to finalize the details before the user switches you to ACT MODE to implement the solution.
|
||||
Parameters:
|
||||
@@ -283,7 +300,23 @@ Array of options here (optional), e.g. ["Option 1", "Option 2", "Option 3"]
|
||||
</content>
|
||||
</write_to_file>
|
||||
|
||||
## Example 3: Requesting to make targeted edits to a file
|
||||
## Example 3: Creating a new task
|
||||
|
||||
<new_task>
|
||||
Authentication System Implementation:
|
||||
- We've implemented the basic user model with email/password
|
||||
- Password hashing is working with bcrypt
|
||||
- Login endpoint is functional with proper validation
|
||||
- JWT token generation is implemented
|
||||
|
||||
Next Steps:
|
||||
- Implement refresh token functionality
|
||||
- Add token validation middleware
|
||||
- Create password reset flow
|
||||
- Implement role-based access control
|
||||
</new_task>
|
||||
|
||||
## Example 4: Requesting to make targeted edits to a file
|
||||
|
||||
<replace_in_file>
|
||||
<path>src/components/App.tsx</path>
|
||||
@@ -321,7 +354,7 @@ ${
|
||||
mcpHub.getMode() !== "off"
|
||||
? `
|
||||
|
||||
## Example 4: Requesting to use an MCP tool
|
||||
## Example 5: Requesting to use an MCP tool
|
||||
|
||||
<use_mcp_tool>
|
||||
<server_name>weather-server</server_name>
|
||||
@@ -334,14 +367,14 @@ ${
|
||||
</arguments>
|
||||
</use_mcp_tool>
|
||||
|
||||
## Example 5: Requesting to access an MCP resource
|
||||
## Example 6: Requesting to access an MCP resource
|
||||
|
||||
<access_mcp_resource>
|
||||
<server_name>weather-server</server_name>
|
||||
<uri>weather://san-francisco/current</uri>
|
||||
</access_mcp_resource>
|
||||
|
||||
## Example 6: Another example of using an MCP tool (where the server name is a unique identifier such as a URL)
|
||||
## Example 7: Another example of using an MCP tool (where the server name is a unique identifier such as a URL)
|
||||
|
||||
<use_mcp_tool>
|
||||
<server_name>github.com/modelcontextprotocol/servers/tree/main/src/github</server_name>
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import * as path from "path"
|
||||
import * as vscode from "vscode"
|
||||
import fs from "fs/promises"
|
||||
import { Anthropic } from "@anthropic-ai/sdk"
|
||||
import { fileExistsAtPath } from "../../utils/fs"
|
||||
import { ClineMessage } from "../../shared/ExtensionMessage"
|
||||
|
||||
export const GlobalFileNames = {
|
||||
apiConversationHistory: "api_conversation_history.json",
|
||||
uiMessages: "ui_messages.json",
|
||||
openRouterModels: "openrouter_models.json",
|
||||
mcpSettings: "cline_mcp_settings.json",
|
||||
clineRules: ".clinerules",
|
||||
}
|
||||
|
||||
export async function ensureTaskDirectoryExists(context: vscode.ExtensionContext, taskId: string): Promise<string> {
|
||||
const globalStoragePath = context.globalStorageUri.fsPath
|
||||
const taskDir = path.join(globalStoragePath, "tasks", taskId)
|
||||
await fs.mkdir(taskDir, { recursive: true })
|
||||
return taskDir
|
||||
}
|
||||
|
||||
export async function getSavedApiConversationHistory(
|
||||
context: vscode.ExtensionContext,
|
||||
taskId: string,
|
||||
): Promise<Anthropic.MessageParam[]> {
|
||||
const filePath = path.join(await ensureTaskDirectoryExists(context, taskId), GlobalFileNames.apiConversationHistory)
|
||||
const fileExists = await fileExistsAtPath(filePath)
|
||||
if (fileExists) {
|
||||
return JSON.parse(await fs.readFile(filePath, "utf8"))
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
export async function saveApiConversationHistory(
|
||||
context: vscode.ExtensionContext,
|
||||
taskId: string,
|
||||
apiConversationHistory: Anthropic.MessageParam[],
|
||||
) {
|
||||
try {
|
||||
const filePath = path.join(await ensureTaskDirectoryExists(context, taskId), GlobalFileNames.apiConversationHistory)
|
||||
await fs.writeFile(filePath, JSON.stringify(apiConversationHistory))
|
||||
} catch (error) {
|
||||
// in the off chance this fails, we don't want to stop the task
|
||||
console.error("Failed to save API conversation history:", error)
|
||||
}
|
||||
}
|
||||
|
||||
export async function getSavedClineMessages(context: vscode.ExtensionContext, taskId: string): Promise<ClineMessage[]> {
|
||||
const filePath = path.join(await ensureTaskDirectoryExists(context, taskId), GlobalFileNames.uiMessages)
|
||||
if (await fileExistsAtPath(filePath)) {
|
||||
return JSON.parse(await fs.readFile(filePath, "utf8"))
|
||||
} else {
|
||||
// check old location
|
||||
const oldPath = path.join(await ensureTaskDirectoryExists(context, taskId), "claude_messages.json")
|
||||
if (await fileExistsAtPath(oldPath)) {
|
||||
const data = JSON.parse(await fs.readFile(oldPath, "utf8"))
|
||||
await fs.unlink(oldPath) // remove old file
|
||||
return data
|
||||
}
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
export async function saveClineMessages(context: vscode.ExtensionContext, taskId: string, uiMessages: ClineMessage[]) {
|
||||
try {
|
||||
const taskDir = await ensureTaskDirectoryExists(context, taskId)
|
||||
const filePath = path.join(taskDir, GlobalFileNames.uiMessages)
|
||||
await fs.writeFile(filePath, JSON.stringify(uiMessages))
|
||||
} catch (error) {
|
||||
console.error("Failed to save ui messages:", error)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
export type SecretKey =
|
||||
| "apiKey"
|
||||
| "clineApiKey"
|
||||
| "openRouterApiKey"
|
||||
| "awsAccessKey"
|
||||
| "awsSecretKey"
|
||||
| "awsSessionToken"
|
||||
| "openAiApiKey"
|
||||
| "geminiApiKey"
|
||||
| "openAiNativeApiKey"
|
||||
| "deepSeekApiKey"
|
||||
| "requestyApiKey"
|
||||
| "togetherApiKey"
|
||||
| "qwenApiKey"
|
||||
| "mistralApiKey"
|
||||
| "liteLlmApiKey"
|
||||
| "authNonce"
|
||||
| "asksageApiKey"
|
||||
| "xaiApiKey"
|
||||
| "sambanovaApiKey"
|
||||
export type GlobalStateKey =
|
||||
| "apiProvider"
|
||||
| "apiModelId"
|
||||
| "awsRegion"
|
||||
| "awsUseCrossRegionInference"
|
||||
| "awsBedrockUsePromptCache"
|
||||
| "awsBedrockEndpoint"
|
||||
| "awsProfile"
|
||||
| "awsUseProfile"
|
||||
| "vertexProjectId"
|
||||
| "vertexRegion"
|
||||
| "lastShownAnnouncementId"
|
||||
| "customInstructions"
|
||||
| "taskHistory"
|
||||
| "openAiBaseUrl"
|
||||
| "openAiModelId"
|
||||
| "openAiModelInfo"
|
||||
| "ollamaModelId"
|
||||
| "ollamaBaseUrl"
|
||||
| "ollamaApiOptionsCtxNum"
|
||||
| "lmStudioModelId"
|
||||
| "lmStudioBaseUrl"
|
||||
| "anthropicBaseUrl"
|
||||
| "azureApiVersion"
|
||||
| "openRouterModelId"
|
||||
| "openRouterModelInfo"
|
||||
| "openRouterProviderSorting"
|
||||
| "autoApprovalSettings"
|
||||
| "browserSettings"
|
||||
| "chatSettings"
|
||||
| "vsCodeLmModelSelector"
|
||||
| "userInfo"
|
||||
| "previousModeApiProvider"
|
||||
| "previousModeModelId"
|
||||
| "previousModeThinkingBudgetTokens"
|
||||
| "previousModeVsCodeLmModelSelector"
|
||||
| "previousModeModelInfo"
|
||||
| "liteLlmBaseUrl"
|
||||
| "liteLlmModelId"
|
||||
| "qwenApiLine"
|
||||
| "requestyModelId"
|
||||
| "togetherModelId"
|
||||
| "mcpMarketplaceCatalog"
|
||||
| "telemetrySetting"
|
||||
| "asksageApiUrl"
|
||||
| "thinkingBudgetTokens"
|
||||
| "planActSeparateModelsSetting"
|
||||
@@ -0,0 +1,421 @@
|
||||
import * as vscode from "vscode"
|
||||
import { DEFAULT_CHAT_SETTINGS } from "../../shared/ChatSettings"
|
||||
import { DEFAULT_BROWSER_SETTINGS } from "../../shared/BrowserSettings"
|
||||
import { DEFAULT_AUTO_APPROVAL_SETTINGS } from "../../shared/AutoApprovalSettings"
|
||||
import { GlobalStateKey, SecretKey } from "./state-keys"
|
||||
import { ApiConfiguration, ApiProvider, ModelInfo } from "../../shared/api"
|
||||
import { HistoryItem } from "../../shared/HistoryItem"
|
||||
import { AutoApprovalSettings } from "../../shared/AutoApprovalSettings"
|
||||
import { BrowserSettings } from "../../shared/BrowserSettings"
|
||||
import { ChatSettings } from "../../shared/ChatSettings"
|
||||
import { TelemetrySetting } from "../../shared/TelemetrySetting"
|
||||
import { UserInfo } from "../../shared/UserInfo"
|
||||
/*
|
||||
Storage
|
||||
https://dev.to/kompotkot/how-to-use-secretstorage-in-your-vscode-extensions-2hco
|
||||
https://www.eliostruyf.com/devhack-code-extension-storage-options/
|
||||
*/
|
||||
|
||||
// global
|
||||
|
||||
export async function updateGlobalState(context: vscode.ExtensionContext, key: GlobalStateKey, value: any) {
|
||||
await context.globalState.update(key, value)
|
||||
}
|
||||
|
||||
export async function getGlobalState(context: vscode.ExtensionContext, key: GlobalStateKey) {
|
||||
return await context.globalState.get(key)
|
||||
}
|
||||
|
||||
// secrets
|
||||
|
||||
export async function storeSecret(context: vscode.ExtensionContext, key: SecretKey, value?: string) {
|
||||
if (value) {
|
||||
await context.secrets.store(key, value)
|
||||
} else {
|
||||
await context.secrets.delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
export async function getSecret(context: vscode.ExtensionContext, key: SecretKey) {
|
||||
return await context.secrets.get(key)
|
||||
}
|
||||
|
||||
// workspace
|
||||
|
||||
export async function updateWorkspaceState(context: vscode.ExtensionContext, key: string, value: any) {
|
||||
await context.workspaceState.update(key, value)
|
||||
}
|
||||
|
||||
export async function getWorkspaceState(context: vscode.ExtensionContext, key: string) {
|
||||
return await context.workspaceState.get(key)
|
||||
}
|
||||
|
||||
export async function getAllExtensionState(context: vscode.ExtensionContext) {
|
||||
const [
|
||||
storedApiProvider,
|
||||
apiModelId,
|
||||
apiKey,
|
||||
openRouterApiKey,
|
||||
clineApiKey,
|
||||
awsAccessKey,
|
||||
awsSecretKey,
|
||||
awsSessionToken,
|
||||
awsRegion,
|
||||
awsUseCrossRegionInference,
|
||||
awsBedrockUsePromptCache,
|
||||
awsBedrockEndpoint,
|
||||
awsProfile,
|
||||
awsUseProfile,
|
||||
vertexProjectId,
|
||||
vertexRegion,
|
||||
openAiBaseUrl,
|
||||
openAiApiKey,
|
||||
openAiModelId,
|
||||
openAiModelInfo,
|
||||
ollamaModelId,
|
||||
ollamaBaseUrl,
|
||||
ollamaApiOptionsCtxNum,
|
||||
lmStudioModelId,
|
||||
lmStudioBaseUrl,
|
||||
anthropicBaseUrl,
|
||||
geminiApiKey,
|
||||
openAiNativeApiKey,
|
||||
deepSeekApiKey,
|
||||
requestyApiKey,
|
||||
requestyModelId,
|
||||
togetherApiKey,
|
||||
togetherModelId,
|
||||
qwenApiKey,
|
||||
mistralApiKey,
|
||||
azureApiVersion,
|
||||
openRouterModelId,
|
||||
openRouterModelInfo,
|
||||
openRouterProviderSorting,
|
||||
lastShownAnnouncementId,
|
||||
customInstructions,
|
||||
taskHistory,
|
||||
autoApprovalSettings,
|
||||
browserSettings,
|
||||
chatSettings,
|
||||
vsCodeLmModelSelector,
|
||||
liteLlmBaseUrl,
|
||||
liteLlmModelId,
|
||||
userInfo,
|
||||
previousModeApiProvider,
|
||||
previousModeModelId,
|
||||
previousModeModelInfo,
|
||||
previousModeVsCodeLmModelSelector,
|
||||
previousModeThinkingBudgetTokens,
|
||||
qwenApiLine,
|
||||
liteLlmApiKey,
|
||||
telemetrySetting,
|
||||
asksageApiKey,
|
||||
asksageApiUrl,
|
||||
xaiApiKey,
|
||||
thinkingBudgetTokens,
|
||||
sambanovaApiKey,
|
||||
planActSeparateModelsSettingRaw,
|
||||
] = await Promise.all([
|
||||
getGlobalState(context, "apiProvider") as Promise<ApiProvider | undefined>,
|
||||
getGlobalState(context, "apiModelId") as Promise<string | undefined>,
|
||||
getSecret(context, "apiKey") as Promise<string | undefined>,
|
||||
getSecret(context, "openRouterApiKey") as Promise<string | undefined>,
|
||||
getSecret(context, "clineApiKey") as Promise<string | undefined>,
|
||||
getSecret(context, "awsAccessKey") as Promise<string | undefined>,
|
||||
getSecret(context, "awsSecretKey") as Promise<string | undefined>,
|
||||
getSecret(context, "awsSessionToken") as Promise<string | undefined>,
|
||||
getGlobalState(context, "awsRegion") as Promise<string | undefined>,
|
||||
getGlobalState(context, "awsUseCrossRegionInference") as Promise<boolean | undefined>,
|
||||
getGlobalState(context, "awsBedrockUsePromptCache") as Promise<boolean | undefined>,
|
||||
getGlobalState(context, "awsBedrockEndpoint") as Promise<string | undefined>,
|
||||
getGlobalState(context, "awsProfile") as Promise<string | undefined>,
|
||||
getGlobalState(context, "awsUseProfile") as Promise<boolean | undefined>,
|
||||
getGlobalState(context, "vertexProjectId") as Promise<string | undefined>,
|
||||
getGlobalState(context, "vertexRegion") as Promise<string | undefined>,
|
||||
getGlobalState(context, "openAiBaseUrl") as Promise<string | undefined>,
|
||||
getSecret(context, "openAiApiKey") as Promise<string | undefined>,
|
||||
getGlobalState(context, "openAiModelId") as Promise<string | undefined>,
|
||||
getGlobalState(context, "openAiModelInfo") as Promise<ModelInfo | undefined>,
|
||||
getGlobalState(context, "ollamaModelId") as Promise<string | undefined>,
|
||||
getGlobalState(context, "ollamaBaseUrl") as Promise<string | undefined>,
|
||||
getGlobalState(context, "ollamaApiOptionsCtxNum") as Promise<string | undefined>,
|
||||
getGlobalState(context, "lmStudioModelId") as Promise<string | undefined>,
|
||||
getGlobalState(context, "lmStudioBaseUrl") as Promise<string | undefined>,
|
||||
getGlobalState(context, "anthropicBaseUrl") as Promise<string | undefined>,
|
||||
getSecret(context, "geminiApiKey") as Promise<string | undefined>,
|
||||
getSecret(context, "openAiNativeApiKey") as Promise<string | undefined>,
|
||||
getSecret(context, "deepSeekApiKey") as Promise<string | undefined>,
|
||||
getSecret(context, "requestyApiKey") as Promise<string | undefined>,
|
||||
getGlobalState(context, "requestyModelId") as Promise<string | undefined>,
|
||||
getSecret(context, "togetherApiKey") as Promise<string | undefined>,
|
||||
getGlobalState(context, "togetherModelId") as Promise<string | undefined>,
|
||||
getSecret(context, "qwenApiKey") as Promise<string | undefined>,
|
||||
getSecret(context, "mistralApiKey") as Promise<string | undefined>,
|
||||
getGlobalState(context, "azureApiVersion") as Promise<string | undefined>,
|
||||
getGlobalState(context, "openRouterModelId") as Promise<string | undefined>,
|
||||
getGlobalState(context, "openRouterModelInfo") as Promise<ModelInfo | undefined>,
|
||||
getGlobalState(context, "openRouterProviderSorting") as Promise<string | undefined>,
|
||||
getGlobalState(context, "lastShownAnnouncementId") as Promise<string | undefined>,
|
||||
getGlobalState(context, "customInstructions") as Promise<string | undefined>,
|
||||
getGlobalState(context, "taskHistory") as Promise<HistoryItem[] | undefined>,
|
||||
getGlobalState(context, "autoApprovalSettings") as Promise<AutoApprovalSettings | undefined>,
|
||||
getGlobalState(context, "browserSettings") as Promise<BrowserSettings | undefined>,
|
||||
getGlobalState(context, "chatSettings") as Promise<ChatSettings | undefined>,
|
||||
getGlobalState(context, "vsCodeLmModelSelector") as Promise<vscode.LanguageModelChatSelector | undefined>,
|
||||
getGlobalState(context, "liteLlmBaseUrl") as Promise<string | undefined>,
|
||||
getGlobalState(context, "liteLlmModelId") as Promise<string | undefined>,
|
||||
getGlobalState(context, "userInfo") as Promise<UserInfo | undefined>,
|
||||
getGlobalState(context, "previousModeApiProvider") as Promise<ApiProvider | undefined>,
|
||||
getGlobalState(context, "previousModeModelId") as Promise<string | undefined>,
|
||||
getGlobalState(context, "previousModeModelInfo") as Promise<ModelInfo | undefined>,
|
||||
getGlobalState(context, "previousModeVsCodeLmModelSelector") as Promise<vscode.LanguageModelChatSelector | undefined>,
|
||||
getGlobalState(context, "previousModeThinkingBudgetTokens") as Promise<number | undefined>,
|
||||
getGlobalState(context, "qwenApiLine") as Promise<string | undefined>,
|
||||
getSecret(context, "liteLlmApiKey") as Promise<string | undefined>,
|
||||
getGlobalState(context, "telemetrySetting") as Promise<TelemetrySetting | undefined>,
|
||||
getSecret(context, "asksageApiKey") as Promise<string | undefined>,
|
||||
getGlobalState(context, "asksageApiUrl") as Promise<string | undefined>,
|
||||
getSecret(context, "xaiApiKey") as Promise<string | undefined>,
|
||||
getGlobalState(context, "thinkingBudgetTokens") as Promise<number | undefined>,
|
||||
getSecret(context, "sambanovaApiKey") as Promise<string | undefined>,
|
||||
getGlobalState(context, "planActSeparateModelsSetting") as Promise<boolean | undefined>,
|
||||
])
|
||||
|
||||
let apiProvider: ApiProvider
|
||||
if (storedApiProvider) {
|
||||
apiProvider = storedApiProvider
|
||||
} else {
|
||||
// Either new user or legacy user that doesn't have the apiProvider stored in state
|
||||
// (If they're using OpenRouter or Bedrock, then apiProvider state will exist)
|
||||
if (apiKey) {
|
||||
apiProvider = "anthropic"
|
||||
} else {
|
||||
// New users should default to openrouter, since they've opted to use an API key instead of signing in
|
||||
apiProvider = "openrouter"
|
||||
}
|
||||
}
|
||||
|
||||
const o3MiniReasoningEffort = vscode.workspace.getConfiguration("cline.modelSettings.o3Mini").get("reasoningEffort", "medium")
|
||||
|
||||
const mcpMarketplaceEnabled = vscode.workspace.getConfiguration("cline").get<boolean>("mcpMarketplace.enabled", true)
|
||||
|
||||
// Plan/Act separate models setting is a boolean indicating whether the user wants to use different models for plan and act. Existing users expect this to be enabled, while we want new users to opt in to this being disabled by default.
|
||||
// On win11 state sometimes initializes as empty string instead of undefined
|
||||
let planActSeparateModelsSetting: boolean | undefined = undefined
|
||||
if (planActSeparateModelsSettingRaw === true || planActSeparateModelsSettingRaw === false) {
|
||||
planActSeparateModelsSetting = planActSeparateModelsSettingRaw
|
||||
} else {
|
||||
// default to true for existing users
|
||||
if (storedApiProvider) {
|
||||
planActSeparateModelsSetting = true
|
||||
} else {
|
||||
// default to false for new users
|
||||
planActSeparateModelsSetting = false
|
||||
}
|
||||
// this is a special case where it's a new state, but we want it to default to different values for existing and new users.
|
||||
// persist so next time state is retrieved it's set to the correct value.
|
||||
await updateGlobalState(context, "planActSeparateModelsSetting", planActSeparateModelsSetting)
|
||||
}
|
||||
|
||||
return {
|
||||
apiConfiguration: {
|
||||
apiProvider,
|
||||
apiModelId,
|
||||
apiKey,
|
||||
openRouterApiKey,
|
||||
clineApiKey,
|
||||
awsAccessKey,
|
||||
awsSecretKey,
|
||||
awsSessionToken,
|
||||
awsRegion,
|
||||
awsUseCrossRegionInference,
|
||||
awsBedrockUsePromptCache,
|
||||
awsBedrockEndpoint,
|
||||
awsProfile,
|
||||
awsUseProfile,
|
||||
vertexProjectId,
|
||||
vertexRegion,
|
||||
openAiBaseUrl,
|
||||
openAiApiKey,
|
||||
openAiModelId,
|
||||
openAiModelInfo,
|
||||
ollamaModelId,
|
||||
ollamaBaseUrl,
|
||||
ollamaApiOptionsCtxNum,
|
||||
lmStudioModelId,
|
||||
lmStudioBaseUrl,
|
||||
anthropicBaseUrl,
|
||||
geminiApiKey,
|
||||
openAiNativeApiKey,
|
||||
deepSeekApiKey,
|
||||
requestyApiKey,
|
||||
requestyModelId,
|
||||
togetherApiKey,
|
||||
togetherModelId,
|
||||
qwenApiKey,
|
||||
qwenApiLine,
|
||||
mistralApiKey,
|
||||
azureApiVersion,
|
||||
openRouterModelId,
|
||||
openRouterModelInfo,
|
||||
openRouterProviderSorting,
|
||||
vsCodeLmModelSelector,
|
||||
o3MiniReasoningEffort,
|
||||
thinkingBudgetTokens,
|
||||
liteLlmBaseUrl,
|
||||
liteLlmModelId,
|
||||
liteLlmApiKey,
|
||||
asksageApiKey,
|
||||
asksageApiUrl,
|
||||
xaiApiKey,
|
||||
sambanovaApiKey,
|
||||
},
|
||||
lastShownAnnouncementId,
|
||||
customInstructions,
|
||||
taskHistory,
|
||||
autoApprovalSettings: autoApprovalSettings || DEFAULT_AUTO_APPROVAL_SETTINGS, // default value can be 0 or empty string
|
||||
browserSettings: browserSettings || DEFAULT_BROWSER_SETTINGS,
|
||||
chatSettings: chatSettings || DEFAULT_CHAT_SETTINGS,
|
||||
userInfo,
|
||||
previousModeApiProvider,
|
||||
previousModeModelId,
|
||||
previousModeModelInfo,
|
||||
previousModeVsCodeLmModelSelector,
|
||||
previousModeThinkingBudgetTokens,
|
||||
mcpMarketplaceEnabled,
|
||||
telemetrySetting: telemetrySetting || "unset",
|
||||
planActSeparateModelsSetting,
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateApiConfiguration(context: vscode.ExtensionContext, apiConfiguration: ApiConfiguration) {
|
||||
const {
|
||||
apiProvider,
|
||||
apiModelId,
|
||||
apiKey,
|
||||
openRouterApiKey,
|
||||
awsAccessKey,
|
||||
awsSecretKey,
|
||||
awsSessionToken,
|
||||
awsRegion,
|
||||
awsUseCrossRegionInference,
|
||||
awsBedrockUsePromptCache,
|
||||
awsBedrockEndpoint,
|
||||
awsProfile,
|
||||
awsUseProfile,
|
||||
vertexProjectId,
|
||||
vertexRegion,
|
||||
openAiBaseUrl,
|
||||
openAiApiKey,
|
||||
openAiModelId,
|
||||
openAiModelInfo,
|
||||
ollamaModelId,
|
||||
ollamaBaseUrl,
|
||||
ollamaApiOptionsCtxNum,
|
||||
lmStudioModelId,
|
||||
lmStudioBaseUrl,
|
||||
anthropicBaseUrl,
|
||||
geminiApiKey,
|
||||
openAiNativeApiKey,
|
||||
deepSeekApiKey,
|
||||
requestyApiKey,
|
||||
requestyModelId,
|
||||
togetherApiKey,
|
||||
togetherModelId,
|
||||
qwenApiKey,
|
||||
mistralApiKey,
|
||||
azureApiVersion,
|
||||
openRouterModelId,
|
||||
openRouterModelInfo,
|
||||
openRouterProviderSorting,
|
||||
vsCodeLmModelSelector,
|
||||
liteLlmBaseUrl,
|
||||
liteLlmModelId,
|
||||
liteLlmApiKey,
|
||||
qwenApiLine,
|
||||
asksageApiKey,
|
||||
asksageApiUrl,
|
||||
xaiApiKey,
|
||||
thinkingBudgetTokens,
|
||||
clineApiKey,
|
||||
sambanovaApiKey,
|
||||
} = apiConfiguration
|
||||
await updateGlobalState(context, "apiProvider", apiProvider)
|
||||
await updateGlobalState(context, "apiModelId", apiModelId)
|
||||
await storeSecret(context, "apiKey", apiKey)
|
||||
await storeSecret(context, "openRouterApiKey", openRouterApiKey)
|
||||
await storeSecret(context, "awsAccessKey", awsAccessKey)
|
||||
await storeSecret(context, "awsSecretKey", awsSecretKey)
|
||||
await storeSecret(context, "awsSessionToken", awsSessionToken)
|
||||
await updateGlobalState(context, "awsRegion", awsRegion)
|
||||
await updateGlobalState(context, "awsUseCrossRegionInference", awsUseCrossRegionInference)
|
||||
await updateGlobalState(context, "awsBedrockUsePromptCache", awsBedrockUsePromptCache)
|
||||
await updateGlobalState(context, "awsBedrockEndpoint", awsBedrockEndpoint)
|
||||
await updateGlobalState(context, "awsProfile", awsProfile)
|
||||
await updateGlobalState(context, "awsUseProfile", awsUseProfile)
|
||||
await updateGlobalState(context, "vertexProjectId", vertexProjectId)
|
||||
await updateGlobalState(context, "vertexRegion", vertexRegion)
|
||||
await updateGlobalState(context, "openAiBaseUrl", openAiBaseUrl)
|
||||
await storeSecret(context, "openAiApiKey", openAiApiKey)
|
||||
await updateGlobalState(context, "openAiModelId", openAiModelId)
|
||||
await updateGlobalState(context, "openAiModelInfo", openAiModelInfo)
|
||||
await updateGlobalState(context, "ollamaModelId", ollamaModelId)
|
||||
await updateGlobalState(context, "ollamaBaseUrl", ollamaBaseUrl)
|
||||
await updateGlobalState(context, "ollamaApiOptionsCtxNum", ollamaApiOptionsCtxNum)
|
||||
await updateGlobalState(context, "lmStudioModelId", lmStudioModelId)
|
||||
await updateGlobalState(context, "lmStudioBaseUrl", lmStudioBaseUrl)
|
||||
await updateGlobalState(context, "anthropicBaseUrl", anthropicBaseUrl)
|
||||
await storeSecret(context, "geminiApiKey", geminiApiKey)
|
||||
await storeSecret(context, "openAiNativeApiKey", openAiNativeApiKey)
|
||||
await storeSecret(context, "deepSeekApiKey", deepSeekApiKey)
|
||||
await storeSecret(context, "requestyApiKey", requestyApiKey)
|
||||
await storeSecret(context, "togetherApiKey", togetherApiKey)
|
||||
await storeSecret(context, "qwenApiKey", qwenApiKey)
|
||||
await storeSecret(context, "mistralApiKey", mistralApiKey)
|
||||
await storeSecret(context, "liteLlmApiKey", liteLlmApiKey)
|
||||
await storeSecret(context, "xaiApiKey", xaiApiKey)
|
||||
await updateGlobalState(context, "azureApiVersion", azureApiVersion)
|
||||
await updateGlobalState(context, "openRouterModelId", openRouterModelId)
|
||||
await updateGlobalState(context, "openRouterModelInfo", openRouterModelInfo)
|
||||
await updateGlobalState(context, "openRouterProviderSorting", openRouterProviderSorting)
|
||||
await updateGlobalState(context, "vsCodeLmModelSelector", vsCodeLmModelSelector)
|
||||
await updateGlobalState(context, "liteLlmBaseUrl", liteLlmBaseUrl)
|
||||
await updateGlobalState(context, "liteLlmModelId", liteLlmModelId)
|
||||
await updateGlobalState(context, "qwenApiLine", qwenApiLine)
|
||||
await updateGlobalState(context, "requestyModelId", requestyModelId)
|
||||
await updateGlobalState(context, "togetherModelId", togetherModelId)
|
||||
await storeSecret(context, "asksageApiKey", asksageApiKey)
|
||||
await updateGlobalState(context, "asksageApiUrl", asksageApiUrl)
|
||||
await updateGlobalState(context, "thinkingBudgetTokens", thinkingBudgetTokens)
|
||||
await storeSecret(context, "clineApiKey", clineApiKey)
|
||||
await storeSecret(context, "sambanovaApiKey", sambanovaApiKey)
|
||||
}
|
||||
|
||||
export async function resetExtensionState(context: vscode.ExtensionContext) {
|
||||
for (const key of context.globalState.keys()) {
|
||||
await context.globalState.update(key, undefined)
|
||||
}
|
||||
const secretKeys: SecretKey[] = [
|
||||
"apiKey",
|
||||
"openRouterApiKey",
|
||||
"awsAccessKey",
|
||||
"awsSecretKey",
|
||||
"awsSessionToken",
|
||||
"openAiApiKey",
|
||||
"geminiApiKey",
|
||||
"openAiNativeApiKey",
|
||||
"deepSeekApiKey",
|
||||
"requestyApiKey",
|
||||
"togetherApiKey",
|
||||
"qwenApiKey",
|
||||
"mistralApiKey",
|
||||
"clineApiKey",
|
||||
"liteLlmApiKey",
|
||||
"asksageApiKey",
|
||||
"xaiApiKey",
|
||||
"sambanovaApiKey",
|
||||
]
|
||||
for (const key of secretKeys) {
|
||||
await storeSecret(context, key, undefined)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,322 @@
|
||||
import axios from "axios"
|
||||
import * as vscode from "vscode"
|
||||
import { getNonce } from "./getNonce"
|
||||
import { getUri } from "./getUri"
|
||||
import { getTheme } from "../../integrations/theme/getTheme"
|
||||
import { Controller } from "../controller"
|
||||
import { findLast } from "../../shared/array"
|
||||
/*
|
||||
https://github.com/microsoft/vscode-webview-ui-toolkit-samples/blob/main/default/weather-webview/src/providers/WeatherViewProvider.ts
|
||||
https://github.com/KumarVariable/vscode-extension-sidebar-html/blob/master/src/customSidebarViewProvider.ts
|
||||
*/
|
||||
|
||||
export class WebviewProvider implements vscode.WebviewViewProvider {
|
||||
public static readonly sideBarId = "claude-dev.SidebarProvider" // used in package.json as the view's id. This value cannot be changed due to how vscode caches views based on their id, and updating the id would break existing instances of the extension.
|
||||
public static readonly tabPanelId = "claude-dev.TabPanelProvider"
|
||||
private static activeInstances: Set<WebviewProvider> = new Set()
|
||||
public view?: vscode.WebviewView | vscode.WebviewPanel
|
||||
private disposables: vscode.Disposable[] = []
|
||||
controller: Controller
|
||||
|
||||
constructor(
|
||||
readonly context: vscode.ExtensionContext,
|
||||
private readonly outputChannel: vscode.OutputChannel,
|
||||
) {
|
||||
WebviewProvider.activeInstances.add(this)
|
||||
this.controller = new Controller(context, outputChannel, this)
|
||||
}
|
||||
|
||||
async dispose() {
|
||||
if (this.view && "dispose" in this.view) {
|
||||
this.view.dispose()
|
||||
}
|
||||
while (this.disposables.length) {
|
||||
const x = this.disposables.pop()
|
||||
if (x) {
|
||||
x.dispose()
|
||||
}
|
||||
}
|
||||
await this.controller.dispose()
|
||||
WebviewProvider.activeInstances.delete(this)
|
||||
}
|
||||
|
||||
public static getVisibleInstance(): WebviewProvider | undefined {
|
||||
return findLast(Array.from(this.activeInstances), (instance) => instance.view?.visible === true)
|
||||
}
|
||||
|
||||
async resolveWebviewView(webviewView: vscode.WebviewView | vscode.WebviewPanel) {
|
||||
this.view = webviewView
|
||||
|
||||
webviewView.webview.options = {
|
||||
// Allow scripts in the webview
|
||||
enableScripts: true,
|
||||
localResourceRoots: [this.context.extensionUri],
|
||||
}
|
||||
|
||||
webviewView.webview.html =
|
||||
this.context.extensionMode === vscode.ExtensionMode.Development
|
||||
? await this.getHMRHtmlContent(webviewView.webview)
|
||||
: this.getHtmlContent(webviewView.webview)
|
||||
|
||||
// Sets up an event listener to listen for messages passed from the webview view context
|
||||
// and executes code based on the message that is received
|
||||
this.setWebviewMessageListener(webviewView.webview)
|
||||
|
||||
// Logs show up in bottom panel > Debug Console
|
||||
//console.log("registering listener")
|
||||
|
||||
// Listen for when the panel becomes visible
|
||||
// https://github.com/microsoft/vscode-discussions/discussions/840
|
||||
if ("onDidChangeViewState" in webviewView) {
|
||||
// WebviewView and WebviewPanel have all the same properties except for this visibility listener
|
||||
// panel
|
||||
webviewView.onDidChangeViewState(
|
||||
() => {
|
||||
if (this.view?.visible) {
|
||||
this.controller.postMessageToWebview({
|
||||
type: "action",
|
||||
action: "didBecomeVisible",
|
||||
})
|
||||
}
|
||||
},
|
||||
null,
|
||||
this.disposables,
|
||||
)
|
||||
} else if ("onDidChangeVisibility" in webviewView) {
|
||||
// sidebar
|
||||
webviewView.onDidChangeVisibility(
|
||||
() => {
|
||||
if (this.view?.visible) {
|
||||
this.controller.postMessageToWebview({
|
||||
type: "action",
|
||||
action: "didBecomeVisible",
|
||||
})
|
||||
}
|
||||
},
|
||||
null,
|
||||
this.disposables,
|
||||
)
|
||||
}
|
||||
|
||||
// Listen for when the view is disposed
|
||||
// This happens when the user closes the view or when the view is closed programmatically
|
||||
webviewView.onDidDispose(
|
||||
async () => {
|
||||
await this.dispose()
|
||||
},
|
||||
null,
|
||||
this.disposables,
|
||||
)
|
||||
|
||||
// // if the extension is starting a new session, clear previous task state
|
||||
// this.clearTask()
|
||||
{
|
||||
// Listen for configuration changes
|
||||
vscode.workspace.onDidChangeConfiguration(
|
||||
async (e) => {
|
||||
if (e && e.affectsConfiguration("workbench.colorTheme")) {
|
||||
// Sends latest theme name to webview
|
||||
await this.controller.postMessageToWebview({
|
||||
type: "theme",
|
||||
text: JSON.stringify(await getTheme()),
|
||||
})
|
||||
}
|
||||
if (e && e.affectsConfiguration("cline.mcpMarketplace.enabled")) {
|
||||
// Update state when marketplace tab setting changes
|
||||
await this.controller.postStateToWebview()
|
||||
}
|
||||
},
|
||||
null,
|
||||
this.disposables,
|
||||
)
|
||||
|
||||
// if the extension is starting a new session, clear previous task state
|
||||
this.controller.clearTask()
|
||||
|
||||
this.outputChannel.appendLine("Webview view resolved")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Defines and returns the HTML that should be rendered within the webview panel.
|
||||
*
|
||||
* @remarks This is also the place where references to the React webview build files
|
||||
* are created and inserted into the webview HTML.
|
||||
*
|
||||
* @param webview A reference to the extension webview
|
||||
* @param extensionUri The URI of the directory containing the extension
|
||||
* @returns A template string literal containing the HTML that should be
|
||||
* rendered within the webview panel
|
||||
*/
|
||||
private getHtmlContent(webview: vscode.Webview): string {
|
||||
// Get the local path to main script run in the webview,
|
||||
// then convert it to a uri we can use in the webview.
|
||||
|
||||
// The CSS file from the React build output
|
||||
const stylesUri = getUri(webview, this.context.extensionUri, ["webview-ui", "build", "assets", "index.css"])
|
||||
// The JS file from the React build output
|
||||
const scriptUri = getUri(webview, this.context.extensionUri, ["webview-ui", "build", "assets", "index.js"])
|
||||
|
||||
// The codicon font from the React build output
|
||||
// https://github.com/microsoft/vscode-extension-samples/blob/main/webview-codicons-sample/src/extension.ts
|
||||
// we installed this package in the extension so that we can access it how its intended from the extension (the font file is likely bundled in vscode), and we just import the css fileinto our react app we don't have access to it
|
||||
// don't forget to add font-src ${webview.cspSource};
|
||||
const codiconsUri = getUri(webview, this.context.extensionUri, [
|
||||
"node_modules",
|
||||
"@vscode",
|
||||
"codicons",
|
||||
"dist",
|
||||
"codicon.css",
|
||||
])
|
||||
|
||||
// const scriptUri = webview.asWebviewUri(vscode.Uri.joinPath(this._extensionUri, "assets", "main.js"))
|
||||
|
||||
// const styleResetUri = webview.asWebviewUri(vscode.Uri.joinPath(this._extensionUri, "assets", "reset.css"))
|
||||
// const styleVSCodeUri = webview.asWebviewUri(vscode.Uri.joinPath(this._extensionUri, "assets", "vscode.css"))
|
||||
|
||||
// // Same for stylesheet
|
||||
// const stylesheetUri = webview.asWebviewUri(vscode.Uri.joinPath(this._extensionUri, "assets", "main.css"))
|
||||
|
||||
// Use a nonce to only allow a specific script to be run.
|
||||
/*
|
||||
content security policy of your webview to only allow scripts that have a specific nonce
|
||||
create a content security policy meta tag so that only loading scripts with a nonce is allowed
|
||||
As your extension grows you will likely want to add custom styles, fonts, and/or images to your webview. If you do, you will need to update the content security policy meta tag to explicity allow for these resources. E.g.
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src ${webview.cspSource}; font-src ${webview.cspSource}; img-src ${webview.cspSource} https:; script-src 'nonce-${nonce}';">
|
||||
- 'unsafe-inline' is required for styles due to vscode-webview-toolkit's dynamic style injection
|
||||
- since we pass base64 images to the webview, we need to specify img-src ${webview.cspSource} data:;
|
||||
|
||||
in meta tag we add nonce attribute: A cryptographic nonce (only used once) to allow scripts. The server must generate a unique nonce value each time it transmits a policy. It is critical to provide a nonce that cannot be guessed as bypassing a resource's policy is otherwise trivial.
|
||||
*/
|
||||
const nonce = getNonce()
|
||||
|
||||
// Tip: Install the es6-string-html VS Code extension to enable code highlighting below
|
||||
return /*html*/ `
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1,shrink-to-fit=no">
|
||||
<meta name="theme-color" content="#000000">
|
||||
<link rel="stylesheet" type="text/css" href="${stylesUri}">
|
||||
<link href="${codiconsUri}" rel="stylesheet" />
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; connect-src https://*.posthog.com https://*.firebaseauth.com https://*.firebaseio.com https://*.googleapis.com https://*.firebase.com; font-src ${webview.cspSource}; style-src ${webview.cspSource} 'unsafe-inline'; img-src ${webview.cspSource} https: data:; script-src 'nonce-${nonce}' 'unsafe-eval';">
|
||||
<title>Cline</title>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root"></div>
|
||||
<script type="module" nonce="${nonce}" src="${scriptUri}"></script>
|
||||
</body>
|
||||
</html>
|
||||
`
|
||||
}
|
||||
|
||||
/**
|
||||
* Connects to the local Vite dev server to allow HMR, with fallback to the bundled assets
|
||||
*
|
||||
* @param webview A reference to the extension webview
|
||||
* @returns A template string literal containing the HTML that should be
|
||||
* rendered within the webview panel
|
||||
*/
|
||||
private async getHMRHtmlContent(webview: vscode.Webview): Promise<string> {
|
||||
const localPort = 25463
|
||||
const localServerUrl = `localhost:${localPort}`
|
||||
|
||||
// Check if local dev server is running.
|
||||
try {
|
||||
await axios.get(`http://${localServerUrl}`)
|
||||
} catch (error) {
|
||||
vscode.window.showErrorMessage(
|
||||
"Cline: Local webview dev server is not running, HMR will not work. Please run 'npm run dev:webview' before launching the extension to enable HMR. Using bundled assets.",
|
||||
)
|
||||
|
||||
return this.getHtmlContent(webview)
|
||||
}
|
||||
|
||||
const nonce = getNonce()
|
||||
const stylesUri = getUri(webview, this.context.extensionUri, ["webview-ui", "build", "assets", "index.css"])
|
||||
const codiconsUri = getUri(webview, this.context.extensionUri, [
|
||||
"node_modules",
|
||||
"@vscode",
|
||||
"codicons",
|
||||
"dist",
|
||||
"codicon.css",
|
||||
])
|
||||
|
||||
const scriptEntrypoint = "src/main.tsx"
|
||||
const scriptUri = `http://${localServerUrl}/${scriptEntrypoint}`
|
||||
|
||||
const reactRefresh = /*html*/ `
|
||||
<script nonce="${nonce}" type="module">
|
||||
import RefreshRuntime from "http://${localServerUrl}/@react-refresh"
|
||||
RefreshRuntime.injectIntoGlobalHook(window)
|
||||
window.$RefreshReg$ = () => {}
|
||||
window.$RefreshSig$ = () => (type) => type
|
||||
window.__vite_plugin_react_preamble_installed__ = true
|
||||
</script>
|
||||
`
|
||||
|
||||
const csp = [
|
||||
"default-src 'none'",
|
||||
`font-src ${webview.cspSource}`,
|
||||
`style-src ${webview.cspSource} 'unsafe-inline' https://* http://${localServerUrl} http://0.0.0.0:${localPort}`,
|
||||
`img-src ${webview.cspSource} https: data:`,
|
||||
`script-src 'unsafe-eval' https://* http://${localServerUrl} http://0.0.0.0:${localPort} 'nonce-${nonce}'`,
|
||||
`connect-src https://* ws://${localServerUrl} ws://0.0.0.0:${localPort} http://${localServerUrl} http://0.0.0.0:${localPort}`,
|
||||
]
|
||||
|
||||
return /*html*/ `
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1,shrink-to-fit=no">
|
||||
<meta http-equiv="Content-Security-Policy" content="${csp.join("; ")}">
|
||||
<link rel="stylesheet" type="text/css" href="${stylesUri}">
|
||||
<link href="${codiconsUri}" rel="stylesheet" />
|
||||
<title>Cline</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
${reactRefresh}
|
||||
<script type="module" src="${scriptUri}"></script>
|
||||
</body>
|
||||
</html>
|
||||
`
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up an event listener to listen for messages passed from the webview context and
|
||||
* executes code based on the message that is received.
|
||||
*
|
||||
* IMPORTANT: When passing methods as callbacks in JavaScript/TypeScript, the method's
|
||||
* 'this' context can be lost. This happens because the method is passed as a
|
||||
* standalone function reference, detached from its original object.
|
||||
*
|
||||
* The Problem:
|
||||
* Doing: webview.onDidReceiveMessage(this.controller.handleWebviewMessage)
|
||||
* Would cause 'this' inside handleWebviewMessage to be undefined or wrong,
|
||||
* leading to "TypeError: this.setUserInfo is not a function"
|
||||
*
|
||||
* The Solution:
|
||||
* We wrap the method call in an arrow function, which:
|
||||
* 1. Preserves the lexical scope's 'this' binding
|
||||
* 2. Ensures handleWebviewMessage is called as a method on the controller instance
|
||||
* 3. Maintains access to all controller methods and properties
|
||||
*
|
||||
* Alternative solutions could use .bind() or making handleWebviewMessage an arrow
|
||||
* function property, but this approach is clean and explicit.
|
||||
*
|
||||
* @param webview The webview instance to attach the message listener to
|
||||
*/
|
||||
private setWebviewMessageListener(webview: vscode.Webview) {
|
||||
webview.onDidReceiveMessage(
|
||||
(message) => {
|
||||
this.controller.handleWebviewMessage(message)
|
||||
},
|
||||
null,
|
||||
this.disposables,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as vscode from "vscode"
|
||||
import * as fs from "fs/promises"
|
||||
import * as path from "path"
|
||||
import { ClineProvider } from "../../core/webview/ClineProvider"
|
||||
import { Controller } from "../../core/controller"
|
||||
import { HistoryItem } from "../../shared/HistoryItem"
|
||||
import { ClineMessage } from "../../shared/ExtensionMessage"
|
||||
|
||||
@@ -9,7 +9,7 @@ import { ClineMessage } from "../../shared/ExtensionMessage"
|
||||
* Registers development-only commands for task manipulation.
|
||||
* These are only activated in development mode.
|
||||
*/
|
||||
export function registerTaskCommands(context: vscode.ExtensionContext, provider: ClineProvider): vscode.Disposable[] {
|
||||
export function registerTaskCommands(context: vscode.ExtensionContext, controller: Controller): vscode.Disposable[] {
|
||||
return [
|
||||
vscode.commands.registerCommand("cline.dev.createTestTasks", async () => {
|
||||
const count = await vscode.window.showInputBox({
|
||||
@@ -88,13 +88,13 @@ export function registerTaskCommands(context: vscode.ExtensionContext, provider:
|
||||
}
|
||||
|
||||
// Update task history in global state
|
||||
await provider.updateTaskHistory(historyItem)
|
||||
await controller.updateTaskHistory(historyItem)
|
||||
|
||||
progress.report({ increment: 100 / tasksCount })
|
||||
}
|
||||
|
||||
// Update the UI to show the new tasks
|
||||
await provider.postStateToWebview()
|
||||
await controller.postStateToWebview()
|
||||
|
||||
vscode.window.showInformationMessage(`Created ${tasksCount} test tasks`)
|
||||
},
|
||||
|
||||
+12
-11
@@ -1,27 +1,28 @@
|
||||
import * as vscode from "vscode"
|
||||
import { ClineProvider } from "../core/webview/ClineProvider"
|
||||
import { Controller } from "../core/controller"
|
||||
import { ClineAPI } from "./cline"
|
||||
import { getGlobalState } from "../core/storage/state"
|
||||
|
||||
export function createClineAPI(outputChannel: vscode.OutputChannel, sidebarProvider: ClineProvider): ClineAPI {
|
||||
export function createClineAPI(outputChannel: vscode.OutputChannel, sidebarController: Controller): ClineAPI {
|
||||
const api: ClineAPI = {
|
||||
setCustomInstructions: async (value: string) => {
|
||||
await sidebarProvider.updateCustomInstructions(value)
|
||||
await sidebarController.updateCustomInstructions(value)
|
||||
outputChannel.appendLine("Custom instructions set")
|
||||
},
|
||||
|
||||
getCustomInstructions: async () => {
|
||||
return (await sidebarProvider.getGlobalState("customInstructions")) as string | undefined
|
||||
return (await getGlobalState(sidebarController.context, "customInstructions")) as string | undefined
|
||||
},
|
||||
|
||||
startNewTask: async (task?: string, images?: string[]) => {
|
||||
outputChannel.appendLine("Starting new task")
|
||||
await sidebarProvider.clearTask()
|
||||
await sidebarProvider.postStateToWebview()
|
||||
await sidebarProvider.postMessageToWebview({
|
||||
await sidebarController.clearTask()
|
||||
await sidebarController.postStateToWebview()
|
||||
await sidebarController.postMessageToWebview({
|
||||
type: "action",
|
||||
action: "chatButtonClicked",
|
||||
})
|
||||
await sidebarProvider.postMessageToWebview({
|
||||
await sidebarController.postMessageToWebview({
|
||||
type: "invoke",
|
||||
invoke: "sendMessage",
|
||||
text: task,
|
||||
@@ -36,7 +37,7 @@ export function createClineAPI(outputChannel: vscode.OutputChannel, sidebarProvi
|
||||
outputChannel.appendLine(
|
||||
`Sending message: ${message ? `"${message}"` : "undefined"} with ${images?.length || 0} image(s)`,
|
||||
)
|
||||
await sidebarProvider.postMessageToWebview({
|
||||
await sidebarController.postMessageToWebview({
|
||||
type: "invoke",
|
||||
invoke: "sendMessage",
|
||||
text: message,
|
||||
@@ -46,7 +47,7 @@ export function createClineAPI(outputChannel: vscode.OutputChannel, sidebarProvi
|
||||
|
||||
pressPrimaryButton: async () => {
|
||||
outputChannel.appendLine("Pressing primary button")
|
||||
await sidebarProvider.postMessageToWebview({
|
||||
await sidebarController.postMessageToWebview({
|
||||
type: "invoke",
|
||||
invoke: "primaryButtonClick",
|
||||
})
|
||||
@@ -54,7 +55,7 @@ export function createClineAPI(outputChannel: vscode.OutputChannel, sidebarProvi
|
||||
|
||||
pressSecondaryButton: async () => {
|
||||
outputChannel.appendLine("Pressing secondary button")
|
||||
await sidebarProvider.postMessageToWebview({
|
||||
await sidebarController.postMessageToWebview({
|
||||
type: "invoke",
|
||||
invoke: "secondaryButtonClick",
|
||||
})
|
||||
|
||||
+36
-40
@@ -2,13 +2,13 @@
|
||||
// Import the module and reference it with the alias vscode in your code below
|
||||
import { setTimeout as setTimeoutPromise } from "node:timers/promises"
|
||||
import * as vscode from "vscode"
|
||||
import { ClineProvider } from "./core/webview/ClineProvider"
|
||||
import { Logger } from "./services/logging/Logger"
|
||||
import { createClineAPI } from "./exports"
|
||||
import "./utils/path" // necessary to have access to String.prototype.toPosix
|
||||
import { DIFF_VIEW_URI_SCHEME } from "./integrations/editor/DiffViewProvider"
|
||||
import assert from "node:assert"
|
||||
import { telemetryService } from "./services/telemetry/TelemetryService"
|
||||
import { WebviewProvider } from "./core/webview"
|
||||
|
||||
/*
|
||||
Built using https://github.com/microsoft/vscode-webview-ui-toolkit
|
||||
@@ -30,12 +30,12 @@ export function activate(context: vscode.ExtensionContext) {
|
||||
Logger.initialize(outputChannel)
|
||||
Logger.log("Cline extension activated")
|
||||
|
||||
const sidebarProvider = new ClineProvider(context, outputChannel)
|
||||
const sidebarWebview = new WebviewProvider(context, outputChannel)
|
||||
|
||||
vscode.commands.executeCommand("setContext", "cline.isDevMode", IS_DEV && IS_DEV === "true")
|
||||
|
||||
context.subscriptions.push(
|
||||
vscode.window.registerWebviewViewProvider(ClineProvider.sideBarId, sidebarProvider, {
|
||||
vscode.window.registerWebviewViewProvider(WebviewProvider.sideBarId, sidebarWebview, {
|
||||
webviewOptions: { retainContextWhenHidden: true },
|
||||
}),
|
||||
)
|
||||
@@ -43,15 +43,15 @@ export function activate(context: vscode.ExtensionContext) {
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand("cline.plusButtonClicked", async () => {
|
||||
Logger.log("Plus button Clicked")
|
||||
const visibleProvider = ClineProvider.getVisibleInstance()
|
||||
if (!visibleProvider) {
|
||||
const visibleWebview = WebviewProvider.getVisibleInstance()
|
||||
if (!visibleWebview) {
|
||||
Logger.log("Cannot find any visible Cline instances.")
|
||||
return
|
||||
}
|
||||
|
||||
await visibleProvider.clearTask()
|
||||
await visibleProvider.postStateToWebview()
|
||||
await visibleProvider.postMessageToWebview({
|
||||
await visibleWebview.controller.clearTask()
|
||||
await visibleWebview.controller.postStateToWebview()
|
||||
await visibleWebview.controller.postMessageToWebview({
|
||||
type: "action",
|
||||
action: "chatButtonClicked",
|
||||
})
|
||||
@@ -60,13 +60,13 @@ export function activate(context: vscode.ExtensionContext) {
|
||||
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand("cline.mcpButtonClicked", () => {
|
||||
const visibleProvider = ClineProvider.getVisibleInstance()
|
||||
if (!visibleProvider) {
|
||||
const visibleWebview = WebviewProvider.getVisibleInstance()
|
||||
if (!visibleWebview) {
|
||||
Logger.log("Cannot find any visible Cline instances.")
|
||||
return
|
||||
}
|
||||
|
||||
visibleProvider.postMessageToWebview({
|
||||
visibleWebview.controller.postMessageToWebview({
|
||||
type: "action",
|
||||
action: "mcpButtonClicked",
|
||||
})
|
||||
@@ -77,7 +77,7 @@ export function activate(context: vscode.ExtensionContext) {
|
||||
Logger.log("Opening Cline in new tab")
|
||||
// (this example uses webviewProvider activation event which is necessary to deserialize cached webview, but since we use retainContextWhenHidden, we don't need to use that event)
|
||||
// https://github.com/microsoft/vscode-extension-samples/blob/main/webview-sample/src/extension.ts
|
||||
const tabProvider = new ClineProvider(context, outputChannel)
|
||||
const tabWebview = new WebviewProvider(context, outputChannel)
|
||||
//const column = vscode.window.activeTextEditor ? vscode.window.activeTextEditor.viewColumn : undefined
|
||||
const lastCol = Math.max(...vscode.window.visibleTextEditors.map((editor) => editor.viewColumn || 0))
|
||||
|
||||
@@ -88,7 +88,7 @@ export function activate(context: vscode.ExtensionContext) {
|
||||
}
|
||||
const targetCol = hasVisibleEditors ? Math.max(lastCol + 1, 1) : vscode.ViewColumn.Two
|
||||
|
||||
const panel = vscode.window.createWebviewPanel(ClineProvider.tabPanelId, "Cline", targetCol, {
|
||||
const panel = vscode.window.createWebviewPanel(WebviewProvider.tabPanelId, "Cline", targetCol, {
|
||||
enableScripts: true,
|
||||
retainContextWhenHidden: true,
|
||||
localResourceRoots: [context.extensionUri],
|
||||
@@ -99,7 +99,7 @@ export function activate(context: vscode.ExtensionContext) {
|
||||
light: vscode.Uri.joinPath(context.extensionUri, "assets", "icons", "robot_panel_light.png"),
|
||||
dark: vscode.Uri.joinPath(context.extensionUri, "assets", "icons", "robot_panel_dark.png"),
|
||||
}
|
||||
tabProvider.resolveWebviewView(panel)
|
||||
tabWebview.resolveWebviewView(panel)
|
||||
|
||||
// Lock the editor group so clicking on files doesn't open them over the panel
|
||||
await setTimeoutPromise(100)
|
||||
@@ -112,13 +112,13 @@ export function activate(context: vscode.ExtensionContext) {
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand("cline.settingsButtonClicked", () => {
|
||||
//vscode.window.showInformationMessage(message)
|
||||
const visibleClineProvider = ClineProvider.getVisibleInstance()
|
||||
if (!visibleClineProvider) {
|
||||
const visibleWebview = WebviewProvider.getVisibleInstance()
|
||||
if (!visibleWebview) {
|
||||
Logger.log("Cannot find any visible Cline instances.")
|
||||
return
|
||||
}
|
||||
|
||||
visibleClineProvider.postMessageToWebview({
|
||||
visibleWebview.controller.postMessageToWebview({
|
||||
type: "action",
|
||||
action: "settingsButtonClicked",
|
||||
})
|
||||
@@ -127,13 +127,13 @@ export function activate(context: vscode.ExtensionContext) {
|
||||
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand("cline.historyButtonClicked", () => {
|
||||
const visibleProvider = ClineProvider.getVisibleInstance()
|
||||
if (!visibleProvider) {
|
||||
const visibleWebview = WebviewProvider.getVisibleInstance()
|
||||
if (!visibleWebview) {
|
||||
Logger.log("Cannot find any visible Cline instances.")
|
||||
return
|
||||
}
|
||||
|
||||
visibleProvider.postMessageToWebview({
|
||||
visibleWebview.controller.postMessageToWebview({
|
||||
type: "action",
|
||||
action: "historyButtonClicked",
|
||||
})
|
||||
@@ -142,25 +142,19 @@ export function activate(context: vscode.ExtensionContext) {
|
||||
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand("cline.accountButtonClicked", () => {
|
||||
const visibleProvider = ClineProvider.getVisibleInstance()
|
||||
if (!visibleProvider) {
|
||||
const visibleWebview = WebviewProvider.getVisibleInstance()
|
||||
if (!visibleWebview) {
|
||||
Logger.log("Cannot find any visible Cline instances.")
|
||||
return
|
||||
}
|
||||
|
||||
visibleProvider.postMessageToWebview({
|
||||
visibleWebview.controller.postMessageToWebview({
|
||||
type: "action",
|
||||
action: "accountButtonClicked",
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand("cline.openDocumentation", () => {
|
||||
vscode.env.openExternal(vscode.Uri.parse("https://docs.cline.bot/"))
|
||||
}),
|
||||
)
|
||||
|
||||
/*
|
||||
We use the text document content provider API to show the left side for diff view by creating a virtual document for the original content. This makes it readonly so users know to edit the right side if they want to keep their changes.
|
||||
|
||||
@@ -185,15 +179,15 @@ export function activate(context: vscode.ExtensionContext) {
|
||||
|
||||
const path = uri.path
|
||||
const query = new URLSearchParams(uri.query.replace(/\+/g, "%2B"))
|
||||
const visibleProvider = ClineProvider.getVisibleInstance()
|
||||
if (!visibleProvider) {
|
||||
const visibleWebview = WebviewProvider.getVisibleInstance()
|
||||
if (!visibleWebview) {
|
||||
return
|
||||
}
|
||||
switch (path) {
|
||||
case "/openrouter": {
|
||||
const code = query.get("code")
|
||||
if (code) {
|
||||
await visibleProvider.handleOpenRouterCallback(code)
|
||||
await visibleWebview?.controller.handleOpenRouterCallback(code)
|
||||
}
|
||||
break
|
||||
}
|
||||
@@ -209,13 +203,13 @@ export function activate(context: vscode.ExtensionContext) {
|
||||
})
|
||||
|
||||
// Validate state parameter
|
||||
if (!(await visibleProvider.validateAuthState(state))) {
|
||||
if (!(await visibleWebview?.controller.validateAuthState(state))) {
|
||||
vscode.window.showErrorMessage("Invalid auth state")
|
||||
return
|
||||
}
|
||||
|
||||
if (token && apiKey) {
|
||||
await visibleProvider.handleAuthCallback(token, apiKey)
|
||||
await visibleWebview?.controller.handleAuthCallback(token, apiKey)
|
||||
}
|
||||
break
|
||||
}
|
||||
@@ -230,7 +224,7 @@ export function activate(context: vscode.ExtensionContext) {
|
||||
// Use dynamic import to avoid loading the module in production
|
||||
import("./dev/commands/tasks")
|
||||
.then((module) => {
|
||||
const devTaskCommands = module.registerTaskCommands(context, sidebarProvider)
|
||||
const devTaskCommands = module.registerTaskCommands(context, sidebarWebview.controller)
|
||||
context.subscriptions.push(...devTaskCommands)
|
||||
Logger.log("Cline dev task commands registered")
|
||||
})
|
||||
@@ -259,8 +253,8 @@ export function activate(context: vscode.ExtensionContext) {
|
||||
const filePath = editor.document.uri.fsPath
|
||||
const languageId = editor.document.languageId
|
||||
|
||||
// Send to sidebar provider
|
||||
await sidebarProvider.addSelectedCodeToChat(
|
||||
const visibleWebview = WebviewProvider.getVisibleInstance()
|
||||
await visibleWebview?.controller.addSelectedCodeToChat(
|
||||
selectedText,
|
||||
filePath,
|
||||
languageId,
|
||||
@@ -309,7 +303,8 @@ export function activate(context: vscode.ExtensionContext) {
|
||||
*/
|
||||
|
||||
// Send to sidebar provider
|
||||
await sidebarProvider.addSelectedTerminalOutputToChat(terminalContents, terminal.name)
|
||||
const visibleWebview = WebviewProvider.getVisibleInstance()
|
||||
await visibleWebview?.controller.addSelectedTerminalOutputToChat(terminalContents, terminal.name)
|
||||
} catch (error) {
|
||||
// Ensure clipboard is restored even if an error occurs
|
||||
await vscode.env.clipboard.writeText(tempCopyBuffer)
|
||||
@@ -380,11 +375,12 @@ export function activate(context: vscode.ExtensionContext) {
|
||||
const languageId = editor.document.languageId
|
||||
|
||||
// Send to sidebar provider with diagnostics
|
||||
await sidebarProvider.fixWithCline(selectedText, filePath, languageId, diagnostics)
|
||||
const visibleWebview = WebviewProvider.getVisibleInstance()
|
||||
await visibleWebview?.controller.fixWithCline(selectedText, filePath, languageId, diagnostics)
|
||||
}),
|
||||
)
|
||||
|
||||
return createClineAPI(outputChannel, sidebarProvider)
|
||||
return createClineAPI(outputChannel, sidebarWebview.controller)
|
||||
}
|
||||
|
||||
// This method is called when your extension is deactivated
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
// NOTE: These are here temporarily until we find a better home for them
|
||||
export const GlobalFileNames = {
|
||||
apiConversationHistory: "api_conversation_history.json",
|
||||
uiMessages: "ui_messages.json",
|
||||
openRouterModels: "openrouter_models.json",
|
||||
mcpSettings: "cline_mcp_settings.json",
|
||||
clineRules: ".clinerules",
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import os from "os"
|
||||
import * as path from "path"
|
||||
import simpleGit, { SimpleGit } from "simple-git"
|
||||
import * as vscode from "vscode"
|
||||
import { ClineProvider } from "../../core/webview/ClineProvider"
|
||||
import { Controller as ClineProvider } from "../../core/controller"
|
||||
import { fileExistsAtPath } from "../../utils/fs"
|
||||
import { globby } from "globby"
|
||||
|
||||
|
||||
@@ -46,6 +46,14 @@ class CheckpointTracker {
|
||||
private lastRetrievedShadowGitConfigWorkTree?: string
|
||||
private gitOperations: GitOperations
|
||||
|
||||
/**
|
||||
* Helper method to clean commit hashes that might have a "HEAD " prefix.
|
||||
* Used for backward compatibility with old tasks that stored hashes with the prefix.
|
||||
*/
|
||||
private cleanCommitHash(hash: string): string {
|
||||
return hash.startsWith("HEAD ") ? hash.slice(5) : hash
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new CheckpointTracker instance to manage checkpoints for a specific task.
|
||||
* The constructor is private - use the static create() method to instantiate.
|
||||
@@ -166,8 +174,8 @@ class CheckpointTracker {
|
||||
"--allow-empty": null,
|
||||
"--no-verify": null,
|
||||
})
|
||||
const commitHash = result.commit || ""
|
||||
console.warn(`Checkpoint commit created.`)
|
||||
const commitHash = (result.commit || "").replace(/^HEAD\s+/, "")
|
||||
console.warn(`Checkpoint commit created: `, commitHash)
|
||||
|
||||
const durationMs = Math.round(performance.now() - startTime)
|
||||
telemetryService.captureCheckpointUsage(this.taskId, "commit_created", durationMs)
|
||||
@@ -242,7 +250,7 @@ class CheckpointTracker {
|
||||
const gitPath = await getShadowGitPath(this.globalStoragePath, this.taskId, this.cwdHash)
|
||||
const git = simpleGit(path.dirname(gitPath))
|
||||
console.debug(`Using shadow git at: ${gitPath}`)
|
||||
await git.reset(["--hard", commitHash]) // Hard reset to target commit
|
||||
await git.reset(["--hard", this.cleanCommitHash(commitHash)]) // Hard reset to target commit
|
||||
console.debug(`Successfully reset to checkpoint: ${commitHash}`)
|
||||
|
||||
const durationMs = Math.round(performance.now() - startTime)
|
||||
@@ -283,7 +291,8 @@ class CheckpointTracker {
|
||||
// Stage all changes so that untracked files appear in diff summary
|
||||
await this.gitOperations.addCheckpointFiles(git)
|
||||
|
||||
const diffRange = rhsHash ? `${lhsHash}..${rhsHash}` : lhsHash
|
||||
const cleanRhs = rhsHash ? this.cleanCommitHash(rhsHash) : undefined
|
||||
const diffRange = cleanRhs ? `${this.cleanCommitHash(lhsHash)}..${cleanRhs}` : this.cleanCommitHash(lhsHash)
|
||||
console.info(`Diff range: ${diffRange}`)
|
||||
const diffSummary = await git.diffSummary([diffRange])
|
||||
|
||||
@@ -294,7 +303,7 @@ class CheckpointTracker {
|
||||
|
||||
let beforeContent = ""
|
||||
try {
|
||||
beforeContent = await git.show([`${lhsHash}:${filePath}`])
|
||||
beforeContent = await git.show([`${this.cleanCommitHash(lhsHash)}:${filePath}`])
|
||||
} catch (_) {
|
||||
// file didn't exist in older commit => remains empty
|
||||
}
|
||||
@@ -302,7 +311,7 @@ class CheckpointTracker {
|
||||
let afterContent = ""
|
||||
if (rhsHash) {
|
||||
try {
|
||||
afterContent = await git.show([`${rhsHash}:${filePath}`])
|
||||
afterContent = await git.show([`${this.cleanCommitHash(rhsHash)}:${filePath}`])
|
||||
} catch (_) {
|
||||
// file didn't exist in newer commit => remains empty
|
||||
}
|
||||
@@ -347,7 +356,8 @@ class CheckpointTracker {
|
||||
// Stage all changes so that untracked files appear in diff summary
|
||||
await this.gitOperations.addCheckpointFiles(git)
|
||||
|
||||
const diffRange = rhsHash ? `${lhsHash}..${rhsHash}` : lhsHash
|
||||
const cleanRhs = rhsHash ? this.cleanCommitHash(rhsHash) : undefined
|
||||
const diffRange = cleanRhs ? `${this.cleanCommitHash(lhsHash)}..${cleanRhs}` : this.cleanCommitHash(lhsHash)
|
||||
const diffSummary = await git.diffSummary([diffRange])
|
||||
|
||||
const durationMs = Math.round(performance.now() - startTime)
|
||||
|
||||
@@ -71,7 +71,7 @@ export class TerminalProcess extends EventEmitter<TerminalProcessEvents> {
|
||||
// remove ansi
|
||||
data = stripAnsi(data)
|
||||
// Split data by newlines
|
||||
let lines = data ? data.split("\n") : []
|
||||
const lines = data ? data.split("\n") : []
|
||||
// Remove non-human readable characters from the first line
|
||||
if (lines.length > 0) {
|
||||
lines[0] = lines[0].replace(/[^\x20-\x7E]/g, "")
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
import * as vscode from "vscode"
|
||||
import * as path from "path"
|
||||
import { listFiles } from "../../services/glob/list-files"
|
||||
import { ClineProvider } from "../../core/webview/ClineProvider"
|
||||
import { Controller } from "../../core/controller"
|
||||
|
||||
const cwd = vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath).at(0)
|
||||
|
||||
// Note: this is not a drop-in replacement for listFiles at the start of tasks, since that will be done for Desktops when there is no workspace selected
|
||||
class WorkspaceTracker {
|
||||
private providerRef: WeakRef<ClineProvider>
|
||||
private controllerRef: WeakRef<Controller>
|
||||
private disposables: vscode.Disposable[] = []
|
||||
private filePaths: Set<string> = new Set()
|
||||
|
||||
constructor(provider: ClineProvider) {
|
||||
this.providerRef = new WeakRef(provider)
|
||||
constructor(controller: Controller) {
|
||||
this.controllerRef = new WeakRef(controller)
|
||||
this.registerListeners()
|
||||
}
|
||||
|
||||
@@ -85,7 +85,7 @@ class WorkspaceTracker {
|
||||
if (!cwd) {
|
||||
return
|
||||
}
|
||||
this.providerRef.deref()?.postMessageToWebview({
|
||||
this.controllerRef.deref()?.postMessageToWebview({
|
||||
type: "workspaceUpdated",
|
||||
filePaths: Array.from(this.filePaths).map((file) => {
|
||||
const relativePath = path.relative(cwd, file).toPosix()
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
import axios, { AxiosRequestConfig, AxiosResponse } from "axios"
|
||||
import { ClineProvider } from "../../core/webview/ClineProvider"
|
||||
import { Controller } from "../../core/controller"
|
||||
import type { BalanceResponse, PaymentTransaction, UsageTransaction } from "../../shared/ClineAccount"
|
||||
|
||||
export class ClineAccountService {
|
||||
private readonly baseUrl = "https://api.cline.bot/v1"
|
||||
private providerRef: WeakRef<ClineProvider>
|
||||
private controllerRef: WeakRef<Controller>
|
||||
|
||||
constructor(provider: ClineProvider) {
|
||||
this.providerRef = new WeakRef(provider)
|
||||
constructor(controller: Controller) {
|
||||
this.controllerRef = new WeakRef(controller)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the user's Cline Account key from the apiConfiguration
|
||||
*/
|
||||
private async getClineApiKey(): Promise<string | undefined> {
|
||||
const provider = this.providerRef.deref()
|
||||
const provider = this.controllerRef.deref()
|
||||
if (!provider) {
|
||||
return undefined
|
||||
}
|
||||
@@ -64,7 +64,7 @@ export class ClineAccountService {
|
||||
const data = await this.authenticatedRequest<BalanceResponse>("/user/credits/balance")
|
||||
|
||||
// Post to webview
|
||||
await this.providerRef.deref()?.postMessageToWebview({
|
||||
await this.controllerRef.deref()?.postMessageToWebview({
|
||||
type: "userCreditsBalance",
|
||||
userCreditsBalance: data,
|
||||
})
|
||||
@@ -84,7 +84,7 @@ export class ClineAccountService {
|
||||
const data = await this.authenticatedRequest<UsageTransaction[]>("/user/credits/usage")
|
||||
|
||||
// Post to webview
|
||||
await this.providerRef.deref()?.postMessageToWebview({
|
||||
await this.controllerRef.deref()?.postMessageToWebview({
|
||||
type: "userCreditsUsage",
|
||||
userCreditsUsage: data,
|
||||
})
|
||||
@@ -104,7 +104,7 @@ export class ClineAccountService {
|
||||
const data = await this.authenticatedRequest<PaymentTransaction[]>("/user/credits/payments")
|
||||
|
||||
// Post to webview
|
||||
await this.providerRef.deref()?.postMessageToWebview({
|
||||
await this.controllerRef.deref()?.postMessageToWebview({
|
||||
type: "userCreditsPayments",
|
||||
userCreditsPayments: data,
|
||||
})
|
||||
|
||||
@@ -212,7 +212,7 @@ export class BrowserSession {
|
||||
interval: 100,
|
||||
}).catch(() => {})
|
||||
|
||||
let options: ScreenshotOptions = {
|
||||
const options: ScreenshotOptions = {
|
||||
encoding: "base64",
|
||||
|
||||
// clip: {
|
||||
|
||||
@@ -68,8 +68,8 @@ Breadth-first traversal of directory structure level by level up to a limit:
|
||||
- Timeout mechanism prevents infinite loops
|
||||
*/
|
||||
async function globbyLevelByLevel(limit: number, options?: Options) {
|
||||
let results: Set<string> = new Set()
|
||||
let queue: string[] = ["*"]
|
||||
const results: Set<string> = new Set()
|
||||
const queue: string[] = ["*"]
|
||||
|
||||
const globbingProcess = async () => {
|
||||
while (queue.length > 0 && results.size < limit) {
|
||||
|
||||
+96
-75
@@ -14,7 +14,7 @@ import * as fs from "fs/promises"
|
||||
import * as path from "path"
|
||||
import * as vscode from "vscode"
|
||||
import { z } from "zod"
|
||||
import { ClineProvider } from "../../core/webview/ClineProvider"
|
||||
import { Controller } from "../../core/controller"
|
||||
import {
|
||||
DEFAULT_MCP_TIMEOUT_SECONDS,
|
||||
McpMode,
|
||||
@@ -29,9 +29,12 @@ import {
|
||||
import { fileExistsAtPath } from "../../utils/fs"
|
||||
import { arePathsEqual } from "../../utils/path"
|
||||
import { secondsToMs } from "../../utils/time"
|
||||
import { GlobalFileNames } from "../../global-constants"
|
||||
import { GlobalFileNames } from "../../core/storage/disk"
|
||||
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"
|
||||
|
||||
// Default timeout for internal MCP data requests in milliseconds; is not the same as the user facing timeout stored as DEFAULT_MCP_TIMEOUT_SECONDS
|
||||
const DEFAULT_REQUEST_TIMEOUT_MS = 5000
|
||||
|
||||
export type McpConnection = {
|
||||
server: McpServer
|
||||
client: Client
|
||||
@@ -73,15 +76,15 @@ const McpSettingsSchema = z.object({
|
||||
})
|
||||
|
||||
export class McpHub {
|
||||
private providerRef: WeakRef<ClineProvider>
|
||||
private controllerRef: WeakRef<Controller>
|
||||
private disposables: vscode.Disposable[] = []
|
||||
private settingsWatcher?: vscode.FileSystemWatcher
|
||||
private fileWatchers: Map<string, FSWatcher> = new Map()
|
||||
connections: McpConnection[] = []
|
||||
isConnecting: boolean = false
|
||||
|
||||
constructor(provider: ClineProvider) {
|
||||
this.providerRef = new WeakRef(provider)
|
||||
constructor(controller: Controller) {
|
||||
this.controllerRef = new WeakRef(controller)
|
||||
this.watchMcpSettingsFile()
|
||||
this.initializeMcpServers()
|
||||
}
|
||||
@@ -96,7 +99,7 @@ export class McpHub {
|
||||
}
|
||||
|
||||
async getMcpServersPath(): Promise<string> {
|
||||
const provider = this.providerRef.deref()
|
||||
const provider = this.controllerRef.deref()
|
||||
if (!provider) {
|
||||
throw new Error("Provider not available")
|
||||
}
|
||||
@@ -105,7 +108,7 @@ export class McpHub {
|
||||
}
|
||||
|
||||
async getMcpSettingsFilePath(): Promise<string> {
|
||||
const provider = this.providerRef.deref()
|
||||
const provider = this.controllerRef.deref()
|
||||
if (!provider) {
|
||||
throw new Error("Provider not available")
|
||||
}
|
||||
@@ -194,7 +197,7 @@ export class McpHub {
|
||||
const client = new Client(
|
||||
{
|
||||
name: "Cline",
|
||||
version: this.providerRef.deref()?.context.extension?.packageJSON?.version ?? "1.0.0",
|
||||
version: this.controllerRef.deref()?.context.extension?.packageJSON?.version ?? "1.0.0",
|
||||
},
|
||||
{
|
||||
capabilities: {},
|
||||
@@ -255,15 +258,23 @@ export class McpHub {
|
||||
const stderrStream = (transport as StdioClientTransport).stderr
|
||||
if (stderrStream) {
|
||||
stderrStream.on("data", async (data: Buffer) => {
|
||||
const errorOutput = data.toString()
|
||||
console.error(`Server "${name}" stderr:`, errorOutput)
|
||||
const connection = this.connections.find((conn) => conn.server.name === name)
|
||||
if (connection) {
|
||||
// NOTE: we do not set server status to "disconnected" because stderr logs do not necessarily mean the server crashed or disconnected, it could just be informational. In fact when the server first starts up, it immediately logs "<name> server running on stdio" to stderr.
|
||||
this.appendErrorMessage(connection, errorOutput)
|
||||
// Only need to update webview right away if it's already disconnected
|
||||
if (connection.server.status === "disconnected") {
|
||||
await this.notifyWebviewOfServerChanges()
|
||||
const output = data.toString()
|
||||
// Check if output contains INFO level log
|
||||
const isInfoLog = /^\s*INFO\b/.test(output)
|
||||
|
||||
if (isInfoLog) {
|
||||
// Log normal informational messages
|
||||
console.info(`Server "${name}" info:`, output)
|
||||
} else {
|
||||
// Treat as error log
|
||||
console.error(`Server "${name}" stderr:`, output)
|
||||
const connection = this.connections.find((conn) => conn.server.name === name)
|
||||
if (connection) {
|
||||
this.appendErrorMessage(connection, output)
|
||||
// Only notify webview if server is already disconnected
|
||||
if (connection.server.status === "disconnected") {
|
||||
await this.notifyWebviewOfServerChanges()
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -275,6 +286,7 @@ export class McpHub {
|
||||
|
||||
// Connect
|
||||
await client.connect(transport)
|
||||
|
||||
connection.server.status = "connected"
|
||||
connection.server.error = ""
|
||||
|
||||
@@ -300,9 +312,15 @@ export class McpHub {
|
||||
|
||||
private async fetchToolsList(serverName: string): Promise<McpTool[]> {
|
||||
try {
|
||||
const response = await this.connections
|
||||
.find((conn) => conn.server.name === serverName)
|
||||
?.client.request({ method: "tools/list" }, ListToolsResultSchema)
|
||||
const connection = this.connections.find((conn) => conn.server.name === serverName)
|
||||
|
||||
if (!connection) {
|
||||
throw new Error(`No connection found for server: ${serverName}`)
|
||||
}
|
||||
|
||||
const response = await connection.client.request({ method: "tools/list" }, ListToolsResultSchema, {
|
||||
timeout: DEFAULT_REQUEST_TIMEOUT_MS,
|
||||
})
|
||||
|
||||
// Get autoApprove settings
|
||||
const settingsPath = await this.getMcpSettingsFilePath()
|
||||
@@ -328,7 +346,7 @@ export class McpHub {
|
||||
try {
|
||||
const response = await this.connections
|
||||
.find((conn) => conn.server.name === serverName)
|
||||
?.client.request({ method: "resources/list" }, ListResourcesResultSchema)
|
||||
?.client.request({ method: "resources/list" }, ListResourcesResultSchema, { timeout: DEFAULT_REQUEST_TIMEOUT_MS })
|
||||
return response?.resources || []
|
||||
} catch (error) {
|
||||
// console.error(`Failed to fetch resources for ${serverName}:`, error)
|
||||
@@ -340,7 +358,10 @@ export class McpHub {
|
||||
try {
|
||||
const response = await this.connections
|
||||
.find((conn) => conn.server.name === serverName)
|
||||
?.client.request({ method: "resources/templates/list" }, ListResourceTemplatesResultSchema)
|
||||
?.client.request({ method: "resources/templates/list" }, ListResourceTemplatesResultSchema, {
|
||||
timeout: DEFAULT_REQUEST_TIMEOUT_MS,
|
||||
})
|
||||
|
||||
return response?.resourceTemplates || []
|
||||
} catch (error) {
|
||||
// console.error(`Failed to fetch resource templates for ${serverName}:`, error)
|
||||
@@ -434,7 +455,7 @@ export class McpHub {
|
||||
|
||||
async restartConnection(serverName: string): Promise<void> {
|
||||
this.isConnecting = true
|
||||
const provider = this.providerRef.deref()
|
||||
const provider = this.controllerRef.deref()
|
||||
if (!provider) {
|
||||
return
|
||||
}
|
||||
@@ -469,7 +490,7 @@ export class McpHub {
|
||||
const content = await fs.readFile(settingsPath, "utf-8")
|
||||
const config = JSON.parse(content)
|
||||
const serverOrder = Object.keys(config.mcpServers || {})
|
||||
await this.providerRef.deref()?.postMessageToWebview({
|
||||
await this.controllerRef.deref()?.postMessageToWebview({
|
||||
type: "mcpServers",
|
||||
mcpServers: [...this.connections]
|
||||
.sort((a, b) => {
|
||||
@@ -490,64 +511,21 @@ export class McpHub {
|
||||
// Public methods for server management
|
||||
|
||||
public async toggleServerDisabled(serverName: string, disabled: boolean): Promise<void> {
|
||||
let settingsPath: string
|
||||
try {
|
||||
settingsPath = await this.getMcpSettingsFilePath()
|
||||
|
||||
// Ensure the settings file exists and is accessible
|
||||
try {
|
||||
await fs.access(settingsPath)
|
||||
} catch (error) {
|
||||
console.error("Settings file not accessible:", error)
|
||||
throw new Error("Settings file not accessible")
|
||||
}
|
||||
const content = await fs.readFile(settingsPath, "utf-8")
|
||||
const config = JSON.parse(content)
|
||||
|
||||
// Validate the config structure
|
||||
if (!config || typeof config !== "object") {
|
||||
throw new Error("Invalid config structure")
|
||||
}
|
||||
|
||||
if (!config.mcpServers || typeof config.mcpServers !== "object") {
|
||||
config.mcpServers = {}
|
||||
const config = await this.readAndValidateMcpSettingsFile()
|
||||
if (!config) {
|
||||
throw new Error("Failed to read or validate MCP settings")
|
||||
}
|
||||
|
||||
if (config.mcpServers[serverName]) {
|
||||
// Create a new server config object to ensure clean structure
|
||||
const serverConfig = {
|
||||
...config.mcpServers[serverName],
|
||||
disabled,
|
||||
}
|
||||
config.mcpServers[serverName].disabled = disabled
|
||||
|
||||
// Ensure required fields exist
|
||||
if (!serverConfig.autoApprove) {
|
||||
serverConfig.autoApprove = []
|
||||
}
|
||||
|
||||
config.mcpServers[serverName] = serverConfig
|
||||
|
||||
// Write the entire config back
|
||||
const updatedConfig = {
|
||||
mcpServers: config.mcpServers,
|
||||
}
|
||||
|
||||
await fs.writeFile(settingsPath, JSON.stringify(updatedConfig, null, 2))
|
||||
const settingsPath = await this.getMcpSettingsFilePath()
|
||||
await fs.writeFile(settingsPath, JSON.stringify(config, null, 2))
|
||||
|
||||
const connection = this.connections.find((conn) => conn.server.name === serverName)
|
||||
if (connection) {
|
||||
try {
|
||||
connection.server.disabled = disabled
|
||||
|
||||
// Only refresh capabilities if connected
|
||||
if (connection.server.status === "connected") {
|
||||
connection.server.tools = await this.fetchToolsList(serverName)
|
||||
connection.server.resources = await this.fetchResourcesList(serverName)
|
||||
connection.server.resourceTemplates = await this.fetchResourceTemplatesList(serverName)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to refresh capabilities for ${serverName}:`, error)
|
||||
}
|
||||
connection.server.disabled = disabled
|
||||
}
|
||||
|
||||
await this.notifyWebviewOfServerChanges()
|
||||
@@ -650,7 +628,6 @@ export class McpHub {
|
||||
// Update the tools list to reflect the change
|
||||
const connection = this.connections.find((conn) => conn.server.name === serverName)
|
||||
if (connection) {
|
||||
connection.server.tools = await this.fetchToolsList(serverName)
|
||||
await this.notifyWebviewOfServerChanges()
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -660,6 +637,50 @@ export class McpHub {
|
||||
}
|
||||
}
|
||||
|
||||
public async addRemoteServer(serverName: string, serverUrl: string) {
|
||||
try {
|
||||
const settings = await this.readAndValidateMcpSettingsFile()
|
||||
if (!settings) {
|
||||
throw new Error("Failed to read MCP settings")
|
||||
}
|
||||
|
||||
if (settings.mcpServers[serverName]) {
|
||||
throw new Error(`An MCP server with the name "${serverName}" already exists`)
|
||||
}
|
||||
|
||||
const urlValidation = z.string().url().safeParse(serverUrl)
|
||||
if (!urlValidation.success) {
|
||||
throw new Error(`Invalid server URL: ${serverUrl}. Please provide a valid URL.`)
|
||||
}
|
||||
|
||||
const serverConfig = {
|
||||
url: serverUrl,
|
||||
disabled: false,
|
||||
autoApprove: [],
|
||||
}
|
||||
|
||||
// TS expects the server config to be a McpServerConfig, but we know it's valid
|
||||
// The issue is that the type is not having the transportType field added to it
|
||||
|
||||
// ToDo: Add input types reflecting the non-transformed version
|
||||
settings.mcpServers[serverName] = serverConfig as unknown as McpServerConfig
|
||||
const settingsPath = await this.getMcpSettingsFilePath()
|
||||
await fs.writeFile(settingsPath, JSON.stringify(settings, null, 2))
|
||||
|
||||
await this.updateServerConnections(settings.mcpServers)
|
||||
|
||||
vscode.window.showInformationMessage(`Added and connected to ${serverName} MCP server`)
|
||||
} catch (error) {
|
||||
console.error("Failed to add remote MCP server:", error)
|
||||
|
||||
vscode.window.showErrorMessage(
|
||||
`Failed to add remote MCP server: ${error instanceof Error ? error.message : String(error)}`,
|
||||
)
|
||||
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
public async deleteServer(serverName: string) {
|
||||
try {
|
||||
const settingsPath = await this.getMcpSettingsFilePath()
|
||||
|
||||
@@ -2,6 +2,8 @@ import { PostHog } from "posthog-node"
|
||||
import * as vscode from "vscode"
|
||||
import { version as extensionVersion } from "../../../package.json"
|
||||
|
||||
import type { TaskFeedbackType } from "../../shared/WebviewMessage"
|
||||
|
||||
/**
|
||||
* PostHogClient handles telemetry event tracking for the Cline extension
|
||||
* Uses PostHog analytics to track user interactions and system events
|
||||
@@ -18,6 +20,8 @@ class PostHogClient {
|
||||
RESTARTED: "task.restarted",
|
||||
// Tracks when a task is finished, with acceptance or rejection status
|
||||
COMPLETED: "task.completed",
|
||||
// Tracks user feedback on completed tasks
|
||||
FEEDBACK: "task.feedback",
|
||||
// Tracks when a message is sent in a conversation
|
||||
CONVERSATION_TURN: "task.conversation_turn",
|
||||
// Tracks token consumption for cost and usage analysis
|
||||
@@ -32,6 +36,8 @@ class PostHogClient {
|
||||
HISTORICAL_LOADED: "task.historical_loaded",
|
||||
// Tracks when the retry button is clicked for failed operations
|
||||
RETRY_CLICKED: "task.retry_clicked",
|
||||
// Tracks when a diff edit (replace_in_file) operation fails
|
||||
DIFF_EDIT_FAILED: "task.diff_edit_failed",
|
||||
},
|
||||
// UI interaction events for tracking user engagement
|
||||
UI: {
|
||||
@@ -234,6 +240,22 @@ class PostHogClient {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Records user feedback on completed tasks
|
||||
* @param taskId Unique identifier for the task
|
||||
* @param feedbackType The type of feedback ("thumbs_up" or "thumbs_down")
|
||||
*/
|
||||
public captureTaskFeedback(taskId: string, feedbackType: TaskFeedbackType) {
|
||||
console.info("TelemetryService: Capturing task feedback", { taskId, feedbackType })
|
||||
this.capture({
|
||||
event: PostHogClient.EVENTS.TASK.FEEDBACK,
|
||||
properties: {
|
||||
taskId,
|
||||
feedbackType,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Tool events
|
||||
/**
|
||||
* Records when a tool is used during task execution
|
||||
@@ -377,6 +399,21 @@ class PostHogClient {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Records when a diff edit (replace_in_file) operation fails
|
||||
* @param taskId Unique identifier for the task
|
||||
* @param errorType Type of error that occurred (e.g., "search_not_found", "invalid_format")
|
||||
*/
|
||||
public captureDiffEditFailure(taskId: string, errorType?: string) {
|
||||
this.capture({
|
||||
event: PostHogClient.EVENTS.TASK.DIFF_EDIT_FAILED,
|
||||
properties: {
|
||||
taskId,
|
||||
errorType,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Records when a different model is selected for use
|
||||
* @param model Name of the selected model
|
||||
|
||||
@@ -142,6 +142,7 @@ export type ClineAsk =
|
||||
| "auto_approval_max_req_reached"
|
||||
| "browser_action_launch"
|
||||
| "use_mcp_server"
|
||||
| "new_task"
|
||||
|
||||
export type ClineSay =
|
||||
| "task"
|
||||
@@ -222,6 +223,10 @@ export interface ClineAskQuestion {
|
||||
selected?: string
|
||||
}
|
||||
|
||||
export interface ClineAskNewTask {
|
||||
context: string
|
||||
}
|
||||
|
||||
export interface ClineApiReqInfo {
|
||||
request?: string
|
||||
tokensIn?: number
|
||||
|
||||
@@ -8,6 +8,7 @@ import { TelemetrySetting } from "./TelemetrySetting"
|
||||
|
||||
export interface WebviewMessage {
|
||||
type:
|
||||
| "addRemoteServer"
|
||||
| "apiConfiguration"
|
||||
| "webviewDidLaunch"
|
||||
| "newTask"
|
||||
@@ -65,6 +66,7 @@ export interface WebviewMessage {
|
||||
| "fetchUserCreditsData"
|
||||
| "optionsResponse"
|
||||
| "requestTotalTasksSize"
|
||||
| "taskFeedback"
|
||||
// | "relaunchChromeDebugMode"
|
||||
text?: string
|
||||
disabled?: boolean
|
||||
@@ -81,6 +83,7 @@ export interface WebviewMessage {
|
||||
timeout?: number
|
||||
// For toggleToolAutoApprove
|
||||
serverName?: string
|
||||
serverUrl?: string
|
||||
toolNames?: string[]
|
||||
autoApprove?: boolean
|
||||
|
||||
@@ -92,8 +95,12 @@ export interface WebviewMessage {
|
||||
planActSeparateModelsSetting?: boolean
|
||||
telemetrySetting?: TelemetrySetting
|
||||
customInstructionsSetting?: string
|
||||
// For task feedback
|
||||
feedbackType?: TaskFeedbackType
|
||||
}
|
||||
|
||||
export type ClineAskResponse = "yesButtonClicked" | "noButtonClicked" | "messageResponse"
|
||||
|
||||
export type ClineCheckpointRestore = "task" | "workspace" | "taskAndWorkspace"
|
||||
|
||||
export type TaskFeedbackType = "thumbs_up" | "thumbs_down"
|
||||
|
||||
+4
-3
@@ -24,6 +24,7 @@ export interface ApiHandlerOptions {
|
||||
apiModelId?: string
|
||||
apiKey?: string // anthropic
|
||||
clineApiKey?: string
|
||||
taskId?: string // Used to identify the task in API requests
|
||||
liteLlmBaseUrl?: string
|
||||
liteLlmModelId?: string
|
||||
liteLlmApiKey?: string
|
||||
@@ -367,9 +368,9 @@ export const vertexModels = {
|
||||
inputPrice: 0,
|
||||
outputPrice: 0,
|
||||
},
|
||||
"gemini-2.0-pro-exp-02-05": {
|
||||
maxTokens: 8192,
|
||||
contextWindow: 2_097_152,
|
||||
"gemini-2.5-pro-exp-03-25": {
|
||||
maxTokens: 65536,
|
||||
contextWindow: 1_048_576,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 0,
|
||||
|
||||
@@ -23,13 +23,13 @@ export function combineApiRequests(messages: ClineMessage[]): ClineMessage[] {
|
||||
|
||||
for (let i = 0; i < messages.length; i++) {
|
||||
if (messages[i].type === "say" && messages[i].say === "api_req_started") {
|
||||
let startedRequest = JSON.parse(messages[i].text || "{}")
|
||||
const startedRequest = JSON.parse(messages[i].text || "{}")
|
||||
let j = i + 1
|
||||
|
||||
while (j < messages.length) {
|
||||
if (messages[j].type === "say" && messages[j].say === "api_req_finished") {
|
||||
let finishedRequest = JSON.parse(messages[j].text || "{}")
|
||||
let combinedRequest = {
|
||||
const finishedRequest = JSON.parse(messages[j].text || "{}")
|
||||
const combinedRequest = {
|
||||
...startedRequest,
|
||||
...finishedRequest,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import * as assert from "assert"
|
||||
import * as vscode from "vscode"
|
||||
import { ClineMessage } from "../shared/ExtensionMessage"
|
||||
import { formatResponse } from "../core/prompts/responses"
|
||||
|
||||
suite("New Task Tool Tests", () => {
|
||||
test("formatResponse.newTaskContext formats context correctly", () => {
|
||||
const context = "This is a test context for a new task"
|
||||
const result = formatResponse.newTaskContext(context)
|
||||
|
||||
assert.strictEqual(
|
||||
result,
|
||||
`Cline wants to create a new task with the following context:\n\nThis is a test context for a new task\n\nClick "Create New Task" to start a new task with this context preloaded in Plan Mode.`,
|
||||
)
|
||||
})
|
||||
|
||||
test("ClineAskNewTask interface exists", () => {
|
||||
// This is a type check test, it will fail at compile time if the interface doesn't exist
|
||||
const message: ClineMessage = {
|
||||
ts: Date.now(),
|
||||
type: "ask",
|
||||
ask: "new_task",
|
||||
text: JSON.stringify({ context: "Test context" }),
|
||||
}
|
||||
|
||||
assert.ok(message)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,57 @@
|
||||
import { describe, it } from "mocha"
|
||||
import "should"
|
||||
import { fixModelHtmlEscaping, removeInvalidChars } from "./string"
|
||||
|
||||
describe("fixModelHtmlEscaping", () => {
|
||||
it("should convert > to >", () => {
|
||||
fixModelHtmlEscaping("foo > bar").should.equal("foo > bar")
|
||||
})
|
||||
|
||||
it("should convert < to <", () => {
|
||||
fixModelHtmlEscaping("foo < bar").should.equal("foo < bar")
|
||||
})
|
||||
|
||||
it('should convert " to "', () => {
|
||||
fixModelHtmlEscaping("foo "bar"").should.equal('foo "bar"')
|
||||
})
|
||||
|
||||
it("should convert & to &", () => {
|
||||
fixModelHtmlEscaping("foo & bar").should.equal("foo & bar")
|
||||
})
|
||||
|
||||
it("should convert ' to '", () => {
|
||||
fixModelHtmlEscaping("foo 'bar'").should.equal("foo 'bar'")
|
||||
})
|
||||
|
||||
it("should handle multiple entities in the same string", () => {
|
||||
fixModelHtmlEscaping("<div>Hello "World" & 'Universe'</div>").should.equal(
|
||||
"<div>Hello \"World\" & 'Universe'</div>",
|
||||
)
|
||||
})
|
||||
|
||||
it("should return unchanged string when no HTML entities are present", () => {
|
||||
fixModelHtmlEscaping("normal string").should.equal("normal string")
|
||||
})
|
||||
})
|
||||
|
||||
describe("removeInvalidChars", () => {
|
||||
it("should remove replacement characters", () => {
|
||||
removeInvalidChars("hello\uFFFDworld").should.equal("helloworld")
|
||||
})
|
||||
|
||||
it("should remove � characters", () => {
|
||||
removeInvalidChars("hello�world").should.equal("helloworld")
|
||||
})
|
||||
|
||||
it("should remove multiple replacement characters", () => {
|
||||
removeInvalidChars("h\uFFFDe\uFFFDl\uFFFDl\uFFFDo").should.equal("hello")
|
||||
})
|
||||
|
||||
it("should remove multiple � characters", () => {
|
||||
removeInvalidChars("h�e�l�lo").should.equal("hello")
|
||||
})
|
||||
|
||||
it("should return unchanged string when no replacement characters are present", () => {
|
||||
removeInvalidChars("normal string").should.equal("normal string")
|
||||
})
|
||||
})
|
||||
Generated
+4
-4
@@ -51,7 +51,7 @@
|
||||
"tailwindcss": "^4.0.12",
|
||||
"typescript": "^5.7.3",
|
||||
"typescript-eslint": "^8.18.2",
|
||||
"vite": "^6.2.3",
|
||||
"vite": "^6.2.4",
|
||||
"vitest": "^3.0.5"
|
||||
}
|
||||
},
|
||||
@@ -8876,9 +8876,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "6.2.3",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.2.3.tgz",
|
||||
"integrity": "sha512-IzwM54g4y9JA/xAeBPNaDXiBF8Jsgl3VBQ2YQ/wOY6fyW3xMdSoltIV3Bo59DErdqdE6RxUfv8W69DvUorE4Eg==",
|
||||
"version": "6.2.4",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.2.4.tgz",
|
||||
"integrity": "sha512-veHMSew8CcRzhL5o8ONjy8gkfmFJAd5Ac16oxBUjlwgX3Gq2Wqr+qNC3TjPIpy7TPV/KporLga5GT9HqdrCizw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
||||
@@ -56,7 +56,7 @@
|
||||
"tailwindcss": "^4.0.12",
|
||||
"typescript": "^5.7.3",
|
||||
"typescript-eslint": "^8.18.2",
|
||||
"vite": "^6.2.3",
|
||||
"vite": "^6.2.4",
|
||||
"vitest": "^3.0.5"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -147,7 +147,7 @@ export const ClineAccountView = () => {
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center pr-3 max-w-[400px]">
|
||||
<div className="flex flex-col items-center pr-3">
|
||||
<ClineLogoWhite className="size-16 mb-4" />
|
||||
|
||||
<p style={{}}>
|
||||
|
||||
@@ -30,6 +30,8 @@ import CreditLimitError from "./CreditLimitError"
|
||||
import { OptionsButtons } from "./OptionsButtons"
|
||||
import { highlightMentions } from "./TaskHeader"
|
||||
import SuccessButton from "../common/SuccessButton"
|
||||
import TaskFeedbackButtons from "./TaskFeedbackButtons"
|
||||
import NewTaskPreview from "./NewTaskPreview"
|
||||
|
||||
const ChatRowContainer = styled.div`
|
||||
padding: 10px 6px 10px 15px;
|
||||
@@ -1000,6 +1002,17 @@ export const ChatRowContent = ({ message, isExpanded, onToggleExpand, lastModifi
|
||||
}}>
|
||||
{icon}
|
||||
{title}
|
||||
<TaskFeedbackButtons
|
||||
messageTs={message.ts}
|
||||
isFromHistory={
|
||||
!isLast ||
|
||||
lastModifiedMessage?.ask === "resume_completed_task" ||
|
||||
lastModifiedMessage?.ask === "resume_task"
|
||||
}
|
||||
style={{
|
||||
marginLeft: "auto",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
@@ -1142,6 +1155,17 @@ export const ChatRowContent = ({ message, isExpanded, onToggleExpand, lastModifi
|
||||
}}>
|
||||
{icon}
|
||||
{title}
|
||||
<TaskFeedbackButtons
|
||||
messageTs={message.ts}
|
||||
isFromHistory={
|
||||
!isLast ||
|
||||
lastModifiedMessage?.ask === "resume_completed_task" ||
|
||||
lastModifiedMessage?.ask === "resume_task"
|
||||
}
|
||||
style={{
|
||||
marginLeft: "auto",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
@@ -1210,6 +1234,21 @@ export const ChatRowContent = ({ message, isExpanded, onToggleExpand, lastModifi
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
case "new_task":
|
||||
return (
|
||||
<>
|
||||
<div style={headerStyle}>
|
||||
<span
|
||||
className="codicon codicon-new-file"
|
||||
style={{
|
||||
color: normalColor,
|
||||
marginBottom: "-1.5px",
|
||||
}}></span>
|
||||
<span style={{ color: normalColor, fontWeight: "bold" }}>Cline wants to start a new task:</span>
|
||||
</div>
|
||||
<NewTaskPreview context={message.text || ""} />
|
||||
</>
|
||||
)
|
||||
case "plan_mode_respond": {
|
||||
let response: string | undefined
|
||||
let options: string[] | undefined
|
||||
|
||||
@@ -195,6 +195,13 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
|
||||
setSecondaryButtonText(undefined)
|
||||
setDidClickCancel(false)
|
||||
break
|
||||
case "new_task":
|
||||
setTextAreaDisabled(isPartial)
|
||||
setClineAsk("new_task")
|
||||
setEnableButtons(!isPartial)
|
||||
setPrimaryButtonText("Start New Task with Context")
|
||||
setSecondaryButtonText(undefined)
|
||||
break
|
||||
}
|
||||
break
|
||||
case "say":
|
||||
@@ -295,6 +302,7 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
|
||||
case "resume_task":
|
||||
case "resume_completed_task":
|
||||
case "mistake_limit_reached":
|
||||
case "new_task": // user can provide feedback or reject the new task suggestion
|
||||
vscode.postMessage({
|
||||
type: "askResponse",
|
||||
askResponse: "messageResponse",
|
||||
@@ -360,6 +368,13 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
|
||||
// extension waiting for feedback. but we can just present a new task button
|
||||
startNewTask()
|
||||
break
|
||||
case "new_task":
|
||||
console.log("new task button clicked!", { lastMessage, messages, clineAsk, text })
|
||||
vscode.postMessage({
|
||||
type: "newTask",
|
||||
text: lastMessage?.text,
|
||||
})
|
||||
break
|
||||
}
|
||||
setTextAreaDisabled(true)
|
||||
setClineAsk(undefined)
|
||||
@@ -368,7 +383,7 @@ const ChatView = ({ isHidden, showAnnouncement, hideAnnouncement, showHistoryVie
|
||||
// setSecondaryButtonText(undefined)
|
||||
disableAutoScrollRef.current = false
|
||||
},
|
||||
[clineAsk, startNewTask],
|
||||
[clineAsk, startNewTask, lastMessage],
|
||||
)
|
||||
|
||||
const handleSecondaryButtonClick = useCallback(
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import React from "react"
|
||||
import MarkdownBlock from "../common/MarkdownBlock"
|
||||
|
||||
interface NewTaskPreviewProps {
|
||||
context: string
|
||||
}
|
||||
|
||||
const NewTaskPreview: React.FC<NewTaskPreviewProps> = ({ context }) => {
|
||||
return (
|
||||
<div className="bg-[var(--vscode-badge-background)] text-[var(--vscode-badge-foreground)] rounded-[3px] p-[9px] pt-0 border-t-8 border-[var(--vscode-charts-green)]">
|
||||
<MarkdownBlock markdown={context} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default NewTaskPreview
|
||||
@@ -0,0 +1,126 @@
|
||||
import React, { useState, useEffect } from "react"
|
||||
import styled from "styled-components"
|
||||
import { vscode } from "../../utils/vscode"
|
||||
import { TaskFeedbackType } from "../../../../src/shared/WebviewMessage"
|
||||
import { VSCodeButton } from "@vscode/webview-ui-toolkit/react"
|
||||
import VSCodeButtonLink from "../common/VSCodeButtonLink"
|
||||
|
||||
interface TaskFeedbackButtonsProps {
|
||||
messageTs: number
|
||||
isFromHistory?: boolean
|
||||
style?: React.CSSProperties
|
||||
}
|
||||
|
||||
const IconWrapper = styled.span`
|
||||
color: var(--vscode-descriptionForeground);
|
||||
`
|
||||
|
||||
const ButtonWrapper = styled.div`
|
||||
transform: scale(0.85);
|
||||
`
|
||||
|
||||
const TaskFeedbackButtons: React.FC<TaskFeedbackButtonsProps> = ({ messageTs, isFromHistory = false, style }) => {
|
||||
const [feedback, setFeedback] = useState<TaskFeedbackType | null>(null)
|
||||
const [shouldShow, setShouldShow] = useState<boolean>(true)
|
||||
|
||||
// Check localStorage on mount to see if feedback was already given for this message
|
||||
useEffect(() => {
|
||||
try {
|
||||
const feedbackHistory = localStorage.getItem("taskFeedbackHistory") || "{}"
|
||||
const history = JSON.parse(feedbackHistory)
|
||||
// Check if this specific message timestamp has received feedback
|
||||
if (history[messageTs]) {
|
||||
setShouldShow(false)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Error checking feedback history:", e)
|
||||
}
|
||||
}, [messageTs])
|
||||
|
||||
// Don't show buttons if this is from history or feedback was already given
|
||||
if (isFromHistory || !shouldShow) {
|
||||
return null
|
||||
}
|
||||
|
||||
const handleFeedback = (type: TaskFeedbackType) => {
|
||||
if (feedback !== null) return // Already provided feedback
|
||||
|
||||
setFeedback(type)
|
||||
|
||||
// Send feedback to extension
|
||||
vscode.postMessage({
|
||||
type: "taskFeedback",
|
||||
feedbackType: type,
|
||||
})
|
||||
|
||||
// Store in localStorage that feedback was provided for this message
|
||||
try {
|
||||
const feedbackHistory = localStorage.getItem("taskFeedbackHistory") || "{}"
|
||||
const history = JSON.parse(feedbackHistory)
|
||||
history[messageTs] = true
|
||||
localStorage.setItem("taskFeedbackHistory", JSON.stringify(history))
|
||||
} catch (e) {
|
||||
console.error("Error updating feedback history:", e)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Container style={style}>
|
||||
<ButtonsContainer>
|
||||
<ButtonWrapper>
|
||||
<VSCodeButton
|
||||
appearance="icon"
|
||||
onClick={() => handleFeedback("thumbs_up")}
|
||||
disabled={feedback !== null}
|
||||
title="This was helpful"
|
||||
aria-label="This was helpful">
|
||||
<IconWrapper>
|
||||
<span
|
||||
className={`codicon ${feedback === "thumbs_up" ? "codicon-thumbsup-filled" : "codicon-thumbsup"}`}
|
||||
/>
|
||||
</IconWrapper>
|
||||
</VSCodeButton>
|
||||
</ButtonWrapper>
|
||||
<ButtonWrapper>
|
||||
<VSCodeButton
|
||||
appearance="icon"
|
||||
onClick={() => handleFeedback("thumbs_down")}
|
||||
disabled={feedback !== null && feedback !== "thumbs_down"}
|
||||
title="This wasn't helpful"
|
||||
aria-label="This wasn't helpful">
|
||||
<IconWrapper>
|
||||
<span
|
||||
className={`codicon ${feedback === "thumbs_down" ? "codicon-thumbsdown-filled" : "codicon-thumbsdown"}`}
|
||||
/>
|
||||
</IconWrapper>
|
||||
</VSCodeButton>
|
||||
</ButtonWrapper>
|
||||
{/* <VSCodeButtonLink
|
||||
href="https://github.com/cline/cline/issues/new?template=bug_report.yml"
|
||||
appearance="icon"
|
||||
title="Report a bug"
|
||||
aria-label="Report a bug">
|
||||
<span className="codicon codicon-bug" />
|
||||
</VSCodeButtonLink> */}
|
||||
</ButtonsContainer>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
const Container = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
`
|
||||
|
||||
const ButtonsContainer = styled.div`
|
||||
display: flex;
|
||||
gap: 0px;
|
||||
opacity: 0.5;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
`
|
||||
|
||||
export default TaskFeedbackButtons
|
||||
@@ -85,7 +85,7 @@ const HistoryView = ({ onDone }: HistoryViewProps) => {
|
||||
}, [presentableTasks])
|
||||
|
||||
const taskHistorySearchResults = useMemo(() => {
|
||||
let results = searchQuery ? highlight(fuse.search(searchQuery)) : presentableTasks
|
||||
const results = searchQuery ? highlight(fuse.search(searchQuery)) : presentableTasks
|
||||
|
||||
results.sort((a, b) => {
|
||||
switch (sortOption) {
|
||||
|
||||
@@ -733,7 +733,7 @@ const ApiOptions = ({ showModelOptions, apiErrorMessage, modelIdErrorMessage, is
|
||||
This key is stored locally and only used to make API requests from this extension.
|
||||
{!apiConfiguration?.geminiApiKey && (
|
||||
<VSCodeLink
|
||||
href="https://ai.google.dev/"
|
||||
href="https://aistudio.google.com/apikey"
|
||||
style={{
|
||||
display: "inline",
|
||||
fontSize: "inherit",
|
||||
@@ -820,7 +820,7 @@ const ApiOptions = ({ showModelOptions, apiErrorMessage, modelIdErrorMessage, is
|
||||
checked={!!apiConfiguration?.openAiModelInfo?.supportsImages}
|
||||
onChange={(e: any) => {
|
||||
const isChecked = e.target.checked === true
|
||||
let modelInfo = apiConfiguration?.openAiModelInfo
|
||||
const modelInfo = apiConfiguration?.openAiModelInfo
|
||||
? apiConfiguration.openAiModelInfo
|
||||
: { ...openAiModelInfoSaneDefaults }
|
||||
modelInfo.supportsImages = isChecked
|
||||
@@ -871,7 +871,7 @@ const ApiOptions = ({ showModelOptions, apiErrorMessage, modelIdErrorMessage, is
|
||||
}
|
||||
style={{ flex: 1 }}
|
||||
onInput={(input: any) => {
|
||||
let modelInfo = apiConfiguration?.openAiModelInfo
|
||||
const modelInfo = apiConfiguration?.openAiModelInfo
|
||||
? apiConfiguration.openAiModelInfo
|
||||
: { ...openAiModelInfoSaneDefaults }
|
||||
modelInfo.contextWindow = Number(input.target.value)
|
||||
@@ -890,7 +890,7 @@ const ApiOptions = ({ showModelOptions, apiErrorMessage, modelIdErrorMessage, is
|
||||
}
|
||||
style={{ flex: 1 }}
|
||||
onInput={(input: any) => {
|
||||
let modelInfo = apiConfiguration?.openAiModelInfo
|
||||
const modelInfo = apiConfiguration?.openAiModelInfo
|
||||
? apiConfiguration.openAiModelInfo
|
||||
: { ...openAiModelInfoSaneDefaults }
|
||||
modelInfo.maxTokens = input.target.value
|
||||
@@ -911,7 +911,7 @@ const ApiOptions = ({ showModelOptions, apiErrorMessage, modelIdErrorMessage, is
|
||||
}
|
||||
style={{ flex: 1 }}
|
||||
onInput={(input: any) => {
|
||||
let modelInfo = apiConfiguration?.openAiModelInfo
|
||||
const modelInfo = apiConfiguration?.openAiModelInfo
|
||||
? apiConfiguration.openAiModelInfo
|
||||
: { ...openAiModelInfoSaneDefaults }
|
||||
modelInfo.inputPrice = input.target.value
|
||||
@@ -930,7 +930,7 @@ const ApiOptions = ({ showModelOptions, apiErrorMessage, modelIdErrorMessage, is
|
||||
}
|
||||
style={{ flex: 1 }}
|
||||
onInput={(input: any) => {
|
||||
let modelInfo = apiConfiguration?.openAiModelInfo
|
||||
const modelInfo = apiConfiguration?.openAiModelInfo
|
||||
? apiConfiguration.openAiModelInfo
|
||||
: { ...openAiModelInfoSaneDefaults }
|
||||
modelInfo.outputPrice = input.target.value
|
||||
@@ -950,7 +950,7 @@ const ApiOptions = ({ showModelOptions, apiErrorMessage, modelIdErrorMessage, is
|
||||
: openAiModelInfoSaneDefaults.temperature?.toString()
|
||||
}
|
||||
onInput={(input: any) => {
|
||||
let modelInfo = apiConfiguration?.openAiModelInfo
|
||||
const modelInfo = apiConfiguration?.openAiModelInfo
|
||||
? apiConfiguration.openAiModelInfo
|
||||
: { ...openAiModelInfoSaneDefaults }
|
||||
|
||||
@@ -1702,6 +1702,12 @@ export function normalizeApiConfiguration(apiConfiguration?: ApiConfiguration):
|
||||
selectedModelId: apiConfiguration?.lmStudioModelId || "",
|
||||
selectedModelInfo: openAiModelInfoSaneDefaults,
|
||||
}
|
||||
case "requesty":
|
||||
return {
|
||||
selectedProvider: provider,
|
||||
selectedModelId: apiConfiguration?.requestyModelId || "",
|
||||
selectedModelInfo: openAiModelInfoSaneDefaults,
|
||||
}
|
||||
case "vscode-lm":
|
||||
return {
|
||||
selectedProvider: provider,
|
||||
|
||||
@@ -66,7 +66,7 @@ const OpenAiModelPicker: React.FC = () => {
|
||||
}, [searchableItems])
|
||||
|
||||
const modelSearchResults = useMemo(() => {
|
||||
let results: { id: string; html: string }[] = searchTerm
|
||||
const results: { id: string; html: string }[] = searchTerm
|
||||
? highlight(fuse.search(searchTerm), "model-item-highlight")
|
||||
: searchableItems
|
||||
// results.sort((a, b) => a.id.localeCompare(b.id)) NOTE: sorting like this causes ids in objects to be reordered and mismatched
|
||||
|
||||
@@ -87,7 +87,7 @@ const OpenRouterModelPicker: React.FC<OpenRouterModelPickerProps> = ({ isPopup }
|
||||
}, [searchableItems])
|
||||
|
||||
const modelSearchResults = useMemo(() => {
|
||||
let results: { id: string; html: string }[] = searchTerm
|
||||
const results: { id: string; html: string }[] = searchTerm
|
||||
? highlight(fuse.search(searchTerm), "model-item-highlight")
|
||||
: searchableItems
|
||||
// results.sort((a, b) => a.id.localeCompare(b.id)) NOTE: sorting like this causes ids in objects to be reordered and mismatched
|
||||
|
||||
Reference in New Issue
Block a user