diff --git a/.changeset/changelog-config.js b/.changeset/changelog-config.js index 0ab9a9e48e..00f93f281e 100644 --- a/.changeset/changelog-config.js +++ b/.changeset/changelog-config.js @@ -1,9 +1,9 @@ const getReleaseLine = async (changeset) => { - const [firstLine] = changeset.summary + const lines = changeset.summary .split("\n") .map((l) => l.trim()) .filter(Boolean) - return `- ${firstLine}` + return lines.map((line) => (line.startsWith("- ") ? line : `- ${line}`)).join("\n") } const getDependencyReleaseLine = async () => { diff --git a/.changeset/sly-candles-hide.md b/.changeset/sly-candles-hide.md new file mode 100644 index 0000000000..be720c0250 --- /dev/null +++ b/.changeset/sly-candles-hide.md @@ -0,0 +1,5 @@ +--- +"roo-cline": patch +--- + +Fix OpenAI Codex and OpenAI Native stream parsing for done-only and `content_part` events, including duplicate-text guards when deltas are already streamed. diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index a3daa0f144..e2e8fa34b6 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,2 +1,2 @@ # These owners will be the default owners for everything in the repo -* @mrubens @cte @jr +* @mrubens @cte @jr @hannesrudolph @daniel-lxs diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 0351ad1930..8c7969776d 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,8 +1,5 @@ blank_issues_enabled: false contact_links: - - name: Feature Request - url: https://github.com/RooCodeInc/Roo-Code/discussions/categories/feature-requests - about: Share and vote on feature requests for Roo Code - name: Leave a Review url: https://marketplace.visualstudio.com/items?itemName=RooVeterinaryInc.roo-cline&ssr=false#review-details about: Enjoying Roo Code? Leave a review here! diff --git a/.github/workflows/cli-release.yml b/.github/workflows/cli-release.yml new file mode 100644 index 0000000000..20961a9f2d --- /dev/null +++ b/.github/workflows/cli-release.yml @@ -0,0 +1,394 @@ +name: CLI Release + +on: + workflow_dispatch: + inputs: + version: + description: 'Version to release (e.g., 0.1.0). Leave empty to use package.json version.' + required: false + type: string + dry_run: + description: 'Dry run (build and test but do not create release).' + required: false + type: boolean + default: false + +jobs: + # Build CLI for each platform. + build: + strategy: + fail-fast: false + matrix: + include: + - os: macos-latest + platform: darwin-arm64 + runs-on: macos-latest + - os: ubuntu-latest + platform: linux-x64 + runs-on: ubuntu-latest + - os: ubuntu-24.04-arm + platform: linux-arm64 + runs-on: ubuntu-24.04-arm + + runs-on: ${{ matrix.runs-on }} + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup Node.js and pnpm + uses: ./.github/actions/setup-node-pnpm + + - name: Get version + id: version + run: | + if [ -n "${{ inputs.version }}" ]; then + VERSION="${{ inputs.version }}" + else + VERSION=$(node -p "require('./apps/cli/package.json').version") + fi + echo "version=$VERSION" >> $GITHUB_OUTPUT + echo "tag=cli-v$VERSION" >> $GITHUB_OUTPUT + echo "Using version: $VERSION" + + - name: Build extension bundle + run: pnpm bundle + + - name: Build CLI + run: pnpm --filter @roo-code/cli build + + - name: Create release tarball + id: tarball + env: + VERSION: ${{ steps.version.outputs.version }} + PLATFORM: ${{ matrix.platform }} + run: | + RELEASE_DIR="roo-cli-${PLATFORM}" + TARBALL="roo-cli-${PLATFORM}.tar.gz" + + # Clean up any previous build. + rm -rf "$RELEASE_DIR" + rm -f "$TARBALL" + + # Create directory structure. + mkdir -p "$RELEASE_DIR/bin" + mkdir -p "$RELEASE_DIR/lib" + mkdir -p "$RELEASE_DIR/extension" + + # Copy CLI dist files. + echo "Copying CLI files..." + cp -r apps/cli/dist/* "$RELEASE_DIR/lib/" + + # Create package.json for npm install. + echo "Creating package.json..." + node -e " + const pkg = require('./apps/cli/package.json'); + const newPkg = { + name: '@roo-code/cli', + version: '$VERSION', + type: 'module', + dependencies: { + '@inkjs/ui': pkg.dependencies['@inkjs/ui'], + '@trpc/client': pkg.dependencies['@trpc/client'], + 'commander': pkg.dependencies.commander, + 'fuzzysort': pkg.dependencies.fuzzysort, + 'ink': pkg.dependencies.ink, + 'p-wait-for': pkg.dependencies['p-wait-for'], + 'react': pkg.dependencies.react, + 'superjson': pkg.dependencies.superjson, + 'zustand': pkg.dependencies.zustand + } + }; + console.log(JSON.stringify(newPkg, null, 2)); + " > "$RELEASE_DIR/package.json" + + # Copy extension bundle. + echo "Copying extension bundle..." + cp -r src/dist/* "$RELEASE_DIR/extension/" + + # Add package.json to extension directory for CommonJS. + echo '{"type": "commonjs"}' > "$RELEASE_DIR/extension/package.json" + + # Find and copy ripgrep binary. + echo "Looking for ripgrep binary..." + RIPGREP_PATH=$(find node_modules -path "*/@vscode/ripgrep/bin/rg" -type f 2>/dev/null | head -1) + if [ -n "$RIPGREP_PATH" ] && [ -f "$RIPGREP_PATH" ]; then + echo "Found ripgrep at: $RIPGREP_PATH" + mkdir -p "$RELEASE_DIR/node_modules/@vscode/ripgrep/bin" + cp "$RIPGREP_PATH" "$RELEASE_DIR/node_modules/@vscode/ripgrep/bin/" + chmod +x "$RELEASE_DIR/node_modules/@vscode/ripgrep/bin/rg" + mkdir -p "$RELEASE_DIR/bin" + cp "$RIPGREP_PATH" "$RELEASE_DIR/bin/" + chmod +x "$RELEASE_DIR/bin/rg" + else + echo "Warning: ripgrep binary not found" + fi + + # Create the wrapper script + echo "Creating wrapper script..." + printf '%s\n' '#!/usr/bin/env node' \ + '' \ + "import { fileURLToPath } from 'url';" \ + "import { dirname, join } from 'path';" \ + '' \ + 'const __filename = fileURLToPath(import.meta.url);' \ + 'const __dirname = dirname(__filename);' \ + '' \ + '// Set environment variables for the CLI' \ + "process.env.ROO_CLI_ROOT = join(__dirname, '..');" \ + "process.env.ROO_EXTENSION_PATH = join(__dirname, '..', 'extension');" \ + "process.env.ROO_RIPGREP_PATH = join(__dirname, 'rg');" \ + '' \ + '// Import and run the actual CLI' \ + "await import(join(__dirname, '..', 'lib', 'index.js'));" \ + > "$RELEASE_DIR/bin/roo" + + chmod +x "$RELEASE_DIR/bin/roo" + + # Create empty .env file. + touch "$RELEASE_DIR/.env" + + # Create tarball. + echo "Creating tarball..." + tar -czvf "$TARBALL" "$RELEASE_DIR" + + # Clean up release directory. + rm -rf "$RELEASE_DIR" + + # Create checksum. + if command -v sha256sum &> /dev/null; then + sha256sum "$TARBALL" > "${TARBALL}.sha256" + elif command -v shasum &> /dev/null; then + shasum -a 256 "$TARBALL" > "${TARBALL}.sha256" + fi + + echo "tarball=$TARBALL" >> $GITHUB_OUTPUT + echo "Created: $TARBALL" + ls -la "$TARBALL" + + - name: Verify tarball + env: + PLATFORM: ${{ matrix.platform }} + run: | + TARBALL="roo-cli-${PLATFORM}.tar.gz" + + # Create temp directory for verification. + VERIFY_DIR=$(mktemp -d) + + # Extract and verify structure. + tar -xzf "$TARBALL" -C "$VERIFY_DIR" + + echo "Verifying tarball contents..." + ls -la "$VERIFY_DIR/roo-cli-${PLATFORM}/" + + # Check required files exist. + test -f "$VERIFY_DIR/roo-cli-${PLATFORM}/bin/roo" || { echo "Missing bin/roo"; exit 1; } + test -f "$VERIFY_DIR/roo-cli-${PLATFORM}/lib/index.js" || { echo "Missing lib/index.js"; exit 1; } + test -f "$VERIFY_DIR/roo-cli-${PLATFORM}/package.json" || { echo "Missing package.json"; exit 1; } + test -d "$VERIFY_DIR/roo-cli-${PLATFORM}/extension" || { echo "Missing extension directory"; exit 1; } + + echo "Tarball verification passed!" + + # Cleanup. + rm -rf "$VERIFY_DIR" + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: cli-${{ matrix.platform }} + path: | + roo-cli-${{ matrix.platform }}.tar.gz + roo-cli-${{ matrix.platform }}.tar.gz.sha256 + retention-days: 7 + + # Create GitHub release with all platform artifacts. + release: + needs: build + runs-on: ubuntu-latest + if: ${{ !inputs.dry_run }} + permissions: + contents: write + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Get version + id: version + run: | + if [ -n "${{ inputs.version }}" ]; then + VERSION="${{ inputs.version }}" + else + VERSION=$(node -p "require('./apps/cli/package.json').version") + fi + echo "version=$VERSION" >> $GITHUB_OUTPUT + echo "tag=cli-v$VERSION" >> $GITHUB_OUTPUT + + - name: Download all artifacts + uses: actions/download-artifact@v4 + with: + path: artifacts + + - name: Prepare release files + run: | + mkdir -p release + find artifacts -name "*.tar.gz" -exec cp {} release/ \; + find artifacts -name "*.sha256" -exec cp {} release/ \; + ls -la release/ + + - name: Extract changelog + id: changelog + env: + VERSION: ${{ steps.version.outputs.version }} + run: | + CHANGELOG_FILE="apps/cli/CHANGELOG.md" + + if [ -f "$CHANGELOG_FILE" ]; then + # Extract content between version headers. + CONTENT=$(awk -v version="$VERSION" ' + BEGIN { found = 0; content = ""; target = "[" version "]" } + /^## \[/ { + if (found) { exit } + if (index($0, target) > 0) { found = 1; next } + } + found { content = content $0 "\n" } + END { print content } + ' "$CHANGELOG_FILE") + + if [ -n "$CONTENT" ]; then + echo "Found changelog content" + echo "content<> $GITHUB_OUTPUT + echo "$CONTENT" >> $GITHUB_OUTPUT + echo "EOF" >> $GITHUB_OUTPUT + else + echo "No changelog content found for version $VERSION" + echo "content=" >> $GITHUB_OUTPUT + fi + else + echo "No changelog file found" + echo "content=" >> $GITHUB_OUTPUT + fi + + - name: Generate checksums summary + id: checksums + run: | + echo "checksums<> $GITHUB_OUTPUT + cat release/*.sha256 >> $GITHUB_OUTPUT + echo "EOF" >> $GITHUB_OUTPUT + + - name: Check for existing release + id: check_release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG: ${{ steps.version.outputs.tag }} + run: | + if gh release view "$TAG" &> /dev/null; then + echo "exists=true" >> $GITHUB_OUTPUT + else + echo "exists=false" >> $GITHUB_OUTPUT + fi + + - name: Delete existing release + if: steps.check_release.outputs.exists == 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG: ${{ steps.version.outputs.tag }} + run: | + echo "Deleting existing release $TAG..." + gh release delete "$TAG" --yes || true + git push origin ":refs/tags/$TAG" || true + + - name: Create GitHub Release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + VERSION: ${{ steps.version.outputs.version }} + TAG: ${{ steps.version.outputs.tag }} + CHANGELOG_CONTENT: ${{ steps.changelog.outputs.content }} + CHECKSUMS: ${{ steps.checksums.outputs.checksums }} + run: | + NOTES_FILE=$(mktemp) + + if [ -n "$CHANGELOG_CONTENT" ]; then + echo "## What's New" >> "$NOTES_FILE" + echo "" >> "$NOTES_FILE" + echo "$CHANGELOG_CONTENT" >> "$NOTES_FILE" + echo "" >> "$NOTES_FILE" + fi + + echo "## Installation" >> "$NOTES_FILE" + echo "" >> "$NOTES_FILE" + echo '```bash' >> "$NOTES_FILE" + echo "curl -fsSL https://raw.githubusercontent.com/RooCodeInc/Roo-Code/main/apps/cli/install.sh | sh" >> "$NOTES_FILE" + echo '```' >> "$NOTES_FILE" + echo "" >> "$NOTES_FILE" + echo "Or install a specific version:" >> "$NOTES_FILE" + echo '```bash' >> "$NOTES_FILE" + echo "ROO_VERSION=$VERSION curl -fsSL https://raw.githubusercontent.com/RooCodeInc/Roo-Code/main/apps/cli/install.sh | sh" >> "$NOTES_FILE" + echo '```' >> "$NOTES_FILE" + echo "" >> "$NOTES_FILE" + echo "## Requirements" >> "$NOTES_FILE" + echo "" >> "$NOTES_FILE" + echo "- Node.js 20 or higher" >> "$NOTES_FILE" + echo "- macOS Apple Silicon (M1/M2/M3/M4), Linux x64, or Linux ARM64" >> "$NOTES_FILE" + echo "" >> "$NOTES_FILE" + echo "## Usage" >> "$NOTES_FILE" + echo "" >> "$NOTES_FILE" + echo '```bash' >> "$NOTES_FILE" + echo "# Run a task" >> "$NOTES_FILE" + echo 'roo "What is this project?"' >> "$NOTES_FILE" + echo "" >> "$NOTES_FILE" + echo "# See all options" >> "$NOTES_FILE" + echo "roo --help" >> "$NOTES_FILE" + echo '```' >> "$NOTES_FILE" + echo "" >> "$NOTES_FILE" + echo "## Platform Support" >> "$NOTES_FILE" + echo "" >> "$NOTES_FILE" + echo "This release includes binaries for:" >> "$NOTES_FILE" + echo '- `roo-cli-darwin-arm64.tar.gz` - macOS Apple Silicon (M1/M2/M3)' >> "$NOTES_FILE" + echo '- `roo-cli-linux-x64.tar.gz` - Linux x64' >> "$NOTES_FILE" + echo '- `roo-cli-linux-arm64.tar.gz` - Linux ARM64' >> "$NOTES_FILE" + echo "" >> "$NOTES_FILE" + echo "## Checksums" >> "$NOTES_FILE" + echo "" >> "$NOTES_FILE" + echo '```' >> "$NOTES_FILE" + echo "$CHECKSUMS" >> "$NOTES_FILE" + echo '```' >> "$NOTES_FILE" + + gh release create "$TAG" \ + --title "Roo Code CLI v$VERSION" \ + --notes-file "$NOTES_FILE" \ + --prerelease \ + release/* + + rm -f "$NOTES_FILE" + echo "Release created: https://github.com/${{ github.repository }}/releases/tag/$TAG" + + # Summary job for dry runs + summary: + needs: build + runs-on: ubuntu-latest + if: ${{ inputs.dry_run }} + + steps: + - name: Download all artifacts + uses: actions/download-artifact@v4 + with: + path: artifacts + + - name: Show build summary + run: | + echo "## Dry Run Complete" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "The following artifacts were built:" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + find artifacts -name "*.tar.gz" | while read f; do + SIZE=$(ls -lh "$f" | awk '{print $5}') + echo "- $(basename $f) ($SIZE)" >> $GITHUB_STEP_SUMMARY + done + echo "" >> $GITHUB_STEP_SUMMARY + echo "### Checksums" >> $GITHUB_STEP_SUMMARY + echo "\`\`\`" >> $GITHUB_STEP_SUMMARY + cat artifacts/*/*.sha256 >> $GITHUB_STEP_SUMMARY + echo "\`\`\`" >> $GITHUB_STEP_SUMMARY diff --git a/.github/workflows/code-qa.yml b/.github/workflows/code-qa.yml index f8ac0c8642..1592b15669 100644 --- a/.github/workflows/code-qa.yml +++ b/.github/workflows/code-qa.yml @@ -58,66 +58,3 @@ jobs: uses: ./.github/actions/setup-node-pnpm - name: Run unit tests run: pnpm test - - check-openrouter-api-key: - runs-on: ubuntu-latest - outputs: - exists: ${{ steps.openrouter-api-key-check.outputs.defined }} - steps: - - name: Check if OpenRouter API key exists - id: openrouter-api-key-check - shell: bash - run: | - if [ "${{ secrets.OPENROUTER_API_KEY }}" != '' ]; then - echo "defined=true" >> $GITHUB_OUTPUT; - else - echo "defined=false" >> $GITHUB_OUTPUT; - fi - - integration-test: - runs-on: ubuntu-latest - needs: [check-openrouter-api-key] - if: needs.check-openrouter-api-key.outputs.exists == 'true' - steps: - - name: Checkout code - uses: actions/checkout@v4 - - name: Setup Node.js and pnpm - uses: ./.github/actions/setup-node-pnpm - - name: Create .env.local file - working-directory: apps/vscode-e2e - run: echo "OPENROUTER_API_KEY=${{ secrets.OPENROUTER_API_KEY }}" > .env.local - - name: Set VS Code test version - run: echo "VSCODE_VERSION=1.101.2" >> $GITHUB_ENV - - name: Cache VS Code test runtime - uses: actions/cache@v4 - with: - path: apps/vscode-e2e/.vscode-test - key: ${{ runner.os }}-vscode-test-${{ env.VSCODE_VERSION }} - - name: Pre-download VS Code test runtime with retry - working-directory: apps/vscode-e2e - run: | - for attempt in 1 2 3; do - echo "Download attempt $attempt of 3..." - node -e " - const { downloadAndUnzipVSCode } = require('@vscode/test-electron'); - downloadAndUnzipVSCode({ version: process.env.VSCODE_VERSION || '1.101.2' }) - .then(() => { - console.log('✅ VS Code test runtime downloaded successfully'); - process.exit(0); - }) - .catch(err => { - console.error('❌ Failed to download VS Code (attempt $attempt):', err); - process.exit(1); - }); - " && break || { - if [ $attempt -eq 3 ]; then - echo "All download attempts failed" - exit 1 - fi - echo "Retrying in 5 seconds..." - sleep 5 - } - done - - name: Run integration tests - working-directory: apps/vscode-e2e - run: xvfb-run -a pnpm test:ci diff --git a/.gitignore b/.gitignore index 364b391a01..1dbcdc6a36 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,7 @@ bin/ # Local prompts and rules /local-prompts +AGENTS.local.md # Test environment .test_env diff --git a/.roo/commands/cli-release.md b/.roo/commands/cli-release.md index 70b3698528..5e68e4df2d 100644 --- a/.roo/commands/cli-release.md +++ b/.roo/commands/cli-release.md @@ -1,5 +1,5 @@ --- -description: "Create a new release of the Roo Code CLI" +description: "Prepare a new release of the Roo Code CLI" argument-hint: "[version-description]" mode: code --- @@ -84,41 +84,3 @@ mode: code - [ ] All CI checks pass" \ --base main ``` - -7. Wait for PR approval and merge: - - - Request review if required by your workflow - - Ensure CI checks pass - - Merge the PR using: `gh pr merge --squash --delete-branch` - - Or merge via the GitHub UI - -8. Run the release script from the monorepo root: - - ```bash - # Ensure you're on the updated main branch after the PR merge - git checkout main - git pull origin main - - # Run the release script - ./apps/cli/scripts/release.sh - ``` - - The release script will automatically: - - - Build the extension and CLI - - Create a platform-specific tarball - - Verify the installation works correctly (runs --help, --version, and e2e test) - - Extract changelog content and include it in the GitHub release notes - - Create the GitHub release with the tarball attached - -9. After a successful release, verify: - - Check the release page: https://github.com/RooCodeInc/Roo-Code/releases - - Verify the "What's New" section contains the changelog content - - Test installation: `curl -fsSL https://raw.githubusercontent.com/RooCodeInc/Roo-Code/main/apps/cli/install.sh | sh` - -**Notes:** - -- The release script requires GitHub CLI (`gh`) to be installed and authenticated -- If a release already exists for the tag, the script will prompt to delete and recreate it -- The script creates a tarball for the current platform only (darwin-arm64, darwin-x64, linux-arm64, or linux-x64) -- Multi-platform releases require running the script on each platform and manually uploading additional tarballs diff --git a/.roo/commands/roo-resolve-conflicts.md b/.roo/commands/roo-resolve-conflicts.md new file mode 100644 index 0000000000..38b2038658 --- /dev/null +++ b/.roo/commands/roo-resolve-conflicts.md @@ -0,0 +1,74 @@ +--- +description: "Resolve merge conflicts intelligently using git history analysis" +argument-hint: "#PR-number" +mode: merge-resolver +--- + +Resolve merge conflicts for a specific pull request by analyzing git history, commit messages, and code changes to make intelligent resolution decisions. + +## Quick Start + +1. **Provide a PR number** (e.g., `#123` or just `123`) + +2. The workflow will automatically: + - Fetch PR information (title, description, branches) + - Checkout the PR branch + - Rebase onto the target branch to reveal conflicts + - Analyze and resolve conflicts using git history + +## Workflow Steps + +### 1. Initialize PR Resolution + +```bash +# Fetch PR info +gh pr view [PR_NUMBER] --json title,body,headRefName,baseRefName + +# Checkout and rebase +gh pr checkout [PR_NUMBER] --force +git fetch origin main +GIT_EDITOR=true git rebase origin/main +``` + +### 2. Identify Conflicts + +```bash +git status --porcelain | grep "^UU" +``` + +### 3. Analyze Each Conflict + +For each conflicted file: + +- Read the conflict markers +- Run `git blame` on conflicting sections +- Fetch commit messages for context +- Determine the intent behind each change + +### 4. Apply Resolution Strategy + +Based on the analysis: + +- **Bugfixes** generally take precedence over features +- **Recent changes** are often more relevant (unless older is a security fix) +- **Combine** non-conflicting changes when possible +- **Preserve** test updates alongside code changes + +### 5. Complete Resolution + +```bash +git add [resolved-files] +GIT_EDITOR=true git rebase --continue +``` + +## Key Guidelines + +- Always escape conflict markers with `\` when using `apply_diff` +- Document resolution decisions in the summary +- Verify no syntax errors after resolution +- Preserve valuable changes from both sides when possible + +## Examples + +- `/roo-resolve-conflicts #123` - Resolve conflicts for PR #123 +- `/roo-resolve-conflicts 456` - Resolve conflicts for PR #456 diff --git a/.roo/commands/roo-translate.md b/.roo/commands/roo-translate.md new file mode 100644 index 0000000000..28a8dc67c8 --- /dev/null +++ b/.roo/commands/roo-translate.md @@ -0,0 +1,53 @@ +--- +description: "Translate and localize strings in the Roo Code extension" +argument-hint: "[language-code or 'all'] [string-key or file-path]" +mode: translate +--- + +Perform translation and localization tasks for the Roo Code extension. This command activates the translation workflow with comprehensive i18n guidelines. + +## Quick Start + +1. **Identify the translation scope:** + + - If a specific language code is provided (e.g., `de`, `zh-CN`), focus on that language + - If `all` is specified, translate to all supported languages + - If a string key is provided, locate and translate that specific string + - If a file path is provided, work with that translation file + +2. **Supported languages:** ca, de, en, es, fr, hi, id, it, ja, ko, nl, pl, pt-BR, ru, tr, vi, zh-CN, zh-TW + +3. **Translation locations:** + - Core Extension: `src/i18n/locales/` + - WebView UI: `webview-ui/src/i18n/locales/` + +## Workflow + +1. If adding new strings: + + - Add the English string first + - Ask for confirmation before translating to other languages + - Use `apply_diff` for efficient file updates + +2. If updating existing strings: + + - Identify all affected language files + - Update English first, then propagate changes + +3. Validate your changes: + ```bash + node scripts/find-missing-translations.js + ``` + +## Key Guidelines + +- Use informal speech (e.g., "du" not "Sie" in German) +- Keep technical terms like "token", "Prompt" in English +- Preserve all `{{variable}}` placeholders exactly +- Use `apply_diff` instead of `write_to_file` for existing files + +## Examples + +- `/roo-translate de` - Focus on German translations +- `/roo-translate all welcome.title` - Translate a specific key to all languages +- `/roo-translate zh-CN src/i18n/locales/zh-CN/core.json` - Work on specific file diff --git a/.roo/guidance/roo-translator.md b/.roo/guidance/roo-translator.md new file mode 100644 index 0000000000..2539778f27 --- /dev/null +++ b/.roo/guidance/roo-translator.md @@ -0,0 +1,15 @@ +# Roo Code Translation Guidance + +This file contains brand voice, tone, and word choice guidelines for Roo Code translations. + +## Brand Voice + + + +## Tone + + + +## Word Choice + + diff --git a/.roo/rules-docs-extractor/1_extraction_workflow.xml b/.roo/rules-docs-extractor/1_extraction_workflow.xml index c707fa7809..200e48da0c 100644 --- a/.roo/rules-docs-extractor/1_extraction_workflow.xml +++ b/.roo/rules-docs-extractor/1_extraction_workflow.xml @@ -1,163 +1,113 @@ - - The Docs Extractor mode has exactly two workflow paths: - 1) Verify provided documentation for factual accuracy against the codebase - 2) Generate source material for user-facing docs about a requested feature or aspect of the codebase + + Extract raw facts from a codebase about a feature or aspect. + Output is structured data for documentation teams to use. + Do NOT write documentation. Do NOT format prose. Do NOT make structure decisions. + - Outputs are designed to support explanatory documentation (not merely descriptive): - - Capture why users need steps and why certain actions are restricted - - Surface constraints, limitations, and trade‑offs - - Provide troubleshooting playbooks (symptoms → causes → fixes → prevention) - - Recommend targeted visuals for complex states (not step‑by‑step screenshots) - - This mode does not generate final user documentation; it produces verification and source-material reports for docs teams. - - - + - Parse Request + Identify Target - Identify the feature/aspect in the user's request. - Decide path: verification vs. source-material generation. - For source-material: capture audience (user or developer) and depth (overview vs task-focused). - For verification: identify the documentation to be verified (provided text/links/files). - Note any specific areas to emphasize or check. + Parse the user's request to identify the feature/aspect + Clarify scope if ambiguous (ask one question max) - Discover Feature + Discover Code - Locate relevant code and assets using appropriate discovery methods. - Identify entry points and key components that affect user experience. - Map the high-level workflow a user follows. + Use codebase_search to find relevant files + Identify entry points, components, and related code + Map the boundaries of the feature - - - UI components and their interactions - User workflows and decision points - Configuration that changes user-visible behavior - Error states, messages, and recovery - Benefits, limits, prerequisites, and version notes - Why this exists: user goals, constraints, and design intent - “Cannot do” boundaries: permissions, invariants, and business rules - Troubleshooting: symptoms, likely causes, diagnostics, fixes, prevention - Common pitfalls and anti‑patterns (what to avoid and why) - Decision rationale and trade‑offs that affect user choices - Complex UI states that merit visuals (criteria for screenshots/diagrams) - + + Extract Facts + + Read code and extract facts into categories (see fact_categories) + Record file paths as sources for each fact + Do NOT interpret, summarize, or explain - just extract + + - - - Generate Source Material for User-Facing Docs - Extract concise, user-oriented facts and structure them for documentation teams. - - - Scope and Audience - - Confirm the feature/aspect and intended audience. - List primary tasks the audience performs with this feature. - - - - Extract User-Facing Facts - - Summarize what the feature does and key benefits. - Explain why users need this (jobs-to-be-done, outcomes) and when to use it. - Document step-by-step user workflows and UI interactions. - Capture configuration options that impact user behavior (name, default, effect). - Clarify constraints, limits, and “cannot do” cases with rationale. - Identify common pitfalls and anti-patterns; include “Do/Don’t” guidance. - List common errors with user-facing messages, diagnostics, fixes, and prevention. - Record prerequisites, permissions, and compatibility/version notes. - Flag complex states that warrant visuals (what to show and why), not every step. - - - - Create Source Material Report - - Organize findings using user-focused structure (benefits, use cases, how it works, configuration, FAQ, troubleshooting). - Include short code/UI snippets or paths where relevant. - Create `EXTRACTION-[feature].md` with findings. - Highlight items that need visuals (screenshots/diagrams). - - - - Executive summary of the feature/aspect - - Why it matters (goals, value, when to use) - - User workflows and interactions - - Configuration and setup affecting users (with defaults and impact) - - Constraints and limitations (with rationale) - - Common scenarios and troubleshooting playbooks (symptoms → causes → fixes → prevention) - - Do/Don’t and anti‑patterns - - Recommended visuals (what complex states to illustrate and why) - - FAQ and tips - - Version/compatibility notes - - - - + + Output Structured Data + + Write extraction to .roo/extraction/EXTRACT-[feature].yaml + Use the output schema (see output_format.xml) + + + - - Verify Documentation Accuracy - Check provided documentation against codebase reality and actual UX. - - - Analyze Provided Documentation - - Parse the documentation to identify claims and descriptions. - Extract technical or user-facing specifics mentioned. - Note workflows, configuration, and examples described. - - - - Verify Against Codebase - - Check claims against actual implementation and UX. - Verify endpoints/parameters if referenced. - Confirm configuration options and defaults. - Validate code snippets and examples. - Ensure described workflows match implementation. - - - - Create Verification Report - - Categorize findings by severity (Critical, Major, Minor). - List inaccuracies with the correct information. - Identify missing important information. - Provide specific corrections and suggestions. - Create `VERIFICATION-[feature].md` with findings. - - - - Verification summary (Accurate/Needs Updates) - - Critical inaccuracies that could mislead users - - Corrections and missing information - - Explanatory gaps (missing “why”, constraints, or decision rationale) - - Troubleshooting coverage gaps (missing symptoms/diagnostics/fixes/prevention) - - Visual recommendations (which complex states warrant screenshots/diagrams) - - Suggestions for clarity improvements - - - - - + + + + Feature name as it appears in code + File paths where feature is implemented + Entry points (commands, UI elements, API endpoints) + + - - - Audience and scope captured - User workflows and UI interactions documented - User-impacting configuration recorded - Common errors and troubleshooting documented - Report organized for documentation team use - - - All documentation claims verified - Inaccuracies identified and corrected - Missing information noted - Suggestions for improvement provided - Clear verification report created - - + + + What the feature does (from code logic) + Inputs it accepts + Outputs it produces + Side effects (files created, state changed, etc.) + + + + + + Settings/options that affect behavior + Default values + Valid ranges or allowed values + Where configured (settings file, env var, UI) + + + + + + Prerequisites and dependencies + Limitations (what it cannot do) + Permissions required + Compatibility requirements + + + + + + Error conditions in code + Error messages (exact text) + Recovery paths in code + + + + + + UI components involved + User-visible labels and text + Interaction patterns + + + + + + Other features this interacts with + External APIs or services called + Events emitted or consumed + + + + + + Extract facts, not opinions + Include source file paths for every fact + Use code identifiers and exact strings from source + Do NOT paraphrase - quote when possible + Do NOT decide what's important - extract everything relevant + Do NOT format for end users - output is for docs team + \ No newline at end of file diff --git a/.roo/rules-docs-extractor/2_documentation_patterns.xml b/.roo/rules-docs-extractor/2_documentation_patterns.xml deleted file mode 100644 index da743483da..0000000000 --- a/.roo/rules-docs-extractor/2_documentation_patterns.xml +++ /dev/null @@ -1,357 +0,0 @@ - - - Standard templates for structuring extracted documentation. - - - - -# [Feature Name] - -[Description of what the feature does and why a user should care.] - -### Key Features -- [Benefit-oriented feature 1] -- [Benefit-oriented feature 2] -- [Benefit-oriented feature 3] - ---- - -## Use Case - -**Before**: [Description of the old way] -- [Pain point 1] -- [Pain point 2] - -**With this feature**: [Description of the new experience.] - -## How it Works - -[Simple explanation of the feature's operation.] - -[Suggest visual representations where helpful.] - ---- - -## Configuration - -[Explanation of relevant settings.] - -1. **[Setting Name]**: - - **Setting**: `[technical_name]` - - **Description**: [What this does.] - - **Default**: [Default value and its meaning.] - -2. **[Setting Name]**: - - **Setting**: `[technical_name]` - - **Description**: [What this does.] - - **Default**: [Default value and its meaning.] - ---- - -## FAQ - -**"[User question]"** -- [Answer.] -- [Optional tip.] - -**"[User question]"** -- [Answer.] -- [Optional tip.] - - - - -# [Feature Name] Technical Documentation - -## Table of Contents -1. Overview -2. Quick Start -3. Architecture -4. API Reference -5. Configuration -6. User Guide -7. Developer Guide -8. Security -9. Performance -10. Troubleshooting -11. FAQ -12. Changelog -13. References - -[Use this as an internal source-material outline for technical sections; not for final docs.] - - - - - - - - - - --- - Separate sections. - - - - - - - - Show tool output or UI elements. - Use actual file paths and setting names. - Include common errors and solutions. - - - - - - - - - - - - - - - Tutorials - Use cases - Troubleshooting - Benefits - - - - - - - Code examples - API specs - Integration patterns - Performance - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - [Link Text](#section-anchor) - [See Configuration Guide](#configuration) - - - - [Link Text](https://external.url) - [Official Documentation](https://docs.example.com) - - - - - - - - - - - \ No newline at end of file diff --git a/.roo/rules-docs-extractor/2_verification_workflow.xml b/.roo/rules-docs-extractor/2_verification_workflow.xml new file mode 100644 index 0000000000..4635d8eb45 --- /dev/null +++ b/.roo/rules-docs-extractor/2_verification_workflow.xml @@ -0,0 +1,85 @@ + + + Compare provided documentation against actual codebase implementation. + Output is a structured diff of claims vs reality. + Do NOT rewrite the docs. Do NOT suggest wording. Just report discrepancies. + + + + + Receive Documentation + + User provides documentation to verify (text, file, or URL) + Identify the feature/aspect being documented + + + + + Extract Claims + + Parse the documentation into discrete claims + Tag each claim with a category (behavior, config, constraint, etc.) + Record the exact quote from the documentation + + + + + Verify Against Code + + For each claim, find the relevant code + Compare claim to actual implementation + Record: ACCURATE, INACCURATE, OUTDATED, MISSING_CONTEXT, or UNVERIFIABLE + For inaccuracies, record what the code actually does + + + + + Output Verification Report + + Write verification to .roo/extraction/VERIFY-[feature].yaml + Use the output schema (see output_format.xml) + + + + + + + Claim matches implementation + + + Claim contradicts implementation + What the code actually does + + + Claim was once true but code has changed + Current behavior + + + Claim is true but omits important information + The missing context + + + Cannot find code to verify this claim + Search paths attempted + + + + + behavior + configuration + constraint + error_handling + ui + integration + prerequisite + + + + Verify facts, not writing quality + Report what code does, not what docs should say + Include source file paths as evidence + Do NOT suggest documentation rewrites + Do NOT evaluate if docs are "good" - only if they're accurate + Quote exact code when showing discrepancies + + \ No newline at end of file diff --git a/.roo/rules-docs-extractor/3_analysis_techniques.xml b/.roo/rules-docs-extractor/3_analysis_techniques.xml deleted file mode 100644 index 12b3d1fd26..0000000000 --- a/.roo/rules-docs-extractor/3_analysis_techniques.xml +++ /dev/null @@ -1,349 +0,0 @@ - - - Heuristics for analyzing a codebase to extract reliable, user-facing documentation. - This file contains technique checklists only—no tool instructions or invocations. - - - - - Find and analyze UI components and their interactions - - Start from feature or route directories and enumerate components related to the requested topic. - Differentiate container vs presentational components; note composition patterns. - Trace inputs/outputs: props, state, context, events, and side effects. - Record conditional rendering that affects user-visible states. - - - Primary components and responsibilities. - Props/state/context that change behavior. - High-level dependency/composition map. - - - - - Analyze styling and visual elements - - Identify design tokens and utility classes used to drive layout and state. - Capture responsive behavior and breakpoint rules that materially change UX. - Document visual affordances tied to state (loading, error, disabled). - - - Key classes/selectors influencing layout/state. - Responsive behavior summary and breakpoints. - - - - - Map user interactions and navigation flows - - Route definitions and navigation - Form submissions and validations - Button clicks and event handlers - State changes and UI updates - Loading and error states - - - Outline entry points and expected outcomes for each primary flow. - Summarize validation rules and failure states the user can encounter. - Record redirects and deep-link behavior relevant to the feature. - - - Flow diagrams or bullet sequences for main tasks. - Validation conditions and error messages. - Navigation transitions and guards. - - - - - Analyze how the system communicates with users - - Error messages and alerts - Success notifications - Loading indicators - Tooltips and help text - Confirmation dialogs - Progress indicators - - - Map message triggers to the user actions that cause them. - Capture severity, persistence, and dismissal behavior. - Note localization or accessibility considerations in messages. - - - Catalog of messages with purpose and conditions. - Loading/progress patterns and timeouts. - - - - - Check for accessibility features and compliance - - ARIA labels and roles - Keyboard navigation support - Screen reader compatibility - Focus management - Color contrast considerations - - - Confirm interactive elements have clear focus and labels. - Describe keyboard-only navigation paths for core flows. - - - Accessibility gaps affecting task completion. - - - - - Analyze responsive design and mobile experience - - Breakpoint definitions - Mobile-specific components - Touch event handlers - Viewport configurations - Media queries - - - Summarize layout changes across breakpoints that alter workflow. - Note touch targets and gestures required on mobile. - - - Table of key differences per breakpoint. - - - - - - - Understand feature entry points and control flow - - Identify main functions, controllers, or route handlers. - Trace execution and decision branches. - Document input validation and preconditions. - - - Entry points list and short purpose statements. - Decision matrix or flow sketch. - - - - - Extract API specifications from code - - - - HTTP method and route path - Path/query parameters - Request/response schemas - Status codes and error bodies - - - - - Schema and input types - Resolvers and return types - Field arguments and constraints - - - - - - - Map dependencies and integration points - - Imports and module boundaries - Package and runtime dependencies - External API/SDK usage - DB connections and migrations - Messaging/queue/event streams - Filesystem or network side effects - - - Dependency graph summary and hot spots. - List of external integrations and auth methods. - - - - - Extract data models, schemas, and type definitions - - - - interfaces, types, classes, enums - - - - Schema definitions, migration files, ORM models - - - - JSON Schema, Joi/Yup/Zod schemas, validation decorators - - - - Canonical definitions and field constraints. - Entity relationships and ownership. - - - - - Identify and document business rules - - Complex conditionals - Calculation functions - Validation rules - State machines - Domain-specific constants and algorithms - - - Why the logic exists (business need) - When the logic applies (conditions) - What the logic does (transformation) - Edge cases and invariants - Impact of changes - - - - - Document error handling and recovery - - try/catch blocks and error boundaries - Custom error classes and codes - Logging, fallbacks, retries, circuit breakers - - - Error taxonomy and user-facing messages. - Recovery/rollback strategies and timeouts. - - - - - Identify security measures and vulnerabilities - - JWT, sessions, OAuth, API keys - RBAC, permission checks, ownership validation - Encryption, hashing, sensitive data handling - Sanitization and injection prevention - - - Threat surfaces and mitigations relevant to the feature. - - - - - Identify performance factors and optimization opportunities - - Expensive loops/algorithms - DB query patterns (e.g., N+1) - Caching strategies - Concurrency and async usage - Batching and resource pooling - Memory management and object lifetimes - - - Time/space complexity - DB query counts - API response times - Memory usage - Concurrency handling - - - - - Assess test coverage at a useful granularity - - - Function-level coverage and edge cases - - - Workflow coverage and contract boundaries - - - Endpoint success/failure paths and schemas - - - - List of critical behaviors missing tests. - - - - - Extract configuration options and their impacts - - .env files, config files, CLI args, feature flags - - - Default values and valid ranges - Behavioral impact of each option - Dependencies between options - Security implications - - - - - - - Map user workflows through the feature - - Identify entry points (UI, API, CLI) - Trace user actions and decision points - Map data transformations - Identify outcomes and completion criteria - - - Flow diagrams, procedures, decision trees, state diagrams - - - - - Document integration with other systems - - Sync API calls, async messaging, events, batch processing, streaming - - - Protocols, auth, error handling, data transforms, SLAs - - - - - - - Summarize version constraints and compatibility - - package manifests, READMEs, migration guides, breaking changes docs - - - Minimum/recommended versions and notable constraints. - - - - - Track deprecations and migrations - - Explicit deprecation notices and TODO markers - Legacy code paths and adapters - - - Deprecation date and removal timeline - Migration path and alternatives - - - - - - - - Public APIs documented with inputs/outputs and errors - Examples for complex features - Error scenarios covered with recovery guidance - Config options explained with defaults and impacts - Security considerations addressed - - - - - Cyclomatic complexity - Code duplication - Test coverage and gaps - Documentation coverage for user-visible behaviors - Known technical debt affecting UX - - - - \ No newline at end of file diff --git a/.roo/rules-docs-extractor/3_output_format.xml b/.roo/rules-docs-extractor/3_output_format.xml new file mode 100644 index 0000000000..185f7b23b8 --- /dev/null +++ b/.roo/rules-docs-extractor/3_output_format.xml @@ -0,0 +1,133 @@ + + + Structured data output formats for extraction and verification. + All output is YAML. No prose. No markdown formatting. + This data feeds into documentation-writer mode. + + + + Schema for EXTRACT-[feature].yaml files + + + + + Schema for VERIFY-[feature].yaml files + + + + + Use YAML, not JSON or markdown + Include source file:line for every fact + Quote exact strings from code using double quotes + Use null for unknown/missing values, not empty strings + Keep descriptions factual and brief - one line max + Do NOT add commentary, suggestions, or explanations + + + + EXTRACT-[feature-slug].yaml + VERIFY-[feature-slug].yaml + .roo/extraction/ + + \ No newline at end of file diff --git a/.roo/rules-docs-extractor/4_communication_guidelines.xml b/.roo/rules-docs-extractor/4_communication_guidelines.xml deleted file mode 100644 index 43ec8479fc..0000000000 --- a/.roo/rules-docs-extractor/4_communication_guidelines.xml +++ /dev/null @@ -1,298 +0,0 @@ - - - Guidelines for user communication and output formatting. - - - - - Act on the user's request immediately. - Only ask for clarification if the request is ambiguous. - - - - - Multiple features with similar names are found. - The request is ambiguous. - The user explicitly asks for options. - - - - - - - Starting a major analysis phase. - Extraction is complete. - Unexpected complexity is found. - - - - - - - - - - - Alert user to security concerns found during analysis. - - - Note deprecated features needing migration docs. - - - Highlight code that lacks inline documentation. - - - Warn about complex dependency chains. - - - - - - - - - - - - - - - Use # for main title, ## for major sections, ### for subsections. - Never skip heading levels. - - - - Always specify language for syntax highlighting (e.g., typescript, json, bash). - Include file paths as comments where relevant. - -```typescript -// src/auth/auth.service.ts -export class AuthService { - async validateUser(email: string, password: string): Promise { - // Implementation - } -} -``` - - - - - Use tables for structured data like configs. - Include headers and align columns. - Keep cell content brief. - -| Variable | Type | Default | Description | -|----------|------|---------|-------------| -| `JWT_SECRET` | string | - | Secret key for JWT signing | -| `JWT_EXPIRATION` | string | '15m' | Token expiration time | - - - - - Use bullets for unordered lists, numbers for sequential steps. - Keep list items parallel in structure. - - - - - - [Link text](#section-anchor) - Use lowercase, hyphenated anchors. Test all links. - - - - [Link text](https://example.com) - Use HTTPS. Link to official docs. - - - - `path/to/file.ts` - Use relative paths from project root, in backticks. - - - - - - - > ⚠️ **Warning**: [message] - Security, breaking changes, deprecations. - - - > 📝 **Note**: [message] - Important info, clarifications. - - - > 💡 **Tip**: [message] - Best practices, optimizations. - - - - - ---- -Feature: Authentication System -Version: 2.1.0 -Last Updated: 2024-01-15 -Status: Stable ---- - - - - - - - - Be direct, not conversational. - Use active voice. - Lead with benefits. - Use concrete examples. - Keep paragraphs short. - Avoid unnecessary technical details. - - - - - Technical and direct. - Standard programming terms. - Code snippets, implementation details. - - - Instructional, step-by-step. - Simple language, no jargon. - Screenshots, real-world scenarios. - - - - - - - Summary of analysis performed. - Key findings or issues identified. - Report file location. - Recommended next steps. - - - -Feature extraction complete for the authentication system. - -**Extraction Report**: `EXTRACTION-authentication-system.md` - -**Technical Summary**: -- JWT-based authentication with refresh tokens -- 5 API endpoints (login, logout, refresh, register, profile) -- 12 configuration options -- bcrypt password hashing, rate limiting - -**Non-Technical Summary**: -- Users can register, login, and manage sessions -- Supports "remember me" functionality -- Automatic session refresh for seamless experience -- Account lockout after failed attempts - -**Documentation Considerations**: -- Token expiration times need clear explanation -- Password requirements should be prominently displayed -- Error messages need user-friendly translations - -The extraction report contains all details needed for comprehensive documentation. - - - -Documentation verification complete for the authentication system. - -**Verification Report**: `VERIFICATION-authentication-system.md` - -**Overall Assessment**: Needs Updates - -**Critical Issues Found**: -1. JWT_SECRET documented as optional, but it's required -2. Token expiration listed as 30m, actual is 15m -3. Missing documentation for rate limiting feature - -**Technical Corrections**: 7 items -**Missing Information**: 4 sections -**Clarity Improvements**: 3 suggestions - -Please review the verification report for specific corrections needed. - - - - - - - - Could not find a feature matching "[feature name]". Similar features found: - - [List similar features] - Document one of these instead? - - - - - - Code for [feature] has limited inline documentation. Extracting from code structure, tests, and usage patterns. - - - - - - This feature is complex. Choose documentation scope: - - Document comprehensively - - Focus on core functionality - - Split into multiple documents - - - - - - - - No placeholder content remains. - Code examples are correct. - Links and cross-references work. - Tables are formatted correctly. - Version info is included. - Filename follows conventions. - - - \ No newline at end of file diff --git a/.roo/rules-integration-tester/1_workflow.xml b/.roo/rules-integration-tester/1_workflow.xml deleted file mode 100644 index b0ebc535e2..0000000000 --- a/.roo/rules-integration-tester/1_workflow.xml +++ /dev/null @@ -1,198 +0,0 @@ - - - Understand Test Requirements - - Use ask_followup_question to determine what type of integration test is needed: - - - What type of integration test would you like me to create or work on? - - New E2E test for a specific feature or workflow - Fix or update an existing integration test - Create test utilities or helpers for common patterns - Debug failing integration tests - - - - - - - Gather Test Specifications - - Based on the test type, gather detailed requirements: - - For New E2E Tests: - - What specific user workflow or feature needs testing? - - What are the expected inputs and outputs? - - What edge cases or error scenarios should be covered? - - Are there specific API interactions to validate? - - What events should be monitored during the test? - - For Existing Test Issues: - - Which test file is failing or needs updates? - - What specific error messages or failures are occurring? - - What changes in the codebase might have affected the test? - - For Test Utilities: - - What common patterns are being repeated across tests? - - What helper functions would improve test maintainability? - - Use multiple ask_followup_question calls if needed to gather complete information. - - - - - Explore Existing Test Patterns - - Use codebase_search FIRST to understand existing test patterns and similar functionality: - - For New Tests: - - Search for similar test scenarios in apps/vscode-e2e/src/suite/ - - Find existing test utilities and helpers - - Identify patterns for the type of functionality being tested - - For Test Fixes: - - Search for the failing test file and related code - - Find similar working tests for comparison - - Look for recent changes that might have broken the test - - Example searches: - - "file creation test mocha" for file operation tests - - "task completion waitUntilCompleted" for task monitoring patterns - - "api message validation" for API interaction tests - - After codebase_search, use: - - read_file on relevant test files to understand structure - - list_code_definition_names on test directories - - search_files for specific test patterns or utilities - - - - - Analyze Test Environment and Setup - - Examine the test environment configuration: - - 1. Read the test runner configuration: - - apps/vscode-e2e/package.json for test scripts - - apps/vscode-e2e/src/runTest.ts for test setup - - Any test configuration files - - 2. Understand the test workspace setup: - - How test workspaces are created - - What files are available during tests - - How the extension API is accessed - - 3. Review existing test utilities: - - Helper functions for common operations - - Event listening patterns - - Assertion utilities - - Cleanup procedures - - Document findings including: - - Test environment structure - - Available utilities and helpers - - Common patterns and best practices - - - - - Design Test Structure - - Plan the test implementation based on gathered information: - - For New Tests: - - Define test suite structure with suite/test blocks - - Plan setup and teardown procedures - - Identify required test data and fixtures - - Design event listeners and validation points - - Plan for both success and failure scenarios - - For Test Fixes: - - Identify the root cause of the failure - - Plan the minimal changes needed to fix the issue - - Consider if the test needs to be updated due to code changes - - Plan for improved error handling or debugging - - Create a detailed test plan including: - - Test file structure and organization - - Required setup and cleanup - - Specific assertions and validations - - Error handling and edge cases - - - - - Implement Test Code - - Implement the test following established patterns: - - CRITICAL: Never write a test file with a single write_to_file call. - Always implement tests in parts: - - 1. Start with the basic test structure (suite, setup, teardown) - 2. Add individual test cases one by one - 3. Implement helper functions separately - 4. Add event listeners and validation logic incrementally - - Follow these implementation guidelines: - - Use suite() and test() blocks following Mocha TDD style - - Always use the global api object for extension interactions - - Implement proper async/await patterns with waitFor utility - - Use waitUntilCompleted and waitUntilAborted helpers for task monitoring - - Listen to and validate appropriate events (message, taskCompleted, etc.) - - Test both positive flows and error scenarios - - Validate message content using proper type assertions - - Create reusable test utilities when patterns emerge - - Use meaningful test descriptions that explain the scenario - - Always clean up tasks with cancelCurrentTask or clearCurrentTask - - Ensure tests are independent and can run in any order - - - - - Run and Validate Tests - - Execute the tests to ensure they work correctly: - - ALWAYS use the correct working directory and commands: - - Working directory: apps/vscode-e2e - - Test command: npm run test:run - - For specific tests: TEST_FILE="filename.test" npm run test:run - - Example: cd apps/vscode-e2e && TEST_FILE="apply-diff.test" npm run test:run - - Test execution process: - 1. Run the specific test file first - 2. Check for any failures or errors - 3. Analyze test output and logs - 4. Debug any issues found - 5. Re-run tests after fixes - - If tests fail: - - Add console.log statements to track execution flow - - Log important events like task IDs, file paths, and AI responses - - Check test output carefully for error messages and stack traces - - Verify file creation in correct workspace directories - - Ensure proper event handling and timeouts - - - - - Document and Complete - - Finalize the test implementation: - - 1. Add comprehensive comments explaining complex test logic - 2. Document any new test utilities or patterns created - 3. Ensure test descriptions clearly explain what is being tested - 4. Verify all cleanup procedures are in place - 5. Confirm tests can run independently and in any order - - Provide the user with: - - Summary of tests created or fixed - - Instructions for running the tests - - Any new patterns or utilities that can be reused - - Recommendations for future test improvements - - - \ No newline at end of file diff --git a/.roo/rules-integration-tester/2_test_patterns.xml b/.roo/rules-integration-tester/2_test_patterns.xml deleted file mode 100644 index 62bef1631b..0000000000 --- a/.roo/rules-integration-tester/2_test_patterns.xml +++ /dev/null @@ -1,303 +0,0 @@ - - - Standard Mocha TDD structure for integration tests - - Basic Test Suite Structure - - ```typescript - import { suite, test, suiteSetup, suiteTeardown } from 'mocha'; - import * as assert from 'assert'; - import * as vscode from 'vscode'; - import { waitFor, waitUntilCompleted, waitUntilAborted } from '../utils/testUtils'; - - suite('Feature Name Tests', () => { - let testWorkspaceDir: string; - let testFiles: { [key: string]: string } = {}; - - suiteSetup(async () => { - // Setup test workspace and files - testWorkspaceDir = vscode.workspace.workspaceFolders![0].uri.fsPath; - // Create test files in workspace - }); - - suiteTeardown(async () => { - // Cleanup test files and tasks - await api.cancelCurrentTask(); - }); - - test('should perform specific functionality', async () => { - // Test implementation - }); - }); - ``` - - - - - Event Listening Pattern - - ```typescript - test('should handle task completion events', async () => { - const events: any[] = []; - - const messageListener = (message: any) => { - events.push({ type: 'message', data: message }); - }; - - const taskCompletedListener = (result: any) => { - events.push({ type: 'taskCompleted', data: result }); - }; - - api.onDidReceiveMessage(messageListener); - api.onTaskCompleted(taskCompletedListener); - - try { - // Perform test actions - await api.startTask('test prompt'); - await waitUntilCompleted(); - - // Validate events - assert(events.some(e => e.type === 'taskCompleted')); - } finally { - // Cleanup listeners - api.onDidReceiveMessage(() => {}); - api.onTaskCompleted(() => {}); - } - }); - ``` - - - - - File Creation Test Pattern - - ```typescript - test('should create files in workspace', async () => { - const fileName = 'test-file.txt'; - const expectedContent = 'test content'; - - await api.startTask(`Create a file named ${fileName} with content: ${expectedContent}`); - await waitUntilCompleted(); - - // Check multiple possible locations - const possiblePaths = [ - path.join(testWorkspaceDir, fileName), - path.join(process.cwd(), fileName), - // Add other possible locations - ]; - - let fileFound = false; - let actualContent = ''; - - for (const filePath of possiblePaths) { - if (fs.existsSync(filePath)) { - actualContent = fs.readFileSync(filePath, 'utf8'); - fileFound = true; - break; - } - } - - assert(fileFound, `File ${fileName} not found in any expected location`); - assert.strictEqual(actualContent.trim(), expectedContent); - }); - ``` - - - - - - - Basic Task Execution - - ```typescript - // Start a task and wait for completion - await api.startTask('Your prompt here'); - await waitUntilCompleted(); - ``` - - - - - Task with Auto-Approval Settings - - ```typescript - // Enable auto-approval for specific actions - await api.updateSettings({ - alwaysAllowWrite: true, - alwaysAllowExecute: true - }); - - await api.startTask('Create and execute a script'); - await waitUntilCompleted(); - ``` - - - - - Message Validation - - ```typescript - const messages: any[] = []; - api.onDidReceiveMessage((message) => { - messages.push(message); - }); - - await api.startTask('test prompt'); - await waitUntilCompleted(); - - // Validate specific message types - const toolMessages = messages.filter(m => - m.type === 'say' && m.say === 'api_req_started' - ); - assert(toolMessages.length > 0, 'Expected tool execution messages'); - ``` - - - - - - - Task Abortion Handling - - ```typescript - test('should handle task abortion', async () => { - await api.startTask('long running task'); - - // Abort after short delay - setTimeout(() => api.abortTask(), 1000); - - await waitUntilAborted(); - - // Verify task was properly aborted - const status = await api.getTaskStatus(); - assert.strictEqual(status, 'aborted'); - }); - ``` - - - - - Error Message Validation - - ```typescript - test('should handle invalid input gracefully', async () => { - const errorMessages: any[] = []; - - api.onDidReceiveMessage((message) => { - if (message.type === 'error' || message.text?.includes('error')) { - errorMessages.push(message); - } - }); - - await api.startTask('invalid prompt that should fail'); - await waitFor(() => errorMessages.length > 0, 5000); - - assert(errorMessages.length > 0, 'Expected error messages'); - }); - ``` - - - - - - - File Location Helper - - ```typescript - function findFileInWorkspace(fileName: string, workspaceDir: string): string | null { - const possiblePaths = [ - path.join(workspaceDir, fileName), - path.join(process.cwd(), fileName), - path.join(os.tmpdir(), fileName), - // Add other common locations - ]; - - for (const filePath of possiblePaths) { - if (fs.existsSync(filePath)) { - return filePath; - } - } - - return null; - } - ``` - - - - - Event Collection Helper - - ```typescript - class EventCollector { - private events: any[] = []; - - constructor(private api: any) { - this.setupListeners(); - } - - private setupListeners() { - this.api.onDidReceiveMessage((message: any) => { - this.events.push({ type: 'message', timestamp: Date.now(), data: message }); - }); - - this.api.onTaskCompleted((result: any) => { - this.events.push({ type: 'taskCompleted', timestamp: Date.now(), data: result }); - }); - } - - getEvents(type?: string) { - return type ? this.events.filter(e => e.type === type) : this.events; - } - - clear() { - this.events = []; - } - } - ``` - - - - - - - Comprehensive Logging - - ```typescript - test('should log execution flow for debugging', async () => { - console.log('Starting test execution'); - - const events: any[] = []; - api.onDidReceiveMessage((message) => { - console.log('Received message:', JSON.stringify(message, null, 2)); - events.push(message); - }); - - console.log('Starting task with prompt'); - await api.startTask('test prompt'); - - console.log('Waiting for task completion'); - await waitUntilCompleted(); - - console.log('Task completed, events received:', events.length); - console.log('Final workspace state:', fs.readdirSync(testWorkspaceDir)); - }); - ``` - - - - - State Validation - - ```typescript - function validateTestState(description: string) { - console.log(`=== ${description} ===`); - console.log('Workspace files:', fs.readdirSync(testWorkspaceDir)); - console.log('Current working directory:', process.cwd()); - console.log('Task status:', api.getTaskStatus?.() || 'unknown'); - console.log('========================'); - } - ``` - - - - \ No newline at end of file diff --git a/.roo/rules-integration-tester/3_best_practices.xml b/.roo/rules-integration-tester/3_best_practices.xml deleted file mode 100644 index e495ea5f0a..0000000000 --- a/.roo/rules-integration-tester/3_best_practices.xml +++ /dev/null @@ -1,104 +0,0 @@ - - - - Always use suite() and test() blocks following Mocha TDD style - - Use descriptive test names that explain the scenario being tested - - Implement proper setup and teardown in suiteSetup() and suiteTeardown() - - Create test files in the VSCode workspace directory during suiteSetup() - - Store file paths in a test-scoped object for easy reference across tests - - Ensure tests are independent and can run in any order - - Clean up all test files and tasks in suiteTeardown() to avoid test pollution - - - - - Always use the global api object for extension interactions - - Implement proper async/await patterns with the waitFor utility - - Use waitUntilCompleted and waitUntilAborted helpers for task monitoring - - Set appropriate auto-approval settings (alwaysAllowWrite, alwaysAllowExecute) for the functionality being tested - - Listen to and validate appropriate events (message, taskCompleted, taskAborted, etc.) - - Always clean up tasks with cancelCurrentTask or clearCurrentTask after tests - - Use meaningful timeouts that account for actual task execution time - - - - - Be aware that files may be created in the workspace directory (/tmp/roo-test-workspace-*) rather than expected locations - - Always check multiple possible file locations when verifying file creation - - Use flexible file location checking that searches workspace directories - - Verify files exist after creation to catch setup issues early - - Account for the fact that the workspace directory is created by runTest.ts - - The AI may use internal tools instead of the documented tools - verify outcomes rather than methods - - - - - Add multiple event listeners (taskStarted, taskCompleted, taskAborted) for better debugging - - Don't rely on parsing AI messages to detect tool usage - the AI's message format may vary - - Use terminal shell execution events (onDidStartTerminalShellExecution, onDidEndTerminalShellExecution) for command tracking - - Tool executions are reported via api_req_started messages with type="say" and say="api_req_started" - - Focus on testing outcomes (files created, commands executed) rather than message parsing - - There is no "tool_result" message type - tool results appear in "completion_result" or "text" messages - - - - - Test both positive flows and error scenarios - - Validate message content using proper type assertions - - Implement proper error handling and edge cases - - Use try-catch blocks around critical test operations - - Log important events like task IDs, file paths, and AI responses for debugging - - Check test output carefully for error messages and stack traces - - - - - Remove unnecessary waits for specific tool executions - wait for task completion instead - - Simplify message handlers to only capture essential error information - - Use the simplest possible test structure that verifies the outcome - - Avoid complex message parsing logic that depends on AI behavior - - Terminal events are more reliable than message parsing for command execution verification - - Keep prompts simple and direct - complex instructions may confuse the AI - - - - - Add console.log statements to track test execution flow - - Log important events like task IDs, file paths, and AI responses - - Use codebase_search first to find similar test patterns before writing new tests - - Create helper functions for common file location checks - - Use descriptive variable names for file paths and content - - Always log the expected vs actual locations when tests fail - - Add comprehensive comments explaining complex test logic - - - - - Create reusable test utilities when patterns emerge - - Implement helper functions for common operations like file finding - - Use event collection utilities for consistent event handling - - Create assertion helpers for common validation patterns - - Document any new test utilities or patterns created - - Share common utilities across test files to reduce duplication - - - - - Keep prompts simple and direct - complex instructions may lead to unexpected behavior - - Allow for variations in how the AI accomplishes tasks - - The AI may not always use the exact tool you specify in the prompt - - Be prepared to adapt tests based on actual AI behavior rather than expected behavior - - The AI may interpret instructions creatively - test results rather than implementation details - - The AI will not see the files in the workspace directory, you must tell it to assume they exist and proceed - - - - - ALWAYS use the correct working directory: apps/vscode-e2e - - The test command is: npm run test:run - - To run specific tests use environment variable: TEST_FILE="filename.test" npm run test:run - - Example: cd apps/vscode-e2e && TEST_FILE="apply-diff.test" npm run test:run - - Never use npm test directly as it doesn't exist - - Always check available scripts with npm run if unsure - - Run tests incrementally during development to catch issues early - - - - - Never write a test file with a single write_to_file tool call - - Always implement tests in parts: structure first, then individual test cases - - Group related tests in the same suite - - Use consistent naming conventions for test files and functions - - Separate test utilities into their own files when they become substantial - - Follow the existing project structure and conventions - - \ No newline at end of file diff --git a/.roo/rules-integration-tester/4_common_mistakes.xml b/.roo/rules-integration-tester/4_common_mistakes.xml deleted file mode 100644 index 88a7473643..0000000000 --- a/.roo/rules-integration-tester/4_common_mistakes.xml +++ /dev/null @@ -1,109 +0,0 @@ - - - - Writing a test file with a single write_to_file tool call instead of implementing in parts - - Not using proper Mocha TDD structure with suite() and test() blocks - - Forgetting to implement suiteSetup() and suiteTeardown() for proper cleanup - - Creating tests that depend on each other or specific execution order - - Not cleaning up tasks and files after test completion - - Using describe/it blocks instead of the required suite/test blocks - - - - - Not using the global api object for extension interactions - - Forgetting to set auto-approval settings (alwaysAllowWrite, alwaysAllowExecute) when testing functionality that requires user approval - - Not implementing proper async/await patterns with waitFor utilities - - Using incorrect timeout values that are too short for actual task execution - - Not properly cleaning up tasks with cancelCurrentTask or clearCurrentTask - - Assuming the AI will use specific tools instead of testing outcomes - - - - - Assuming files will be created in the expected location without checking multiple paths - - Not accounting for the workspace directory being created by runTest.ts - - Creating test files in temporary directories instead of the VSCode workspace directory - - Not verifying files exist after creation during setup - - Forgetting that the AI may not see files in the workspace directory - - Not using flexible file location checking that searches workspace directories - - - - - Relying on parsing AI messages to detect tool usage instead of using proper event listeners - - Expecting tool results in "tool_result" message type (which doesn't exist) - - Not listening to terminal shell execution events for command tracking - - Depending on specific message formats that may vary - - Not implementing proper event cleanup after tests - - Parsing complex AI conversation messages instead of focusing on outcomes - - - - - Using npm test instead of npm run test:run - - Not using the correct working directory (apps/vscode-e2e) - - Running tests from the wrong directory - - Not checking available scripts with npm run when unsure - - Forgetting to use TEST_FILE environment variable for specific tests - - Not running tests incrementally during development - - - - - Not adding sufficient logging to track test execution flow - - Not logging important events like task IDs, file paths, and AI responses - - Not using codebase_search to find similar test patterns before writing new tests - - Not checking test output carefully for error messages and stack traces - - Not validating test state at critical points - - Assuming test failures are due to code issues without checking test logic - - - - - Using complex instructions that may confuse the AI - - Expecting the AI to use exact tools specified in prompts - - Not allowing for variations in how the AI accomplishes tasks - - Testing implementation details instead of outcomes - - Not adapting tests based on actual AI behavior - - Forgetting to tell the AI to assume files exist in the workspace directory - - - - - Adding unnecessary waits for specific tool executions - - Using complex message parsing logic that depends on AI behavior - - Not using the simplest possible test structure - - Depending on specific AI message formats - - Not using terminal events for reliable command execution verification - - Making tests too brittle by depending on exact AI responses - - - - - Not understanding that files may be created in /tmp/roo-test-workspace-* directories - - Assuming the AI can see files in the workspace directory - - Not checking multiple possible file locations when verifying creation - - Creating files outside the VSCode workspace during tests - - Not properly setting up the test workspace in suiteSetup() - - Forgetting to clean up workspace files in suiteTeardown() - - - - - Expecting specific message types for tool execution results - - Not understanding that ClineMessage types have specific values - - Trying to parse tool execution from AI conversation messages - - Not checking packages/types/src/message.ts for valid message types - - Depending on message parsing instead of outcome verification - - Not using api_req_started messages to verify tool execution - - - - - Using timeouts that are too short for actual task execution - - Not accounting for AI processing time in test timeouts - - Waiting for specific tool executions instead of task completion - - Not implementing proper retry logic for flaky operations - - Using fixed delays instead of condition-based waiting - - Not considering that some operations may take longer in CI environments - - - - - Not creating test files in the correct workspace directory - - Using hardcoded paths that don't work across different environments - - Not storing file paths in test-scoped objects for easy reference - - Creating test data that conflicts with other tests - - Not cleaning up test data properly after tests complete - - Using test data that's too complex for the AI to handle reliably - - \ No newline at end of file diff --git a/.roo/rules-integration-tester/5_test_environment.xml b/.roo/rules-integration-tester/5_test_environment.xml deleted file mode 100644 index 8e872b1dfc..0000000000 --- a/.roo/rules-integration-tester/5_test_environment.xml +++ /dev/null @@ -1,209 +0,0 @@ - - - VSCode E2E testing framework using Mocha and VSCode Test - - - Mocha TDD framework for test structure - - VSCode Test framework for extension testing - - Custom test utilities and helpers - - Event-driven testing patterns - - Workspace-based test execution - - - - - apps/vscode-e2e/src/suite/ - apps/vscode-e2e/src/utils/ - apps/vscode-e2e/src/runTest.ts - apps/vscode-e2e/package.json - packages/types/ - - - - apps/vscode-e2e - - npm run test:run - TEST_FILE="filename.test" npm run test:run - cd apps/vscode-e2e && TEST_FILE="apply-diff.test" npm run test:run - npm run - - - - Never use npm test directly as it doesn't exist - - Always use the correct working directory - - Use TEST_FILE environment variable for specific tests - - Check available scripts with npm run if unsure - - - - - Global api object for extension interactions - - - - api.startTask(prompt: string): Start a new task - - api.cancelCurrentTask(): Cancel the current task - - api.clearCurrentTask(): Clear the current task - - api.abortTask(): Abort the current task - - api.getTaskStatus(): Get current task status - - - - api.onDidReceiveMessage(callback): Listen to messages - - api.onTaskCompleted(callback): Listen to task completion - - api.onTaskAborted(callback): Listen to task abortion - - api.onTaskStarted(callback): Listen to task start - - api.onDidStartTerminalShellExecution(callback): Terminal start events - - api.onDidEndTerminalShellExecution(callback): Terminal end events - - - - api.updateSettings(settings): Update extension settings - - api.getSettings(): Get current settings - - - - - - - - Wait for a condition to be true - await waitFor(() => condition, timeout) - await waitFor(() => fs.existsSync(filePath), 5000) - - - Wait until current task is completed - await waitUntilCompleted() - Default timeout for task completion - - - Wait until current task is aborted - await waitUntilAborted() - Default timeout for task abortion - - - - - - Helper to find files in multiple possible locations - Use when files might be created in different workspace directories - - - Utility to collect and analyze events during test execution - Use for comprehensive event tracking and validation - - - Custom assertion functions for common test patterns - Use for consistent validation across tests - - - - - - - Test workspaces are created by runTest.ts - /tmp/roo-test-workspace-* - vscode.workspace.workspaceFolders![0].uri.fsPath - - - - Create all test files in suiteSetup() before any tests run - Always create files in the VSCode workspace directory - Verify files exist after creation to catch setup issues early - Clean up all test files in suiteTeardown() to avoid test pollution - Store file paths in a test-scoped object for easy reference - - - - The AI will not see the files in the workspace directory - Tell the AI to assume files exist and proceed as if they do - Always verify outcomes rather than relying on AI file visibility - - - - - Understanding message types for proper event handling - Check packages/types/src/message.ts for valid message types - - - - say - api_req_started - Indicates tool execution started - JSON with tool name and execution details - Most reliable way to verify tool execution - - - - Contains tool execution results - Tool results appear here, not in "tool_result" type - - - - General AI conversation messages - Format may vary, don't rely on parsing these for tool detection - - - - - - Settings to enable automatic approval of AI actions - - Enable for file creation/modification tests - Enable for command execution tests - Enable for browser-related tests - - - ```typescript - await api.updateSettings({ - alwaysAllowWrite: true, - alwaysAllowExecute: true - }); - ``` - - Without proper auto-approval settings, the AI won't be able to perform actions without user approval - - - - - Use console.log for tracking test execution flow - - - Log test phase transitions - - Log important events and data - - Log file paths and workspace state - - Log expected vs actual outcomes - - - - - Helper functions to validate test state at critical points - - - Workspace file listing - - Current working directory - - Task status - - Event counts - - - - - Tools for analyzing test failures - - - Stack trace analysis - - Event timeline reconstruction - - File system state comparison - - Message flow analysis - - - - - - - Appropriate timeout values for different operations - Use generous timeouts for task completion (30+ seconds) - Shorter timeouts for file system operations (5-10 seconds) - Medium timeouts for event waiting (10-15 seconds) - - - - Proper cleanup to avoid resource leaks - Always clean up event listeners after tests - Cancel or clear tasks in teardown - Remove test files to avoid disk space issues - - - \ No newline at end of file diff --git a/.roo/rules-issue-investigator/1_workflow.xml b/.roo/rules-issue-investigator/1_workflow.xml index 561b275120..4d2528e775 100644 --- a/.roo/rules-issue-investigator/1_workflow.xml +++ b/.roo/rules-issue-investigator/1_workflow.xml @@ -70,7 +70,7 @@ Draft Comment - Draft a comment for the GitHub issue that explains your findings and suggested solution in a conversational, human-like tone. + Draft a comment for the GitHub issue that explains your findings and suggested solution in a conversational, human-like tone. Start the comment with "Hey @roomote-agent,". diff --git a/.roo/rules-issue-investigator/2_best_practices.xml b/.roo/rules-issue-investigator/2_best_practices.xml index 31ad2c2267..1445822ccd 100644 --- a/.roo/rules-issue-investigator/2_best_practices.xml +++ b/.roo/rules-issue-investigator/2_best_practices.xml @@ -52,6 +52,7 @@ Does the draft comment sound conversational and human? + Does the draft comment start with "Hey @roomote-agent,"? Have I avoided technical jargon where possible? Is the tone helpful and not condescending? diff --git a/.roo/rules-issue-investigator/4_tool_usage.xml b/.roo/rules-issue-investigator/4_tool_usage.xml index c43c41a8c3..f34f57f5ff 100644 --- a/.roo/rules-issue-investigator/4_tool_usage.xml +++ b/.roo/rules-issue-investigator/4_tool_usage.xml @@ -23,10 +23,16 @@ Use `gh issue view [URL] --json title,body,labels,comments` to fetch initial details. Use `gh issue comment [URL] --body "..."` to add comments, but only after explicit user approval. Always wrap the comment body in quotes to handle special characters. + When posting a comment, the body must start with "Hey @roomote-agent," exactly. gh issue view https://github.com/RooCodeInc/Roo-Code/issues/123 --json title,body + + ]]> + +gh issue comment https://github.com/RooCodeInc/Roo-Code/issues/123 --body "Hey @roomote-agent, I've investigated and proposed a theoretical fix above." ]]> diff --git a/.roo/rules-issue-investigator/5_examples.xml b/.roo/rules-issue-investigator/5_examples.xml index dbd4ae2f23..af417e3e69 100644 --- a/.roo/rules-issue-investigator/5_examples.xml +++ b/.roo/rules-issue-investigator/5_examples.xml @@ -70,7 +70,7 @@ Formulate a solution and draft a comment. -"Hey, I took a look at this issue and it seems like the problem is in the `handleLogin` function in `src/auth/login.ts`. There's an empty `try...catch` block that is likely catching the login error and preventing any message from being shown to the user. +"Hey @roomote-agent, I took a look at this issue and it seems like the problem is in the `handleLogin` function in `src/auth/login.ts`. There's an empty `try...catch` block that is likely catching the login error and preventing any message from being shown to the user. A good first step to fixing this would be to add some logging to the `catch` block to see what error is actually happening. Then, we should probably implement proper error feedback to the user on the login page. This should solve the issue of the silent failure." diff --git a/.roo/rules-issue-investigator/6_communication.xml b/.roo/rules-issue-investigator/6_communication.xml index 348ecf1878..6ed68c0043 100644 --- a/.roo/rules-issue-investigator/6_communication.xml +++ b/.roo/rules-issue-investigator/6_communication.xml @@ -2,18 +2,20 @@ Be conversational and helpful, not robotic. Write comments as if you are a human developer collaborating on the project. + Always start any GitHub issue comment body with "Hey @roomote-agent,". Analysis complete. The investigation has yielded the following result. + Starting comments with "Hey," instead of "Hey @roomote-agent,". - Hey, I took a look at this and found something interesting... - I've been digging into this issue, and I think I've found a possible cause. + Hey @roomote-agent, I took a look at this and found something interesting... + Hey @roomote-agent, I've been digging into this issue, and I think I've found a possible cause. - Start with a friendly opening. + Start every GitHub issue comment with "Hey @roomote-agent,". State your main finding or hypothesis clearly but not definitively. Provide context, like file paths and function names. Propose a next step or a theoretical solution. diff --git a/.roo/rules-issue-writer/1_workflow.xml b/.roo/rules-issue-writer/1_workflow.xml index 99ef7db5d9..0dc2e279d2 100644 --- a/.roo/rules-issue-writer/1_workflow.xml +++ b/.roo/rules-issue-writer/1_workflow.xml @@ -1,1161 +1,391 @@ + + This mode focuses solely on assembling a template-free GitHub issue prompt for an AI coding agent. + It integrates codebase exploration to ground the prompt in reality while keeping the output non-technical. + It also captures the user-facing value/impact (who is affected, how often, and why it matters) to support prioritization, all in plain language. + + + + + - Codebase exploration is iterative and may repeat as many times as needed based on user-agent back-and-forth. + - Early-stop and escalate-once apply per iteration; when new info arrives, start a fresh iteration. + - One-tool-per-message is respected; narrate succinct progress and update TODOs each iteration. + + + - New details from the user (environment, steps, screenshots, constraints) + - Clarifications that change scope or target component/feature + - Discrepancies found between user claims and code + - Reclassification between Bug and Enhancement + + + + + - Treat the user's FIRST message as the issue description; do not ask if they want to create an issue. + - Begin immediately: initialize a focused TODO list and start repository detection before discovery. + - CLI submission via gh happens only after the user confirms during the merged review/submit step. + + + + [ ] Detect repository context (OWNER/REPO, monorepo, roots) + [ ] Perform targeted codebase discovery (iteration 1) + [ ] Clarify missing details (repro or desired outcome) + [ ] Classify type (Bug | Enhancement) + [ ] Assemble Issue Body + [ ] Review and submit (Submit now | Submit now and assign to me) + + + + + - Initialize Issue Creation Process + Kickoff - IMPORTANT: This mode assumes the first user message is already a request to create an issue. - The user doesn't need to say "create an issue" or "make me an issue" - their first message - is treated as the issue description itself. - - When the session starts, immediately: - 1. Treat the user's first message as the issue description - 2. Initialize the workflow by using the update_todo_list tool - 3. Begin the issue creation process without asking what they want to do - + Rephrase the user's goal and outline a brief plan, then proceed without delay. + Maintain low narrative verbosity; use structured outputs for details. + + + + + Detect Current Repository Information + + Verify we're in a Git repository and capture the GitHub remote for safe submission. + + 1) Check if inside a git repository: + + git rev-parse --is-inside-work-tree 2>/dev/null || echo "not-git-repo" + + + If the output is "not-git-repo", stop: + + + This mode must be run from within a GitHub repository. Navigate to a git repository and try again. + + + + 2) Get origin remote and normalize to OWNER/REPO: + + git remote get-url origin 2>/dev/null | sed -E 's/.*[:/]([^/]+)\/([^/]+)(\.git)?$/\1\/\2/' | sed 's/\.git$//' + + + If no origin remote exists, stop: + + + No GitHub 'origin' remote found. Configure a GitHub remote and retry. + + + + Record the normalized OWNER/REPO (e.g., owner/repo) as [OWNER_REPO] to pass via --repo during submission. + + 3) Combined monorepo check and roots discovery (single command): + + set -e; if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then echo "not-git-repo"; exit 0; fi; OWNER_REPO=$(git remote get-url origin 2>/dev/null | sed -E 's/.*[:/]([^/]+)\/([^/]+)(\.git)?$/\1\/\2/' | sed 's/\.git$//'); IS_MONO=false; [ -f package.json ] && grep -q '"workspaces"' package.json && IS_MONO=true; for f in lerna.json pnpm-workspace.yaml rush.json; do [ -f "$f" ] && IS_MONO=true; done; ROOTS="."; if [ "$IS_MONO" = true ]; then ROOTS=$(git ls-files -z | tr '\0' '\n' | grep -E '^(apps|packages|services|libs)/[^/]+/package\.json$' | sed -E 's#/package\.json$##' | sort -u | paste -sd, -); [ -z "$ROOTS" ] && ROOTS=$(find . -maxdepth 3 -name package.json -not -path "./node_modules/*" -print0 | xargs -0 -n1 dirname | grep -E '^(\.|\.\/(apps|packages|services|libs)\/[^/]+)$' | sort -u | paste -sd, -); fi; echo "OWNER_REPO=$OWNER_REPO"; echo "IS_MONOREPO=$IS_MONO"; echo "ROOTS=$ROOTS" + + + Interpretation: + - If output contains OWNER_REPO, IS_MONOREPO, and ROOTS, record them and treat Step 3 as satisfied. + - If output is "not-git-repo", stop as above. + - If IS_MONOREPO=true but ROOTS is empty, perform Step 3 to determine roots manually. + + + + [x] Detect repository context (OWNER/REPO, monorepo, roots) + [ ] Perform targeted codebase discovery (iteration N) + [ ] Clarify missing details (repro or desired outcome) + [ ] Classify type (Bug | Enhancement) + [ ] Assemble Issue Body + [ ] Review and submit (Submit now | Submit now and assign to me) + + + + + + + Determine Repository Structure (Monorepo/Standard) + + If Step 2's combined detection output includes IS_MONOREPO and ROOTS, mark this step complete and proceed to Step 4. Otherwise, use the manual process below. + + Identify whether this is a monorepo and record the search root(s). + + 1) List top-level entries: + + . + false + + + 2) Monorepo indicators: + - package.json with "workspaces" + - lerna.json, pnpm-workspace.yaml, rush.json + - Top-level directories like apps/, packages/, services/, libs/ + + If monorepo is detected: + - Discover package roots by locating package.json files under these directories + - Prefer scoping searches to the package most aligned with the user's description + - Ask for package selection if ambiguous + + If standard repository: + - Use repository root for searches + - - [ ] Detect current repository information - [ ] Determine repository structure (monorepo/standard) - [ ] Perform initial codebase discovery - [ ] Analyze user request to determine issue type - [ ] Gather and verify additional information - [ ] Determine if user wants to contribute - [ ] Perform issue scoping (if contributing) - [ ] Draft issue content - [ ] Review and confirm with user - [ ] Create GitHub issue - + + [x] Detect repository context (OWNER/REPO, monorepo, roots) + [-] Perform targeted codebase discovery (iteration N) + [ ] Clarify missing details (repro or desired outcome) + [ ] Classify type (Bug | Enhancement) + [ ] Assemble Issue Body + [ ] Review and submit (Submit now | Submit now and assign to me) + - - - Detect current repository information - - CRITICAL FIRST STEP: Verify we're in a git repository and get repository information. - - 1. Check if we're in a git repository: - - git rev-parse --is-inside-work-tree 2>/dev/null || echo "not-git-repo" - - - If the output is "not-git-repo", immediately stop and inform the user: - - - - This mode must be run from within a GitHub repository. Please navigate to a git repository and try again. - - - - 2. If in a git repository, get the repository information: - - git remote get-url origin 2>/dev/null | sed -E 's/.*[:/]([^/]+)\/([^/]+)(\.git)?$/\1\/\2/' | sed 's/\.git$//' - - - Store this as REPO_FULL_NAME for use throughout the workflow. - - If no origin remote exists, stop with: - - - No GitHub remote found. This mode requires a GitHub repository with an 'origin' remote configured. - - - - Update todo after detecting repository: - - - [x] Detect current repository information - [-] Determine repository structure (monorepo/standard) - [ ] Perform initial codebase discovery - [ ] Analyze user request to determine issue type - [ ] Gather and verify additional information - [ ] Determine if user wants to contribute - [ ] Perform issue scoping (if contributing) - [ ] Draft issue content - [ ] Review and confirm with user - [ ] Create GitHub issue - - - - + + Codebase-Aware Context Discovery (Iterative) + + Purpose: Understand the context of the user's description by exploring the codebase. This step is repeatable. - - Determine Repository Structure - - Check if this is a monorepo or standard repository by looking for common patterns. - - First, check for monorepo indicators: - 1. Look for workspace configuration: - - package.json with "workspaces" field - - lerna.json - - pnpm-workspace.yaml - - rush.json - - 2. Check for common monorepo directory patterns: - - . - false - - - Look for directories like: - - apps/ (application packages) - - packages/ (shared packages) - - services/ (service packages) - - libs/ (library packages) - - modules/ (module packages) - - src/ (main source if not using workspaces) - - If monorepo detected: - - Dynamically discover packages by looking for package.json files in detected directories - - Build a list of available packages with their paths - - Based on the user's description, try to identify which package they're referring to. - If unclear, ask for clarification: - - - I see this is a monorepo with multiple packages. Which specific package or application is your issue related to? - - [Dynamically generated list of discovered packages] - Let me describe which package: [specify] - - - - If standard repository: - - Skip package selection - - Use repository root for all searches - - Store the repository context for all future codebase searches and explorations. - - Update todo after determining context: - - - [x] Detect current repository information - [x] Determine repository structure (monorepo/standard) - [-] Perform initial codebase discovery - [ ] Analyze user request to determine issue type - [ ] Gather and verify additional information - [ ] Determine if user wants to contribute - [ ] Perform issue scoping (if contributing) - [ ] Draft issue content - [ ] Review and confirm with user - [ ] Create GitHub issue - - - - + Discovery workflow (respect one-tool-per-message): + 1) Extract keywords, component names, error phrases, and concepts from the user's message or latest reply. + 2) Run semantic search: + + [Keywords from user's description or latest reply] + - - Perform Initial Codebase Discovery - - Now that we know the repository structure, immediately search the codebase to understand - what the user is talking about before determining the issue type. - - DISCOVERY ACTIVITIES: - - 1. Extract keywords and concepts from the user's INITIAL MESSAGE (their issue description) - 2. Search the codebase to verify these concepts exist - 3. Build understanding of the actual implementation - 4. Identify relevant files, components, and code patterns - - - [Keywords from user's initial message/description] - [Repository or package path from step 2] - - - Additional searches based on initial findings: - - If error mentioned: search for exact error strings - - If feature mentioned: search for related functionality - - If component mentioned: search for implementation details - - - [repository or package path] - [specific patterns found in initial search] - - - Document findings: - - Components/features found that match user's description - - Actual implementation details discovered - - Related code sections identified - - Any discrepancies between user description and code reality - - Update todos: - - - [x] Detect current repository information - [x] Determine repository structure (monorepo/standard) - [x] Perform initial codebase discovery - [-] Analyze user request to determine issue type - [ ] Gather and verify additional information - [ ] Determine if user wants to contribute - [ ] Perform issue scoping (if contributing) - [ ] Draft issue content - [ ] Review and confirm with user - [ ] Create GitHub issue - - - - + 3) Refine with targeted regex where helpful: + + . + [exact error strings|component names|feature flags] + - - Analyze Request to Determine Issue Type - - Using the codebase discoveries from step 2, analyze the user's request to determine - the appropriate issue type with informed context. - - CRITICAL GUIDANCE FOR ISSUE TYPE SELECTION: - For issues that affect user workflows or require behavior changes: - - PREFER the feature proposal template over bug report - - Focus on explaining WHO is affected and WHEN this happens - - Describe the user impact before diving into technical details - - Based on your findings, classify the issue: - - Bug indicators (verified against code): - - Error messages that match actual error handling in code - - Broken functionality in existing features found in codebase - - Regression from previous behavior documented in code/tests - - Code paths that don't work as documented - - Feature indicators (verified against code): - - New functionality not found in current codebase - - Enhancement to existing features found in code - - Missing capabilities compared to similar features - - Integration points that could be extended - - WORKFLOW IMPROVEMENTS: When existing behavior works but doesn't meet user needs - - IMPORTANT: Use your codebase findings to inform the question: - - - Based on your request about [specific feature/component found in code], what type of issue would you like to create? - - [Order based on codebase findings and user description] - Bug Report - [Specific component] is not working as expected - Feature Proposal - Add [specific capability] to [existing component] - - - - Update todos: - - - [x] Detect current repository information - [x] Determine repository structure (monorepo/standard) - [x] Perform initial codebase discovery - [x] Analyze user request to determine issue type - [-] Gather and verify additional information - [ ] Determine if user wants to contribute - [ ] Perform issue scoping (if contributing) - [ ] Draft issue content - [ ] Review and confirm with user - [ ] Create GitHub issue - - - - + 4) Read key files for verification when necessary: + + [relevant file path from search hits] + - - Gather and Verify Additional Information - - Based on the issue type and initial codebase discovery, gather information while - continuously verifying against the actual code implementation. - - CRITICAL FOR FEATURE REQUESTS: Be fact-driven and challenge assumptions! - When users describe current behavior as problematic for a feature request, you MUST verify - their claims against the actual code. If their description doesn't match reality, this - might actually be a bug report, not a feature request. - - For Bug Reports: - 1. When user describes steps to reproduce: - - Search for the UI components/commands mentioned - - Verify the code paths that would be executed - - Check for existing error handling or known issues - - 2. When user provides error messages: - - Search for exact error strings in codebase - - Find where errors are thrown - - Understand the conditions that trigger them - - 3. For version information: - - Check package.json for actual version - - Look for version-specific code or migrations - - Example verification searches: - - [repository or package path] - [exact error message from user] - - - - [feature or component name] implementation - [repository or package path] - - - For Feature Requests - AGGRESSIVE VERIFICATION WITH CONCRETE EXAMPLES: - 1. When user claims current behavior is X: - - ALWAYS search for the actual implementation - - Read the relevant code to verify their claim - - Check CSS/styling files if UI-related - - Look at configuration files - - Examine test files to understand expected behavior - - TRACE THE DATA FLOW: Follow values from where they're calculated to where they're used - - 2. CRITICAL: Look for existing variables/code that could be reused: - - Search for variables that are calculated but not used where expected - - Identify existing patterns that could be extended - - Find similar features that work correctly for comparison - - 3. If discrepancy found between claim and code: - - Do NOT proceed without clarification - - Present CONCRETE before/after examples with actual values - - Show exactly what happens vs what should happen - - Ask if this might be a bug instead - - Example verification approach: - User says: "Feature X doesn't work properly" - - Your investigation should follow this pattern: - a) What is calculated: Search for where X is computed/defined - b) Where it's stored: Find variables/state holding the value - c) Where it's used: Trace all usages of that value - d) What's missing: Identify gaps in the flow - - Present findings with concrete examples: - - - I investigated the implementation and found something interesting: - - Current behavior: - - The value is calculated at [file:line]: `value = computeX()` - - It's stored in variable `calculatedValue` at [file:line] - - BUT it's only used for [purpose A] at [file:line] - - It's NOT used for [purpose B] where you expected it - - Concrete example: - - When you do [action], the system calculates [value] - - This value goes to [location A] - - But [location B] still uses [old/different value] - - Is this the issue you're experiencing? This seems like the calculated value isn't being used where it should be. - - Yes, exactly! The value is calculated but not used in the right place - No, the issue is that the calculation itself is wrong - Actually, I see now that [location B] should use a different value - - - - 4. Continue verification until facts are established: - - If user confirms it's a bug, switch to bug report workflow - - If user provides more specific context, search again - - Do not accept vague claims without code verification - - 5. For genuine feature requests after verification: - - Document what the code currently does (with evidence and line numbers) - - Show the exact data flow: input → processing → output - - Confirm what the user wants changed with concrete examples - - Ensure the request is based on accurate understanding - - CRITICAL: For feature requests, if user's description doesn't match codebase reality: - - Challenge the assumption with code evidence AND concrete examples - - Show actual vs expected behavior with specific values - - Suggest it might be a bug if code shows different intent - - Ask for clarification repeatedly if needed - - Do NOT proceed until facts are established - - Only proceed when you have: - - Verified current behavior in code with line-by-line analysis - - Confirmed user's understanding matches reality - - Determined if it's truly a feature request or actually a bug - - Identified any existing code that could be reused for the fix - - Update todos after verification: - - - [x] Detect current repository information - [x] Determine repository structure (monorepo/standard) - [x] Perform initial codebase discovery - [x] Analyze user request to determine issue type - [x] Gather and verify additional information - [-] Determine if user wants to contribute - [ ] Perform issue scoping (if contributing) - [ ] Draft issue content - [ ] Review and confirm with user - [ ] Create GitHub issue - - - - + Guidance: + - Early-stop per iteration when top hits converge (~70%) or you can name the exact feature/component involved. + - Escalate-once per iteration if signals conflict: run one refined batch, then proceed. + - Keep findings internal; do NOT include file paths, line numbers, stack traces, or diffs in the final prompt. - - Determine Contribution Intent with Context - - Before asking about contribution, perform a quick technical assessment to provide context: - - 1. Search for complexity indicators: - - Number of files that would need changes - - Existing tests that would need updates - - Dependencies and integration points - - 2. Look for contribution helpers: - - CONTRIBUTING.md guidelines - - Existing similar implementations - - Test patterns to follow - - - CONTRIBUTING guide setup development - - - Based on findings, provide informed context in the question: - - - Based on my analysis, this [issue type] involves [brief complexity assessment from code exploration]. Are you interested in implementing this yourself, or are you reporting it for the project team to handle? - - Just reporting the problem - the project team can design the solution - I want to contribute and implement this myself - I'd like to provide issue scoping to help whoever implements it - - - - Update todos based on response: - - - [x] Detect current repository information - [x] Determine repository structure (monorepo/standard) - [x] Perform initial codebase discovery - [x] Analyze user request to determine issue type - [x] Gather and verify additional information - [x] Determine if user wants to contribute - [If contributing: [-] Perform issue scoping (if contributing)] - [If not contributing: [-] Perform issue scoping (skipped - not contributing)] - [-] Draft issue content - [ ] Review and confirm with user - [ ] Create GitHub issue - - - - + Iteration rules: + - After ANY new user input or clarification, return to this step with updated keywords. + - Update internal notes and TODOs to reflect the current iteration (e.g., iteration 2, 3, ...). - - Issue Scoping for Contributors - - ONLY perform this step if the user wants to contribute or provide issue scoping. - - This step performs a comprehensive, aggressive investigation to create detailed technical - scoping that can guide implementation. The process involves multiple sub-phases: - - - - Perform an exhaustive investigation to produce a comprehensive technical solution - with extreme detail, suitable for automated fix workflows. - - - - Expand the todo list to include detailed investigation steps - - When starting the issue scoping phase, update the main todo list to include - the detailed investigation steps: - - - - [x] Detect current repository information - [x] Determine repository structure (monorepo/standard) - [x] Perform initial codebase discovery - [x] Analyze user request to determine issue type - [x] Gather and verify additional information - [x] Determine if user wants to contribute - [-] Perform issue scoping (if contributing) - [ ] Extract keywords from the issue description - [ ] Perform initial broad codebase search - [ ] Analyze search results and identify key components - [ ] Deep dive into relevant files and implementations - [ ] Form initial hypothesis about the issue/feature - [ ] Attempt to disprove hypothesis through further investigation - [ ] Identify all affected files and dependencies - [ ] Map out the complete implementation approach - [ ] Document technical risks and edge cases - [ ] Formulate comprehensive technical solution - [ ] Create detailed acceptance criteria - [ ] Prepare issue scoping summary - [ ] Draft issue content - [ ] Review and confirm with user - [ ] Create GitHub issue - - - - - - - Extract all relevant keywords, concepts, and technical terms - - - Identify primary technical concepts from user's description - - Extract error messages or specific symptoms - - Note any mentioned file paths or components - - List related features or functionality - - Include synonyms and related terms - - - Update the main todo list to mark "Extract keywords" as complete and move to next phase - - - - - Perform multiple rounds of increasingly focused searches - - - Use codebase_search with all extracted keywords to get an overview of relevant code. - - [Combined keywords from extraction phase] - [Repository or package path] - - - - - Based on initial results, identify key components and search for: - - Related class/function definitions - - Import statements and dependencies - - Configuration files - - Test files that might reveal expected behavior - - - - Search for specific implementation details: - - Error handling patterns - - State management - - API endpoints or routes - - Database queries or models - - UI components and their interactions - - - - Look for: - - Edge cases in the code - - Integration points with other systems - - Configuration options that affect behavior - - Feature flags or conditional logic - - - - After completing all search iterations, update the todo list to show progress - - - - - Thoroughly analyze all relevant files discovered - - - Use list_code_definition_names to understand file structure - - Read complete files to understand full context - - Trace execution paths through the code - - Identify all dependencies and imports - - Map relationships between components - - - Document findings including: - - File paths and their purposes - - Key functions and their responsibilities - - Data flow through the system - - External dependencies - - Potential impact areas - - - - - Form a comprehensive hypothesis about the issue or feature - - - Identify the most likely root cause - - Trace the bug through the execution path - - Determine why the current implementation fails - - Consider environmental factors - - - - Identify the optimal integration points - - Determine required architectural changes - - Plan the implementation approach - - Consider scalability and maintainability - - - - - Aggressively attempt to disprove the hypothesis - - - - Look for similar features implemented differently - - Check for deprecated code that might interfere - - - - Search for configuration that could change behavior - - Look for environment-specific code paths - - - - Find existing tests that might contradict hypothesis - - Look for test cases that reveal edge cases - - - - Search for comments explaining design decisions - - Look for TODO or FIXME comments related to the area - - - - If hypothesis is disproven, return to search phase with new insights. - If hypothesis stands, proceed to solution formulation. - - - - - Create a comprehensive technical solution - PRIORITIZE SIMPLICITY - - CRITICAL: Before proposing any solution, ask yourself: - 1. What existing variables/functions can I reuse? - 2. What's the minimal change that fixes the issue? - 3. Can I leverage existing patterns in the codebase? - 4. Is there a simpler approach I'm overlooking? - - The best solution often reuses existing code rather than creating new complexity. - - - - ALWAYS consider backwards compatibility: - 1. Will existing data/configurations still work with the new code? - 2. Can we detect and handle legacy formats automatically? - 3. What migration paths are needed for existing users? - 4. Are there ways to make changes additive rather than breaking? - 5. Document any compatibility considerations clearly - - - - FIRST, identify what can be reused: - - Variables that are already calculated but not used where needed - - Functions that already do what we need - - Patterns in similar features we can follow - - Configuration that already exists but isn't applied - - Example finding: - "The variable `calculatedValue` already contains what we need at line X, - we just need to use it at line Y instead of recalculating" - - - - - Start with the SIMPLEST possible fix - - Exact files to modify with line numbers - - Prefer changing variable usage over creating new logic - - Specific code changes required (minimal diff) - - Order of implementation steps - - Migration strategy if needed - - - - - All files that import affected code - - API contracts that must be maintained - - Existing tests that validate current behavior - - Configuration changes required (prefer reusing existing) - - Documentation updates needed - - - - - Unit tests to add or modify - - Integration tests required - - Edge cases to test - - Performance testing needs - - Manual testing scenarios - - - - - Breaking changes identified - - Performance implications - - Security considerations - - Backward compatibility issues - - Rollback strategy - - - - - - Create extremely detailed acceptance criteria - - Given [detailed context including system state] - When [specific user or system action] - Then [exact expected outcome] - And [additional verifiable outcomes] - But [what should NOT happen] - - Include: - - Specific UI changes with exact text/behavior - - API response formats - - Database state changes - - Performance requirements - - Error handling scenarios - - - - Each criterion must be independently testable - - Include both positive and negative test cases - - Specify exact error messages and codes - - Define performance thresholds where applicable - - - - - Format the comprehensive issue scoping section - + + [x] Detect repository context (OWNER/REPO, monorepo, roots) + [-] Perform targeted codebase discovery (iteration N) + [ ] Clarify missing details (repro or desired outcome) + [ ] Classify type (Bug | Enhancement) + [ ] Assemble Issue Body + [ ] Review and submit (Submit now | Submit now and assign to me) + + + + -### Root Cause / Implementation Target -[Detailed explanation of the core issue or feature target, focusing on the practical problem first] + + Clarify Missing Details (Guided by Findings) + + Ask minimal, targeted questions grounded by what you found in code. -### Affected Components -- **Primary Files:** - - `path/to/file1.ts` (lines X-Y): [Purpose and changes needed] - - `path/to/file2.ts` (lines A-B): [Purpose and changes needed] + For Bug reports: + + I’m verifying the behavior around [feature/component inferred from code]. Could you provide a minimal reproduction and quick impact details? + + Repro format: 1) Environment/setup 2) Steps 3) Expected 4) Actual 5) Variations (only if you tried them) + Impact: Who is affected and how often does this happen? + Cost: Approximate time or outcome cost per occurrence (optional) + + -- **Secondary Impact:** - - Files that import affected components - - Related test files - - Documentation files + For Enhancements: + + To capture the improvement well, what is the user goal and value in plain language? + + State the user goal and when it occurs + Describe the desired behavior conceptually (no code) + Value: Who benefits and what improves (speed, clarity, fewer errors, conversions)? + + -### Current Implementation Analysis -[Detailed explanation of how the current code works, with specific examples showing the data flow] -Example: "The function at line X calculates [value] by [method], which results in [actual behavior]" + Discrepancies: + - If you found contradictions between description and code, present concrete, plain-language examples (no code) and ask for confirmation. -### Proposed Implementation + Loop-back: + - After receiving any answer, return to Step 4 (Discovery) with the new information and repeat as needed. -#### Step 1: [First implementation step] -- File: `path/to/file.ts` -- Changes: [Specific code changes] -- Rationale: [Why this change is needed] + + + [x] Detect repository context (OWNER/REPO, monorepo, roots) + [x] Perform targeted codebase discovery (iteration N) + [-] Clarify missing details (repro or desired outcome) + [ ] Classify type (Bug | Enhancement) + [ ] Assemble Issue Body + [ ] Review and submit (Submit now | Submit now and assign to me) + + + + -#### Step 2: [Second implementation step] -[Continue for all steps...] + + Classify Type (Provisional and Repeatable) + + Use the user's description plus verified findings to choose: + - Bug indicators: matched error strings; broken behavior in existing features; regression indicators. + - Enhancement indicators: capability absent; extension of existing feature; workflow improvement. + - Impact snapshot (optional): Severity (Blocker/High/Medium/Low) and Reach (Few/Some/Many). If uncertain, omit and proceed. -### Code Architecture Considerations -- Design patterns to follow -- Existing patterns in codebase to match -- Architectural constraints + Confirm with the user if uncertain: + + Based on the behavior around [feature/component], should we frame this as a Bug or an Enhancement? + + Bug Report + Enhancement + + -### Testing Requirements -- Unit Tests: - - [ ] Test case 1: [Description] - - [ ] Test case 2: [Description] -- Integration Tests: - - [ ] Test scenario 1: [Description] -- Edge Cases: - - [ ] Edge case 1: [Description] + Reclassification: + - If later evidence or user info changes the type, reclassify and loop back to Step 4 for a fresh discovery iteration. -### Performance Impact -- Expected performance change: [Increase/Decrease/Neutral] -- Benchmarking needed: [Yes/No, specifics] -- Optimization opportunities: [List any] + + + [x] Detect repository context (OWNER/REPO, monorepo, roots) + [x] Perform targeted codebase discovery (iteration N) + [x] Clarify missing details (repro or desired outcome) + [-] Classify type (Bug | Enhancement) + [ ] Assemble Issue Body + [ ] Review and submit (Submit now | Submit now and assign to me) + + + + -### Security Considerations -- Input validation requirements -- Authentication/Authorization changes -- Data exposure risks + + Assemble Issue Body + + Build a concise, non-technical issue body. Omit empty sections entirely. -### Migration Strategy -[If applicable, how to migrate existing data/functionality] + Format: + ``` + ## Type + Bug | Enhancement -### Rollback Plan -[How to safely rollback if issues arise] + ## Problem / Value + [One or two sentences that capture the problem and why it matters in plain language] -### Dependencies and Breaking Changes -- External dependencies affected: [List] -- API contract changes: [List] -- Breaking changes for users: [List with mitigation] - ]]> - - - - Additional considerations for monorepo repositories: - - Scope all searches to the identified package (if monorepo) - - Check for cross-package dependencies - - Verify against package-specific conventions - - Look for package-specific configuration - - Check if changes affect multiple packages - - Identify shared dependencies that might be impacted - - Look for workspace-specific scripts or tooling - - Consider package versioning implications - - After completing the comprehensive issue scoping, update the main todo list to show - all investigation steps are complete: - - - [x] Detect current repository information - [x] Determine repository structure (monorepo/standard) - [x] Perform initial codebase discovery - [x] Analyze user request to determine issue type - [x] Gather and verify additional information - [x] Determine if user wants to contribute - [x] Perform issue scoping (if contributing) - [x] Extract keywords from the issue description - [x] Perform initial broad codebase search - [x] Analyze search results and identify key components - [x] Deep dive into relevant files and implementations - [x] Form initial hypothesis about the issue/feature - [x] Attempt to disprove hypothesis through further investigation - [x] Identify all affected files and dependencies - [x] Map out the complete implementation approach - [x] Document technical risks and edge cases - [x] Formulate comprehensive technical solution - [x] Create detailed acceptance criteria - [x] Prepare issue scoping summary - [-] Draft issue content - [ ] Review and confirm with user - [ ] Create GitHub issue - - - - + ## Context + [Who is affected and when it happens] + [Enhancement: desired behavior conceptually, in the user's words] + [Bug: current observed behavior in plain language] - - Check for Repository Issue Templates - - Check if the repository has custom issue templates and use them. If not, create a simple generic template. - - 1. Check for issue templates in standard locations: - - .github/ISSUE_TEMPLATE - true - - - 2. Also check for single template file: - - .github - false - - - Look for files like: - - .github/ISSUE_TEMPLATE/*.md - - .github/ISSUE_TEMPLATE/*.yml - - .github/ISSUE_TEMPLATE/*.yaml - - .github/issue_template.md - - .github/ISSUE_TEMPLATE.md - - 3. If templates are found: - a. Parse the template files to extract: - - Template name and description - - Required fields - - Template body structure - - Labels to apply - - b. For YAML templates, look for: - - name: Template display name - - description: Template description - - labels: Default labels - - body: Form fields or markdown template - - c. For Markdown templates, look for: - - Front matter with metadata - - Template structure with placeholders - - 4. If multiple templates exist, ask user to choose: - - I found the following issue templates in this repository. Which one would you like to use? - - [Template 1 name]: [Template 1 description] - [Template 2 name]: [Template 2 description] - - - - 5. If no templates are found: - - Create a simple generic template based on issue type - - For bugs: Basic structure with description, steps to reproduce, expected vs actual - - For features: Problem description, proposed solution, impact - - 6. Store the selected/created template information: - - Template content/structure - - Required fields - - Default labels - - Any special formatting requirements - - Update todos: - - - [x] Detect current repository information - [x] Determine repository structure (monorepo/standard) - [x] Perform initial codebase discovery - [x] Analyze user request to determine issue type - [x] Gather and verify additional information - [x] Determine if user wants to contribute - [x] Perform issue scoping (if contributing) - [x] Check for repository issue templates - [-] Draft issue content - [ ] Review and confirm with user - [ ] Create GitHub issue - - - - + ## Reproduction (Bug only, if available) + 1) Steps (each action/command) + 2) Expected result + 3) Actual result + 4) Variations tried (include only if the user explicitly provided them) - - Draft Issue Content - - Create the issue body using the template from step 8 and all verified information from codebase exploration. - - If using a repository template: - - Fill in the template fields with gathered information - - Include code references and findings where appropriate - - Respect the template's structure and formatting - - If using a generated template (no repo templates found): - - For Bug Reports: - ``` - ## Description - [Clear description of the bug with code context] - - ## Steps to Reproduce - 1. [Step with relevant code paths] - 2. [Step with component references] - 3. [Continue with specific details] - - ## Expected Behavior - [What should happen based on code logic] - - ## Actual Behavior - [What actually happens] - - ## Additional Context - - Version: [from package.json if found] - - Environment: [any relevant details] - - Error logs: [if any] - - ## Code Investigation - [Include findings from codebase exploration] - - Relevant files: [list with line numbers] - - Possible cause: [hypothesis from code review] - - [If user is contributing, add the comprehensive issue scoping section from step 7] - ``` - - For Feature Requests: - ``` - ## Problem Description - [What problem does this solve, who is affected, when it happens] - - ## Current Behavior - [How it works now with specific examples] - - ## Proposed Solution - [What should change] - - ## Impact - [Who benefits and how] - - ## Technical Context - [Findings from codebase exploration] - - Similar features: [code references] - - Integration points: [from exploration] - - Architecture considerations: [if any] - - [If contributing, add the comprehensive issue scoping section from step 7] - ``` - - Update todos: - - - [x] Detect current repository information - [x] Determine repository structure (monorepo/standard) - [x] Perform initial codebase discovery - [x] Analyze user request to determine issue type - [x] Gather and verify additional information - [x] Determine if user wants to contribute - [x] Perform issue scoping (if contributing) - [x] Check for repository issue templates - [x] Draft issue content - [-] Review and confirm with user - [ ] Create GitHub issue - - - - + ## Constraints/Preferences + [Performance, accessibility, UX, or other considerations] + ``` - - Review and Confirm with User - - Present the complete drafted issue to the user for review, highlighting the - code-verified information: - - - I've prepared the following GitHub issue based on my analysis of the codebase and your description. I've verified the technical details against the actual implementation. Please review: + Rules: + - Keep non-technical; do NOT include code paths, line numbers, stack traces, or diffs. + - Ground the wording in verified behavior, but keep implementation details internal. + - Sourcing: Do not infer or fabricate reproduction details or “Variations tried.” Include them only if explicitly provided by the user; otherwise omit the line. + - Quoting fidelity: If the user lists “Variations tried,” include them faithfully (verbatim or clearly paraphrased without adding new items). + - Value framing: Ensure the “Problem / Value” explains why it matters (impact on users or outcomes) in plain language. + - Title: Produce a concise Title (≤ 80 chars) prefixed with [BUG] or [ENHANCEMENT]; when helpful, append a brief value phrase in parentheses, e.g., “(blocks new runs)”. - [Show the complete formatted issue content] + Iteration note: + - If new info arrives after drafting, loop back to Step 4, then update this draft accordingly. - Key verifications made: - - ✓ Component locations confirmed in code - - ✓ Error messages matched to source - - ✓ Architecture compatibility checked - [List other relevant verifications] + + + [x] Detect repository context (OWNER/REPO, monorepo, roots) + [x] Perform targeted codebase discovery (iteration N) + [x] Clarify missing details (repro or desired outcome) + [x] Classify type (Bug | Enhancement) + [-] Assemble Issue Body + [ ] Review and submit (Submit now | Submit now and assign to me) + + + + - Would you like me to create this issue, or would you like to make any changes? - - Yes, create this issue in the detected repository - Modify the problem description - Add more technical details - Change the title to: [let me specify] - - - - If user requests changes, make them and show the updated version for confirmation. - - After confirmation: - - - [x] Detect current repository information - [x] Determine repository structure (monorepo/standard) - [x] Perform initial codebase discovery - [x] Analyze user request to determine issue type - [x] Gather and verify additional information - [x] Determine if user wants to contribute - [x] Perform issue scoping (if contributing) - [x] Check for repository issue templates - [x] Draft issue content - [x] Review and confirm with user - [-] Prepare issue for submission - [ ] Handle submission choice - - - - + + Review and Submit (Single-Step) + + Present the full current issue details in a code block. Offer two submission options; any other response is treated as a change request. - - Prepare Issue for Submission - - Once user confirms the issue content, prepare it for submission: - - First, perform final duplicate check with refined search based on our findings: - - gh issue list --repo $REPO_FULL_NAME --search "[key terms from verified analysis]" --state all --limit 10 - - - If no exact duplicates are found, save the issue content to a temporary file within the project: - - - ./github_issue_draft.md - [The complete formatted issue body from step 8] - [calculated line count] - - - After saving the issue draft, ask the user how they would like to proceed: - - - I've saved the issue draft to ./github_issue_draft.md. The issue is ready for submission with the following details: + + Review the current issue details. Select one of the options below or specify any changes or other workflow you would like me to perform: - Title: "[Descriptive title with component name]" - Labels: [appropriate labels based on issue type] - Repository: $REPO_FULL_NAME +```md +Title: [ISSUE_TITLE] - How would you like to proceed? - - Submit the issue now to the repository - Let me make some edits to the issue first - I'll submit it manually later - - - - Based on the user's response: - - If "Submit the issue now": - - Use gh issue create with the saved file - - Provide the created issue URL and number - - Clean up the temporary file - - Complete the workflow - - If "Let me make some edits": - - Ask what changes they'd like to make - - Update the draft file with their changes - - Return to the submission question - - If "I'll submit it manually": - - Inform them the draft is saved at the configured location - - Provide the gh command they can use later - - Complete the workflow without submission - - Update todos based on the outcome: - - - [x] Detect current repository information - [x] Determine repository structure (monorepo/standard) - [x] Perform initial codebase discovery - [x] Analyze user request to determine issue type - [x] Gather and verify additional information - [x] Determine if user wants to contribute - [x] Perform issue scoping (if contributing) - [x] Check for repository issue templates - [x] Draft issue content - [x] Review and confirm with user - [x] Prepare issue for submission - [-] Handle submission choice - - - - +[ISSUE_BODY] +``` + + Submit now + Submit now and assign to me + + - - Handle Submission Choice - - This step handles the user's choice from step 9. - - OPTION 1: Submit the issue now - If the user chooses to submit immediately: - - - gh issue create --repo $REPO_FULL_NAME --title "[Descriptive title]" --body-file ./github_issue_draft.md --label "[appropriate labels]" - - - Label selection based on findings: - - Bug: Use "bug" label - - Feature: Use "enhancement" label - - If affects multiple packages in monorepo: add "affects-multiple" label - - After successful creation: - - Capture and display the issue URL - - Clean up the temporary file: - - rm ./github_issue_draft.md - - - Provide a summary of key findings included - - OPTION 2: Make edits - If the user wants to edit: - - - What changes would you like to make to the issue? - - Update the title - Modify the problem description - Add or remove technical details - Change the labels or other metadata - - - - - Apply the requested changes to the draft - - Update the file with write_to_file - - Return to step 9 to ask about submission again - - OPTION 3: Manual submission - If the user will submit manually: - - Provide clear instructions: - "The issue draft has been saved to ./github_issue_draft.md + Responses: + - If "Submit now": + Prepare: + - Title: derive from Summary (≤ 80 chars, plain language) + - Body: the finalized issue body - To submit it later, you can use: - gh issue create --repo $REPO_FULL_NAME --title "[Your title]" --body-file ./github_issue_draft.md --label "[labels]" - - Or you can copy the content and create the issue through the GitHub web interface." - - Final todo update: - - - [x] Detect current repository information - [x] Determine repository structure (monorepo/standard) - [x] Perform initial codebase discovery - [x] Analyze user request to determine issue type - [x] Gather and verify additional information - [x] Determine if user wants to contribute - [x] Perform issue scoping (if contributing) - [x] Check for repository issue templates - [x] Draft issue content - [x] Review and confirm with user - [x] Prepare issue for submission - [x] Handle submission choice - - - - + Execute: + + gh issue create --repo "[OWNER_REPO]" --title "[ISSUE_TITLE]" --body "$(printf '%s\n' "[ISSUE_BODY]")" + + + - If "Submit now and assign to me": + Execute (assignment at creation; falls back to edit if needed): + + ISSUE_URL=$(gh issue create --repo "[OWNER_REPO]" --title "[ISSUE_TITLE]" --body "$(printf '%s\n' "[ISSUE_BODY]")" --assignee "@me") || true; if [ -z "$ISSUE_URL" ]; then ISSUE_URL=$(gh issue create --repo "[OWNER_REPO]" --title "[ISSUE_TITLE]" --body "$(printf '%s\n' "[ISSUE_BODY]")"); gh issue edit "$ISSUE_URL" --add-assignee "@me"; fi; echo "$ISSUE_URL" + + + - Any other response: + - Collect requested edits and apply them + - Loop back to Step 4 (Discovery) if new information affects context + - Re-assemble in Step 7 + - Rerun this step and present the updated issue details + + On success: Capture the created issue URL from stdout and complete: + + + Created issue: [URL] + + + + On failure: Present the error succinctly and offer to retry after fixing gh setup (installation/auth). Provide the computed Title and Body inline so the user can submit manually if needed. + + + + [x] Detect repository context (OWNER/REPO, monorepo, roots) + [x] Perform targeted codebase discovery (iteration N) + [x] Clarify missing details (repro or desired outcome) + [x] Classify type (Bug | Enhancement) + [x] Assemble Issue Body + [x] Review and submit (Submit now | Submit now and assign to me) + + + + + + + + Repository detection (git repo present and origin remote configured) is performed before any submission. + Issue is submitted via gh after choosing "Submit now" or "Submit now and assign to me", and the created issue URL is returned. + When "Submit now and assign to me" is chosen, the issue is assigned to the current GitHub user using --assignee "@me" (or gh issue edit fallback). + Submission uses Title and Body only and specifies --repo [OWNER_REPO] discovered in Step 2; no temporary files or file paths are used. + Language is plain and user-centric; no technical artifacts included in the issue body. + Content grounded by repeated codebase exploration cycles as needed. + Early-stop/escalate-once applied per iteration; unlimited iterations across the conversation. + The merged step offers "Submit now" or "Submit now and assign to me"; any other response is treated as a change request and the step is shown again with the full current issue details. + \ No newline at end of file diff --git a/.roo/rules-issue-writer/2_github_issue_templates.xml b/.roo/rules-issue-writer/2_github_issue_templates.xml deleted file mode 100644 index 36b44125dd..0000000000 --- a/.roo/rules-issue-writer/2_github_issue_templates.xml +++ /dev/null @@ -1,190 +0,0 @@ - - - This mode prioritizes using repository-specific issue templates over hardcoded ones. - If no templates exist in the repository, simple generic templates are created on the fly. - - - - - .github/ISSUE_TEMPLATE/*.yml - .github/ISSUE_TEMPLATE/*.yaml - .github/ISSUE_TEMPLATE/*.md - .github/issue_template.md - .github/ISSUE_TEMPLATE.md - - - - Display name of the template - Brief description of when to use this template - Default issue title (optional) - Array of labels to apply - Array of default assignees - Array of form elements or markdown content - - - - - Static markdown content - - The markdown content to display - - - - - Single-line text input - - Unique identifier - Display label - Help text - Placeholder text - Default value - Boolean - - - - - Multi-line text input - - Unique identifier - Display label - Help text - Placeholder text - Default value - Boolean - Language for syntax highlighting - - - - - Dropdown selection - - Unique identifier - Display label - Help text - Array of options - Boolean - - - - - Multiple checkbox options - - Unique identifier - Display label - Help text - Array of checkbox items - - - - - - - Optional YAML front matter with: - - name: Template name - - about: Template description - - title: Default title - - labels: Comma-separated or array - - assignees: Comma-separated or array - - - Markdown content with sections and placeholders - Common patterns: - - Headers with ## - - Placeholder text in brackets or as comments - - Checklists with - [ ] - - Code blocks with ``` - - - - - - - When no repository templates exist, create simple templates based on issue type. - These should be minimal and focused on gathering essential information. - - - - - - Description: Clear explanation of the bug - - Steps to Reproduce: Numbered list - - Expected Behavior: What should happen - - Actual Behavior: What actually happens - - Additional Context: Version, environment, logs - - Code Investigation: Findings from exploration (if any) - - ["bug"] - - - - - - Problem Description: What problem this solves - - Current Behavior: How it works now - - Proposed Solution: What should change - - Impact: Who benefits and how - - Technical Context: Code findings (if any) - - ["enhancement", "proposal"] - - - - - - When parsing YAML templates: - 1. Use a YAML parser to extract the structure - 2. Convert form elements to markdown sections - 3. Preserve required field indicators - 4. Include descriptions as help text - 5. Maintain the intended flow of the template - - - - When parsing Markdown templates: - 1. Extract front matter if present - 2. Identify section headers - 3. Look for placeholder patterns - 4. Preserve formatting and structure - 5. Replace generic placeholders with user's information - - - - For template selection: - 1. If only one template exists, use it automatically - 2. If multiple exist, let user choose based on name/description - 3. Match template to issue type when possible (bug vs feature) - 4. Respect template metadata (labels, assignees, etc.) - - - - - - Fill templates intelligently using gathered information: - - Map user's description to appropriate sections - - Include code investigation findings where relevant - - Preserve template structure and formatting - - Don't leave placeholder text unfilled - - Add contributor scoping if user is contributing - - - - - - - - - - - - - When no templates exist, create appropriate generic templates on the fly. - Keep them simple and focused on essential information. - - - - - Don't overwhelm with too many fields - - Focus on problem description first - - Include technical details only if user is contributing - - Use clear, simple section headers - - Adapt based on issue type (bug vs feature) - - - \ No newline at end of file diff --git a/.roo/rules-issue-writer/3_best_practices.xml b/.roo/rules-issue-writer/3_best_practices.xml index f2f149ed26..b6f90c8014 100644 --- a/.roo/rules-issue-writer/3_best_practices.xml +++ b/.roo/rules-issue-writer/3_best_practices.xml @@ -1,172 +1,147 @@ + + This mode assembles a template-free issue body grounded by codebase exploration and can submit it via GitHub CLI after explicit confirmation. + Submission uses Title and Body only and targets the detected repository after the merged Review and Submit step. + + - - CRITICAL: This mode assumes the user's FIRST message is already an issue description - - Do NOT ask "What would you like to do?" or "Do you want to create an issue?" - - Immediately start the issue creation workflow when the user begins talking - - Treat their initial message as the problem/feature description - - Begin with repository detection and codebase discovery right away - - The user is already in "issue creation mode" by choosing this mode + - Treat the user's FIRST message as the issue description; do not ask if they want to create an issue. + - Start with repository detection (verify git repo; resolve OWNER/REPO from origin), then determine repository structure (monorepo/standard). + - After detection, begin codebase discovery scoped to the repository root or the selected package (in monorepos). + - Keep final output non-technical; implementation details remain internal. - - - - ALWAYS check for repository-specific issue templates before creating issues - - Use templates from .github/ISSUE_TEMPLATE/ directory if they exist - - Parse both YAML (.yml/.yaml) and Markdown (.md) template formats - - If multiple templates exist, let the user choose the appropriate one - - If no templates exist, create a simple generic template on the fly - - NEVER fall back to hardcoded templates - always use repo templates or generate minimal ones - - Respect template metadata like labels, assignees, and title patterns - - Fill templates intelligently using gathered information from codebase exploration - - - - - Focus on helping users describe problems clearly, not solutions - - The project team will design solutions unless the user explicitly wants to contribute - - Don't push users to provide technical details they may not have - - Make it easy for non-technical users to report issues effectively - - CRITICAL: Lead with user impact: - - Always explain WHO is affected and WHEN the problem occurs - - Use concrete examples with actual values, not abstractions - - Show before/after scenarios with specific data - - Example: "Users trying to [action] see [actual result] instead of [expected result]" - - - - - ALWAYS verify user claims against actual code implementation - - For feature requests, aggressively check if current behavior matches user's description - - If code shows different intent than user describes, it might be a bug not a feature - - Present code evidence when challenging user assumptions - - Do not be agreeable - be fact-driven and question discrepancies - - Continue verification until facts are established - - A "feature request" where code shows the feature should already work is likely a bug - - CRITICAL additions for thorough analysis: - - Trace data flow from where values are created to where they're used - - Look for existing variables/functions that already contain needed data - - Check if the issue is just missing usage of existing code - - Follow imports and exports to understand data availability - - Identify patterns in similar features that work correctly - - - - - Always search for existing similar issues before creating a new one - - Check for and use repository issue templates before creating content - - Include specific version numbers and environment details - - Use code blocks with syntax highlighting for code snippets - - Make titles descriptive but concise (e.g., "Dark theme: Submit button invisible due to white-on-grey text") - - For bugs, always test if the issue is reproducible - - Include screenshots or mockups when relevant (ask user to provide) - - Link to related issues or PRs if found during exploration - - CRITICAL: Use concrete examples throughout: - - Show actual data values, not just descriptions - - Include specific file paths and line numbers - - Demonstrate the data flow with real examples - - Bad: "The value is incorrect" - - Good: "The function returns '123' when it should return '456'" - - - - - Only perform issue scoping if user wants to contribute - - Reference specific files and line numbers from codebase exploration - - Ensure technical proposals align with project architecture - - Include implementation steps and issue scoping - - Provide clear acceptance criteria in Given/When/Then format - - Consider trade-offs and alternative approaches - - CRITICAL: Prioritize simple solutions: - - ALWAYS check if needed functionality already exists before proposing new code - - Look for existing variables that just need to be passed/used differently - - Prefer using existing patterns over creating new ones - - The best fix often involves minimal code changes - - Example: "Use existing `modeInfo` from line 234 in export" vs "Create new mode tracking system" - - - - ALWAYS consider backwards compatibility: - - Think about existing data/configurations already in use - - Propose solutions that handle both old and new formats gracefully - - Consider migration paths for existing users - - Document any breaking changes clearly - - Prefer additive changes over breaking changes when possible - - + + + + - Always pair the problem with user-facing value: who is impacted, when it occurs, and why it matters. + - Keep value non-technical (clarity, time saved, fewer errors, better UX, improved accessibility, reduced confusion). + + + - Severity: Blocker | High | Medium | Low (optional) + - Reach: Few | Some | Many (optional) + + + + + + - Reproduction steps + - Variations tried + - Environment details + + + - Problem/Value statement (plain-language synthesis from user wording) + - Context (who/when) based on user input; keep code-based signals internal + + + - Never fabricate “Variations tried.” If not provided, omit. + - If critical details are missing, ask targeted questions; otherwise proceed with omissions. + + + + + + Use a single merged "Review and Submit" step with options: + - Submit now + - Submit now and assign to me + Any other response is treated as a change request and the step is rerun after applying edits. + + + Submission requires repository detection (git present, origin configured). Capture normalized OWNER/REPO (e.g., owner/repo) and store as [OWNER_REPO] for submission. + + + Always specify the target using --repo "[OWNER_REPO]" to avoid ambiguity and ensure the correct repository is used. + + + When "Submit now and assign to me" is chosen, create using: --assignee "@me". + If creation with --assignee fails (e.g., permissions), create the issue without an assignee and immediately run: + gh issue edit --add-assignee "@me". + + + Use --body with robust quoting (for example: --body "$(printf '%s\n' "[ISSUE_BODY]")") or a heredoc; do not create temporary files or reference file paths. Always include --repo "[OWNER_REPO]" and echo the resulting issue URL. + In execute_command calls, output only the command string; never include XML tags, CDATA markers, code fences, or backticks in the command payload. + + + On gh errors (installation/auth), present the error and offer to retry after fixing gh setup. Surface the computed Title and Body inline + so the user can submit manually if needed. + + + + + + - Use semantic search first to find relevant areas. + - Refine with targeted regex for exact strings (errors, component names, flags). + - Read key files to verify behavior; keep evidence internal. + - Early-stop when hits converge (~70%) or you can name the exact feature/component. + - Escalate-once if signals conflict; run one refined batch, then proceed. + + + 1) codebase_search → 2) search_files → 3) read_file (as needed) + + + In monorepos, scope searches to the selected package when the context is clear; otherwise ask for the relevant package/app if ambiguous. + + + Keep language plain and exclude technical artifacts (paths, line numbers, stack traces, diffs) from the final issue body. + + + + + + - Ask minimal, targeted questions based on what you found in code. + - For bugs: request a minimal reproduction (environment, steps, expected, actual, variations). + - For enhancements: capture user goal, desired behavior in plain language, and any constraints. + - Present discrepancies in plain language (no code) and confirm understanding. + + + + + + + + + - Omit sections that would be empty. + - Do not include "Variations tried" unless explicitly provided by the user. + - Keep language plain and user-centric. + - Exclude technical artifacts (paths, lines, stacks, diffs). + + + + + - At each review stage, present the full current issue details (Title + Body) in a markdown code block. + - Offer "Submit now" or "Submit now and assign to me" suggestions; treat any other response as a change request and rerun the step after applying edits. + + + + - Tool preambles: restate goal briefly, outline a short plan, narrate progress succinctly, summarize final delta. + - One-tool-per-message: await results before continuing. + - Discovery budget: default max 3 searches before escalate-once; stop when sufficient. + - Early-stop: when top hits converge or target is identifiable. + - Verbosity: low narrative; detail appears only in structured outputs. + + - - Be supportive and encouraging to problem reporters - - Don't overwhelm users with technical questions upfront - - Clearly indicate when technical sections are optional - - Guide contributors through the additional requirements - - Make the "submit now" option clear for problem reporters - - When presenting template choices, include template descriptions to help users choose - - Explain that you're using the repository's own templates for consistency + - Be direct and concise; avoid jargon in the final issue body. + - Keep questions optional and easy to answer with suggested options. + - Emphasize WHO is affected and WHEN it happens. - - - - Always check these locations in order: - 1. .github/ISSUE_TEMPLATE/*.yml or *.yaml (GitHub form syntax) - 2. .github/ISSUE_TEMPLATE/*.md (Markdown templates) - 3. .github/issue_template.md (single template) - 4. .github/ISSUE_TEMPLATE.md (alternate naming) - - - - For YAML templates: - - Extract form elements and convert to appropriate markdown sections - - Preserve required field indicators - - Include field descriptions as context - - Respect dropdown options and checkbox lists - - For Markdown templates: - - Parse front matter for metadata - - Identify section headers and structure - - Replace placeholder text with actual information - - Maintain formatting and hierarchy - - - - - Map gathered information to template sections intelligently - - Don't leave placeholder text in the final issue - - Add code investigation findings to relevant sections - - Include contributor scoping in appropriate section if applicable - - Preserve the template's intended structure and flow - - - - When no templates exist: - - Create minimal, focused templates - - Use simple section headers - - Focus on essential information only - - Adapt structure based on issue type - - Don't overwhelm with unnecessary fields - - - - - Before proposing ANY solution: - 1. Use codebase_search extensively to find all related code - 2. Read multiple files to understand the full context - 3. Trace variable usage from creation to consumption - 4. Look for similar working features to understand patterns - 5. Identify what already exists vs what's actually missing - - - - When designing solutions: - 1. Check if the data/function already exists somewhere - 2. Look for configuration options before code changes - 3. Prefer passing existing variables over creating new ones - 4. Use established patterns from similar features - 5. Aim for minimal diff size - - - - Always include: - - Exact file paths and line numbers - - Variable/function names as they appear in code - - Before/after code snippets showing minimal changes - - Clear explanation of why the simple fix works - - \ No newline at end of file diff --git a/.roo/rules-issue-writer/4_common_mistakes_to_avoid.xml b/.roo/rules-issue-writer/4_common_mistakes_to_avoid.xml index a8dd9b590b..4077edfb4d 100644 --- a/.roo/rules-issue-writer/4_common_mistakes_to_avoid.xml +++ b/.roo/rules-issue-writer/4_common_mistakes_to_avoid.xml @@ -1,126 +1,109 @@ - - CRITICAL: Asking "What would you like to do?" when mode starts - - Waiting for user to say "create an issue" or "make me an issue" - - Not treating the first user message as the issue description - - Delaying the workflow start with unnecessary questions - - Asking if they want to create an issue when they've already chosen this mode - - Not immediately beginning repository detection and codebase discovery + - Asking "What would you like to do?" at start instead of treating the first message as the issue description + - Delaying the workflow with unnecessary questions before discovery + - Not immediately beginning codebase-aware discovery (semantic search → regex refine → read key files) + - Skipping repository detection (git + origin) before discovery or submission + - Not validating repository context before gh commands - + + + - Submitting without explicit user confirmation ("Submit now") + - Targeting the wrong repository by relying on current directory defaults; always pass --repo OWNER/REPO detected in Step 2 + - Performing PR prep, complexity estimates, or technical scoping + + + + + Splitting final review and submission into multiple steps + Creates redundant prompts and inconsistent state; leads to janky UX + Use a single merged "Review and Submit" step offering only: Submit now, Submit now and assign to me; treat any other response as a change request + + + Not offering "Submit now and assign to me" + Forces manual assignment later; reduces efficiency + Provide the assignment option and use gh issue create --assignee "@me"; if that fails, immediately run gh issue edit --add-assignee "@me" + + + Using temporary files or --body-file for issue body submission + Introduces filesystem dependencies and leaks paths; contradicts single-command policy + Use inline --body with robust quoting, e.g., --body "$(printf '%s\n' "[ISSUE_BODY]")"; do not reference any file paths + + + Omitting --repo or relying on current directory defaults + May submit to the wrong repository in multi-repo or worktree contexts + Always pass --repo [OWNER_REPO] detected in Step 2 + + + Attempting submission without prior repository detection + Commands may target the wrong repo or fail + Detect git repo and ensure origin is configured before any gh commands + + + + + + Inventing or inferring “Variations tried” when the user didn’t provide any + Misleads triage and wastes time reproducing non-existent attempts + Omit the “Variations tried” line entirely unless explicitly provided; if needed, ask a targeted question first + + + Framing only the problem without the value/impact + Makes prioritization harder; obscures who benefits and why it matters + Pair the problem with a plain-language value statement (who, when, why it matters) + + + Overstating impact without user signal + Damages credibility and misguides prioritization + Use conservative, plain language; if unsure, omit severity/reach or ask a single targeted question + + + - - Vague descriptions like "doesn't work" or "broken" - - Missing reproduction steps for bugs - - Feature requests without clear problem statements - - Not explaining the impact on users - - Forgetting to specify when/how the problem occurs - - Using wrong labels or no labels - - Titles that don't summarize the issue - - Not checking for duplicates + - Vague descriptions like "doesn't work" without who/when impact + - Missing minimal reproduction for bugs (environment, steps, expected, actual, variations) + - Enhancement requests that skip the user goal or desired behavior in plain language + - Titles/summaries that don't quickly communicate the issue - - - - Asking for technical details from non-contributing users - - Performing issue scoping before confirming user wants to contribute - - Requiring acceptance criteria from problem reporters - - Making the process too complex for simple problem reports - - Not clearly indicating the "submit now" option - - Overwhelming users with contributor requirements upfront - - Using hardcoded templates instead of repository templates - - Not checking for issue templates before creating content - - Ignoring template metadata like labels and assignees - - - - - Starting implementation before approval - - Not providing detailed issue scoping when contributing - - Missing acceptance criteria for contributed features - - Forgetting to include technical context from code exploration - - Not considering trade-offs and alternatives - - Proposing solutions without understanding current architecture - - - - Not tracing data flow completely through the system - Missing that data already exists leads to proposing unnecessary new code + + + - Including code paths, line numbers, stack traces, or diffs in the final issue body + - Adding labels, metadata, or repository details to the body + - Leaving empty section placeholders instead of omitting the section + - Using technical jargon instead of plain, user-centric language + + + + Skipping semantic search and jumping straight to assumptions + Leads to misclassification and inaccurate context - - Use codebase_search extensively to find ALL related code - - Trace variables from creation to consumption - - Check if needed data is already calculated but not used - - Look for similar working features as patterns + - Start with codebase_search on extracted keywords + - Refine with search_files for exact strings (errors, component names, flags) + - read_file only as needed to verify behavior; keep evidence internal + - Early-stop when hits converge or you can name the exact feature/component + - Escalate-once if signals conflict (one refined pass), then proceed - - Bad: "Add mode tracking to import function" - Good: "The export already includes mode info at line 234, just use it in import at line 567" - - - - - Proposing complex new systems when simple fixes exist - Creates unnecessary complexity, maintenance burden, and potential bugs + + + + Accepting user claims that contradict the codebase without verification + Produces misleading or incorrect issue framing - - ALWAYS check if functionality already exists first - - Look for minimal changes that solve the problem - - Prefer using existing variables/functions differently - - Aim for the smallest possible diff + - Verify claims against the implementation; trace data from creation → usage + - Compare with similar working features to ground expectations + - If discrepancies arise, present concrete, plain-language examples (no code) and confirm - - Bad: "Create new state management system for mode tracking" - Good: "Pass existing modeInfo variable from line 45 to the function at line 78" - - - - - Not reading actual code before proposing solutions - Solutions don't match the actual codebase structure - - - Always read the relevant files first - - Verify exact line numbers and content - - Check imports/exports to understand data availability - - Look at similar features that work correctly - - - - - Creating new patterns instead of following existing ones - Inconsistent codebase, harder to maintain - - - Find similar features that work correctly - - Follow the same patterns and structures - - Reuse existing utilities and helpers - - Maintain consistency with the codebase style - - - - - Using hardcoded templates when repository templates exist - Issues don't follow repository conventions, may be rejected or need reformatting - - - Always check .github/ISSUE_TEMPLATE/ directory first - - Parse and use repository templates when available - - Only create generic templates when none exist - - - - - Not properly parsing YAML template structure - Missing required fields, incorrect formatting, lost metadata - - - Parse YAML templates to extract all form elements - - Convert form elements to appropriate markdown sections - - Preserve field requirements and descriptions - - Maintain dropdown options and checkbox lists - - - - - Leaving placeholder text in final issue - Unprofessional appearance, confusion about what information is needed - - - Replace all placeholders with actual information - - Remove instruction text meant for template users - - Fill every section with relevant content - - Add "N/A" for truly inapplicable sections - - + + + + - Asking broad, unfocused questions instead of targeted ones based on findings + - Demanding technical details from non-technical users + - Failing to provide easy, suggested answer formats (repro scaffold, goal statement) + + + + - Mixing internal technical evidence into the final body + - Ignoring the issue format or adding extra sections + - Using inconsistent tone or switching between technical and non-technical language + \ No newline at end of file diff --git a/.roo/rules-issue-writer/5_examples.xml b/.roo/rules-issue-writer/5_examples.xml new file mode 100644 index 0000000000..6c19018e6c --- /dev/null +++ b/.roo/rules-issue-writer/5_examples.xml @@ -0,0 +1,134 @@ + + + Examples of assembling template-free issue prompts grounded by codebase exploration, with optional CLI submission after explicit confirmation. + Repository detection precedes submission; review and submission occur in a single merged step offering "Submit now" or "Submit now and assign to me". Any other response is treated as a change request. + + + + + In dark theme the Submit button is almost invisible on the New Run page. + + + + +dark theme submit button visibility + + + +. +Submit|button|dark|theme + + ]]> + + + Internal: matches found in UI components related to theme; wording grounded to user impact. + + + Scroll to bottom -> Look for Submit +2) Expected result: Clearly visible, high-contrast Submit button +3) Actual result: Button appears nearly invisible in dark theme +4) Variations tried: Different browsers (Chrome/Firefox) show same result + ]]> + + + + + I accidentally click "Copy Run" sometimes; would be great to have a simple confirmation. + + + + +Copy Run confirmation + + ]]> + + + Internal: feature entry point identified; keep final output non-technical and user-centric. + + + + + + + + Dark theme Submit button is invisible; I'd like to file this. + + Scroll to bottom -> Look for Submit +2) Expected result: Clearly visible, high-contrast Submit button +3) Actual result: Button appears nearly invisible in dark theme + ]]> + + + Review the current issue details. Select one of the options below or specify any changes or other workflow you would like me to perform: + +```md +Title: [ISSUE_TITLE] + +[ISSUE_BODY] +``` + + Submit now + Submit now and assign to me + + + + + gh issue create --repo "[OWNER_REPO]" --title "[ISSUE_TITLE]" --body "$(printf '%s\n' "[ISSUE_BODY]")" + + + + ISSUE_URL=$(gh issue create --repo "[OWNER_REPO]" --title "[ISSUE_TITLE]" --body "$(printf '%s\n' "[ISSUE_BODY]")" --assignee "@me") || true; if [ -z "$ISSUE_URL" ]; then ISSUE_URL=$(gh issue create --repo "[OWNER_REPO]" --title "[ISSUE_TITLE]" --body "$(printf '%s\n' "[ISSUE_BODY]")"); gh issue edit "$ISSUE_URL" --add-assignee "@me"; fi; echo "$ISSUE_URL" + + + + If a change request is provided, collect the requested edits, update the draft (re-run discovery if new info affects context), then rerun this merged step. + + + https://github.com/OWNER/REPO/issues/123 + + + + + Issues are template-free (Title + Body only). + Repository detection (git + origin → OWNER/REPO) occurs before submission and is passed explicitly via --repo [OWNER_REPO]. + Never use --body-file or temporary files; submit with inline --body only (no file paths). + Review and submission happen in one merged step offering "Submit now" or "Submit now and assign to me"; any other response is treated as a change request. + All discovery is internal; keep final output plain-language. + + \ No newline at end of file diff --git a/.roo/rules-issue-writer/5_github_cli_usage.xml b/.roo/rules-issue-writer/5_github_cli_usage.xml deleted file mode 100644 index 1792be87eb..0000000000 --- a/.roo/rules-issue-writer/5_github_cli_usage.xml +++ /dev/null @@ -1,342 +0,0 @@ - - - The GitHub CLI (gh) provides comprehensive tools for interacting with GitHub. - Here's when and how to use each command in the issue creation workflow. - - Note: This mode prioritizes using repository-specific issue templates over - hardcoded ones. Templates are detected and used dynamically from the repository. - - - - - - ALWAYS use this FIRST before creating any issue to check for duplicates. - Search for keywords from the user's problem description. - - - - gh issue list --repo $REPO_FULL_NAME --search "dark theme button visibility" --state all --limit 20 - - - - --search: Search query for issue titles and bodies - --state: all, open, or closed - --label: Filter by specific labels - --limit: Number of results to show - --json: Get structured JSON output - - - - - - Use for more advanced searches across issues and pull requests. - Supports GitHub's advanced search syntax. - - - - gh search issues --repo $REPO_FULL_NAME "dark theme button" --limit 10 - - - - - - - Use when you find a potentially related issue and need full details. - Check if the user's issue is already reported or related. - - - - gh issue view 123 --repo $REPO_FULL_NAME --comments - - - - --comments: Include issue comments - --json: Get structured data - --web: Open in browser - - - - - - - - Use to check for issue templates in the repository before creating issues. - This is not a gh command but necessary for template detection. - - - Check for templates in standard location: - - .github/ISSUE_TEMPLATE - true - - - Check for single template file: - - .github - false - - - - - - - Read template files to parse their structure and content. - Used after detecting template files. - - - Read YAML template: - - .github/ISSUE_TEMPLATE/bug_report.yml - - - Read Markdown template: - - .github/ISSUE_TEMPLATE/feature_request.md - - - - - - - - These commands should ONLY be used if the user has indicated they want to - contribute the implementation. Skip these for problem reporters. - - - - - Get repository information and recent activity. - - - - gh repo view $REPO_FULL_NAME --json defaultBranchRef,description,updatedAt - - - - - - - Check recent PRs that might be related to the issue. - Look for PRs that modified relevant code. - - - - gh search prs --repo $REPO_FULL_NAME "dark theme" --limit 10 --state all - - - - - - - For bug reports from contributors, check recent commits that might have introduced the issue. - Use after cloning the repository locally. - - - - git log --oneline --grep="theme" -n 20 - - - - - - - - - Only use after: - 1. Confirming no duplicates exist - 2. Checking for and using repository templates - 3. Gathering all required information - 4. Determining if user is contributing or just reporting - 5. Getting user confirmation - - - - gh issue create --repo $REPO_FULL_NAME --title "[Descriptive title of the bug]" --body-file /tmp/issue_body.md --label "bug" - - - - - gh issue create --repo $REPO_FULL_NAME --title "[Problem-focused title]" --body-file /tmp/issue_body.md --label "proposal" --label "enhancement" - - - - --title: Issue title (required) - --body: Issue body text - --body-file: Read body from file - --label: Add labels (can use multiple times) - --assignee: Assign to user - --project: Add to project - --web: Open in browser to create - - - - - - - - ONLY use if user wants to add additional information after creation. - - - - gh issue comment 456 --repo $REPO_FULL_NAME --body "Additional context or comments." - - - - - - - Use if user realizes they need to update the issue after creation. - Can update title, body, or labels. - - - - gh issue edit 456 --repo $REPO_FULL_NAME --title "[Updated title]" --body "[Updated body]" - - - - - - - - After user selects issue type, immediately search for related issues: - 1. Use `gh issue list --search` with keywords from their description - 2. Show any similar issues found - 3. Ask if they want to continue or comment on existing issue - - - - Template detection (NEW): - 1. Use list_files to check .github/ISSUE_TEMPLATE/ directory - 2. Read any template files found (YAML or Markdown) - 3. Parse template structure and metadata - 4. If multiple templates, let user choose - 5. If no templates, prepare to create generic one - - - - Decision point for contribution: - 1. Ask user if they want to contribute implementation - 2. If yes: Use contributor commands for codebase investigation - 3. If no: Skip directly to creating a problem-focused issue - 4. This saves time for problem reporters - - - - During codebase exploration (CONTRIBUTORS ONLY): - 1. Clone repo locally if needed: `gh repo clone $REPO_FULL_NAME` - 2. Use `git log` to find recent changes to affected files - 3. Use `gh search prs` for related pull requests - 4. Include findings in the technical context section - - - - When creating the issue: - 1. Use repository template if found, or generic template if not - 2. Fill template with gathered information - 3. Format differently based on contributor vs problem reporter - 4. Save formatted body to temporary file - 5. Use `gh issue create` with appropriate labels from template - 6. Capture the returned issue URL - 7. Show user the created issue URL - - - - - - When creating issues with long bodies: - 1. Save to temporary file: `cat > /tmp/issue_body.md << 'EOF'` - 2. Use --body-file flag with gh issue create - 3. Clean up after: `rm /tmp/issue_body.md` - - - - Use specific search terms: - - Include error messages in quotes - - Use label filters when appropriate - - Limit results to avoid overwhelming output - - - - Use --json flag for structured data when needed: - - Easier to parse programmatically - - Consistent format across commands - - Example: `gh issue list --json number,title,state` - - - - - - If search finds exact duplicate: - - Show the existing issue to user using `gh issue view` - - Ask if they want to add a comment instead - - Use `gh issue comment` if they agree - - - - If `gh issue create` fails: - - Check error message (auth, permissions, network) - - Ensure gh is authenticated: `gh auth status` - - Save the drafted issue content for user - - Suggest using --web flag to create in browser - - - - Ensure GitHub CLI is authenticated: - - Check status: `gh auth status` - - Login if needed: `gh auth login` - - Select appropriate scopes for issue creation - - - - - - gh issue create - Create new issue - gh issue list - List and search issues - gh issue view - View issue details - gh issue comment - Add comment to issue - gh issue edit - Edit existing issue - gh issue close - Close an issue - gh issue reopen - Reopen closed issue - - - - gh search issues - Search issues and PRs - gh search prs - Search pull requests - gh search repos - Search repositories - - - - gh repo view - View repository info - gh repo clone - Clone repository - - - - - - When parsing YAML templates: - - Extract 'name' for template identification - - Get 'labels' array for automatic labeling - - Parse 'body' array for form elements - - Convert form elements to markdown sections - - Preserve 'required' field indicators - - - - When parsing Markdown templates: - - Check for YAML front matter - - Extract metadata (labels, assignees) - - Identify section headers - - Replace placeholder text - - Maintain formatting structure - - - - 1. Detect templates with list_files - 2. Read templates with read_file - 3. Parse structure and metadata - 4. Let user choose if multiple exist - 5. Fill template with information - 6. Create issue with template content - - - \ No newline at end of file diff --git a/.roo/rules-mode-writer/1_mode_creation_workflow.xml b/.roo/rules-mode-writer/1_mode_creation_workflow.xml deleted file mode 100644 index 77a1728599..0000000000 --- a/.roo/rules-mode-writer/1_mode_creation_workflow.xml +++ /dev/null @@ -1,301 +0,0 @@ - - - This workflow guides you through creating new custom modes or editing existing modes - for the Roo Code Software, ensuring comprehensive understanding and cohesive implementation. - - - - - Determine User Intent - - Identify whether the user wants to create a new mode or edit an existing one - - - - - User mentions a specific mode by name or slug - User references a mode directory path (e.g., .roo/rules-[mode-slug]) - User asks to modify, update, enhance, or fix an existing mode - User says "edit this mode" or "change this mode" - - - - - User asks to create a new mode - User describes a new capability not covered by existing modes - User says "make a mode for" or "create a mode that" - - - - - - I want to make sure I understand correctly. Are you looking to create a brand new mode or modify an existing one? - - Create a new mode for a specific purpose - Edit an existing mode to add new capabilities - Fix issues in an existing mode - Enhance an existing mode with better workflows - - - - - - - - - - Gather Requirements for New Mode - - Understand what the user wants the new mode to accomplish - - - Ask about the mode's primary purpose and use cases - Identify what types of tasks the mode should handle - Determine what tools and file access the mode needs - Clarify any special behaviors or restrictions - - - - What is the primary purpose of this new mode? What types of tasks should it handle? - - A mode for writing and maintaining documentation - A mode for database schema design and migrations - A mode for API endpoint development and testing - A mode for performance optimization and profiling - - - - - - - Design Mode Configuration - - Create the mode definition with all required fields - - - - Unique identifier (lowercase, hyphens allowed) - Keep it short and descriptive (e.g., "api-dev", "docs-writer") - - - Display name with optional emoji - Use an emoji that represents the mode's purpose - - - Detailed description of the mode's role and expertise - - Start with "You are Roo Code, a [specialist type]..." - List specific areas of expertise - Mention key technologies or methodologies - - - - Tool groups the mode can access - - - - - - - - - - - - Clear description for the Orchestrator - Explain specific scenarios and task types - - - - Do not include customInstructions in the .roomodes configuration. - All detailed instructions should be placed in XML files within - the .roo/rules-[mode-slug]/ directory instead. - - - - - Implement File Restrictions - - Configure appropriate file access permissions - - - Restrict edit access to specific file types - -groups: - - read - - - edit - - fileRegex: \.(md|txt|rst)$ - description: Documentation files only - - command - - - - Use regex patterns to limit file editing scope - Provide clear descriptions for restrictions - Consider the principle of least privilege - - - - - Create XML Instruction Files - - Design structured instruction files in .roo/rules-[mode-slug]/ - - - Main workflow and step-by-step processes - Guidelines and conventions - Reusable code patterns and examples - Specific tool usage instructions - Complete workflow examples - - - Use semantic tag names that describe content - Nest tags hierarchically for better organization - Include code examples in CDATA sections when needed - Add comments to explain complex sections - - - - - - - Immerse in Existing Mode - - Fully understand the existing mode before making any changes - - - Locate and read the mode configuration in .roomodes - Read all XML instruction files in .roo/rules-[mode-slug]/ - Analyze the mode's current capabilities and limitations - Understand the mode's role in the broader ecosystem - - - - What specific aspects of the mode would you like to change or enhance? - - Add new capabilities or tool permissions - Fix issues with current workflows or instructions - Improve the mode's roleDefinition or whenToUse description - Enhance XML instructions for better clarity - - - - - - - Analyze Change Impact - - Understand how proposed changes will affect the mode - - - Compatibility with existing workflows - Impact on file permissions and tool access - Consistency with mode's core purpose - Integration with other modes - - - - I've analyzed the existing mode. Here's what I understand about your requested changes. Is this correct? - - Yes, that's exactly what I want to change - Mostly correct, but let me clarify some details - No, I meant something different - I'd like to add additional changes - - - - - - - Plan Modifications - - Create a detailed plan for modifying the mode - - - Identify which files need to be modified - Determine if new XML instruction files are needed - Check for potential conflicts or contradictions - Plan the order of changes for minimal disruption - - - - - Implement Changes - - Apply the planned modifications to the mode - - - Update .roomodes configuration if needed - Modify existing XML instruction files - Create new XML instruction files if required - Update examples and documentation - - - - - - - - Validate Cohesion and Consistency - - Ensure all changes are cohesive and don't contradict each other - - - - Mode slug follows naming conventions - File restrictions align with mode purpose - Tool permissions are appropriate - whenToUse clearly differentiates from other modes - - - All XML files follow consistent structure - No contradicting instructions between files - Examples align with stated workflows - Tool usage matches granted permissions - - - Mode integrates well with Orchestrator - Clear boundaries with other modes - Handoff points are well-defined - - - - - I've completed the validation checks. Would you like me to review any specific aspect in more detail? - - Review the file permission patterns - Check for workflow contradictions - Verify integration with other modes - Everything looks good, proceed to testing - - - - - - - Test and Refine - - Verify the mode works as intended - - - Mode appears in the mode list - File restrictions work correctly - Instructions are clear and actionable - Mode integrates well with Orchestrator - All examples are accurate and helpful - Changes don't break existing functionality (for edits) - New capabilities work as expected - - - - - - Create mode in .roomodes for project-specific modes - Create mode in global custom_modes.yaml for system-wide modes - Use list_files to verify .roo folder structure - Test file regex patterns with search_files - Use codebase_search to find existing mode implementations - Read all XML files in a mode directory to understand its structure - Always validate changes for cohesion and consistency - - \ No newline at end of file diff --git a/.roo/rules-mode-writer/2_xml_structuring_best_practices.xml b/.roo/rules-mode-writer/2_xml_structuring_best_practices.xml deleted file mode 100644 index 639f855c0c..0000000000 --- a/.roo/rules-mode-writer/2_xml_structuring_best_practices.xml +++ /dev/null @@ -1,220 +0,0 @@ - - - XML tags help Claude parse prompts more accurately, leading to higher-quality outputs. - This guide covers best practices for structuring mode instructions using XML. - - - - - Clearly separate different parts of your instructions and ensure well-structured content - - - Reduce errors caused by Claude misinterpreting parts of your instructions - - - Easily find, add, remove, or modify parts of instructions without rewriting everything - - - Having Claude use XML tags in its output makes it easier to extract specific parts of responses - - - - - - Use the same tag names throughout your instructions - - Always use for workflow steps, not sometimes or - - - - - Tag names should clearly describe their content - - detailed_steps - error_handling - validation_rules - - - stuff - misc - data1 - - - - - Nest tags to show relationships and structure - - - - Gather requirements - Validate inputs - - - Process data - Generate output - - - - - - - - - For step-by-step processes - - - - - For providing code examples and demonstrations - - - - - For rules and best practices - - - - - For documenting how to use specific tools - - - - - - - Use consistent indentation (2 or 4 spaces) for nested elements - - - Add line breaks between major sections for readability - - - Use XML comments to explain complex sections - - - Use CDATA for code blocks or content with special characters: - ]]> - - - Use attributes for metadata, elements for content: - - - The actual step content - - - - - - - - Avoid completely flat structures without hierarchy - -Do this -Then this -Finally this - - ]]> - - - Do this - Then this - Finally this - - - ]]> - - - - Don't mix naming conventions - - Mixing camelCase, snake_case, and kebab-case in tag names - - - Pick one convention (preferably snake_case for XML) and stick to it - - - - - Avoid tags that don't convey meaning - data, info, stuff, thing, item - user_input, validation_result, error_message, configuration - - - - - - Reference XML content in instructions: - "Using the workflow defined in <workflow> tags..." - - - Combine XML structure with other techniques like multishot prompting - - - Use XML tags in expected outputs to make parsing easier - - - Create reusable XML templates for common patterns - - - \ No newline at end of file diff --git a/.roo/rules-mode-writer/3_mode_configuration_patterns.xml b/.roo/rules-mode-writer/3_mode_configuration_patterns.xml deleted file mode 100644 index 82a5f845ac..0000000000 --- a/.roo/rules-mode-writer/3_mode_configuration_patterns.xml +++ /dev/null @@ -1,261 +0,0 @@ - - - Common patterns and templates for creating different types of modes, with examples from existing modes in the Roo-Code software. - - - - - - Modes focused on specific technical domains or tasks - - - Deep expertise in a particular area - Restricted file access based on domain - Specialized tool usage patterns - - - - You are Roo Code, an API development specialist with expertise in: - - RESTful API design and implementation - - GraphQL schema design - - API documentation with OpenAPI/Swagger - - Authentication and authorization patterns - - Rate limiting and caching strategies - - API versioning and deprecation - - You ensure APIs are: - - Well-documented and discoverable - - Following REST principles or GraphQL best practices - - Secure and performant - - Properly versioned and maintainable - whenToUse: >- - Use this mode when designing, implementing, or refactoring APIs. - This includes creating new endpoints, updating API documentation, - implementing authentication, or optimizing API performance. - groups: - - read - - - edit - - fileRegex: (api/.*\.(ts|js)|.*\.openapi\.yaml|.*\.graphql|docs/api/.*)$ - description: API implementation files, OpenAPI specs, and API documentation - - command - - mcp - ]]> - - - - - Modes that guide users through multi-step processes - - - Step-by-step workflow guidance - Heavy use of ask_followup_question - Process validation at each step - - - - You are Roo Code, a migration specialist who guides users through - complex migration processes: - - Database schema migrations - - Framework version upgrades - - API version migrations - - Dependency updates - - Breaking change resolutions - - You provide: - - Step-by-step migration plans - - Automated migration scripts - - Rollback strategies - - Testing approaches for migrations - whenToUse: >- - Use this mode when performing any kind of migration or upgrade. - This mode will analyze the current state, plan the migration, - and guide you through each step with validation. - groups: - - read - - edit - - command - ]]> - - - - - Modes focused on code analysis and reporting - - - Read-heavy operations - Limited or no edit permissions - Comprehensive reporting outputs - - - - You are Roo Code, a security analysis specialist focused on: - - Identifying security vulnerabilities - - Analyzing authentication and authorization - - Reviewing data validation and sanitization - - Checking for common security anti-patterns - - Evaluating dependency vulnerabilities - - Assessing API security - - You provide detailed security reports with: - - Vulnerability severity ratings - - Specific remediation steps - - Security best practice recommendations - whenToUse: >- - Use this mode to perform security audits on codebases. - This mode will analyze code for vulnerabilities, check - dependencies, and provide actionable security recommendations. - groups: - - read - - command - - - edit - - fileRegex: (SECURITY\.md|\.github/security/.*|docs/security/.*)$ - description: Security documentation files only - ]]> - - - - - Modes for generating new content or features - - - Broad file creation permissions - Template and boilerplate generation - Interactive design process - - - - You are Roo Code, a UI component design specialist who creates: - - Reusable React/Vue/Angular components - - Component documentation and examples - - Storybook stories - - Unit tests for components - - Accessibility-compliant interfaces - - You follow design system principles and ensure components are: - - Highly reusable and composable - - Well-documented with examples - - Fully tested - - Accessible (WCAG compliant) - - Performance optimized - whenToUse: >- - Use this mode when creating new UI components or refactoring - existing ones. This mode helps design component APIs, implement - the components, and create comprehensive documentation. - groups: - - read - - - edit - - fileRegex: (components/.*|stories/.*|__tests__/.*\.test\.(tsx?|jsx?))$ - description: Component files, stories, and component tests - - browser - - command - ]]> - - - - - - For modes that only work with documentation - - - - - For modes that work with test files - - - - - For modes that manage configuration - - - - - For modes that need broad access - - - - - - - Use lowercase with hyphens - api-dev, test-writer, docs-manager - apiDev, test_writer, DocsManager - - - - Use title case with descriptive emoji - 🔧 API Developer, 📝 Documentation Writer - api developer, DOCUMENTATION WRITER - - - - - 🧪 - 📝 - 🎨 - 🪲 - 🏗️ - 🔒 - 🔌 - 🗄️ - - ⚙️ - - - - - - - Ensure whenToUse is clear for Orchestrator mode - - Specify concrete task types the mode handles - Include trigger keywords or phrases - Differentiate from similar modes - Mention specific file types or areas - - - - - Define clear boundaries between modes - - Avoid overlapping responsibilities - Make handoff points explicit - Use switch_mode when appropriate - Document mode interactions - - - - \ No newline at end of file diff --git a/.roo/rules-mode-writer/4_instruction_file_templates.xml b/.roo/rules-mode-writer/4_instruction_file_templates.xml deleted file mode 100644 index 3afcfa28f4..0000000000 --- a/.roo/rules-mode-writer/4_instruction_file_templates.xml +++ /dev/null @@ -1,367 +0,0 @@ - - - Templates and examples for creating XML instruction files that provide - detailed guidance for each mode's behavior and workflows. - - - - Number files to indicate execution order - Use descriptive names that indicate content - Keep related instructions together - - 1_workflow.xml - Main workflow and processes - 2_best_practices.xml - Guidelines and conventions - 3_common_patterns.xml - Reusable code patterns - 4_tool_usage.xml - Specific tool instructions - 5_examples.xml - Complete workflow examples - 6_error_handling.xml - Error scenarios and recovery - 7_communication.xml - User interaction guidelines - - - - - Template for main workflow files (1_workflow.xml) - - - - - Template for best practices files (2_best_practices.xml) - - - - - Template for tool usage files (4_tool_usage.xml) - - - - - Template for example files (5_examples.xml) - - - - - Template for communication guidelines (7_communication.xml) - - - \ No newline at end of file diff --git a/.roo/rules-mode-writer/5_complete_mode_examples.xml b/.roo/rules-mode-writer/5_complete_mode_examples.xml deleted file mode 100644 index 7c8f8a4cde..0000000000 --- a/.roo/rules-mode-writer/5_complete_mode_examples.xml +++ /dev/null @@ -1,214 +0,0 @@ - - - - Edit the test mode to add support for performance testing and benchmarking - - - - I want to edit the test mode to add benchmark testing capabilities - - - - - Determine user intent and clarify scope - -I understand you want to edit the test mode to add performance testing capabilities. Before we proceed, let me confirm the scope of changes. What specific performance testing features do you need? - -Add benchmark testing with Vitest's bench API -Include performance profiling and memory usage tracking -Add load testing capabilities for API endpoints -All of the above - comprehensive performance testing suite - - - ]]> - User selected: Add benchmark testing with Vitest's bench API - - - - Immerse in existing test mode configuration - -.roomodes - - ]]> - Found test mode configuration with current roleDefinition and file restrictions - - - - Explore existing test mode instructions - -.roo/rules-test -true - - ]]> - -.roo/rules-test/1_workflow.xml - - ]]> - Understood current test workflows and patterns - - - - Validate changes for cohesion - -I've analyzed the test mode. Adding benchmark testing would require: -1. Updating the roleDefinition to include performance testing expertise -2. Modifying file restrictions to include .bench.ts files -3. Adding new workflow instructions for benchmark creation -4. Including Vitest bench API examples - -This aligns well with the existing testing focus. Should I proceed with all these changes? - -Yes, implement all the changes as described -Start with just the roleDefinition and file restrictions -Focus only on the workflow instructions first -Let me review the specific changes before proceeding - - - ]]> - User selected: Yes, implement all the changes as described - - - - Update mode configuration in .roomodes - -.roomodes - -<<<<<<< SEARCH - roleDefinition: |- - You are Roo, a Vitest testing specialist with deep expertise in: - Writing and maintaining Vitest test suites - Test-driven development (TDD) practices - Mocking and stubbing with Vitest - Integration testing strategies - TypeScript testing patterns - Code coverage analysis - Test performance optimization -======= - roleDefinition: |- - You are Roo, a Vitest testing specialist with deep expertise in: - Writing and maintaining Vitest test suites - Test-driven development (TDD) practices - Mocking and stubbing with Vitest - Integration testing strategies - TypeScript testing patterns - Code coverage analysis - Test performance optimization - Benchmark testing with Vitest bench API - Performance profiling and optimization - Memory usage analysis in tests ->>>>>>> REPLACE - - - ]]> - - - - Update file restrictions to include benchmark files - -.roomodes - -<<<<<<< SEARCH - - fileRegex: (__tests__/.*|__mocks__/.*|\.test\.(ts|tsx|js|jsx)$|\.spec\.(ts|tsx|js|jsx)$|/test/.*|vitest\.config\.(js|ts)$|vitest\.setup\.(js|ts)$) - description: Test files, mocks, and Vitest configuration -======= - - fileRegex: (__tests__/.*|__mocks__/.*|\.test\.(ts|tsx|js|jsx)$|\.spec\.(ts|tsx|js|jsx)$|\.bench\.(ts|tsx|js|jsx)$|/test/.*|vitest\.config\.(js|ts)$|vitest\.setup\.(js|ts)$) - description: Test files, benchmarks, mocks, and Vitest configuration ->>>>>>> REPLACE - - - ]]> - - - - Create new benchmark workflow instructions - -.roo/rules-test/5_benchmark_testing.xml - - - - Guidelines for creating and maintaining performance benchmarks using Vitest bench API - - - - - Basic benchmark structure - { - bench('Array.push', () => { - const arr = []; - for (let i = 0; i < 1000; i++) { - arr.push(i); - } - }); - - bench('Array spread', () => { - let arr = []; - for (let i = 0; i < 1000; i++) { - arr = [...arr, i]; - } - }); -}); - ]]> - - - - - Use meaningful benchmark names that describe what's being measured - Include baseline comparisons when optimizing code - Run benchmarks in isolation to avoid interference - Document performance expectations and thresholds - - - -38 - - ]]> - - - - Validate all changes for consistency - -I've completed all the changes to add benchmark testing capabilities to the test mode: - -1. ✓ Updated roleDefinition to include benchmark expertise -2. ✓ Modified file restrictions to include .bench.ts files -3. ✓ Created new benchmark workflow instructions -4. ✓ Added Vitest bench API examples - -All changes are cohesive and align with the existing test mode structure. Would you like me to test any specific aspect of these changes? - -Test creating a new benchmark file to verify file permissions -Review the complete changes one more time -Everything looks good, the mode is ready -Create a simple benchmark example to test - - - ]]> - - - - - -I've successfully enhanced the test mode with benchmark testing capabilities: - -1. Updated the roleDefinition to include: - - Benchmark testing with Vitest bench API - - Performance profiling and optimization - - Memory usage analysis in tests - -2. Modified file restrictions to include .bench.ts files, allowing the mode to create and edit benchmark files - -3. Created new workflow instructions at .roo/rules-test/5_benchmark_testing.xml with: - - Basic benchmark patterns - - Best practices for performance testing - - Example code using Vitest's bench API - -All changes have been validated for cohesion and consistency with the existing test mode structure. The mode now supports comprehensive performance testing alongside its existing unit and integration testing capabilities. - - - ]]> - - - Always immerse yourself in the existing mode before making changes - Use ask_followup_question aggressively to clarify scope and validate changes - Validate all changes for cohesion and consistency - Update all relevant parts: configuration, file restrictions, and instructions - Test changes to ensure they work as expected - - - \ No newline at end of file diff --git a/.roo/rules-mode-writer/6_mode_testing_validation.xml b/.roo/rules-mode-writer/6_mode_testing_validation.xml deleted file mode 100644 index db65b31c22..0000000000 --- a/.roo/rules-mode-writer/6_mode_testing_validation.xml +++ /dev/null @@ -1,207 +0,0 @@ - - - Guidelines for testing and validating newly created modes to ensure they function correctly and integrate well with the Roo Code ecosystem. - - - - - - Mode slug is unique and follows naming conventions - No spaces, lowercase, hyphens only - - - All required fields are present and non-empty - slug, name, roleDefinition, groups - - - No customInstructions field in .roomodes - All instructions must be in XML files in .roo/rules-[slug]/ - - - File restrictions use valid regex patterns - -. -your_file_regex_here - - ]]> - - - whenToUse clearly differentiates from other modes - Compare with existing mode descriptions - - - - - - XML files are well-formed and valid - No syntax errors, proper closing tags - - - Instructions follow XML best practices - Semantic tag names, proper nesting - - - Examples use correct tool syntax - Tool parameters match current API - - - File paths in examples are consistent - Use project-relative paths - - - - - - Mode appears in mode list - Switch to the new mode and verify it loads - - - Tool permissions work as expected - Try using each tool group and verify access - - - File restrictions are enforced - Attempt to edit allowed and restricted files - - - Mode handles edge cases gracefully - Test with minimal input, errors, edge cases - - - - - - - Configuration Testing - - Verify mode appears in available modes list - Check that mode metadata displays correctly - Confirm mode can be activated - - -I've created the mode configuration. Can you see the new mode in your mode list? - -Yes, I can see the new mode and switch to it -No, the mode doesn't appear in the list -The mode appears but has errors when switching - - - ]]> - - - - Permission Testing - - - Use read tools on various files - All read operations should work - - - Try editing allowed file types - Edits succeed for matching patterns - - - Try editing restricted file types - FileRestrictionError for non-matching files - - - - - - Workflow Testing - - Execute main workflow from start to finish - Test each decision point - Verify error handling - Check completion criteria - - - - - Integration Testing - - Orchestrator mode compatibility - Mode switching functionality - Tool handoff between modes - Consistent behavior with other modes - - - - - - - Mode doesn't appear in list - - Syntax error in YAML - Invalid mode slug - File not saved - - Check YAML syntax, validate slug format - - - - File restriction not working - - Invalid regex pattern - Escaping issues in regex - Wrong file path format - - Test regex pattern, use proper escaping - - - - - Mode not following instructions - - Instructions not in .roo/rules-[slug]/ folder - XML parsing errors - Conflicting instructions - - Verify file locations and XML validity - - - - - - Verify instruction files exist in correct location - -.roo -true - - ]]> - - - - Check mode configuration syntax - -.roomodes - - ]]> - - - - Test file restriction patterns - -. -your_file_pattern_here - - ]]> - - - - - Test incrementally as you build the mode - Start with minimal configuration and add complexity - Document any special requirements or dependencies - Consider edge cases and error scenarios - Get feedback from potential users of the mode - - \ No newline at end of file diff --git a/.roo/rules-mode-writer/7_validation_cohesion_checking.xml b/.roo/rules-mode-writer/7_validation_cohesion_checking.xml deleted file mode 100644 index a327a1e465..0000000000 --- a/.roo/rules-mode-writer/7_validation_cohesion_checking.xml +++ /dev/null @@ -1,201 +0,0 @@ - - - Guidelines for thoroughly validating mode changes to ensure cohesion, - consistency, and prevent contradictions across all mode components. - - - - - - Every change must be reviewed in context of the entire mode - - - Read all existing XML instruction files - Verify new changes align with existing patterns - Check for duplicate or conflicting instructions - Ensure terminology is consistent throughout - - - - - - Use ask_followup_question extensively to clarify ambiguities - - - User's intent is unclear - Multiple interpretations are possible - Changes might conflict with existing functionality - Impact on other modes needs clarification - - -I notice this change might affect how the mode interacts with file permissions. Should we also update the file regex patterns to match? - -Yes, update the file regex to include the new file types -No, keep the current file restrictions as they are -Let me explain what file types I need to work with -Show me the current file restrictions first - - - ]]> - - - - - Actively search for and resolve contradictions - - - - Permission Mismatch - Instructions reference tools the mode doesn't have access to - Either grant the tool permission or update the instructions - - - Workflow Conflicts - Different XML files describe conflicting workflows - Consolidate workflows and ensure single source of truth - - - Role Confusion - Mode's roleDefinition doesn't match its actual capabilities - Update roleDefinition to accurately reflect the mode's purpose - - - - - - - - Before making any changes - - Read and understand all existing mode files - Create a mental model of current mode behavior - Identify potential impact areas - Ask clarifying questions about intended changes - - - - - While making changes - - Document each change and its rationale - Cross-reference with other files after each change - Verify examples still work with new changes - Update related documentation immediately - - - - - After changes are complete - - - All XML files are well-formed and valid - File naming follows established patterns - Tag names are consistent across files - No orphaned or unused instructions - - - - roleDefinition accurately describes the mode - whenToUse is clear and distinguishable - Tool permissions match instruction requirements - File restrictions align with mode purpose - Examples are accurate and functional - - - - Mode boundaries are well-defined - Handoff points to other modes are clear - No overlap with other modes' responsibilities - Orchestrator can correctly route to this mode - - - - - - - - Maintain consistent tone and terminology - - Use the same terms for the same concepts throughout - Keep instruction style consistent across files - Maintain the same level of detail in similar sections - - - - - Ensure instructions flow logically - - Prerequisites come before dependent steps - Complex concepts build on simpler ones - Examples follow the explained patterns - - - - - Ensure all aspects are covered without gaps - - Every mentioned tool has usage instructions - All workflows have complete examples - Error scenarios are addressed - - - - - - - - Before we proceed with changes, I want to ensure I understand the full scope. What is the main goal of these modifications? - - Add new functionality while keeping existing features - Fix issues with current implementation - Refactor for better organization - Expand the mode's capabilities into new areas - - - - - - - This change might affect other parts of the mode. How should we handle the impact on [specific area]? - - Update all affected areas to maintain consistency - Keep the existing behavior for backward compatibility - Create a migration path from old to new behavior - Let me review the impact first - - - - - - - I've completed the changes and validation. Which aspect would you like me to test more thoroughly? - - Test the new workflow end-to-end - Verify file permissions work correctly - Check integration with other modes - Review all changes one more time - - - - - - - - Instructions reference tools not in the mode's groups - Either add the tool group or remove the instruction - - - File regex doesn't match described file types - Update regex pattern to match intended files - - - Examples don't follow stated best practices - Update examples to demonstrate best practices - - - Duplicate instructions in different files - Consolidate to single location and reference - - - \ No newline at end of file diff --git a/.roo/skills/roo-conflict-resolution/SKILL.md b/.roo/skills/roo-conflict-resolution/SKILL.md new file mode 100644 index 0000000000..4807180522 --- /dev/null +++ b/.roo/skills/roo-conflict-resolution/SKILL.md @@ -0,0 +1,256 @@ +--- +name: roo-conflict-resolution +description: Provides comprehensive guidelines for resolving merge conflicts intelligently using git history and commit context. Use when tasks involve merge conflicts, rebasing, PR conflicts, or git conflict resolution. This skill analyzes commit messages, git blame, and code intent to make intelligent resolution decisions. +--- + +# Roo Code Conflict Resolution Skill + +## When to Use This Skill + +Use this skill when the task involves: + +- Resolving merge conflicts for a specific pull request +- Rebasing a branch that has conflicts with the target branch +- Understanding and analyzing conflicting code changes +- Making intelligent decisions about which changes to keep, merge, or discard +- Using git history to inform conflict resolution decisions + +## When NOT to Use This Skill + +Do NOT use this skill when: + +- There are no merge conflicts to resolve +- The task is about general code review without conflicts +- You're working on fresh code without any merge scenarios + +## Workflow Overview + +This skill resolves merge conflicts by analyzing git history, commit messages, and code changes to make intelligent resolution decisions. Given a PR number (e.g., "#123"), it handles the entire conflict resolution process. + +## Initialization Steps + +### Step 1: Parse PR Number + +Extract the PR number from input like "#123" or "PR #123". Validate that a PR number was provided. + +### Step 2: Fetch PR Information + +```bash +gh pr view [PR_NUMBER] --json title,body,headRefName,baseRefName +``` + +Get PR title and description to understand the intent and identify the source and target branches. + +### Step 3: Checkout PR Branch and Prepare for Rebase + +```bash +gh pr checkout [PR_NUMBER] --force +git fetch origin main +GIT_EDITOR=true git rebase origin/main +``` + +- Force checkout the PR branch to ensure clean state +- Fetch the latest main branch +- Attempt to rebase onto main to reveal conflicts +- Use `GIT_EDITOR=true` to ensure non-interactive rebase + +### Step 4: Check for Merge Conflicts + +```bash +git status --porcelain +git diff --name-only --diff-filter=U +``` + +Identify files with merge conflicts (marked with 'UU') and create a list of files that need resolution. + +## Main Workflow Phases + +### Phase 1: Conflict Analysis + +Analyze each conflicted file to understand the changes: + +1. Read the conflicted file to identify conflict markers +2. Extract the conflicting sections between `<<<<<<<` and `>>>>>>>` +3. Run git blame on both sides of the conflict +4. Fetch commit messages and diffs for relevant commits +5. Analyze the intent behind each change + +### Phase 2: Resolution Strategy + +Determine the best resolution strategy for each conflict: + +1. Categorize changes by intent (bugfix, feature, refactor, etc.) +2. Evaluate recency and relevance of changes +3. Check for structural overlap vs formatting differences +4. Identify if changes can be combined or if one should override +5. Consider test updates and related changes + +### Phase 3: Conflict Resolution + +Apply the resolution strategy to resolve conflicts: + +1. For each conflict, apply the chosen resolution +2. Ensure proper escaping of conflict markers in diffs +3. Validate that resolved code is syntactically correct +4. Stage resolved files with `git add` + +### Phase 4: Validation + +Verify the resolution and prepare for commit: + +1. Run `git status` to confirm all conflicts are resolved +2. Check for any compilation or syntax errors +3. Review the final diff to ensure sensible resolutions +4. Prepare a summary of resolution decisions + +## Git Commands Reference + +| Command | Purpose | +| ---------------------------------------------------------------- | ------------------------------------------------- | +| `gh pr checkout [PR_NUMBER] --force` | Force checkout the PR branch | +| `git fetch origin main` | Get the latest main branch | +| `GIT_EDITOR=true git rebase origin/main` | Rebase current branch onto main (non-interactive) | +| `git blame -L [start],[end] [commit] -- [file]` | Get commit information for specific lines | +| `git show --format="%H%n%an%n%ae%n%ad%n%s%n%b" --no-patch [sha]` | Get commit metadata | +| `git show [sha] -- [file]` | Get the actual changes made in a commit | +| `git ls-files -u` | List unmerged files with stage information | +| `GIT_EDITOR=true git rebase --continue` | Continue rebase after resolving conflicts | + +## Best Practices + +### Intent-Based Resolution (High Priority) + +Always prioritize understanding the intent behind changes rather than just looking at the code differences. Commit messages, PR descriptions, and issue references provide crucial context. + +**Example:** When there's a conflict between a bugfix and a refactor, apply the bugfix logic within the refactored structure rather than simply choosing one side. + +### Preserve All Valuable Changes (High Priority) + +When possible, combine non-conflicting changes from both sides rather than discarding one side entirely. Both sides of a conflict often contain valuable changes that can coexist if properly integrated. + +### Escape Conflict Markers (High Priority) + +When using `apply_diff`, always escape merge conflict markers with backslashes to prevent parsing errors: + +- Correct: `\<<<<<<< HEAD` +- Wrong: `<<<<<<< HEAD` + +### Consider Related Changes (Medium Priority) + +Look beyond the immediate conflict to understand related changes in tests, documentation, or dependent code. A change might seem isolated but could be part of a larger feature or fix. + +## Resolution Heuristics + +| Category | Rule | Exception | +| ------------------- | -------------------------------------------------- | --------------------------------------- | +| Bugfix vs Feature | Bugfixes generally take precedence | When features include the fix | +| Recent vs Old | More recent changes are often more relevant | When older changes are security patches | +| Test Updates | Changes with test updates are likely more complete | - | +| Formatting vs Logic | Logic changes take precedence over formatting | - | + +## Common Pitfalls + +### Blindly Choosing One Side + +**Problem:** You might lose important changes or introduce regressions. +**Solution:** Always analyze both sides using git blame and commit history. + +### Ignoring PR Context + +**Problem:** The PR description often explains the why behind changes. +**Solution:** Always fetch and read the PR information before resolving. + +### Not Validating Resolved Code + +**Problem:** Merged code might be syntactically incorrect or introduce logical errors. +**Solution:** Always check for syntax errors and review the final diff. + +### Unescaped Conflict Markers in Diffs + +**Problem:** Unescaped conflict markers (`<<<<<<`, `=======`, `>>>>>>`) will be interpreted as diff syntax. +**Solution:** Always escape with backslash (`\`) when they appear in content. + +## Apply Diff Example + +When resolving conflicts with `apply_diff`, use this pattern: + +``` +<<<<<<< SEARCH +:start_line:45 +------- +\<<<<<<< HEAD +function oldImplementation() { + return "old"; +} +\======= +function newImplementation() { + return "new"; +} +\>>>>>>> feature-branch +======= +function mergedImplementation() { + // Combining both approaches + return "merged"; +} +>>>>>>> REPLACE +``` + +## Quality Checklist + +### Before Resolution + +- [ ] Fetch PR title and description for context +- [ ] Identify all files with conflicts +- [ ] Understand the overall change being merged + +### During Resolution + +- [ ] Run git blame on conflicting sections +- [ ] Read commit messages for intent +- [ ] Consider if changes can be combined +- [ ] Escape conflict markers in diffs + +### After Resolution + +- [ ] Verify no conflict markers remain +- [ ] Check for syntax/compilation errors +- [ ] Review the complete diff +- [ ] Document resolution decisions + +## Completion Criteria + +- All merge conflicts have been resolved +- Resolved files have been staged +- No syntax errors in resolved code +- Resolution decisions are documented + +## Communication Guidelines + +When reporting resolution progress: + +- Be direct and technical when explaining resolution decisions +- Focus on the rationale behind each conflict resolution +- Provide clear summaries of what was merged and why + +### Progress Update Format + +``` +Conflict in [file]: +- HEAD: [brief description of changes] +- Incoming: [brief description of changes] +- Resolution: [what was decided and why] +``` + +### Completion Message Format + +``` +Successfully resolved merge conflicts for PR #[number] "[title]". + +Resolution Summary: +- [file1]: [brief description of resolution] +- [file2]: [brief description of resolution] + +[Key decision explanation if applicable] + +All conflicts have been resolved and files have been staged for commit. +``` diff --git a/.roo/skills/roo-translation/SKILL.md b/.roo/skills/roo-translation/SKILL.md new file mode 100644 index 0000000000..dafffb78c9 --- /dev/null +++ b/.roo/skills/roo-translation/SKILL.md @@ -0,0 +1,155 @@ +--- +name: roo-translation +description: Provides comprehensive guidelines for translating and localizing Roo Code extension strings. Use when tasks involve i18n, translation, localization, adding new languages, or updating existing translation files. This skill covers both core extension (src/i18n/locales/) and WebView UI (webview-ui/src/i18n/locales/) localization. +--- + +# Roo Code Translation Skill + +## When to Use This Skill + +Use this skill when the task involves: + +- Adding new translatable strings to the Roo Code extension +- Translating existing strings to new languages +- Updating or fixing translations in existing language files +- Understanding i18n patterns used in the codebase +- Working with localization files in either core extension or WebView UI + +## When NOT to Use This Skill + +Do NOT use this skill when: + +- Working on non-translation code changes +- The task doesn't involve i18n or localization +- You're only reading translation files for reference without modifying them + +## Supported Languages and Locations + +Localize all strings into the following locale files: ca, de, en, es, fr, hi, id, it, ja, ko, nl, pl, pt-BR, ru, tr, vi, zh-CN, zh-TW + +The VSCode extension has two main areas that require localization: + +| Component | Path | Purpose | +| ------------------ | ------------------------------ | ------------------------- | +| **Core Extension** | `src/i18n/locales/` | Extension backend strings | +| **WebView UI** | `webview-ui/src/i18n/locales/` | User interface strings | + +## Brand Voice, Tone, and Word Choice + +For detailed brand voice, tone, and word choice guidance, refer to the guidance file: + +- [`.roo/guidance/roo-translator.md`](../../guidance/roo-translator.md) + +This guidance file is loaded at runtime and should be consulted for the latest brand and style standards. + +## Voice, Style and Tone Guidelines + +- Always use informal speech (e.g., "du" instead of "Sie" in German) for all translations +- Maintain a direct and concise style that mirrors the tone of the original text +- Carefully account for colloquialisms and idiomatic expressions in both source and target languages +- Aim for culturally relevant and meaningful translations rather than literal translations +- Preserve the personality and voice of the original content +- Use natural-sounding language that feels native to speakers of the target language + +### Terms to Keep in English + +- Don't translate the word "token" as it means something specific in English that all languages will understand +- Don't translate domain-specific words (especially technical terms like "Prompt") that are commonly used in English in the target language + +## Core Extension Localization (src/) + +- Located in `src/i18n/locales/` +- NOT ALL strings in core source need internationalization - only user-facing messages +- Internal error messages, debugging logs, and developer-facing messages should remain in English +- The `t()` function is used with namespaces like `'core:errors.missingToolParameter'` +- Be careful when modifying interpolation variables; they must remain consistent across all translations +- Some strings in `formatResponse.ts` are intentionally not internationalized since they're internal +- When updating strings in `core.json`, maintain all existing interpolation variables +- Check string usages in the codebase before making changes to ensure you're not breaking functionality + +## WebView UI Localization (webview-ui/src/) + +- Located in `webview-ui/src/i18n/locales/` +- Uses standard React i18next patterns with the `useTranslation` hook +- All user interface strings should be internationalized +- Always use the `Trans` component with named components for text with embedded components + +### Trans Component Example + +Translation string: + +```json +"changeSettings": "You can always change this at the bottom of the settings" +``` + +React component usage: + +```tsx +, + }} +/> +``` + +## Technical Implementation + +- Use namespaces to organize translations logically +- Handle pluralization using i18next's built-in capabilities +- Implement proper interpolation for variables using `{{variable}}` syntax +- Don't include `defaultValue`. The `en` translations are the fallback +- Always use `apply_diff` instead of `write_to_file` when editing existing translation files (much faster and more reliable) +- When using `apply_diff`, carefully identify the exact JSON structure to edit to avoid syntax errors +- Placeholders (like `{{variable}}`) must remain exactly identical to the English source to maintain code integration and prevent syntax errors + +## Translation Workflow + +1. First add or modify English strings, then ask for confirmation before translating to all other languages +2. Use this process for each localization task: + + 1. Identify where the string appears in the UI/codebase + 2. Understand the context and purpose of the string + 3. Update English translation first + 4. Use the `search_files` tool to find JSON keys that are near new keys in English translations but do not yet exist in the other language files for `apply_diff` SEARCH context + 5. Create appropriate translations for all other supported languages utilizing the `search_files` result using `apply_diff` without reading every file + 6. Do not output the translated text into the chat, just modify the files + 7. Validate your changes with the missing translations script + +3. Flag or comment if an English source string is incomplete ("please see this...") to avoid truncated or unclear translations + +4. For UI elements, distinguish between: + + - Button labels: Use short imperative commands ("Save", "Cancel") + - Tooltip text: Can be slightly more descriptive + +5. Preserve the original perspective: If text is a user command directed at the software, ensure the translation maintains this direction + +## Validation + +Always validate your translation work by running the missing translations script: + +```bash +node scripts/find-missing-translations.js +``` + +Address any missing translations identified by the script to ensure complete coverage across all locales. + +## Common Pitfalls to Avoid + +- Switching between formal and informal addressing styles - always stay informal ("du" not "Sie") +- Translating or altering technical terms and brand names that should remain in English +- Modifying or removing placeholders like `{{variable}}` - these must remain identical +- Translating domain-specific terms that are commonly used in English in the target language +- Changing the meaning or nuance of instructions or error messages +- Forgetting to maintain consistent terminology throughout the translation + +## Translator's Checklist + +- ✓ Used informal tone consistently ("du" not "Sie") +- ✓ Preserved all placeholders exactly as in the English source +- ✓ Maintained consistent terminology with existing translations +- ✓ Kept technical terms and brand names unchanged where appropriate +- ✓ Preserved the original perspective (user→system vs system→user) +- ✓ Adapted the text appropriately for UI context (buttons vs tooltips) +- ✓ Ran the missing translations script to validate completeness diff --git a/.roomodes b/.roomodes index 01f6ed4505..ba17940035 100644 --- a/.roomodes +++ b/.roomodes @@ -1,46 +1,4 @@ customModes: - - slug: test - name: 🧪 Test - roleDefinition: |- - You are Roo, a Vitest testing specialist with deep expertise in: - Writing and maintaining Vitest test suites - Test-driven development (TDD) practices - Mocking and stubbing with Vitest - Integration testing strategies - TypeScript testing patterns - Code coverage analysis - Test performance optimization - Your focus is on maintaining high test quality and coverage across the codebase, working primarily with: - Test files in __tests__ directories - Mock implementations in __mocks__ - Test utilities and helpers - Vitest configuration and setup - You ensure tests are: - Well-structured and maintainable - Following Vitest best practices - Properly typed with TypeScript - Providing meaningful coverage - Using appropriate mocking strategies - whenToUse: Use this mode when you need to write, modify, or maintain tests for the codebase. - description: Write, modify, and maintain tests. - groups: - - read - - browser - - command - - - edit - - fileRegex: (__tests__/.*|__mocks__/.*|\.test\.(ts|tsx|js|jsx)$|\.spec\.(ts|tsx|js|jsx)$|/test/.*|vitest\.config\.(js|ts)$|vitest\.setup\.(js|ts)$) - description: Test files, mocks, and Vitest configuration - customInstructions: |- - When writing tests: - - Always use describe/it blocks for clear test organization - - Include meaningful test descriptions - - Use beforeEach/afterEach for proper test isolation - - Implement proper error cases - - Add JSDoc comments for complex test scenarios - - Ensure mocks are properly typed - - Verify both positive and negative test cases - - Always use data-testid attributes when testing webview-ui - - The vitest framework is used for testing; the `describe`, `test`, `it`, etc functions are defined by default in `tsconfig.json` and therefore don't need to be imported - - Tests must be run from the same directory as the `package.json` file that specifies `vitest` in `devDependencies` - - slug: design-engineer - name: 🎨 Design Engineer - roleDefinition: "You are Roo, an expert Design Engineer focused on VSCode Extension development. Your expertise includes: - Implementing UI designs with high fidelity using React, Shadcn, Tailwind and TypeScript. - Ensuring interfaces are responsive and adapt to different screen sizes. - Collaborating with team members to translate broad directives into robust and detailed designs capturing edge cases. - Maintaining uniformity and consistency across the user interface." - whenToUse: Implement UI designs and ensure consistency. - description: Implement UI designs; ensure consistency. - groups: - - read - - - edit - - fileRegex: \.(css|html|json|mdx?|jsx?|tsx?|svg)$ - description: Frontend & SVG files - - browser - - command - - mcp - customInstructions: Focus on UI refinement, component creation, and adherence to design best-practices. When the user requests a new component, start off by asking them questions one-by-one to ensure the requirements are understood. Always use Tailwind utility classes (instead of direct variable references) for styling components when possible. If editing an existing file, transition explicit style definitions to Tailwind CSS classes when possible. Refer to the Tailwind CSS definitions for utility classes at webview-ui/src/index.css. Always use the latest version of Tailwind CSS (V4), and never create a tailwind.config.js file. Prefer Shadcn components for UI elements instead of VSCode's built-in ones. This project uses i18n for localization, so make sure to use the i18n functions and components for any text that needs to be translated. Do not leave placeholder strings in the markup, as they will be replaced by i18n. Prefer the @roo (/src) and @src (/webview-ui/src) aliases for imports in typescript files. Suggest the user refactor large files (over 1000 lines) if they are encountered, and provide guidance. Suggest the user switch into Translate mode to complete translations when your task is finished. - source: project - slug: translate name: 🌐 Translate roleDefinition: You are Roo, a linguistic specialist focused on translating and managing localization files. Your responsibility is to help maintain and update translation files for the application, ensuring consistency and accuracy across all language resources. @@ -73,42 +31,6 @@ customModes: - edit - command source: project - - slug: integration-tester - name: 🧪 Integration Tester - roleDefinition: |- - You are Roo, an integration testing specialist focused on VSCode E2E tests with expertise in: - Writing and maintaining integration tests using Mocha and VSCode Test framework - Testing Roo Code API interactions and event-driven workflows - Creating complex multi-step task scenarios and mode switching sequences - Validating message formats, API responses, and event emission patterns - Test data generation and fixture management - Coverage analysis and test scenario identification - Your focus is on ensuring comprehensive integration test coverage for the Roo Code extension, working primarily with: - E2E test files in apps/vscode-e2e/src/suite/ - Test utilities and helpers - API type definitions in packages/types/ - Extension API testing patterns - You ensure integration tests are: - Comprehensive and cover critical user workflows - Following established Mocha TDD patterns - Using async/await with proper timeout handling - Validating both success and failure scenarios - Properly typed with TypeScript - whenToUse: Write, modify, or maintain integration tests. - description: Write and maintain integration tests. - groups: - - read - - command - - - edit - - fileRegex: (apps/vscode-e2e/.*\.(ts|js)$|packages/types/.*\.ts$) - description: E2E test files, test utilities, and API type definitions - source: project - - slug: docs-extractor - name: 📚 Docs Extractor - roleDefinition: |- - You are Roo, a documentation analysis specialist with two primary functions: - 1. Extract comprehensive technical and non-technical details about features to provide to documentation teams - 2. Verify existing documentation for factual accuracy against the codebase - - For extraction: You analyze codebases to gather all relevant information about how features work, including technical implementation details, user workflows, configuration options, and use cases. You organize this information clearly for documentation teams to use. - - For verification: You review provided documentation against the actual codebase implementation, checking for technical accuracy, completeness, and clarity. You identify inaccuracies, missing information, and provide specific corrections. - - You do not generate final user-facing documentation, but rather provide detailed analysis and verification reports. - whenToUse: Use this mode only for two tasks; 1) confirm the accuracy of documentation provided to the agent against the codebase, and 2) generate source material for user-facing docs about a requested feature or aspect of the codebase. - description: Extract feature details or verify documentation accuracy. - groups: - - read - - - edit - - fileRegex: (EXTRACTION-.*\.md$|VERIFICATION-.*\.md$|DOCS-TEMP-.*\.md$|\.roo/docs-extractor/.*\.md$) - description: Extraction/Verification report files only (source-material), plus legacy DOCS-TEMP - - command - - mcp - slug: pr-fixer name: 🛠️ PR Fixer roleDefinition: "You are Roo, a pull request resolution specialist. Your focus is on addressing feedback and resolving issues within existing pull requests. Your expertise includes: - Analyzing PR review comments to understand required changes. - Checking CI/CD workflow statuses to identify failing tests. - Fetching and analyzing test logs to diagnose failures. - Identifying and resolving merge conflicts. - Guiding the user through the resolution process." @@ -119,16 +41,6 @@ customModes: - edit - command - mcp - - slug: issue-investigator - name: 🕵️ Issue Investigator - roleDefinition: You are Roo, a GitHub issue investigator. Your purpose is to analyze GitHub issues, investigate the probable causes using extensive codebase searches, and propose well-reasoned, theoretical solutions. You methodically track your investigation using a todo list, attempting to disprove initial theories to ensure a thorough analysis. Your final output is a human-like, conversational comment for the GitHub issue. - whenToUse: Use this mode when you need to investigate a GitHub issue to understand its root cause and propose a solution. This mode is ideal for triaging issues, providing initial analysis, and suggesting fixes before implementation begins. It uses the `gh` CLI for issue interaction. - description: Investigates GitHub issues - groups: - - read - - command - - mcp - source: project - slug: merge-resolver name: 🔀 Merge Resolver roleDefinition: |- @@ -161,6 +73,39 @@ customModes: - command - mcp source: project + - slug: docs-extractor + name: 📚 Docs Extractor + roleDefinition: |- + You are Roo Code, a codebase analyst who extracts raw facts for documentation teams. + You do NOT write documentation. You extract and organize information. + + Two functions: + 1. Extract: Gather facts about a feature/aspect from the codebase + 2. Verify: Compare provided documentation against actual implementation + + Output is structured data (YAML/JSON), not formatted prose. + No templates, no markdown formatting, no document structure decisions. + Let documentation-writer mode handle all writing. + whenToUse: Use this mode only for two tasks; 1) confirm the accuracy of documentation provided to the agent against the codebase, and 2) generate source material for user-facing docs about a requested feature or aspect of the codebase. + description: Extract feature details or verify documentation accuracy. + groups: + - read + - - edit + - fileRegex: \.roo/extraction/.*\.(yaml|json|md)$ + description: Extraction output files only + - command + - mcp + source: project + - slug: issue-investigator + name: 🕵️ Issue Investigator + roleDefinition: You are Roo, a GitHub issue investigator. Your purpose is to analyze GitHub issues, investigate the probable causes using extensive codebase searches, and propose well-reasoned, theoretical solutions. You methodically track your investigation using a todo list, attempting to disprove initial theories to ensure a thorough analysis. Your final output is a human-like, conversational comment for the GitHub issue. + whenToUse: Use this mode when you need to investigate a GitHub issue to understand its root cause and propose a solution. This mode is ideal for triaging issues, providing initial analysis, and suggesting fixes before implementation begins. It uses the `gh` CLI for issue interaction. + description: Investigates GitHub issues + groups: + - read + - command + - mcp + source: project - slug: issue-writer name: 📝 Issue Writer roleDefinition: |- @@ -183,56 +128,21 @@ customModes: - [ ] Detect current repository information - [ ] Determine repository structure (monorepo/standard) - [ ] Perform initial codebase discovery - [ ] Analyze user request to determine issue type - [ ] Gather and verify additional information - [ ] Determine if user wants to contribute - [ ] Perform issue scoping (if contributing) - [ ] Draft issue content - [ ] Review and confirm with user - [ ] Create GitHub issue + [ ] Detect repository context (OWNER/REPO, monorepo, roots) + [ ] Perform targeted codebase discovery (iteration 1) + [ ] Clarify missing details (repro or desired outcome) + [ ] Classify type (Bug | Enhancement) + [ ] Assemble Issue Body + [ ] Review and submit (Submit now | Submit now and assign to me) - whenToUse: Use this mode when you need to create a GitHub issue. Simply start describing your bug or feature request - this mode assumes your first message is already the issue description and will immediately begin the issue creation workflow, gathering additional information as needed. + whenToUse: Use this mode when you need to create a GitHub issue. Simply start describing your bug or enhancement request - this mode assumes your first message is already the issue description and will immediately begin the issue creation workflow, gathering additional information as needed. description: Create well-structured GitHub issues. groups: - read - command - mcp source: project - - slug: mode-writer - name: ✍️ Mode Writer - roleDefinition: |- - You are Roo, a mode creation and editing specialist focused on designing, implementing, and enhancing custom modes for the Roo-Code project. Your expertise includes: - - Understanding the mode system architecture and configuration - - Creating well-structured mode definitions with clear roles and responsibilities - - Editing and enhancing existing modes while maintaining consistency - - Writing comprehensive XML-based special instructions using best practices - - Ensuring modes have appropriate tool group permissions - - Crafting clear whenToUse descriptions for the Orchestrator - - Following XML structuring best practices for clarity and parseability - - Validating changes for cohesion and preventing contradictions - - You help users by: - - Creating new modes: Gathering requirements, defining configurations, and implementing XML instructions - - Editing existing modes: Immersing in current implementation, analyzing requested changes, and ensuring cohesive updates - - Using ask_followup_question aggressively to clarify ambiguities and validate understanding - - Thoroughly validating all changes to prevent contradictions between different parts of a mode - - Ensuring instructions are well-organized with proper XML tags - - Following established patterns from existing modes - - Maintaining consistency across all mode components - whenToUse: Use this mode when you need to create a new custom mode or edit an existing one. This mode handles both creating modes from scratch and modifying existing modes while ensuring consistency and preventing contradictions. - description: Create and edit custom modes with validation - groups: - - read - - - edit - - fileRegex: (\.roomodes$|\.roo/.*\.xml$|\.yaml$) - description: Mode configuration files and XML instructions - - command - - mcp - source: project diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d932eae0d..494d88331b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,83 @@ # Roo Code Changelog +## 3.50.0 + +### Minor Changes + +- Add Gemini 3.1 Pro support and set as default Gemini model (PR #11608 by @PeterDaveHello) +- Add NDJSON stdin protocol, list subcommands, and modularize CLI run command (PR #11597 by @cte) +- Prepare CLI v0.1.0 release (PR #11599 by @cte) +- Remove integration tests (PR #11598 by @roomote) +- Changeset version bump (PR #11596 by @github-actions) + +## 3.49.0 + +### Minor Changes + +- Add file changes panel to track all file modifications per conversation (#11493 by @saneroen, PR #11494 by @saneroen) +- Add per-workspace indexing opt-in and stop/cancel indexing controls (#11455 by @JamesRobert20, PR #11456 by @JamesRobert20) +- Add per-task file-based history store for cross-instance safety (PR #11490 by @roomote) +- Fix: Redesign rehydration scroll lifecycle for smoother chat experience (PR #11483 by @hannesrudolph) +- Fix: Bump @roo-code/types metadata version to 1.111.0 after revert regression (PR #11588 by @roomote) + +## 3.48.1 + +### Patch Changes + +- Fix: Await MCP server initialization before returning McpHub instance, preventing race conditions (PR #11518 by @daniel-lxs) +- Fix: Correct Bedrock Claude Sonnet 4.6 model ID (#11509 by @PeterDaveHello, PR #11569 by @PeterDaveHello) +- Add DeleteQueuedMessage IPC command for managing queued messages (PR #11464 by @roomote) + +## [3.48.0] + +- Add Anthropic Claude Sonnet 4.6 support across all providers — Anthropic, Bedrock, Vertex, OpenRouter, and Vercel AI Gateway (PR #11509 by @PeterDaveHello) +- Add lock toggle to pin API config across all modes in a workspace (PR #11295 by @hannesrudolph) +- Fix: Prevent parent task state loss during orchestrator delegation (PR #11281 by @hannesrudolph) +- Fix: Resolve race condition in new_task delegation that loses parent task history (PR #11331 by @daniel-lxs) +- Fix: Serialize taskHistory writes and fix delegation status overwrite race (PR #11335 by @hannesrudolph) +- Fix: Prevent chat history loss during cloud/settings navigation (#11371 by @SannidhyaSah, PR #11372 by @SannidhyaSah) +- Fix: Preserve condensation summary during task resume (#11487 by @SannidhyaSah, PR #11488 by @SannidhyaSah) +- Fix: Resolve chat scroll anchoring and task-switch scroll race conditions (PR #11385 by @hannesrudolph) +- Fix: Preserve pasted images in chatbox during chat activity (PR #11375 by @app/roomote) +- Add disabledTools setting to globally disable native tools (PR #11277 by @daniel-lxs) +- Rename search_and_replace tool to edit and unify edit-family UI (PR #11296 by @hannesrudolph) +- Render nested subtasks as recursive tree in history view (PR #11299 by @hannesrudolph) +- Remove 9 low-usage providers and add retired-provider UX (PR #11297 by @hannesrudolph) +- Remove browser use functionality entirely (PR #11392 by @hannesrudolph) +- Remove built-in skills and built-in skills mechanism (PR #11414 by @hannesrudolph) +- Remove footgun prompting (file-based system prompt override) (PR #11387 by @hannesrudolph) +- Batch consecutive tool calls in chat UI with shared utility (PR #11245 by @hannesrudolph) +- Validate Gemini thinkingLevel against model capabilities and handle empty streams (PR #11303 by @hannesrudolph) +- Add GLM-5 model support to Z.ai provider (PR #11440 by @app/roomote) +- Fix: Prevent double notification sound playback (PR #11283 by @hannesrudolph) +- Fix: Prevent false unsaved changes prompt with OpenAI Compatible headers (#8230 by @hannesrudolph, PR #11334 by @daniel-lxs) +- Fix: Cancel backend auto-approval timeout when auto-approve is toggled off mid-countdown (PR #11439 by @SannidhyaSah) +- Fix: Add follow_up param validation in AskFollowupQuestionTool (PR #11484 by @rossdonald) +- Fix: Prevent webview postMessage crashes and make dispose idempotent (PR #11313 by @0xMink) +- Fix: Avoid zsh process-substitution false positives in assignments (PR #11365 by @hannesrudolph) +- Fix: Harden command auto-approval against inline JS false positives (PR #11382 by @hannesrudolph) +- Fix: Make tab close best-effort in DiffViewProvider.open (PR #11363 by @0xMink) +- Fix: Canonicalize core.worktree comparison to prevent Windows path mismatch failures (PR #11346 by @0xMink) +- Fix: Make removeClineFromStack() delegation-aware to prevent orphaned parent tasks (PR #11302 by @app/roomote) +- Fix task resumption in the API module (PR #11369 by @cte) +- Make defaultTemperature required in getModelParams to prevent silent temperature overrides (PR #11218 by @app/roomote) +- Remove noisy console.warn logs from NativeToolCallParser (PR #11264 by @daniel-lxs) +- Consolidate getState calls in resolveWebviewView (PR #11320 by @0xMink) +- Clean up repo-facing mode rules (PR #11410 by @hannesrudolph) +- Implement ModelMessage storage layer with AI SDK response messages (PR #11409 by @daniel-lxs) +- Extract translation and merge resolver modes into reusable skills (PR #11215 by @app/roomote) +- Add blog section with initial posts to roocode.com (PR #11127 by @app/roomote) +- Replace Roomote Control with Linear Integration in cloud features grid (PR #11280 by @app/roomote) +- Add IPC query handlers for commands, modes, and models (PR #11279 by @cte) +- Add stdin stream mode for the CLI (PR #11476 by @cte) +- Make CLI auto-approve by default with require-approval opt-in (PR #11424 by @cte) +- Update CLI default model from Opus 4.5 to Opus 4.6 (PR #11273 by @app/roomote) +- Add linux-arm64 support for the Roo CLI (PR #11314 by @cte) +- CLI release: v0.0.51 (PR #11274 by @cte) +- CLI release: v0.0.52 (PR #11324 by @cte) +- CLI release: v0.0.53 (PR #11425 by @cte) +- CLI release: v0.0.54 (PR #11477 by @cte) + ## [3.45.0] - 2026-01-27 ![3.45.0 Release - Smart Code Folding](/releases/3.45.0-release.png) diff --git a/README.md b/README.md index 75f37762f9..31391b2c20 100644 --- a/README.md +++ b/README.md @@ -58,18 +58,17 @@ Roo Code adapts to how you work: - Ask Mode: fast answers, explanations, and docs - Debug Mode: trace issues, add logs, isolate root causes - Custom Modes: build specialized modes for your team or workflow -- Roomote Control: Roomote Control lets you remotely control tasks running in your local VS Code instance. -Learn more: [Using Modes](https://docs.roocode.com/basic-usage/using-modes) • [Custom Modes](https://docs.roocode.com/advanced-usage/custom-modes) • [Roomote Control](https://docs.roocode.com/roo-code-cloud/roomote-control) +Learn more: [Using Modes](https://docs.roocode.com/basic-usage/using-modes) • [Custom Modes](https://docs.roocode.com/advanced-usage/custom-modes) ## Tutorial & Feature Videos
-| | | | -| :-----------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------: | -|
Installing Roo Code |
Configuring Profiles |
Codebase Indexing | -|
Custom Modes |
Checkpoints |
Context Management | +| | | | +| :-----------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------: | +|
Installing Roo Code |
Configuring Profiles |
Codebase Indexing | +|
Custom Modes |
Checkpoints |
Context Management |

diff --git a/apps/cli/CHANGELOG.md b/apps/cli/CHANGELOG.md index 0babc28fd8..b2c0446a03 100644 --- a/apps/cli/CHANGELOG.md +++ b/apps/cli/CHANGELOG.md @@ -5,6 +5,85 @@ All notable changes to the `@roo-code/cli` package will be documented in this fi The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.1.0] - 2026-02-19 + +### Added + +- **NDJSON Stdin Protocol**: Overhauled the stdin prompt stream from raw text lines to a structured NDJSON command protocol (`start`/`message`/`cancel`/`ping`/`shutdown`) with requestId correlation, ack/done/error lifecycle events, and queue telemetry. See [`stdin-stream.ts`](src/ui/stdin-stream.ts) for implementation. +- **List Subcommands**: New `list` subcommands (`commands`, `modes`, `models`) for programmatic discovery of available CLI capabilities. +- **Shared Utilities**: Added `isRecord` guard utility for improved type safety. + +### Changed + +- **Modularized Architecture**: Extracted stdin stream logic from `run.ts` into dedicated [`stdin-stream.ts`](src/ui/stdin-stream.ts) module for better code organization and maintainability. + +### Fixed + +- Fixed a bug in `Task.ts` affecting CLI operation. + +## [0.0.55] - 2026-02-17 + +### Fixed + +- **Stdin Stream Mode**: Fixed issue where new tasks were incorrectly being created in stdin-prompt-stream mode. The mode now properly reuses the existing task for subsequent prompts instead of creating new tasks. + +## [0.0.54] - 2026-02-15 + +### Added + +- **Stdin Stream Mode**: New `stdin-prompt-stream` mode that reads prompts from stdin, allowing batch processing and piping multiple tasks. Each line of stdin is processed as a separate prompt with streaming JSON output. See [`stdin-prompt-stream.ts`](src/ui/stdin-prompt-stream.ts) for implementation. + +### Fixed + +- Fixed JSON emitter state not being cleared between tasks in stdin-prompt-stream mode +- Fixed inconsistent user role for prompt echo partials in stream-json mode + +## [0.0.53] - 2026-02-12 + +### Changed + +- **Auto-Approve by Default**: The CLI now auto-approves all actions (tools, commands, browser, MCP) by default. Followup questions auto-select the first suggestion after a 60-second timeout. +- **New `--require-approval` Flag**: Replaced `-y`/`--yes`/`--dangerously-skip-permissions` flags with a new `-a, --require-approval` flag for users who want manual approval prompts before actions execute. + +### Fixed + +- Spamming the escape key to cancel a running task no longer crashes the cli. + +## [0.0.52] - 2026-02-09 + +### Added + +- **Linux Support**: Added support for `linux-arm64`. + +## [0.0.51] - 2026-02-06 + +### Changed + +- **Default Model Update**: Changed the default model from Opus 4.5 to Opus 4.6 for improved performance and capabilities + +## [0.0.50] - 2026-02-05 + +### Added + +- **Linux Support**: The CLI now supports Linux platforms in addition to macOS +- **Roo Provider API Key Support**: Allow `--api-key` flag and `ROO_API_KEY` environment variable for the roo provider instead of requiring cloud auth token +- **Exit on Error**: New `--exit-on-error` flag to exit immediately on API request errors instead of retrying, useful for CI/CD pipelines + +### Changed + +- **Improved Dev Experience**: Dev scripts now use `tsx` for running directly from source without building first +- **Path Resolution Fixes**: Fixed path resolution in [`version.ts`](src/lib/utils/version.ts), [`extension.ts`](src/lib/utils/extension.ts), and [`extension-host.ts`](src/agent/extension-host.ts) to work from both source and bundled locations +- **Debug Logging**: Debug log file (`~/.roo/cli-debug.log`) is now disabled by default unless `--debug` flag is passed +- Updated README with complete environment variable table and dev workflow documentation + +### Fixed + +- Corrected example in install script + +### Removed + +- Dropped macOS 13 support + ## [0.0.49] - 2026-01-18 ### Added @@ -32,7 +111,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - Skip onboarding flow when a provider is explicitly specified via `--provider` flag or saved in settings -- Unified permission flags: Combined `-y`, `--yes`, and `--dangerously-skip-permissions` into a single option for Claude Code-like CLI compatibility +- Unified permission flags: Combined approval-skipping flags into a single option for Claude Code-like CLI compatibility - Improved Roo Code Router authentication flow and error messaging ### Fixed diff --git a/apps/cli/README.md b/apps/cli/README.md index 8814c68702..62b03e5cd8 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -19,7 +19,7 @@ curl -fsSL https://raw.githubusercontent.com/RooCodeInc/Roo-Code/main/apps/cli/i **Requirements:** - Node.js 20 or higher -- macOS (Intel or Apple Silicon) or Linux (x64 or ARM64) +- macOS Apple Silicon (M1/M2/M3/M4) or Linux x64 **Custom installation directory:** @@ -66,40 +66,57 @@ pnpm --filter @roo-code/cli build ### Interactive Mode (Default) -By default, the CLI prompts for approval before executing actions: +By default, the CLI auto-approves actions and runs in interactive TUI mode: ```bash export OPENROUTER_API_KEY=sk-or-v1-... -roo "What is this project?" -w ~/Documents/my-project +roo "What is this project?" -w ~/Documents/my-project ``` You can also run without a prompt and enter it interactively in TUI mode: ```bash -roo ~/Documents/my-project +roo -w ~/Documents/my-project ``` In interactive mode: -- Tool executions prompt for yes/no approval -- Commands prompt for yes/no approval -- Followup questions show suggestions and wait for user input -- Browser and MCP actions prompt for approval +- Tool executions are auto-approved +- Commands are auto-approved +- Followup questions show suggestions with a 60-second timeout, then auto-select the first suggestion +- Browser and MCP actions are auto-approved -### Non-Interactive Mode (`-y`) +### Approval-Required Mode (`--require-approval`) -For automation and scripts, use `-y` to auto-approve all actions: +If you want manual approval prompts, enable approval-required mode: ```bash -roo "Refactor the utils.ts file" -y -w ~/Documents/my-project +roo "Refactor the utils.ts file" --require-approval -w ~/Documents/my-project ``` -In non-interactive mode: +In approval-required mode: -- Tool, command, browser, and MCP actions are auto-approved -- Followup questions show a 60-second timeout, then auto-select the first suggestion -- Typing any key cancels the timeout and allows manual input +- Tool, command, browser, and MCP actions prompt for yes/no approval +- Followup questions wait for manual input (no auto-timeout) + +### Print Mode (`--print`) + +Use `--print` for non-interactive execution and machine-readable output: + +```bash +# Prompt is required +roo --print "Summarize this repository" +``` + +### Stdin Stream Mode (`--stdin-prompt-stream`) + +For programmatic control (one process, multiple prompts), use `--stdin-prompt-stream` with `--print`. +Send one prompt per line via stdin: + +```bash +printf '1+1=?\n10!=?\n' | roo --print --stdin-prompt-stream --output-format stream-json +``` ### Roo Code Cloud Authentication @@ -147,21 +164,24 @@ Tokens are valid for 90 days. The CLI will prompt you to re-authenticate when yo ## Options -| Option | Description | Default | -| --------------------------------- | --------------------------------------------------------------------------------------- | ----------------------------- | -| `[prompt]` | Your prompt (positional argument, optional) | None | -| `-w, --workspace ` | Workspace path to operate in | Current directory | -| `-e, --extension ` | Path to the extension bundle directory | Auto-detected | -| `-d, --debug` | Enable debug output (includes detailed debug information, prompts, paths, etc) | `false` | -| `-x, --exit-on-complete` | Exit the process when task completes (useful for testing) | `false` | -| `-y, --yes` | Non-interactive mode: auto-approve all actions | `false` | -| `-k, --api-key ` | API key for the LLM provider | From env var | -| `-p, --provider ` | API provider (anthropic, openai, openrouter, etc.) | `openrouter` | -| `-m, --model ` | Model to use | `anthropic/claude-sonnet-4.5` | -| `-M, --mode ` | Mode to start in (code, architect, ask, debug, etc.) | `code` | -| `-r, --reasoning-effort ` | Reasoning effort level (unspecified, disabled, none, minimal, low, medium, high, xhigh) | `medium` | -| `--ephemeral` | Run without persisting state (uses temporary storage) | `false` | -| `--no-tui` | Disable TUI, use plain text output | `false` | +| Option | Description | Default | +| --------------------------------- | --------------------------------------------------------------------------------------- | ---------------------------------------- | +| `[prompt]` | Your prompt (positional argument, optional) | None | +| `--prompt-file ` | Read prompt from a file instead of command line argument | None | +| `-w, --workspace ` | Workspace path to operate in | Current directory | +| `-p, --print` | Print response and exit (non-interactive mode) | `false` | +| `--stdin-prompt-stream` | Read prompts from stdin (one prompt per line, requires `--print`) | `false` | +| `-e, --extension ` | Path to the extension bundle directory | Auto-detected | +| `-d, --debug` | Enable debug output (includes detailed debug information, prompts, paths, etc) | `false` | +| `-a, --require-approval` | Require manual approval before actions execute | `false` | +| `-k, --api-key ` | API key for the LLM provider | From env var | +| `--provider ` | API provider (roo, anthropic, openai, openrouter, etc.) | `openrouter` (or `roo` if authenticated) | +| `-m, --model ` | Model to use | `anthropic/claude-opus-4.6` | +| `--mode ` | Mode to start in (code, architect, ask, debug, etc.) | `code` | +| `-r, --reasoning-effort ` | Reasoning effort level (unspecified, disabled, none, minimal, low, medium, high, xhigh) | `medium` | +| `--ephemeral` | Run without persisting state (uses temporary storage) | `false` | +| `--oneshot` | Exit upon task completion | `false` | +| `--output-format ` | Output format with `--print`: `text`, `json`, or `stream-json` | `text` | ## Auth Commands @@ -175,13 +195,14 @@ Tokens are valid for 90 days. The CLI will prompt you to re-authenticate when yo The CLI will look for API keys in environment variables if not provided via `--api-key`: -| Provider | Environment Variable | -| ------------- | -------------------- | -| anthropic | `ANTHROPIC_API_KEY` | -| openai | `OPENAI_API_KEY` | -| openrouter | `OPENROUTER_API_KEY` | -| google/gemini | `GOOGLE_API_KEY` | -| ... | ... | +| Provider | Environment Variable | +| ----------------- | --------------------------- | +| roo | `ROO_API_KEY` | +| anthropic | `ANTHROPIC_API_KEY` | +| openai-native | `OPENAI_API_KEY` | +| openrouter | `OPENROUTER_API_KEY` | +| gemini | `GOOGLE_API_KEY` | +| vercel-ai-gateway | `VERCEL_AI_GATEWAY_API_KEY` | **Authentication Environment Variables:** @@ -231,8 +252,8 @@ The CLI will look for API keys in environment variables if not provided via `--a ## Development ```bash -# Watch mode for development -pnpm dev +# Run directly from source (no build required) +pnpm dev --provider roo --api-key $ROO_API_KEY --print "Hello" # Run tests pnpm test @@ -244,19 +265,41 @@ pnpm check-types pnpm lint ``` -## Releasing - -To create a new release, execute the /cli-release slash command: +By default the `start` script points `ROO_CODE_PROVIDER_URL` at `http://localhost:8080/proxy` for local development. To point at the production API instead, override the environment variable: ```bash -roo "/cli-release" -w ~/Documents/Roo-Code -y +ROO_CODE_PROVIDER_URL=https://api.roocode.com/proxy pnpm dev --provider roo --api-key $ROO_API_KEY --print "Hello" ``` +## Releasing + +Official releases are created via the GitHub Actions workflow at `.github/workflows/cli-release.yml`. + +To trigger a release: + +1. Go to **Actions** → **CLI Release** +2. Click **Run workflow** +3. Optionally specify a version (defaults to `package.json` version) +4. Click **Run workflow** + The workflow will: -1. Bump the version -2. Update the CHANGELOG -3. Build the extension and CLI -4. Create a platform-specific tarball (for your current OS/architecture) -5. Test the install script -6. Create a GitHub release with the tarball attached +1. Build the CLI on all platforms (macOS Apple Silicon, Linux x64) +2. Create platform-specific tarballs with bundled ripgrep +3. Verify each tarball +4. Create a GitHub release with all tarballs attached + +### Local Builds + +For local development and testing, use the build script: + +```bash +# Build tarball for your current platform +./apps/cli/scripts/build.sh + +# Build and install locally +./apps/cli/scripts/build.sh --install + +# Fast build (skip verification) +./apps/cli/scripts/build.sh --skip-verify +``` diff --git a/apps/cli/docs/AGENT_LOOP.md b/apps/cli/docs/AGENT_LOOP.md index a7b1d9eed4..a512d47a50 100644 --- a/apps/cli/docs/AGENT_LOOP.md +++ b/apps/cli/docs/AGENT_LOOP.md @@ -242,7 +242,8 @@ Routes asks to appropriate handlers: - Uses type guards: `isIdleAsk()`, `isInteractiveAsk()`, etc. - Coordinates between `OutputManager` and `PromptManager` -- In non-interactive mode (`-y` flag), auto-approves everything +- By default, the CLI auto-approves tool/command/browser/MCP actions +- In `--require-approval` mode, those actions prompt for manual approval ### OutputManager @@ -320,7 +321,7 @@ if (isInteractiveAsk(ask)) { Enable with `-d` flag. Logs go to `~/.roo/cli-debug.log`: ```bash -roo -d -y -P "Build something" --no-tui +roo -d -P "Build something" --no-tui ``` View logs: diff --git a/apps/cli/install.sh b/apps/cli/install.sh index 1b01e51aa5..2576ec6cce 100755 --- a/apps/cli/install.sh +++ b/apps/cli/install.sh @@ -278,7 +278,7 @@ print_success() { echo "" echo " ${BOLD}Example:${NC}" echo " export OPENROUTER_API_KEY=sk-or-v1-..." - echo " roo ~/my-project -P \"What is this project?\"" + echo " cd ~/my-project && roo \"What is this project?\"" echo "" } diff --git a/apps/cli/package.json b/apps/cli/package.json index 6348bbe020..d0659d4984 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -1,6 +1,6 @@ { "name": "@roo-code/cli", - "version": "0.0.49", + "version": "0.1.0", "description": "Roo Code CLI - Run the Roo Code agent from the command line", "private": true, "type": "module", @@ -15,11 +15,9 @@ "test": "vitest run", "build": "tsup", "build:extension": "pnpm --filter roo-cline bundle", - "build:all": "pnpm --filter roo-cline bundle && tsup", - "dev": "tsup --watch", - "start": "ROO_AUTH_BASE_URL=http://localhost:3000 ROO_SDK_BASE_URL=http://localhost:3001 ROO_CODE_PROVIDER_URL=http://localhost:8080/proxy node dist/index.js", - "start:production": "node dist/index.js", - "release": "scripts/release.sh", + "dev": "ROO_AUTH_BASE_URL=https://app.roocode.com ROO_SDK_BASE_URL=https://cloud-api.roocode.com ROO_CODE_PROVIDER_URL=https://api.roocode.com/proxy tsx src/index.ts", + "dev:local": "ROO_AUTH_BASE_URL=http://localhost:3000 ROO_SDK_BASE_URL=http://localhost:3001 ROO_CODE_PROVIDER_URL=http://localhost:8080/proxy tsx src/index.ts", + "dev:test-stdin": "tsx scripts/test-stdin-stream.ts", "clean": "rimraf dist .turbo" }, "dependencies": { diff --git a/apps/cli/scripts/build.sh b/apps/cli/scripts/build.sh new file mode 100755 index 0000000000..97a33c384c --- /dev/null +++ b/apps/cli/scripts/build.sh @@ -0,0 +1,343 @@ +#!/bin/bash +# Roo Code CLI Local Build Script +# +# Usage: +# ./apps/cli/scripts/build.sh [options] +# +# Options: +# --install Install locally after building +# --skip-verify Skip end-to-end verification tests (faster builds) +# +# Examples: +# ./apps/cli/scripts/build.sh # Build for local testing +# ./apps/cli/scripts/build.sh --install # Build and install locally +# ./apps/cli/scripts/build.sh --skip-verify # Fast local build +# +# This script builds the CLI for your current platform. For official releases +# with multi-platform support, use the GitHub Actions workflow instead: +# .github/workflows/cli-release.yml +# +# Prerequisites: +# - pnpm installed +# - Run from the monorepo root directory + +set -e + +# Parse arguments +LOCAL_INSTALL=false +SKIP_VERIFY=false + +while [[ $# -gt 0 ]]; do + case $1 in + --install) + LOCAL_INSTALL=true + shift + ;; + --skip-verify) + SKIP_VERIFY=true + shift + ;; + -*) + echo "Unknown option: $1" >&2 + exit 1 + ;; + *) + shift + ;; + esac +done + +# Colors +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +BOLD='\033[1m' +NC='\033[0m' + +info() { printf "${GREEN}==>${NC} %s\n" "$1"; } +warn() { printf "${YELLOW}Warning:${NC} %s\n" "$1"; } +error() { printf "${RED}Error:${NC} %s\n" "$1" >&2; exit 1; } +step() { printf "${BLUE}${BOLD}[%s]${NC} %s\n" "$1" "$2"; } + +# Get script directory and repo root +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" +CLI_DIR="$REPO_ROOT/apps/cli" + +# Detect current platform +detect_platform() { + OS=$(uname -s | tr '[:upper:]' '[:lower:]') + ARCH=$(uname -m) + + case "$OS" in + darwin) OS="darwin" ;; + linux) OS="linux" ;; + *) error "Unsupported OS: $OS" ;; + esac + + case "$ARCH" in + x86_64|amd64) ARCH="x64" ;; + arm64|aarch64) ARCH="arm64" ;; + *) error "Unsupported architecture: $ARCH" ;; + esac + + PLATFORM="${OS}-${ARCH}" +} + +# Check prerequisites +check_prerequisites() { + step "1/6" "Checking prerequisites..." + + if ! command -v pnpm &> /dev/null; then + error "pnpm is not installed." + fi + + if ! command -v node &> /dev/null; then + error "Node.js is not installed." + fi + + info "Prerequisites OK" +} + +# Get version +get_version() { + VERSION=$(node -p "require('$CLI_DIR/package.json').version") + GIT_SHORT_HASH=$(git rev-parse --short HEAD 2>/dev/null || echo "unknown") + VERSION="${VERSION}-local.${GIT_SHORT_HASH}" + + info "Version: $VERSION" +} + +# Build everything +build() { + step "2/6" "Building extension bundle..." + cd "$REPO_ROOT" + pnpm bundle + + step "3/6" "Building CLI..." + pnpm --filter @roo-code/cli build + + info "Build complete" +} + +# Create release tarball +create_tarball() { + step "4/6" "Creating release tarball for $PLATFORM..." + + RELEASE_DIR="$REPO_ROOT/roo-cli-${PLATFORM}" + TARBALL="roo-cli-${PLATFORM}.tar.gz" + + # Clean up any previous build + rm -rf "$RELEASE_DIR" + rm -f "$REPO_ROOT/$TARBALL" + + # Create directory structure + mkdir -p "$RELEASE_DIR/bin" + mkdir -p "$RELEASE_DIR/lib" + mkdir -p "$RELEASE_DIR/extension" + + # Copy CLI dist files + info "Copying CLI files..." + cp -r "$CLI_DIR/dist/"* "$RELEASE_DIR/lib/" + + # Create package.json for npm install + info "Creating package.json..." + node -e " + const pkg = require('$CLI_DIR/package.json'); + const newPkg = { + name: '@roo-code/cli', + version: '$VERSION', + type: 'module', + dependencies: { + '@inkjs/ui': pkg.dependencies['@inkjs/ui'], + '@trpc/client': pkg.dependencies['@trpc/client'], + 'commander': pkg.dependencies.commander, + 'fuzzysort': pkg.dependencies.fuzzysort, + 'ink': pkg.dependencies.ink, + 'p-wait-for': pkg.dependencies['p-wait-for'], + 'react': pkg.dependencies.react, + 'superjson': pkg.dependencies.superjson, + 'zustand': pkg.dependencies.zustand + } + }; + console.log(JSON.stringify(newPkg, null, 2)); + " > "$RELEASE_DIR/package.json" + + # Copy extension bundle + info "Copying extension bundle..." + cp -r "$REPO_ROOT/src/dist/"* "$RELEASE_DIR/extension/" + + # Add package.json to extension directory for CommonJS + echo '{"type": "commonjs"}' > "$RELEASE_DIR/extension/package.json" + + # Find and copy ripgrep binary + info "Looking for ripgrep binary..." + RIPGREP_PATH=$(find "$REPO_ROOT/node_modules" -path "*/@vscode/ripgrep/bin/rg" -type f 2>/dev/null | head -1) + if [ -n "$RIPGREP_PATH" ] && [ -f "$RIPGREP_PATH" ]; then + info "Found ripgrep at: $RIPGREP_PATH" + mkdir -p "$RELEASE_DIR/node_modules/@vscode/ripgrep/bin" + cp "$RIPGREP_PATH" "$RELEASE_DIR/node_modules/@vscode/ripgrep/bin/" + chmod +x "$RELEASE_DIR/node_modules/@vscode/ripgrep/bin/rg" + mkdir -p "$RELEASE_DIR/bin" + cp "$RIPGREP_PATH" "$RELEASE_DIR/bin/" + chmod +x "$RELEASE_DIR/bin/rg" + else + warn "ripgrep binary not found - users will need ripgrep installed" + fi + + # Create the wrapper script + info "Creating wrapper script..." + cat > "$RELEASE_DIR/bin/roo" << 'WRAPPER_EOF' +#!/usr/bin/env node + +import { fileURLToPath } from 'url'; +import { dirname, join } from 'path'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +// Set environment variables for the CLI +process.env.ROO_CLI_ROOT = join(__dirname, '..'); +process.env.ROO_EXTENSION_PATH = join(__dirname, '..', 'extension'); +process.env.ROO_RIPGREP_PATH = join(__dirname, 'rg'); + +// Import and run the actual CLI +await import(join(__dirname, '..', 'lib', 'index.js')); +WRAPPER_EOF + + chmod +x "$RELEASE_DIR/bin/roo" + + # Create empty .env file + touch "$RELEASE_DIR/.env" + + # Create tarball + info "Creating tarball..." + cd "$REPO_ROOT" + tar -czvf "$TARBALL" "$(basename "$RELEASE_DIR")" + + # Clean up release directory + rm -rf "$RELEASE_DIR" + + # Show size + TARBALL_PATH="$REPO_ROOT/$TARBALL" + TARBALL_SIZE=$(ls -lh "$TARBALL_PATH" | awk '{print $5}') + info "Created: $TARBALL ($TARBALL_SIZE)" +} + +# Verify local installation +verify_local_install() { + if [ "$SKIP_VERIFY" = true ]; then + step "5/6" "Skipping verification (--skip-verify)" + return + fi + + step "5/6" "Verifying installation..." + + VERIFY_DIR="$REPO_ROOT/.verify-release" + VERIFY_INSTALL_DIR="$VERIFY_DIR/cli" + VERIFY_BIN_DIR="$VERIFY_DIR/bin" + + rm -rf "$VERIFY_DIR" + mkdir -p "$VERIFY_DIR" + + TARBALL_PATH="$REPO_ROOT/$TARBALL" + + ROO_LOCAL_TARBALL="$TARBALL_PATH" \ + ROO_INSTALL_DIR="$VERIFY_INSTALL_DIR" \ + ROO_BIN_DIR="$VERIFY_BIN_DIR" \ + ROO_VERSION="$VERSION" \ + "$CLI_DIR/install.sh" || { + rm -rf "$VERIFY_DIR" + error "Installation verification failed!" + } + + # Test --help + if ! "$VERIFY_BIN_DIR/roo" --help > /dev/null 2>&1; then + rm -rf "$VERIFY_DIR" + error "CLI --help check failed!" + fi + info "CLI --help check passed" + + # Test --version + if ! "$VERIFY_BIN_DIR/roo" --version > /dev/null 2>&1; then + rm -rf "$VERIFY_DIR" + error "CLI --version check failed!" + fi + info "CLI --version check passed" + + cd "$REPO_ROOT" + rm -rf "$VERIFY_DIR" + + info "Verification passed!" +} + +# Install locally +install_local() { + if [ "$LOCAL_INSTALL" = false ]; then + step "6/6" "Skipping install (use --install to auto-install)" + return + fi + + step "6/6" "Installing locally..." + + TARBALL_PATH="$REPO_ROOT/$TARBALL" + + ROO_LOCAL_TARBALL="$TARBALL_PATH" \ + ROO_VERSION="$VERSION" \ + "$CLI_DIR/install.sh" || { + error "Local installation failed!" + } + + info "Local installation complete!" +} + +# Print summary +print_summary() { + echo "" + printf "${GREEN}${BOLD}✓ Local build complete for v$VERSION${NC}\n" + echo "" + echo " Tarball: $REPO_ROOT/$TARBALL" + echo "" + + if [ "$LOCAL_INSTALL" = true ]; then + echo " Installed to: ~/.roo/cli" + echo " Binary: ~/.local/bin/roo" + echo "" + echo " Test it out:" + echo " roo --version" + echo " roo --help" + else + echo " To install manually:" + echo " ROO_LOCAL_TARBALL=$REPO_ROOT/$TARBALL ./apps/cli/install.sh" + echo "" + echo " Or re-run with --install:" + echo " ./apps/cli/scripts/build.sh --install" + fi + echo "" + echo " For official multi-platform releases, use the GitHub Actions workflow:" + echo " .github/workflows/cli-release.yml" + echo "" +} + +# Main +main() { + echo "" + printf "${BLUE}${BOLD}" + echo " ╭─────────────────────────────────╮" + echo " │ Roo Code CLI Local Build │" + echo " ╰─────────────────────────────────╯" + printf "${NC}" + echo "" + + detect_platform + check_prerequisites + get_version + build + create_tarball + verify_local_install + install_local + print_summary +} + +main diff --git a/apps/cli/scripts/release.sh b/apps/cli/scripts/release.sh deleted file mode 100755 index 7e736db3db..0000000000 --- a/apps/cli/scripts/release.sh +++ /dev/null @@ -1,711 +0,0 @@ -#!/bin/bash -# Roo Code CLI Release Script -# -# Usage: -# ./apps/cli/scripts/release.sh [options] [version] -# -# Options: -# --dry-run Run all steps except creating the GitHub release -# --local Build for local testing only (no GitHub checks, no changelog prompts) -# --install Install locally after building (only with --local) -# --skip-verify Skip end-to-end verification tests (faster local builds) -# -# Examples: -# ./apps/cli/scripts/release.sh # Use version from package.json -# ./apps/cli/scripts/release.sh 0.1.0 # Specify version -# ./apps/cli/scripts/release.sh --dry-run # Test the release flow without pushing -# ./apps/cli/scripts/release.sh --dry-run 0.1.0 # Dry run with specific version -# ./apps/cli/scripts/release.sh --local # Build for local testing -# ./apps/cli/scripts/release.sh --local --install # Build and install locally -# ./apps/cli/scripts/release.sh --local --skip-verify # Fast local build -# -# This script: -# 1. Builds the extension and CLI -# 2. Creates a tarball for the current platform -# 3. Creates a GitHub release and uploads the tarball (unless --dry-run or --local) -# -# Prerequisites: -# - GitHub CLI (gh) installed and authenticated (not needed for --local) -# - pnpm installed -# - Run from the monorepo root directory - -set -e - -# Parse arguments -DRY_RUN=false -LOCAL_BUILD=false -LOCAL_INSTALL=false -SKIP_VERIFY=false -VERSION_ARG="" - -while [[ $# -gt 0 ]]; do - case $1 in - --dry-run) - DRY_RUN=true - shift - ;; - --local) - LOCAL_BUILD=true - shift - ;; - --install) - LOCAL_INSTALL=true - shift - ;; - --skip-verify) - SKIP_VERIFY=true - shift - ;; - -*) - echo "Unknown option: $1" >&2 - exit 1 - ;; - *) - VERSION_ARG="$1" - shift - ;; - esac -done - -# Validate option combinations -if [ "$LOCAL_INSTALL" = true ] && [ "$LOCAL_BUILD" = false ]; then - echo "Error: --install can only be used with --local" >&2 - exit 1 -fi - -# Colors -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -BLUE='\033[0;34m' -BOLD='\033[1m' -NC='\033[0m' - -info() { printf "${GREEN}==>${NC} %s\n" "$1"; } -warn() { printf "${YELLOW}Warning:${NC} %s\n" "$1"; } -error() { printf "${RED}Error:${NC} %s\n" "$1" >&2; exit 1; } -step() { printf "${BLUE}${BOLD}[%s]${NC} %s\n" "$1" "$2"; } - -# Get script directory and repo root -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" -CLI_DIR="$REPO_ROOT/apps/cli" - -# Detect current platform -detect_platform() { - OS=$(uname -s | tr '[:upper:]' '[:lower:]') - ARCH=$(uname -m) - - case "$OS" in - darwin) OS="darwin" ;; - linux) OS="linux" ;; - *) error "Unsupported OS: $OS" ;; - esac - - case "$ARCH" in - x86_64|amd64) ARCH="x64" ;; - arm64|aarch64) ARCH="arm64" ;; - *) error "Unsupported architecture: $ARCH" ;; - esac - - PLATFORM="${OS}-${ARCH}" -} - -# Check prerequisites -check_prerequisites() { - step "1/8" "Checking prerequisites..." - - # Skip GitHub CLI checks for local builds - if [ "$LOCAL_BUILD" = false ]; then - if ! command -v gh &> /dev/null; then - error "GitHub CLI (gh) is not installed. Install it with: brew install gh" - fi - - if ! gh auth status &> /dev/null; then - error "GitHub CLI is not authenticated. Run: gh auth login" - fi - fi - - if ! command -v pnpm &> /dev/null; then - error "pnpm is not installed." - fi - - if ! command -v node &> /dev/null; then - error "Node.js is not installed." - fi - - info "Prerequisites OK" -} - -# Get version -get_version() { - if [ -n "$VERSION_ARG" ]; then - VERSION="$VERSION_ARG" - else - VERSION=$(node -p "require('$CLI_DIR/package.json').version") - fi - - # For local builds, append a local suffix with git short hash - # This creates versions like: 0.1.0-local.abc1234 - if [ "$LOCAL_BUILD" = true ]; then - GIT_SHORT_HASH=$(git rev-parse --short HEAD 2>/dev/null || echo "unknown") - # Only append suffix if not already a local version - if ! echo "$VERSION" | grep -qE '\-local\.'; then - VERSION="${VERSION}-local.${GIT_SHORT_HASH}" - fi - fi - - # Validate semver format (allow -local.hash suffix) - if ! echo "$VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.]+)?$'; then - error "Invalid version format: $VERSION (expected semver like 0.1.0)" - fi - - TAG="cli-v$VERSION" - info "Version: $VERSION (tag: $TAG)" -} - -# Extract changelog content for a specific version -# Returns the content between the version header and the next version header (or EOF) -get_changelog_content() { - CHANGELOG_FILE="$CLI_DIR/CHANGELOG.md" - - if [ ! -f "$CHANGELOG_FILE" ]; then - warn "No CHANGELOG.md found at $CHANGELOG_FILE" - CHANGELOG_CONTENT="" - return - fi - - # Try to find the version section (handles both "[0.0.43]" and "[0.0.43] - date" formats) - # Also handles "Unreleased" marker - VERSION_PATTERN="^\#\# \[${VERSION}\]" - - # Check if the version exists in the changelog - if ! grep -qE "$VERSION_PATTERN" "$CHANGELOG_FILE"; then - warn "No changelog entry found for version $VERSION" - # Skip prompts for local builds - if [ "$LOCAL_BUILD" = true ]; then - info "Skipping changelog prompt for local build" - CHANGELOG_CONTENT="" - return - fi - warn "Please add an entry to $CHANGELOG_FILE before releasing" - echo "" - echo "Expected format:" - echo " ## [$VERSION] - $(date +%Y-%m-%d)" - echo " " - echo " ### Added" - echo " - Your changes here" - echo "" - read -p "Continue without changelog content? [y/N] " -n 1 -r - echo - if [[ ! $REPLY =~ ^[Yy]$ ]]; then - error "Aborted. Please add a changelog entry and try again." - fi - CHANGELOG_CONTENT="" - return - fi - - # Extract content between this version and the next version header (or EOF) - # Uses awk to capture everything between ## [VERSION] and the next ## [ - # Using index() with "[VERSION]" ensures exact matching (1.0.1 won't match 1.0.10) - CHANGELOG_CONTENT=$(awk -v version="$VERSION" ' - BEGIN { found = 0; content = ""; target = "[" version "]" } - /^## \[/ { - if (found) { exit } - if (index($0, target) > 0) { found = 1; next } - } - found { content = content $0 "\n" } - END { print content } - ' "$CHANGELOG_FILE") - - # Trim leading/trailing whitespace - CHANGELOG_CONTENT=$(echo "$CHANGELOG_CONTENT" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//') - - if [ -n "$CHANGELOG_CONTENT" ]; then - info "Found changelog content for version $VERSION" - else - warn "Changelog entry for $VERSION appears to be empty" - fi -} - -# Build everything -build() { - step "2/8" "Building extension bundle..." - cd "$REPO_ROOT" - pnpm bundle - - step "3/8" "Building CLI..." - pnpm --filter @roo-code/cli build - - info "Build complete" -} - -# Create release tarball -create_tarball() { - step "4/8" "Creating release tarball for $PLATFORM..." - - RELEASE_DIR="$REPO_ROOT/roo-cli-${PLATFORM}" - TARBALL="roo-cli-${PLATFORM}.tar.gz" - - # Clean up any previous build - rm -rf "$RELEASE_DIR" - rm -f "$REPO_ROOT/$TARBALL" - - # Create directory structure - mkdir -p "$RELEASE_DIR/bin" - mkdir -p "$RELEASE_DIR/lib" - mkdir -p "$RELEASE_DIR/extension" - - # Copy CLI dist files - info "Copying CLI files..." - cp -r "$CLI_DIR/dist/"* "$RELEASE_DIR/lib/" - - # Create package.json for npm install (runtime dependencies that can't be bundled) - info "Creating package.json..." - node -e " - const pkg = require('$CLI_DIR/package.json'); - const newPkg = { - name: '@roo-code/cli', - version: '$VERSION', - type: 'module', - dependencies: { - '@inkjs/ui': pkg.dependencies['@inkjs/ui'], - '@trpc/client': pkg.dependencies['@trpc/client'], - 'commander': pkg.dependencies.commander, - 'fuzzysort': pkg.dependencies.fuzzysort, - 'ink': pkg.dependencies.ink, - 'p-wait-for': pkg.dependencies['p-wait-for'], - 'react': pkg.dependencies.react, - 'superjson': pkg.dependencies.superjson, - 'zustand': pkg.dependencies.zustand - } - }; - console.log(JSON.stringify(newPkg, null, 2)); - " > "$RELEASE_DIR/package.json" - - # Copy extension bundle - info "Copying extension bundle..." - cp -r "$REPO_ROOT/src/dist/"* "$RELEASE_DIR/extension/" - - # Add package.json to extension directory to mark it as CommonJS - # This is necessary because the main package.json has "type": "module" - # but the extension bundle is CommonJS - echo '{"type": "commonjs"}' > "$RELEASE_DIR/extension/package.json" - - # Find and copy ripgrep binary - # The extension looks for ripgrep at: appRoot/node_modules/@vscode/ripgrep/bin/rg - # The CLI sets appRoot to the CLI package root, so we need to put ripgrep there - info "Looking for ripgrep binary..." - RIPGREP_PATH=$(find "$REPO_ROOT/node_modules" -path "*/@vscode/ripgrep/bin/rg" -type f 2>/dev/null | head -1) - if [ -n "$RIPGREP_PATH" ] && [ -f "$RIPGREP_PATH" ]; then - info "Found ripgrep at: $RIPGREP_PATH" - # Create the expected directory structure for the extension to find ripgrep - mkdir -p "$RELEASE_DIR/node_modules/@vscode/ripgrep/bin" - cp "$RIPGREP_PATH" "$RELEASE_DIR/node_modules/@vscode/ripgrep/bin/" - chmod +x "$RELEASE_DIR/node_modules/@vscode/ripgrep/bin/rg" - # Also keep a copy in bin/ for direct access - mkdir -p "$RELEASE_DIR/bin" - cp "$RIPGREP_PATH" "$RELEASE_DIR/bin/" - chmod +x "$RELEASE_DIR/bin/rg" - else - warn "ripgrep binary not found - users will need ripgrep installed" - fi - - # Create the wrapper script - info "Creating wrapper script..." - cat > "$RELEASE_DIR/bin/roo" << 'WRAPPER_EOF' -#!/usr/bin/env node - -import { fileURLToPath } from 'url'; -import { dirname, join } from 'path'; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = dirname(__filename); - -// Set environment variables for the CLI -// ROO_CLI_ROOT is the installed CLI package root (where node_modules/@vscode/ripgrep is) -process.env.ROO_CLI_ROOT = join(__dirname, '..'); -process.env.ROO_EXTENSION_PATH = join(__dirname, '..', 'extension'); -process.env.ROO_RIPGREP_PATH = join(__dirname, 'rg'); - -// Import and run the actual CLI -await import(join(__dirname, '..', 'lib', 'index.js')); -WRAPPER_EOF - - chmod +x "$RELEASE_DIR/bin/roo" - - # Create empty .env file to suppress dotenvx warnings - touch "$RELEASE_DIR/.env" - - # Create empty .env file to suppress dotenvx warnings - touch "$RELEASE_DIR/.env" - - # Create tarball - info "Creating tarball..." - cd "$REPO_ROOT" - tar -czvf "$TARBALL" "$(basename "$RELEASE_DIR")" - - # Clean up release directory - rm -rf "$RELEASE_DIR" - - # Show size - TARBALL_PATH="$REPO_ROOT/$TARBALL" - TARBALL_SIZE=$(ls -lh "$TARBALL_PATH" | awk '{print $5}') - info "Created: $TARBALL ($TARBALL_SIZE)" -} - -# Verify local installation -verify_local_install() { - if [ "$SKIP_VERIFY" = true ]; then - step "5/8" "Skipping verification (--skip-verify)" - return - fi - - step "5/8" "Verifying local installation..." - - VERIFY_DIR="$REPO_ROOT/.verify-release" - VERIFY_INSTALL_DIR="$VERIFY_DIR/cli" - VERIFY_BIN_DIR="$VERIFY_DIR/bin" - - # Clean up any previous verification directory - rm -rf "$VERIFY_DIR" - mkdir -p "$VERIFY_DIR" - - # Run the actual install script with the local tarball - info "Running install script with local tarball..." - TARBALL_PATH="$REPO_ROOT/$TARBALL" - - ROO_LOCAL_TARBALL="$TARBALL_PATH" \ - ROO_INSTALL_DIR="$VERIFY_INSTALL_DIR" \ - ROO_BIN_DIR="$VERIFY_BIN_DIR" \ - ROO_VERSION="$VERSION" \ - "$CLI_DIR/install.sh" || { - echo "" - warn "Install script failed. Showing tarball contents:" - tar -tzf "$TARBALL_PATH" 2>&1 || true - echo "" - rm -rf "$VERIFY_DIR" - error "Installation verification failed! The install script could not complete successfully." - } - - # Verify the CLI runs correctly with basic commands - info "Testing installed CLI..." - - # Test --help - if ! "$VERIFY_BIN_DIR/roo" --help > /dev/null 2>&1; then - echo "" - warn "CLI --help output:" - "$VERIFY_BIN_DIR/roo" --help 2>&1 || true - echo "" - rm -rf "$VERIFY_DIR" - error "CLI --help check failed! The release tarball may have missing dependencies." - fi - info "CLI --help check passed" - - # Test --version - if ! "$VERIFY_BIN_DIR/roo" --version > /dev/null 2>&1; then - echo "" - warn "CLI --version output:" - "$VERIFY_BIN_DIR/roo" --version 2>&1 || true - echo "" - rm -rf "$VERIFY_DIR" - error "CLI --version check failed! The release tarball may have missing dependencies." - fi - info "CLI --version check passed" - - # Run a simple end-to-end test to verify the CLI actually works - info "Running end-to-end verification test..." - - # Create a temporary workspace for the test - VERIFY_WORKSPACE="$VERIFY_DIR/workspace" - mkdir -p "$VERIFY_WORKSPACE" - - # Run the CLI with a simple prompt - if timeout 60 "$VERIFY_BIN_DIR/roo" --yes --oneshot -w "$VERIFY_WORKSPACE" "1+1=?" > "$VERIFY_DIR/test-output.log" 2>&1; then - info "End-to-end test passed" - else - EXIT_CODE=$? - echo "" - warn "End-to-end test failed (exit code: $EXIT_CODE). Output:" - cat "$VERIFY_DIR/test-output.log" 2>&1 || true - echo "" - rm -rf "$VERIFY_DIR" - error "CLI end-to-end test failed! The CLI may be broken." - fi - - # Clean up verification directory - cd "$REPO_ROOT" - rm -rf "$VERIFY_DIR" - - info "Local verification passed!" -} - -# Create checksum -create_checksum() { - step "6/8" "Creating checksum..." - cd "$REPO_ROOT" - - if command -v sha256sum &> /dev/null; then - sha256sum "$TARBALL" > "${TARBALL}.sha256" - elif command -v shasum &> /dev/null; then - shasum -a 256 "$TARBALL" > "${TARBALL}.sha256" - else - warn "No sha256sum or shasum found, skipping checksum" - return - fi - - info "Checksum: $(cat "${TARBALL}.sha256")" -} - -# Check if release already exists -check_existing_release() { - step "7/8" "Checking for existing release..." - - if gh release view "$TAG" &> /dev/null; then - warn "Release $TAG already exists" - read -p "Do you want to delete it and create a new one? [y/N] " -n 1 -r - echo - if [[ $REPLY =~ ^[Yy]$ ]]; then - info "Deleting existing release..." - gh release delete "$TAG" --yes - # Also delete the tag if it exists - git tag -d "$TAG" 2>/dev/null || true - git push origin ":refs/tags/$TAG" 2>/dev/null || true - else - error "Aborted. Use a different version or delete the existing release manually." - fi - fi -} - -# Create GitHub release -create_release() { - step "8/8" "Creating GitHub release..." - cd "$REPO_ROOT" - - # Get the current commit SHA for the release target - COMMIT_SHA=$(git rev-parse HEAD) - - # Verify the commit exists on GitHub before attempting to create the release - # This prevents the "Release.target_commitish is invalid" error - info "Verifying commit ${COMMIT_SHA:0:8} exists on GitHub..." - git fetch origin 2>/dev/null || true - if ! git branch -r --contains "$COMMIT_SHA" 2>/dev/null | grep -q "origin/"; then - warn "Commit ${COMMIT_SHA:0:8} has not been pushed to GitHub" - echo "" - echo "The release script needs to create a release at your current commit," - echo "but this commit hasn't been pushed to GitHub yet." - echo "" - read -p "Push current branch to origin now? [Y/n] " -n 1 -r - echo - if [[ ! $REPLY =~ ^[Nn]$ ]]; then - info "Pushing to origin..." - git push origin HEAD || error "Failed to push to origin. Please push manually and try again." - else - error "Aborted. Please push your commits to GitHub and try again." - fi - fi - info "Commit verified on GitHub" - - # Build the What's New section from changelog content - WHATS_NEW_SECTION="" - if [ -n "$CHANGELOG_CONTENT" ]; then - WHATS_NEW_SECTION="## What's New - -$CHANGELOG_CONTENT - -" - fi - - RELEASE_NOTES=$(cat << EOF -${WHATS_NEW_SECTION}## Installation - -\`\`\`bash -curl -fsSL https://raw.githubusercontent.com/RooCodeInc/Roo-Code/main/apps/cli/install.sh | sh -\`\`\` - -Or install a specific version: -\`\`\`bash -ROO_VERSION=$VERSION curl -fsSL https://raw.githubusercontent.com/RooCodeInc/Roo-Code/main/apps/cli/install.sh | sh -\`\`\` - -## Requirements - -- Node.js 20 or higher -- macOS (Intel or Apple Silicon) or Linux (x64 or ARM64) - -## Usage - -\`\`\`bash -# Run a task -roo "What is this project?" - -# See all options -roo --help -\`\`\` - -## Platform Support - -This release includes: -- \`roo-cli-${PLATFORM}.tar.gz\` - Built on $(uname -s) $(uname -m) - -> **Note:** Additional platforms will be added as needed. If you need a different platform, please open an issue. - -## Checksum - -\`\`\` -$(cat "${TARBALL}.sha256" 2>/dev/null || echo "N/A") -\`\`\` -EOF -) - - info "Creating release at commit: ${COMMIT_SHA:0:8}" - - # Create release (gh will create the tag automatically) - info "Creating release..." - RELEASE_FILES="$TARBALL" - if [ -f "${TARBALL}.sha256" ]; then - RELEASE_FILES="$RELEASE_FILES ${TARBALL}.sha256" - fi - - gh release create "$TAG" \ - --title "Roo Code CLI v$VERSION" \ - --notes "$RELEASE_NOTES" \ - --prerelease \ - --target "$COMMIT_SHA" \ - $RELEASE_FILES - - info "Release created!" -} - -# Cleanup -cleanup() { - info "Cleaning up..." - cd "$REPO_ROOT" - rm -f "$TARBALL" "${TARBALL}.sha256" -} - -# Print summary -print_summary() { - echo "" - printf "${GREEN}${BOLD}✓ Release v$VERSION created successfully!${NC}\n" - echo "" - echo " Release URL: https://github.com/RooCodeInc/Roo-Code/releases/tag/$TAG" - echo "" - echo " Install with:" - echo " curl -fsSL https://raw.githubusercontent.com/RooCodeInc/Roo-Code/main/apps/cli/install.sh | sh" - echo "" -} - -# Print dry-run summary -print_dry_run_summary() { - echo "" - printf "${YELLOW}${BOLD}✓ Dry run complete for v$VERSION${NC}\n" - echo "" - echo " The following artifacts were created:" - echo " - $TARBALL" - if [ -f "${TARBALL}.sha256" ]; then - echo " - ${TARBALL}.sha256" - fi - echo "" - echo " To complete the release, run without --dry-run:" - echo " ./apps/cli/scripts/release.sh $VERSION" - echo "" - echo " Or manually upload the tarball to a new GitHub release." - echo "" -} - -# Print local build summary -print_local_summary() { - echo "" - printf "${GREEN}${BOLD}✓ Local build complete for v$VERSION${NC}\n" - echo "" - echo " Tarball: $REPO_ROOT/$TARBALL" - if [ -f "${TARBALL}.sha256" ]; then - echo " Checksum: $REPO_ROOT/${TARBALL}.sha256" - fi - echo "" - echo " To install manually:" - echo " ROO_LOCAL_TARBALL=$REPO_ROOT/$TARBALL ./apps/cli/install.sh" - echo "" - echo " Or re-run with --install to install automatically:" - echo " ./apps/cli/scripts/release.sh --local --install" - echo "" -} - -# Install locally using the install script -install_local() { - step "7/8" "Installing locally..." - - TARBALL_PATH="$REPO_ROOT/$TARBALL" - - ROO_LOCAL_TARBALL="$TARBALL_PATH" \ - ROO_VERSION="$VERSION" \ - "$CLI_DIR/install.sh" || { - error "Local installation failed!" - } - - info "Local installation complete!" -} - -# Print local install summary -print_local_install_summary() { - echo "" - printf "${GREEN}${BOLD}✓ Local build installed for v$VERSION${NC}\n" - echo "" - echo " Tarball: $REPO_ROOT/$TARBALL" - echo " Installed to: ~/.roo/cli" - echo " Binary: ~/.local/bin/roo" - echo "" - echo " Test it out:" - echo " roo --version" - echo " roo --help" - echo "" -} - -# Main -main() { - echo "" - printf "${BLUE}${BOLD}" - echo " ╭─────────────────────────────────╮" - echo " │ Roo Code CLI Release Script │" - echo " ╰─────────────────────────────────╯" - printf "${NC}" - - if [ "$DRY_RUN" = true ]; then - printf "${YELLOW} (DRY RUN MODE)${NC}\n" - elif [ "$LOCAL_BUILD" = true ]; then - printf "${YELLOW} (LOCAL BUILD MODE)${NC}\n" - fi - echo "" - - detect_platform - check_prerequisites - get_version - get_changelog_content - build - create_tarball - verify_local_install - create_checksum - - if [ "$LOCAL_BUILD" = true ]; then - step "7/8" "Skipping GitHub checks (local build)" - if [ "$LOCAL_INSTALL" = true ]; then - install_local - print_local_install_summary - else - step "8/8" "Skipping installation (use --install to auto-install)" - print_local_summary - fi - elif [ "$DRY_RUN" = true ]; then - step "7/8" "Skipping existing release check (dry run)" - step "8/8" "Skipping GitHub release creation (dry run)" - print_dry_run_summary - else - check_existing_release - create_release - cleanup - print_summary - fi -} - -main diff --git a/apps/cli/scripts/test-stdin-stream.ts b/apps/cli/scripts/test-stdin-stream.ts new file mode 100644 index 0000000000..569c30adbb --- /dev/null +++ b/apps/cli/scripts/test-stdin-stream.ts @@ -0,0 +1,85 @@ +import path from "path" +import { fileURLToPath } from "url" +import readline from "readline" + +import { execa } from "execa" + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const cliRoot = path.resolve(__dirname, "..") + +async function main() { + const child = execa( + "pnpm", + ["dev", "--print", "--stdin-prompt-stream", "--provider", "roo", "--output-format", "stream-json"], + { + cwd: cliRoot, + stdin: "pipe", + stdout: "pipe", + stderr: "pipe", + reject: false, + forceKillAfterDelay: 2_000, + }, + ) + + child.stdout?.on("data", (chunk) => process.stdout.write(chunk)) + child.stderr?.on("data", (chunk) => process.stderr.write(chunk)) + + console.log("[wrapper] Type a message and press Enter to send it.") + console.log("[wrapper] Type /exit to close stdin and let the CLI finish.") + + let requestCounter = 0 + let hasStartedTask = false + + const sendCommand = (payload: Record) => { + if (child.stdin?.destroyed) { + return + } + child.stdin?.write(JSON.stringify(payload) + "\n") + } + + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + terminal: true, + }) + + rl.on("line", (line) => { + if (line.trim() === "/exit") { + console.log("[wrapper] Closing stdin...") + sendCommand({ + command: "shutdown", + requestId: `shutdown-${Date.now()}-${++requestCounter}`, + }) + child.stdin?.end() + rl.close() + return + } + + const command = hasStartedTask ? "message" : "start" + sendCommand({ + command, + requestId: `${command}-${Date.now()}-${++requestCounter}`, + prompt: line, + }) + hasStartedTask = true + }) + + const onSignal = (signal: NodeJS.Signals) => { + console.log(`[wrapper] Received ${signal}, forwarding to CLI...`) + rl.close() + child.kill(signal) + } + + process.on("SIGINT", () => onSignal("SIGINT")) + process.on("SIGTERM", () => onSignal("SIGTERM")) + + const result = await child + rl.close() + console.log(`[wrapper] CLI exited with code ${result.exitCode}`) + process.exit(result.exitCode ?? 1) +} + +main().catch((error) => { + console.error("[wrapper] Fatal error:", error) + process.exit(1) +}) diff --git a/apps/cli/src/agent/__tests__/extension-client.test.ts b/apps/cli/src/agent/__tests__/extension-client.test.ts index 3d87a30200..7a63fe0174 100644 --- a/apps/cli/src/agent/__tests__/extension-client.test.ts +++ b/apps/cli/src/agent/__tests__/extension-client.test.ts @@ -93,13 +93,6 @@ describe("detectAgentState", () => { expect(state.requiredAction).toBe("answer") }) - it("should detect waiting for browser_action_launch approval", () => { - const messages = [createMessage({ type: "ask", ask: "browser_action_launch", partial: false })] - const state = detectAgentState(messages) - expect(state.state).toBe(AgentLoopState.WAITING_FOR_INPUT) - expect(state.requiredAction).toBe("approve") - }) - it("should detect waiting for use_mcp_server approval", () => { const messages = [createMessage({ type: "ask", ask: "use_mcp_server", partial: false })] const state = detectAgentState(messages) @@ -202,7 +195,6 @@ describe("Type Guards", () => { expect(isInteractiveAsk("tool")).toBe(true) expect(isInteractiveAsk("command")).toBe(true) expect(isInteractiveAsk("followup")).toBe(true) - expect(isInteractiveAsk("browser_action_launch")).toBe(true) expect(isInteractiveAsk("use_mcp_server")).toBe(true) }) diff --git a/apps/cli/src/agent/__tests__/json-event-emitter-control.test.ts b/apps/cli/src/agent/__tests__/json-event-emitter-control.test.ts new file mode 100644 index 0000000000..8d45538ce3 --- /dev/null +++ b/apps/cli/src/agent/__tests__/json-event-emitter-control.test.ts @@ -0,0 +1,170 @@ +import { Writable } from "stream" + +import { JsonEventEmitter } from "../json-event-emitter.js" + +function createMockStdout(): { stdout: NodeJS.WriteStream; lines: () => Record[] } { + const chunks: string[] = [] + + const writable = new Writable({ + write(chunk, _encoding, callback) { + chunks.push(chunk.toString()) + callback() + }, + }) as unknown as NodeJS.WriteStream + + // Each write is a JSON line terminated by \n + const lines = () => + chunks + .join("") + .split("\n") + .filter((l) => l.length > 0) + .map((l) => JSON.parse(l) as Record) + + return { stdout: writable, lines } +} + +describe("JsonEventEmitter control events", () => { + describe("emitControl", () => { + it("emits an ack event with type control", () => { + const { stdout, lines } = createMockStdout() + const emitter = new JsonEventEmitter({ mode: "stream-json", stdout }) + + emitter.emitControl({ + subtype: "ack", + requestId: "req-1", + command: "start", + content: "starting task", + code: "accepted", + success: true, + }) + + const output = lines() + expect(output).toHaveLength(1) + expect(output[0]!).toMatchObject({ + type: "control", + subtype: "ack", + requestId: "req-1", + command: "start", + content: "starting task", + code: "accepted", + success: true, + }) + expect(output[0]!.done).toBeUndefined() + }) + + it("sets done: true for done events", () => { + const { stdout, lines } = createMockStdout() + const emitter = new JsonEventEmitter({ mode: "stream-json", stdout }) + + emitter.emitControl({ + subtype: "done", + requestId: "req-2", + command: "start", + content: "task completed", + code: "task_completed", + success: true, + }) + + const output = lines() + expect(output[0]!).toMatchObject({ type: "control", subtype: "done", done: true }) + }) + + it("does not set done for error events", () => { + const { stdout, lines } = createMockStdout() + const emitter = new JsonEventEmitter({ mode: "stream-json", stdout }) + + emitter.emitControl({ + subtype: "error", + requestId: "req-3", + command: "start", + content: "something went wrong", + code: "task_error", + success: false, + }) + + const output = lines() + expect(output[0]!.done).toBeUndefined() + expect(output[0]!.success).toBe(false) + }) + }) + + describe("requestIdProvider", () => { + it("injects requestId from provider when event has none", () => { + const { stdout, lines } = createMockStdout() + const emitter = new JsonEventEmitter({ + mode: "stream-json", + stdout, + requestIdProvider: () => "injected-id", + }) + + emitter.emitControl({ subtype: "ack", content: "test" }) + + const output = lines() + expect(output[0]!.requestId).toBe("injected-id") + }) + + it("keeps explicit requestId when provider also returns one", () => { + const { stdout, lines } = createMockStdout() + const emitter = new JsonEventEmitter({ + mode: "stream-json", + stdout, + requestIdProvider: () => "provider-id", + }) + + emitter.emitControl({ subtype: "ack", requestId: "explicit-id", content: "test" }) + + const output = lines() + expect(output[0]!.requestId).toBe("explicit-id") + }) + + it("omits requestId when provider returns undefined and event has none", () => { + const { stdout, lines } = createMockStdout() + const emitter = new JsonEventEmitter({ + mode: "stream-json", + stdout, + requestIdProvider: () => undefined, + }) + + emitter.emitControl({ subtype: "ack", content: "test" }) + + const output = lines() + expect(output[0]!).not.toHaveProperty("requestId") + }) + }) + + describe("emitInit", () => { + it("emits system init with default schema values", () => { + const { stdout, lines } = createMockStdout() + const emitter = new JsonEventEmitter({ mode: "stream-json", stdout }) + + // emitInit requires a client — we call emitControl to test init-like fields instead. + // emitInit is called internally by attach(), so we test the init fields via options. + // Instead, directly verify the constructor defaults by emitting a control event + // and checking that the emitter was created with correct defaults. + + // We can't call emitInit without a client, but we can verify the options + // were stored correctly by checking what emitControl produces. + emitter.emitControl({ subtype: "ack", content: "test" }) + + // The control event itself doesn't include schema fields, but at least + // we verify the emitter was constructed successfully with defaults. + const output = lines() + expect(output).toHaveLength(1) + }) + + it("accepts custom schemaVersion, protocol, and capabilities", () => { + const { stdout } = createMockStdout() + + // Should not throw when constructed with custom values + const emitter = new JsonEventEmitter({ + mode: "stream-json", + stdout, + schemaVersion: 2, + protocol: "custom-protocol", + capabilities: ["stdin:start", "stdin:message"], + }) + + expect(emitter).toBeDefined() + }) + }) +}) diff --git a/apps/cli/src/agent/agent-state.ts b/apps/cli/src/agent/agent-state.ts index ca4a099cca..d1451d62fd 100644 --- a/apps/cli/src/agent/agent-state.ts +++ b/apps/cli/src/agent/agent-state.ts @@ -116,7 +116,7 @@ export enum AgentLoopState { */ export type RequiredAction = | "none" // No action needed (running/streaming) - | "approve" // Can approve/reject (tool, command, browser, mcp) + | "approve" // Can approve/reject (tool, command, mcp) | "answer" // Need to answer a question (followup) | "retry_or_new_task" // Can retry or start new task (api_req_failed) | "proceed_or_new_task" // Can proceed or start new task (mistake_limit) @@ -221,7 +221,6 @@ function getRequiredAction(ask: ClineAsk): RequiredAction { return "answer" case "command": case "tool": - case "browser_action_launch": case "use_mcp_server": return "approve" case "command_output": @@ -264,8 +263,6 @@ function getStateDescription(state: AgentLoopState, ask?: ClineAsk): string { return "Agent wants to execute a command. Approve or reject." case "tool": return "Agent wants to perform a file operation. Approve or reject." - case "browser_action_launch": - return "Agent wants to use the browser. Approve or reject." case "use_mcp_server": return "Agent wants to use an MCP server. Approve or reject." default: diff --git a/apps/cli/src/agent/ask-dispatcher.ts b/apps/cli/src/agent/ask-dispatcher.ts index 8d57e4547c..44e861ae9b 100644 --- a/apps/cli/src/agent/ask-dispatcher.ts +++ b/apps/cli/src/agent/ask-dispatcher.ts @@ -59,6 +59,11 @@ export interface AskDispatcherOptions { */ nonInteractive?: boolean + /** + * Whether to exit on API request errors instead of retrying. + */ + exitOnError?: boolean + /** * Whether to disable ask handling (for TUI mode). * In TUI mode, the TUI handles asks directly. @@ -87,6 +92,7 @@ export class AskDispatcher { private promptManager: PromptManager private sendMessage: (message: WebviewMessage) => void private nonInteractive: boolean + private exitOnError: boolean private disabled: boolean /** @@ -100,6 +106,7 @@ export class AskDispatcher { this.promptManager = options.promptManager this.sendMessage = options.sendMessage this.nonInteractive = options.nonInteractive ?? false + this.exitOnError = options.exitOnError ?? false this.disabled = options.disabled ?? false } @@ -237,7 +244,7 @@ export class AskDispatcher { } /** - * Handle interactive asks (followup, command, tool, browser_action_launch, use_mcp_server). + * Handle interactive asks (followup, command, tool, use_mcp_server). * These require user approval or input. */ private async handleInteractiveAsk(ts: number, ask: ClineAsk, text: string): Promise { @@ -251,9 +258,6 @@ export class AskDispatcher { case "tool": return await this.handleToolApproval(ts, text) - case "browser_action_launch": - return await this.handleBrowserApproval(ts, text) - case "use_mcp_server": return await this.handleMcpApproval(ts, text) @@ -437,32 +441,6 @@ export class AskDispatcher { } } - /** - * Handle browser action approval. - */ - private async handleBrowserApproval(ts: number, text: string): Promise { - this.outputManager.output("\n[browser action request]") - if (text) { - this.outputManager.output(` Action: ${text}`) - } - this.outputManager.markDisplayed(ts, text || "", false) - - if (this.nonInteractive) { - // Auto-approved by extension settings - return { handled: true } - } - - try { - const approved = await this.promptManager.promptForYesNo("Allow browser action? (y/n): ") - this.sendApprovalResponse(approved) - return { handled: true, response: approved ? "yesButtonClicked" : "noButtonClicked" } - } catch { - this.outputManager.output("[Defaulting to: no]") - this.sendApprovalResponse(false) - return { handled: true, response: "noButtonClicked" } - } - } - /** * Handle MCP server access approval. */ @@ -518,6 +496,11 @@ export class AskDispatcher { this.outputManager.output(` Error: ${text || "Unknown error"}`) this.outputManager.markDisplayed(ts, text || "", false) + if (this.exitOnError) { + console.error(`[CLI] API request failed: ${text || "Unknown error"}`) + process.exit(1) + } + if (this.nonInteractive) { this.outputManager.output("\n[retrying api request]") // Auto-retry in non-interactive mode diff --git a/apps/cli/src/agent/extension-host.ts b/apps/cli/src/agent/extension-host.ts index e1f55a30d1..4a0e941b4b 100644 --- a/apps/cli/src/agent/extension-host.ts +++ b/apps/cli/src/agent/extension-host.ts @@ -24,7 +24,7 @@ import type { WebviewMessage, } from "@roo-code/types" import { createVSCodeAPI, IExtensionHost, ExtensionHostEventMap, setRuntimeConfigValues } from "@roo-code/vscode-shim" -import { DebugLogger } from "@roo-code/core/cli" +import { DebugLogger, setDebugLogEnabled } from "@roo-code/core/cli" import type { SupportedProvider } from "@/types/index.js" import type { User } from "@/lib/sdk/index.js" @@ -43,10 +43,25 @@ const cliLogger = new DebugLogger("CLI") // Get the CLI package root directory (for finding node_modules/@vscode/ripgrep) // When running from a release tarball, ROO_CLI_ROOT is set by the wrapper script. -// In development, we fall back to calculating from __dirname. -// After bundling with tsup, the code is in dist/index.js (flat), so we go up one level. +// In development, we fall back to finding the CLI package root by walking up to package.json. +// This works whether running from dist/ (bundled) or src/agent/ (tsx dev). const __dirname = path.dirname(fileURLToPath(import.meta.url)) -const CLI_PACKAGE_ROOT = process.env.ROO_CLI_ROOT || path.resolve(__dirname, "..") + +function findCliPackageRoot(): string { + let dir = __dirname + + while (dir !== path.dirname(dir)) { + if (fs.existsSync(path.join(dir, "package.json"))) { + return dir + } + + dir = path.dirname(dir) + } + + return path.resolve(__dirname, "..") +} + +const CLI_PACKAGE_ROOT = process.env.ROO_CLI_ROOT || findCliPackageRoot() export interface ExtensionHostOptions { mode: string @@ -64,6 +79,10 @@ export interface ExtensionHostOptions { ephemeral: boolean debug: boolean exitOnComplete: boolean + /** + * When true, exit the process on API request errors instead of retrying. + */ + exitOnError?: boolean /** * When true, completely disables all direct stdout/stderr output. * Use this when running in TUI mode where Ink controls the terminal. @@ -154,6 +173,11 @@ export class ExtensionHost extends EventEmitter implements ExtensionHostInterfac this.options = options + // Enable file-based debug logging only when --debug is passed. + if (options.debug) { + setDebugLogEnabled(true) + } + // Set up quiet mode early, before any extension code runs. // This suppresses console output from the extension during load. this.setupQuietMode() @@ -179,6 +203,7 @@ export class ExtensionHost extends EventEmitter implements ExtensionHostInterfac promptManager: this.promptManager, sendMessage: (msg) => this.sendToExtension(msg), nonInteractive: options.nonInteractive, + exitOnError: options.exitOnError, disabled: options.disableOutput, // TUI mode handles asks directly. }) @@ -189,7 +214,6 @@ export class ExtensionHost extends EventEmitter implements ExtensionHostInterfac const baseSettings: RooCodeSettings = { mode: this.options.mode, commandExecutionTimeout: 30, - browserToolEnabled: false, enableCheckpoints: false, ...getProviderSettings(this.options.provider, this.options.apiKey, this.options.model), } @@ -202,7 +226,6 @@ export class ExtensionHost extends EventEmitter implements ExtensionHostInterfac alwaysAllowWrite: true, alwaysAllowWriteOutsideWorkspace: true, alwaysAllowWriteProtected: true, - alwaysAllowBrowser: true, alwaysAllowMcp: true, alwaysAllowModeSwitch: true, alwaysAllowSubtasks: true, @@ -403,12 +426,16 @@ export class ExtensionHost extends EventEmitter implements ExtensionHostInterfac public markWebviewReady(): void { this.isReady = true - // Send initial webview messages to trigger proper extension initialization. - // This is critical for the extension to start sending state updates properly. - this.sendToExtension({ type: "webviewDidLaunch" }) - + // Apply CLI settings to the runtime config and context proxy BEFORE + // sending webviewDidLaunch. This prevents a race condition where the + // webviewDidLaunch handler's first-time init sync reads default state + // (apiProvider: "anthropic") instead of the CLI-provided settings. setRuntimeConfigValues("roo-cline", this.initialSettings as Record) this.sendToExtension({ type: "updateSettings", updatedSettings: this.initialSettings }) + + // Now trigger extension initialization. The context proxy should already + // have CLI-provided values when the webviewDidLaunch handler runs. + this.sendToExtension({ type: "webviewDidLaunch" }) } public isInInitialSetup(): boolean { @@ -448,6 +475,25 @@ export class ExtensionHost extends EventEmitter implements ExtensionHostInterfac const cleanup = () => { this.client.off("taskCompleted", completeHandler) this.client.off("error", errorHandler) + + if (messageHandler) { + this.client.off("message", messageHandler) + } + } + + // When exitOnError is enabled, listen for api_req_retry_delayed messages + // (sent by Task.ts during auto-approval retry backoff) and exit immediately. + let messageHandler: ((msg: ClineMessage) => void) | null = null + + if (this.options.exitOnError) { + messageHandler = (msg: ClineMessage) => { + if (msg.type === "say" && msg.say === "api_req_retry_delayed") { + cleanup() + reject(new Error(msg.text?.split("\n")[0] || "API request failed")) + } + } + + this.client.on("message", messageHandler) } this.client.once("taskCompleted", completeHandler) diff --git a/apps/cli/src/agent/json-event-emitter.ts b/apps/cli/src/agent/json-event-emitter.ts index a1a404e555..b772b13553 100644 --- a/apps/cli/src/agent/json-event-emitter.ts +++ b/apps/cli/src/agent/json-event-emitter.ts @@ -16,10 +16,11 @@ import type { ClineMessage } from "@roo-code/types" -import type { JsonEvent, JsonEventCost, JsonFinalOutput } from "@/types/json-events.js" +import type { JsonEvent, JsonEventCost, JsonEventQueueItem, JsonFinalOutput } from "@/types/json-events.js" import type { ExtensionClient } from "./extension-client.js" -import type { TaskCompletedEvent } from "./events.js" +import type { AgentStateChangeEvent, TaskCompletedEvent } from "./events.js" +import { AgentLoopState } from "./agent-state.js" /** * Options for JsonEventEmitter. @@ -29,6 +30,14 @@ export interface JsonEventEmitterOptions { mode: "json" | "stream-json" /** Output stream (defaults to process.stdout) */ stdout?: NodeJS.WriteStream + /** Optional request id provider for correlating stream events */ + requestIdProvider?: () => string | undefined + /** Transport schema version emitted in system:init */ + schemaVersion?: number + /** Transport protocol identifier emitted in system:init */ + protocol?: string + /** Supported stdin protocol capabilities emitted in system:init */ + capabilities?: string[] } /** @@ -88,15 +97,33 @@ export class JsonEventEmitter { private events: JsonEvent[] = [] private unsubscribers: (() => void)[] = [] private lastCost: JsonEventCost | undefined + private requestIdProvider: () => string | undefined + private schemaVersion: number + private protocol: string + private capabilities: string[] private seenMessageIds = new Set() // Track previous content for delta computation private previousContent = new Map() // Track the completion result content private completionResultContent: string | undefined + // Track the latest assistant text as a fallback for result.content. + private lastAssistantText: string | undefined + // The first non-partial "say:text" per task is the echoed user prompt. + private expectPromptEchoAsUser = true constructor(options: JsonEventEmitterOptions) { this.mode = options.mode this.stdout = options.stdout ?? process.stdout + this.requestIdProvider = options.requestIdProvider ?? (() => undefined) + this.schemaVersion = options.schemaVersion ?? 1 + this.protocol = options.protocol ?? "roo-cli-stream" + this.capabilities = options.capabilities ?? [ + "stdin:start", + "stdin:message", + "stdin:cancel", + "stdin:ping", + "stdin:shutdown", + ] } /** @@ -106,19 +133,72 @@ export class JsonEventEmitter { // Subscribe to message events const unsubMessage = client.on("message", (msg) => this.handleMessage(msg, false)) const unsubMessageUpdated = client.on("messageUpdated", (msg) => this.handleMessage(msg, true)) + const unsubStateChange = client.on("stateChange", (event) => this.handleStateChange(event)) const unsubTaskCompleted = client.on("taskCompleted", (event) => this.handleTaskCompleted(event)) const unsubError = client.on("error", (error) => this.handleError(error)) - this.unsubscribers.push(unsubMessage, unsubMessageUpdated, unsubTaskCompleted, unsubError) + this.unsubscribers.push(unsubMessage, unsubMessageUpdated, unsubStateChange, unsubTaskCompleted, unsubError) // Emit init event this.emitEvent({ type: "system", subtype: "init", content: "Task started", + schemaVersion: this.schemaVersion, + protocol: this.protocol, + capabilities: this.capabilities, }) } + emitControl(event: { + subtype: "ack" | "done" | "error" + requestId?: string + command?: string + taskId?: string + content?: string + success?: boolean + code?: string + }): void { + this.emitEvent({ + type: "control", + subtype: event.subtype, + requestId: event.requestId, + command: event.command, + taskId: event.taskId, + content: event.content, + success: event.success, + code: event.code, + done: event.subtype === "done" ? true : undefined, + }) + } + + emitQueue(event: { + subtype: "snapshot" | "enqueued" | "dequeued" | "drained" | "updated" + taskId?: string + content?: string + queueDepth: number + queue: JsonEventQueueItem[] + }): void { + this.emitEvent({ + type: "queue", + subtype: event.subtype, + taskId: event.taskId, + content: event.content, + queueDepth: event.queueDepth, + queue: event.queue, + }) + } + + private handleStateChange(event: AgentStateChangeEvent): void { + // Only treat the next say:text as a prompt echo when a new task starts. + if ( + event.previousState.state === AgentLoopState.NO_TASK && + event.currentState.state !== AgentLoopState.NO_TASK + ) { + this.expectPromptEchoAsUser = true + } + } + /** * Detach from the client and clean up subscriptions. */ @@ -227,7 +307,17 @@ export class JsonEventEmitter { private handleSayMessage(msg: ClineMessage, contentToSend: string | null, isDone: boolean): void { switch (msg.say) { case "text": - this.emitEvent(this.buildTextEvent("assistant", msg.ts, contentToSend, isDone)) + if (this.expectPromptEchoAsUser) { + this.emitEvent(this.buildTextEvent("user", msg.ts, contentToSend, isDone)) + if (isDone) { + this.expectPromptEchoAsUser = false + } + } else { + this.emitEvent(this.buildTextEvent("assistant", msg.ts, contentToSend, isDone)) + if (msg.text) { + this.lastAssistantText = msg.text + } + } break case "reasoning": @@ -248,6 +338,9 @@ export class JsonEventEmitter { case "user_feedback": case "user_feedback_diff": this.emitEvent(this.buildTextEvent("user", msg.ts, contentToSend, isDone)) + if (isDone) { + this.expectPromptEchoAsUser = false + } break case "api_req_started": { @@ -258,15 +351,6 @@ export class JsonEventEmitter { break } - case "browser_action": - case "browser_action_result": - this.emitEvent({ - type: "tool_result", - subtype: "browser", - tool_result: { name: "browser_action", output: msg.text }, - }) - break - case "mcp_server_response": this.emitEvent({ type: "tool_result", @@ -336,15 +420,6 @@ export class JsonEventEmitter { }) break - case "browser_action_launch": - this.emitEvent({ - type: "tool_use", - id: msg.ts, - subtype: "browser", - tool_use: { name: "browser_action", input: { raw: msg.text } }, - }) - break - case "use_mcp_server": this.emitEvent({ type: "tool_use", @@ -381,7 +456,7 @@ export class JsonEventEmitter { */ private handleTaskCompleted(event: TaskCompletedEvent): void { // Use tracked completion result content, falling back to event message - const resultContent = this.completionResultContent || event.message?.text + const resultContent = this.completionResultContent || event.message?.text || this.lastAssistantText this.emitEvent({ type: "result", @@ -415,10 +490,13 @@ export class JsonEventEmitter { * For json mode: accumulate for final output */ private emitEvent(event: JsonEvent): void { - this.events.push(event) + const requestId = event.requestId ?? this.requestIdProvider() + const payload = requestId ? { ...event, requestId } : event + + this.events.push(payload) if (this.mode === "stream-json") { - this.outputLine(event) + this.outputLine(payload) } } @@ -460,5 +538,7 @@ export class JsonEventEmitter { this.seenMessageIds.clear() this.previousContent.clear() this.completionResultContent = undefined + this.lastAssistantText = undefined + this.expectPromptEchoAsUser = true } } diff --git a/apps/cli/src/commands/cli/__tests__/list.test.ts b/apps/cli/src/commands/cli/__tests__/list.test.ts new file mode 100644 index 0000000000..09e0502244 --- /dev/null +++ b/apps/cli/src/commands/cli/__tests__/list.test.ts @@ -0,0 +1,29 @@ +import { parseFormat } from "../list.js" + +describe("parseFormat", () => { + it("defaults to json when undefined", () => { + expect(parseFormat(undefined)).toBe("json") + }) + + it("returns json for 'json'", () => { + expect(parseFormat("json")).toBe("json") + }) + + it("returns text for 'text'", () => { + expect(parseFormat("text")).toBe("text") + }) + + it("is case-insensitive", () => { + expect(parseFormat("JSON")).toBe("json") + expect(parseFormat("Text")).toBe("text") + expect(parseFormat("TEXT")).toBe("text") + }) + + it("throws on invalid format", () => { + expect(() => parseFormat("xml")).toThrow('Invalid format: xml. Must be "json" or "text".') + }) + + it("throws on empty string", () => { + expect(() => parseFormat("")).toThrow("Invalid format") + }) +}) diff --git a/apps/cli/src/commands/cli/__tests__/parse-stdin-command.test.ts b/apps/cli/src/commands/cli/__tests__/parse-stdin-command.test.ts new file mode 100644 index 0000000000..81b9d06b8b --- /dev/null +++ b/apps/cli/src/commands/cli/__tests__/parse-stdin-command.test.ts @@ -0,0 +1,104 @@ +import { parseStdinStreamCommand } from "../stdin-stream.js" + +describe("parseStdinStreamCommand", () => { + describe("valid commands", () => { + it("parses a start command", () => { + const result = parseStdinStreamCommand( + JSON.stringify({ command: "start", requestId: "req-1", prompt: "hello" }), + 1, + ) + expect(result).toEqual({ command: "start", requestId: "req-1", prompt: "hello" }) + }) + + it("parses a message command", () => { + const result = parseStdinStreamCommand( + JSON.stringify({ command: "message", requestId: "req-2", prompt: "follow up" }), + 1, + ) + expect(result).toEqual({ command: "message", requestId: "req-2", prompt: "follow up" }) + }) + + it.each(["cancel", "ping", "shutdown"] as const)("parses a %s command (no prompt required)", (command) => { + const result = parseStdinStreamCommand(JSON.stringify({ command, requestId: "req-3" }), 1) + expect(result).toEqual({ command, requestId: "req-3" }) + }) + + it("trims whitespace from requestId", () => { + const result = parseStdinStreamCommand(JSON.stringify({ command: "ping", requestId: " req-4 " }), 1) + expect(result.requestId).toBe("req-4") + }) + + it("ignores extra fields", () => { + const result = parseStdinStreamCommand( + JSON.stringify({ command: "ping", requestId: "req-5", extra: "ignored", nested: { a: 1 } }), + 1, + ) + expect(result).toEqual({ command: "ping", requestId: "req-5" }) + }) + }) + + describe("invalid input", () => { + it("throws on invalid JSON", () => { + expect(() => parseStdinStreamCommand("not json", 3)).toThrow("stdin command line 3: invalid JSON") + }) + + it("throws on non-object JSON (string)", () => { + expect(() => parseStdinStreamCommand('"hello"', 1)).toThrow("expected JSON object") + }) + + it("throws on non-object JSON (array)", () => { + // Arrays pass isRecord (typeof [] === "object") but lack a command field + expect(() => parseStdinStreamCommand("[]", 1)).toThrow('missing string "command"') + }) + + it("throws on non-object JSON (number)", () => { + expect(() => parseStdinStreamCommand("42", 1)).toThrow("expected JSON object") + }) + + it("throws on null", () => { + expect(() => parseStdinStreamCommand("null", 1)).toThrow("expected JSON object") + }) + + it("throws when command field is missing", () => { + expect(() => parseStdinStreamCommand(JSON.stringify({ requestId: "req" }), 5)).toThrow( + 'stdin command line 5: missing string "command"', + ) + }) + + it("throws when command is not a string", () => { + expect(() => parseStdinStreamCommand(JSON.stringify({ command: 123, requestId: "req" }), 1)).toThrow( + 'missing string "command"', + ) + }) + + it("throws on unsupported command name", () => { + expect(() => parseStdinStreamCommand(JSON.stringify({ command: "unknown", requestId: "req" }), 2)).toThrow( + 'stdin command line 2: unsupported command "unknown"', + ) + }) + + it("throws when requestId is missing", () => { + expect(() => parseStdinStreamCommand(JSON.stringify({ command: "ping" }), 1)).toThrow( + 'missing non-empty string "requestId"', + ) + }) + + it("throws when requestId is empty", () => { + expect(() => parseStdinStreamCommand(JSON.stringify({ command: "ping", requestId: " " }), 1)).toThrow( + 'missing non-empty string "requestId"', + ) + }) + + it("throws when start command has no prompt", () => { + expect(() => parseStdinStreamCommand(JSON.stringify({ command: "start", requestId: "req" }), 1)).toThrow( + '"start" requires non-empty string "prompt"', + ) + }) + + it("throws when message command has empty prompt", () => { + expect(() => + parseStdinStreamCommand(JSON.stringify({ command: "message", requestId: "req", prompt: " " }), 1), + ).toThrow('"message" requires non-empty string "prompt"') + }) + }) +}) diff --git a/apps/cli/src/commands/cli/index.ts b/apps/cli/src/commands/cli/index.ts index 89e8e9f1ba..629c665a75 100644 --- a/apps/cli/src/commands/cli/index.ts +++ b/apps/cli/src/commands/cli/index.ts @@ -1 +1,2 @@ export * from "./run.js" +export * from "./list.js" diff --git a/apps/cli/src/commands/cli/list.ts b/apps/cli/src/commands/cli/list.ts new file mode 100644 index 0000000000..8d8e779c3a --- /dev/null +++ b/apps/cli/src/commands/cli/list.ts @@ -0,0 +1,287 @@ +import fs from "fs" +import path from "path" +import { fileURLToPath } from "url" + +import pWaitFor from "p-wait-for" + +import type { Command, ModelRecord, WebviewMessage } from "@roo-code/types" +import { getProviderDefaultModelId } from "@roo-code/types" + +import { ExtensionHost, type ExtensionHostOptions } from "@/agent/index.js" +import { loadToken } from "@/lib/storage/index.js" +import { getDefaultExtensionPath } from "@/lib/utils/extension.js" +import { getApiKeyFromEnv } from "@/lib/utils/provider.js" +import { isRecord } from "@/lib/utils/guards.js" + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const REQUEST_TIMEOUT_MS = 10_000 + +type ListFormat = "json" | "text" + +type BaseListOptions = { + workspace?: string + extension?: string + apiKey?: string + format?: string + debug?: boolean +} + +type CommandLike = Pick +type ModeLike = { slug: string; name: string } + +export function parseFormat(rawFormat: string | undefined): ListFormat { + const format = (rawFormat ?? "json").toLowerCase() + if (format === "json" || format === "text") { + return format + } + + throw new Error(`Invalid format: ${rawFormat}. Must be "json" or "text".`) +} + +function resolveWorkspacePath(workspace: string | undefined): string { + const resolved = workspace ? path.resolve(workspace) : process.cwd() + + if (!fs.existsSync(resolved)) { + throw new Error(`Workspace path does not exist: ${resolved}`) + } + + return resolved +} + +function resolveExtensionPath(extension: string | undefined): string { + const resolved = path.resolve(extension || getDefaultExtensionPath(__dirname)) + + if (!fs.existsSync(path.join(resolved, "extension.js"))) { + throw new Error(`Extension bundle not found at: ${resolved}`) + } + + return resolved +} + +function outputJson(data: unknown): void { + process.stdout.write(JSON.stringify(data, null, 2) + "\n") +} + +function outputCommandsText(commands: CommandLike[]): void { + for (const command of commands) { + const description = command.description ? ` - ${command.description}` : "" + process.stdout.write(`/${command.name} (${command.source})${description}\n`) + } +} + +function outputModesText(modes: ModeLike[]): void { + for (const mode of modes) { + process.stdout.write(`${mode.slug}\t${mode.name}\n`) + } +} + +function outputModelsText(models: ModelRecord): void { + for (const modelId of Object.keys(models).sort()) { + process.stdout.write(`${modelId}\n`) + } +} + +async function createListHost(options: BaseListOptions): Promise { + const workspacePath = resolveWorkspacePath(options.workspace) + const extensionPath = resolveExtensionPath(options.extension) + const apiKey = options.apiKey || (await loadToken()) || getApiKeyFromEnv("roo") + + const extensionHostOptions: ExtensionHostOptions = { + mode: "code", + reasoningEffort: undefined, + user: null, + provider: "roo", + model: getProviderDefaultModelId("roo"), + apiKey, + workspacePath, + extensionPath, + nonInteractive: true, + ephemeral: true, + debug: options.debug ?? false, + exitOnComplete: true, + exitOnError: false, + disableOutput: true, + } + + const host = new ExtensionHost(extensionHostOptions) + await host.activate() + + // Best effort wait; mode/commands requests can still succeed without this. + await pWaitFor(() => host.client.isInitialized(), { + interval: 25, + timeout: 2_000, + }).catch(() => undefined) + + return host +} + +/** + * Send a request to the extension and wait for a matching response message. + * Returns `undefined` from `extract` to skip non-matching messages, or the + * parsed value to resolve the promise. + */ +function requestFromExtension( + host: ExtensionHost, + requestType: WebviewMessage["type"], + extract: (message: Record) => T | undefined, +): Promise { + return new Promise((resolve, reject) => { + let settled = false + + const cleanup = () => { + clearTimeout(timeoutId) + host.off("extensionWebviewMessage", onMessage) + offError() + } + + const finish = (fn: () => void) => { + if (settled) return + settled = true + cleanup() + fn() + } + + const onMessage = (message: unknown) => { + if (!isRecord(message)) { + return + } + + let result: T | undefined + try { + result = extract(message) + } catch (error) { + finish(() => reject(error instanceof Error ? error : new Error(String(error)))) + return + } + + if (result !== undefined) { + finish(() => resolve(result)) + } + } + + const offError = host.client.on("error", (error) => { + finish(() => reject(error)) + }) + + const timeoutId = setTimeout(() => { + finish(() => + reject(new Error(`Timed out waiting for ${requestType} response after ${REQUEST_TIMEOUT_MS}ms`)), + ) + }, REQUEST_TIMEOUT_MS) + + host.on("extensionWebviewMessage", onMessage) + host.sendToExtension({ type: requestType }) + }) +} + +function requestCommands(host: ExtensionHost): Promise { + return requestFromExtension(host, "requestCommands", (message) => { + if (message.type !== "commands") { + return undefined + } + return Array.isArray(message.commands) ? (message.commands as CommandLike[]) : [] + }) +} + +function requestModes(host: ExtensionHost): Promise { + return requestFromExtension(host, "requestModes", (message) => { + if (message.type !== "modes") { + return undefined + } + return Array.isArray(message.modes) ? (message.modes as ModeLike[]) : [] + }) +} + +function requestRooModels(host: ExtensionHost): Promise { + return requestFromExtension(host, "requestRooModels", (message) => { + if (message.type !== "singleRouterModelFetchResponse") { + return undefined + } + + const values = isRecord(message.values) ? message.values : undefined + if (values?.provider !== "roo") { + return undefined + } + + if (message.success === false) { + const errorMessage = + typeof message.error === "string" && message.error.length > 0 + ? message.error + : "Failed to fetch Roo models" + throw new Error(errorMessage) + } + + return isRecord(values.models) ? (values.models as ModelRecord) : {} + }) +} + +async function withHostAndSignalHandlers( + options: BaseListOptions, + fn: (host: ExtensionHost) => Promise, +): Promise { + const host = await createListHost(options) + + const shutdown = async (exitCode: number) => { + await host.dispose() + process.exit(exitCode) + } + + const onSigint = () => void shutdown(130) + const onSigterm = () => void shutdown(143) + + process.on("SIGINT", onSigint) + process.on("SIGTERM", onSigterm) + + try { + return await fn(host) + } finally { + process.off("SIGINT", onSigint) + process.off("SIGTERM", onSigterm) + await host.dispose() + } +} + +export async function listCommands(options: BaseListOptions): Promise { + const format = parseFormat(options.format) + + await withHostAndSignalHandlers(options, async (host) => { + const commands = await requestCommands(host) + + if (format === "json") { + outputJson({ commands }) + return + } + + outputCommandsText(commands) + }) +} + +export async function listModes(options: BaseListOptions): Promise { + const format = parseFormat(options.format) + + await withHostAndSignalHandlers(options, async (host) => { + const modes = await requestModes(host) + + if (format === "json") { + outputJson({ modes }) + return + } + + outputModesText(modes) + }) +} + +export async function listModels(options: BaseListOptions): Promise { + const format = parseFormat(options.format) + + await withHostAndSignalHandlers(options, async (host) => { + const models = await requestRooModels(host) + + if (format === "json") { + outputJson({ models }) + return + } + + outputModelsText(models) + }) +} diff --git a/apps/cli/src/commands/cli/run.ts b/apps/cli/src/commands/cli/run.ts index 663ed5cf75..b72e4e7283 100644 --- a/apps/cli/src/commands/cli/run.ts +++ b/apps/cli/src/commands/cli/run.ts @@ -27,6 +27,7 @@ import { getDefaultExtensionPath } from "@/lib/utils/extension.js" import { VERSION } from "@/lib/utils/version.js" import { ExtensionHost, ExtensionHostOptions } from "@/agent/index.js" +import { runStdinStreamMode } from "./stdin-stream.js" const __dirname = path.dirname(fileURLToPath(import.meta.url)) @@ -65,8 +66,10 @@ export async function run(promptArg: string | undefined, flagOptions: FlagOption flagOptions.reasoningEffort || settings.reasoningEffort || DEFAULT_FLAGS.reasoningEffort const effectiveProvider = flagOptions.provider ?? settings.provider ?? (rooToken ? "roo" : "openrouter") const effectiveWorkspacePath = flagOptions.workspace ? path.resolve(flagOptions.workspace) : process.cwd() - const effectiveDangerouslySkipPermissions = - flagOptions.yes || flagOptions.dangerouslySkipPermissions || settings.dangerouslySkipPermissions || false + const legacyRequireApprovalFromSettings = + settings.requireApproval ?? + (settings.dangerouslySkipPermissions === undefined ? undefined : !settings.dangerouslySkipPermissions) + const effectiveRequireApproval = flagOptions.requireApproval || legacyRequireApprovalFromSettings || false const effectiveExitOnComplete = flagOptions.print || flagOptions.oneshot || settings.oneshot || false const extensionHostOptions: ExtensionHostOptions = { @@ -77,7 +80,8 @@ export async function run(promptArg: string | undefined, flagOptions: FlagOption model: effectiveModel, workspacePath: effectiveWorkspacePath, extensionPath: path.resolve(flagOptions.extension || getDefaultExtensionPath(__dirname)), - nonInteractive: effectiveDangerouslySkipPermissions, + nonInteractive: !effectiveRequireApproval, + exitOnError: flagOptions.exitOnError, ephemeral: flagOptions.ephemeral, debug: flagOptions.debug, exitOnComplete: effectiveExitOnComplete, @@ -112,15 +116,18 @@ export async function run(promptArg: string | undefined, flagOptions: FlagOption extensionHostOptions.apiKey = rooToken extensionHostOptions.user = me.user } catch { - console.error("[CLI] Your Roo Code Router token is not valid.") - console.error("[CLI] Please run: roo auth login") - process.exit(1) + // If an explicit API key was provided via flag or env var, fall through + // to the general API key resolution below instead of exiting. + if (!flagOptions.apiKey && !getApiKeyFromEnv(extensionHostOptions.provider)) { + console.error("[CLI] Your Roo Code Router token is not valid.") + console.error("[CLI] Please run: roo auth login") + console.error("[CLI] Or use --api-key or set ROO_API_KEY to provide your own API key.") + process.exit(1) + } } - } else { - console.error("[CLI] Your Roo Code Router token is missing.") - console.error("[CLI] Please run: roo auth login") - process.exit(1) } + // If no rooToken, fall through to the general API key resolution below + // which will check flagOptions.apiKey and ROO_API_KEY env var. } // Validations @@ -179,15 +186,52 @@ export async function run(promptArg: string | undefined, flagOptions: FlagOption // Output format only works with --print mode if (outputFormat !== "text" && !flagOptions.print && isTuiSupported) { console.error("[CLI] Error: --output-format requires --print mode") - console.error("[CLI] Usage: roo --print --output-format json") + console.error("[CLI] Usage: roo --print --output-format json") process.exit(1) } + if (flagOptions.stdinPromptStream && !flagOptions.print) { + console.error("[CLI] Error: --stdin-prompt-stream requires --print mode") + console.error("[CLI] Usage: roo --print --output-format stream-json --stdin-prompt-stream [options]") + process.exit(1) + } + + if (flagOptions.stdinPromptStream && outputFormat !== "stream-json") { + console.error("[CLI] Error: --stdin-prompt-stream requires --output-format=stream-json") + console.error("[CLI] Usage: roo --print --output-format stream-json --stdin-prompt-stream [options]") + process.exit(1) + } + + if (flagOptions.stdinPromptStream && process.stdin.isTTY) { + console.error("[CLI] Error: --stdin-prompt-stream requires piped stdin") + console.error( + '[CLI] Example: printf \'{"command":"start","requestId":"1","prompt":"1+1=?"}\\n\' | roo --print --output-format stream-json --stdin-prompt-stream [options]', + ) + process.exit(1) + } + + if (flagOptions.stdinPromptStream && prompt) { + console.error("[CLI] Error: cannot use positional prompt or --prompt-file with --stdin-prompt-stream") + console.error("[CLI] Usage: roo --print --output-format stream-json --stdin-prompt-stream [options]") + process.exit(1) + } + + const useStdinPromptStream = flagOptions.stdinPromptStream + if (!isTuiEnabled) { - if (!prompt) { - console.error("[CLI] Error: prompt is required in print mode") - console.error("[CLI] Usage: roo --print [options]") - console.error("[CLI] Run without -p for interactive mode") + if (!prompt && !useStdinPromptStream) { + if (flagOptions.print) { + console.error("[CLI] Error: no prompt provided") + console.error("[CLI] Usage: roo --print [options] ") + console.error( + "[CLI] For stdin control mode: roo --print --output-format stream-json --stdin-prompt-stream [options]", + ) + } else { + console.error("[CLI] Error: prompt is required in non-interactive mode") + console.error("[CLI] Usage: roo [options]") + console.error("[CLI] Run without -p for interactive mode") + } + process.exit(1) } @@ -228,9 +272,13 @@ export async function run(promptArg: string | undefined, flagOptions: FlagOption extensionHostOptions.disableOutput = useJsonOutput const host = new ExtensionHost(extensionHostOptions) + let streamRequestId: string | undefined const jsonEmitter = useJsonOutput - ? new JsonEventEmitter({ mode: outputFormat as "json" | "stream-json" }) + ? new JsonEventEmitter({ + mode: outputFormat as "json" | "stream-json", + requestIdProvider: () => streamRequestId, + }) : null async function shutdown(signal: string, exitCode: number): Promise { @@ -252,7 +300,22 @@ export async function run(promptArg: string | undefined, flagOptions: FlagOption jsonEmitter.attachToClient(host.client) } - await host.runTask(prompt!) + if (useStdinPromptStream) { + if (!jsonEmitter || outputFormat !== "stream-json") { + throw new Error("--stdin-prompt-stream requires --output-format=stream-json to emit control events") + } + + await runStdinStreamMode({ + host, + jsonEmitter, + setStreamRequestId: (id) => { + streamRequestId = id + }, + }) + } else { + await host.runTask(prompt!) + } + jsonEmitter?.detach() await host.dispose() process.exit(0) @@ -264,6 +327,7 @@ export async function run(promptArg: string | undefined, flagOptions: FlagOption process.stdout.write(JSON.stringify(errorEvent) + "\n") } else { console.error("[CLI] Error:", errorMessage) + if (error instanceof Error) { console.error(error.stack) } diff --git a/apps/cli/src/commands/cli/stdin-stream.ts b/apps/cli/src/commands/cli/stdin-stream.ts new file mode 100644 index 0000000000..dceca2e84d --- /dev/null +++ b/apps/cli/src/commands/cli/stdin-stream.ts @@ -0,0 +1,610 @@ +import { createInterface } from "readline" + +import { isRecord } from "@/lib/utils/guards.js" + +import type { ExtensionHost } from "@/agent/index.js" +import type { JsonEventEmitter } from "@/agent/json-event-emitter.js" + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export type StdinStreamCommandName = "start" | "message" | "cancel" | "ping" | "shutdown" + +export type StdinStreamCommand = + | { command: "start"; requestId: string; prompt: string } + | { command: "message"; requestId: string; prompt: string } + | { command: "cancel"; requestId: string } + | { command: "ping"; requestId: string } + | { command: "shutdown"; requestId: string } + +// --------------------------------------------------------------------------- +// Parsing +// --------------------------------------------------------------------------- + +export const VALID_STDIN_COMMANDS = new Set(["start", "message", "cancel", "ping", "shutdown"]) + +export function parseStdinStreamCommand(line: string, lineNumber: number): StdinStreamCommand { + let parsed: unknown + + try { + parsed = JSON.parse(line) + } catch { + throw new Error(`stdin command line ${lineNumber}: invalid JSON`) + } + + if (!isRecord(parsed)) { + throw new Error(`stdin command line ${lineNumber}: expected JSON object`) + } + + const commandRaw = parsed.command + const requestIdRaw = parsed.requestId + + if (typeof commandRaw !== "string") { + throw new Error(`stdin command line ${lineNumber}: missing string "command"`) + } + + if (!VALID_STDIN_COMMANDS.has(commandRaw as StdinStreamCommandName)) { + throw new Error( + `stdin command line ${lineNumber}: unsupported command "${commandRaw}" (expected start|message|cancel|ping|shutdown)`, + ) + } + + if (typeof requestIdRaw !== "string" || requestIdRaw.trim().length === 0) { + throw new Error(`stdin command line ${lineNumber}: missing non-empty string "requestId"`) + } + + const command = commandRaw as StdinStreamCommandName + const requestId = requestIdRaw.trim() + + if (command === "start" || command === "message") { + const promptRaw = parsed.prompt + if (typeof promptRaw !== "string" || promptRaw.trim().length === 0) { + throw new Error(`stdin command line ${lineNumber}: "${command}" requires non-empty string "prompt"`) + } + + return { command, requestId, prompt: promptRaw } + } + + return { command, requestId } +} + +// --------------------------------------------------------------------------- +// NDJSON stdin reader +// --------------------------------------------------------------------------- + +async function* readCommandsFromStdinNdjson(): AsyncGenerator { + const lineReader = createInterface({ + input: process.stdin, + crlfDelay: Infinity, + terminal: false, + }) + + let lineNumber = 0 + + try { + for await (const line of lineReader) { + lineNumber += 1 + const trimmed = line.trim() + if (!trimmed) { + continue + } + yield parseStdinStreamCommand(trimmed, lineNumber) + } + } finally { + lineReader.close() + } +} + +// --------------------------------------------------------------------------- +// Queue snapshot helpers +// --------------------------------------------------------------------------- + +interface StreamQueueItem { + id: string + text?: string + imageCount: number + timestamp?: number +} + +function normalizeQueueText(text: string | undefined): string | undefined { + if (!text) { + return undefined + } + + const compact = text.replace(/\s+/g, " ").trim() + if (!compact) { + return undefined + } + + return compact.length <= 180 ? compact : `${compact.slice(0, 177)}...` +} + +function parseQueueSnapshot(rawQueue: unknown): StreamQueueItem[] | undefined { + if (!Array.isArray(rawQueue)) { + return undefined + } + + const snapshot: StreamQueueItem[] = [] + + for (const entry of rawQueue) { + if (!isRecord(entry)) { + continue + } + + const idRaw = entry.id + if (typeof idRaw !== "string" || idRaw.trim().length === 0) { + continue + } + + const imagesRaw = entry.images + const timestampRaw = entry.timestamp + const imageCount = Array.isArray(imagesRaw) ? imagesRaw.length : 0 + + snapshot.push({ + id: idRaw, + text: normalizeQueueText(typeof entry.text === "string" ? entry.text : undefined), + imageCount, + timestamp: typeof timestampRaw === "number" ? timestampRaw : undefined, + }) + } + + return snapshot +} + +function areStringArraysEqual(a: string[], b: string[]): boolean { + if (a.length !== b.length) { + return false + } + + for (let i = 0; i < a.length; i++) { + if (a[i] !== b[i]) { + return false + } + } + + return true +} + +// --------------------------------------------------------------------------- +// Orchestrator +// --------------------------------------------------------------------------- + +export interface StdinStreamModeOptions { + host: ExtensionHost + jsonEmitter: JsonEventEmitter + setStreamRequestId: (id: string | undefined) => void +} + +function isCancellationLikeError(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error) + const normalized = message.toLowerCase() + return normalized.includes("aborted") || normalized.includes("cancelled") || normalized.includes("canceled") +} + +export async function runStdinStreamMode({ host, jsonEmitter, setStreamRequestId }: StdinStreamModeOptions) { + let hasReceivedStdinCommand = false + let shouldShutdown = false + let activeTaskPromise: Promise | null = null + let fatalStreamError: Error | null = null + let activeRequestId: string | undefined + let activeTaskCommand: "start" | undefined + let latestTaskId: string | undefined + let cancelRequestedForActiveTask = false + let hasSeenQueueState = false + let lastQueueDepth = 0 + let lastQueueMessageIds: string[] = [] + + const waitForPreviousTaskToSettle = async () => { + if (!activeTaskPromise) { + return + } + + try { + await activeTaskPromise + } catch { + // Errors are emitted through control/error events. + } + } + + const offClientError = host.client.on("error", (error) => { + if (cancelRequestedForActiveTask && isCancellationLikeError(error)) { + if (activeTaskCommand === "start") { + jsonEmitter.emitControl({ + subtype: "done", + requestId: activeRequestId, + command: "start", + taskId: latestTaskId, + content: "task cancelled", + code: "task_aborted", + success: false, + }) + } + activeTaskCommand = undefined + activeRequestId = undefined + setStreamRequestId(undefined) + cancelRequestedForActiveTask = false + return + } + + fatalStreamError = error + jsonEmitter.emitControl({ + subtype: "error", + requestId: activeRequestId, + command: activeTaskCommand, + taskId: latestTaskId, + content: error.message, + code: "client_error", + success: false, + }) + }) + + const onExtensionMessage = (message: { + type?: string + state?: { + currentTaskItem?: { id?: unknown } + messageQueue?: unknown + } + }) => { + if (message.type !== "state") { + return + } + + const currentTaskId = message.state?.currentTaskItem?.id + if (typeof currentTaskId === "string" && currentTaskId.trim().length > 0) { + latestTaskId = currentTaskId + } + + const queueSnapshot = parseQueueSnapshot(message.state?.messageQueue) + if (!queueSnapshot) { + return + } + + const queueDepth = queueSnapshot.length + const queueMessageIds = queueSnapshot.map((item) => item.id) + + if (!hasSeenQueueState) { + hasSeenQueueState = true + lastQueueDepth = queueDepth + lastQueueMessageIds = queueMessageIds + + if (queueDepth === 0) { + return + } + + jsonEmitter.emitQueue({ + subtype: "snapshot", + taskId: latestTaskId, + content: `queue snapshot (${queueDepth} item${queueDepth === 1 ? "" : "s"})`, + queueDepth, + queue: queueSnapshot, + }) + return + } + + const depthChanged = queueDepth !== lastQueueDepth + const idsChanged = !areStringArraysEqual(queueMessageIds, lastQueueMessageIds) + + if (!depthChanged && !idsChanged) { + return + } + + const subtype: "enqueued" | "dequeued" | "drained" | "updated" = depthChanged + ? queueDepth > lastQueueDepth + ? "enqueued" + : queueDepth === 0 + ? "drained" + : "dequeued" + : "updated" + + const content = + subtype === "drained" + ? "queue drained" + : `queue ${subtype} (${queueDepth} item${queueDepth === 1 ? "" : "s"})` + + jsonEmitter.emitQueue({ + subtype, + taskId: latestTaskId, + content, + queueDepth, + queue: queueSnapshot, + }) + + lastQueueDepth = queueDepth + lastQueueMessageIds = queueMessageIds + } + + host.on("extensionWebviewMessage", onExtensionMessage) + + const offTaskCompleted = host.client.on("taskCompleted", (event) => { + if (activeTaskCommand === "start") { + const completionCode = event.success + ? "task_completed" + : cancelRequestedForActiveTask + ? "task_aborted" + : "task_failed" + + jsonEmitter.emitControl({ + subtype: "done", + requestId: activeRequestId, + command: "start", + taskId: latestTaskId, + content: event.success + ? "task completed" + : cancelRequestedForActiveTask + ? "task cancelled" + : "task failed", + code: completionCode, + success: event.success, + }) + activeTaskCommand = undefined + activeRequestId = undefined + setStreamRequestId(undefined) + cancelRequestedForActiveTask = false + } + }) + + try { + for await (const stdinCommand of readCommandsFromStdinNdjson()) { + hasReceivedStdinCommand = true + + if (fatalStreamError) { + throw fatalStreamError + } + + switch (stdinCommand.command) { + case "start": + // A task can emit completion events before runTask() finalizers run. + // Wait for full settlement to avoid false "task_busy" on immediate next start. + // Safe from races: `for await` processes stdin commands serially, so no + // concurrent command can mutate state between the check and the await. + if (activeTaskPromise && !host.client.hasActiveTask()) { + await waitForPreviousTaskToSettle() + } + + if (activeTaskPromise || host.client.hasActiveTask()) { + jsonEmitter.emitControl({ + subtype: "error", + requestId: stdinCommand.requestId, + command: "start", + taskId: latestTaskId, + content: "cannot start a new task while another task is active", + code: "task_busy", + success: false, + }) + break + } + + activeRequestId = stdinCommand.requestId + activeTaskCommand = "start" + setStreamRequestId(stdinCommand.requestId) + latestTaskId = undefined + cancelRequestedForActiveTask = false + + jsonEmitter.emitControl({ + subtype: "ack", + requestId: stdinCommand.requestId, + command: "start", + taskId: latestTaskId, + content: "starting task", + code: "accepted", + success: true, + }) + + activeTaskPromise = host + .runTask(stdinCommand.prompt) + .catch((error) => { + const message = error instanceof Error ? error.message : String(error) + + if (cancelRequestedForActiveTask || isCancellationLikeError(error)) { + if (activeTaskCommand === "start") { + jsonEmitter.emitControl({ + subtype: "done", + requestId: stdinCommand.requestId, + command: "start", + taskId: latestTaskId, + content: "task cancelled", + code: "task_aborted", + success: false, + }) + } + activeTaskCommand = undefined + activeRequestId = undefined + setStreamRequestId(undefined) + cancelRequestedForActiveTask = false + return + } + + fatalStreamError = error instanceof Error ? error : new Error(message) + activeTaskCommand = undefined + activeRequestId = undefined + setStreamRequestId(undefined) + jsonEmitter.emitControl({ + subtype: "error", + requestId: stdinCommand.requestId, + command: "start", + taskId: latestTaskId, + content: message, + code: "task_error", + success: false, + }) + }) + .finally(() => { + activeTaskPromise = null + }) + break + + case "message": + if (!host.client.hasActiveTask()) { + jsonEmitter.emitControl({ + subtype: "error", + requestId: stdinCommand.requestId, + command: "message", + taskId: latestTaskId, + content: "no active task; send a start command first", + code: "no_active_task", + success: false, + }) + break + } + + setStreamRequestId(stdinCommand.requestId) + jsonEmitter.emitControl({ + subtype: "ack", + requestId: stdinCommand.requestId, + command: "message", + taskId: latestTaskId, + content: "message accepted", + code: "accepted", + success: true, + }) + host.sendToExtension({ type: "queueMessage", text: stdinCommand.prompt }) + jsonEmitter.emitControl({ + subtype: "done", + requestId: stdinCommand.requestId, + command: "message", + taskId: latestTaskId, + content: "message queued", + code: "queued", + success: true, + }) + break + + case "cancel": { + setStreamRequestId(stdinCommand.requestId) + + const hasTaskInFlight = Boolean( + activeTaskPromise || activeTaskCommand === "start" || host.client.hasActiveTask(), + ) + + if (!hasTaskInFlight) { + jsonEmitter.emitControl({ + subtype: "ack", + requestId: stdinCommand.requestId, + command: "cancel", + taskId: latestTaskId, + content: "no active task to cancel", + code: "accepted", + success: true, + }) + jsonEmitter.emitControl({ + subtype: "done", + requestId: stdinCommand.requestId, + command: "cancel", + taskId: latestTaskId, + content: "cancel ignored (no active task)", + code: "no_active_task", + success: true, + }) + break + } + + cancelRequestedForActiveTask = true + jsonEmitter.emitControl({ + subtype: "ack", + requestId: stdinCommand.requestId, + command: "cancel", + taskId: latestTaskId, + content: host.client.hasActiveTask() ? "cancel requested" : "cancel requested (task starting)", + code: "accepted", + success: true, + }) + try { + host.client.cancelTask() + jsonEmitter.emitControl({ + subtype: "done", + requestId: stdinCommand.requestId, + command: "cancel", + taskId: latestTaskId, + content: "cancel signal sent", + code: "cancel_requested", + success: true, + }) + } catch (error) { + if (!isCancellationLikeError(error)) { + const message = error instanceof Error ? error.message : String(error) + jsonEmitter.emitControl({ + subtype: "error", + requestId: stdinCommand.requestId, + command: "cancel", + taskId: latestTaskId, + content: message, + code: "cancel_error", + success: false, + }) + } + } + break + } + + case "ping": + jsonEmitter.emitControl({ + subtype: "ack", + requestId: stdinCommand.requestId, + command: "ping", + taskId: latestTaskId, + content: "pong", + code: "accepted", + success: true, + }) + jsonEmitter.emitControl({ + subtype: "done", + requestId: stdinCommand.requestId, + command: "ping", + taskId: latestTaskId, + content: "pong", + code: "pong", + success: true, + }) + break + + case "shutdown": + jsonEmitter.emitControl({ + subtype: "ack", + requestId: stdinCommand.requestId, + command: "shutdown", + taskId: latestTaskId, + content: "shutdown requested", + code: "accepted", + success: true, + }) + jsonEmitter.emitControl({ + subtype: "done", + requestId: stdinCommand.requestId, + command: "shutdown", + taskId: latestTaskId, + content: "shutting down process", + code: "shutdown_requested", + success: true, + }) + shouldShutdown = true + break + } + + if (shouldShutdown) { + break + } + } + + if (!hasReceivedStdinCommand) { + throw new Error("no stdin command provided") + } + + if (shouldShutdown && host.client.hasActiveTask()) { + host.client.cancelTask() + } + + if (!shouldShutdown && host.client.hasActiveTask() && host.isWaitingForInput()) { + const currentAsk = host.client.getCurrentAsk() + throw new Error(`stdin ended while task was waiting for input (${currentAsk ?? "unknown"})`) + } + + if (!shouldShutdown && activeTaskPromise) { + await activeTaskPromise + } + } finally { + offClientError() + host.off("extensionWebviewMessage", onExtensionMessage) + offTaskCompleted() + } +} diff --git a/apps/cli/src/index.ts b/apps/cli/src/index.ts index 5b663c2bdc..8b817db77f 100644 --- a/apps/cli/src/index.ts +++ b/apps/cli/src/index.ts @@ -2,7 +2,7 @@ import { Command } from "commander" import { DEFAULT_FLAGS } from "@/types/constants.js" import { VERSION } from "@/lib/utils/version.js" -import { run, login, logout, status } from "@/commands/index.js" +import { run, login, logout, status, listCommands, listModes, listModels } from "@/commands/index.js" const program = new Command() @@ -16,9 +16,14 @@ program .option("--prompt-file ", "Read prompt from a file instead of command line argument") .option("-w, --workspace ", "Workspace directory path (defaults to current working directory)") .option("-p, --print", "Print response and exit (non-interactive mode)", false) + .option( + "--stdin-prompt-stream", + "Read NDJSON commands from stdin (requires --print and --output-format stream-json)", + false, + ) .option("-e, --extension ", "Path to the extension bundle directory") .option("-d, --debug", "Enable debug output (includes detailed debug information)", false) - .option("-y, --yes, --dangerously-skip-permissions", "Auto-approve all prompts (use with caution)", false) + .option("-a, --require-approval", "Require manual approval for actions", false) .option("-k, --api-key ", "API key for the LLM provider") .option("--provider ", "API provider (roo, anthropic, openai, openrouter, etc.)") .option("-m, --model ", "Model to use", DEFAULT_FLAGS.model) @@ -28,6 +33,7 @@ program "Reasoning effort level (unspecified, disabled, none, minimal, low, medium, high, xhigh)", DEFAULT_FLAGS.reasoningEffort, ) + .option("--exit-on-error", "Exit on API request errors instead of retrying", false) .option("--ephemeral", "Run without persisting state (uses temporary storage)", false) .option("--oneshot", "Exit upon task completion", false) .option( @@ -37,6 +43,45 @@ program ) .action(run) +const listCommand = program.command("list").description("List commands, modes, or models") + +const applyListOptions = (command: Command) => + command + .option("-w, --workspace ", "Workspace directory path (defaults to current working directory)") + .option("-e, --extension ", "Path to the extension bundle directory") + .option("-k, --api-key ", "Roo API key (falls back to saved login/session token)") + .option("--format ", 'Output format: "json" (default) or "text"', "json") + .option("-d, --debug", "Enable debug output", false) + +const runListAction = async (action: () => Promise) => { + try { + await action() + process.exit(0) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + console.error(`[CLI] Error: ${message}`) + process.exit(1) + } +} + +applyListOptions(listCommand.command("commands").description("List available slash commands")).action( + async (options: Parameters[0]) => { + await runListAction(() => listCommands(options)) + }, +) + +applyListOptions(listCommand.command("modes").description("List available modes")).action( + async (options: Parameters[0]) => { + await runListAction(() => listModes(options)) + }, +) + +applyListOptions(listCommand.command("models").description("List available Roo models")).action( + async (options: Parameters[0]) => { + await runListAction(() => listModels(options)) + }, +) + const authCommand = program.command("auth").description("Manage authentication for Roo Code Cloud") authCommand diff --git a/apps/cli/src/lib/storage/__tests__/settings.test.ts b/apps/cli/src/lib/storage/__tests__/settings.test.ts index c133f733b9..30f1dbe8ec 100644 --- a/apps/cli/src/lib/storage/__tests__/settings.test.ts +++ b/apps/cli/src/lib/storage/__tests__/settings.test.ts @@ -103,7 +103,7 @@ describe("Settings Storage", () => { await saveSettings({ mode: "architect", provider: "anthropic" as const, - model: "claude-opus-4.5", + model: "claude-opus-4.6", reasoningEffort: "medium" as const, }) @@ -112,7 +112,7 @@ describe("Settings Storage", () => { expect(settings.mode).toBe("architect") expect(settings.provider).toBe("anthropic") - expect(settings.model).toBe("claude-opus-4.5") + expect(settings.model).toBe("claude-opus-4.6") expect(settings.reasoningEffort).toBe("medium") }) @@ -179,20 +179,20 @@ describe("Settings Storage", () => { expect(loaded.reasoningEffort).toBe("low") }) - it("should support dangerouslySkipPermissions setting", async () => { - await saveSettings({ dangerouslySkipPermissions: true }) + it("should support requireApproval setting", async () => { + await saveSettings({ requireApproval: true }) const loaded = await loadSettings() - expect(loaded.dangerouslySkipPermissions).toBe(true) + expect(loaded.requireApproval).toBe(true) }) - it("should support all settings together including dangerouslySkipPermissions", async () => { + it("should support all settings together including requireApproval", async () => { const allSettings = { mode: "architect", provider: "anthropic" as const, model: "claude-sonnet-4-20250514", reasoningEffort: "high" as const, - dangerouslySkipPermissions: true, + requireApproval: true, } await saveSettings(allSettings) @@ -202,7 +202,7 @@ describe("Settings Storage", () => { expect(loaded.provider).toBe("anthropic") expect(loaded.model).toBe("claude-sonnet-4-20250514") expect(loaded.reasoningEffort).toBe("high") - expect(loaded.dangerouslySkipPermissions).toBe(true) + expect(loaded.requireApproval).toBe(true) }) it("should support oneshot setting", async () => { @@ -218,7 +218,7 @@ describe("Settings Storage", () => { provider: "anthropic" as const, model: "claude-sonnet-4-20250514", reasoningEffort: "high" as const, - dangerouslySkipPermissions: true, + requireApproval: true, oneshot: true, } @@ -229,8 +229,15 @@ describe("Settings Storage", () => { expect(loaded.provider).toBe("anthropic") expect(loaded.model).toBe("claude-sonnet-4-20250514") expect(loaded.reasoningEffort).toBe("high") - expect(loaded.dangerouslySkipPermissions).toBe(true) + expect(loaded.requireApproval).toBe(true) expect(loaded.oneshot).toBe(true) }) + + it("should still load legacy dangerouslySkipPermissions setting", async () => { + await saveSettings({ dangerouslySkipPermissions: true }) + const loaded = await loadSettings() + + expect(loaded.dangerouslySkipPermissions).toBe(true) + }) }) }) diff --git a/apps/cli/src/lib/utils/__tests__/extension.test.ts b/apps/cli/src/lib/utils/__tests__/extension.test.ts index 31fdbe87f0..4b4a2db585 100644 --- a/apps/cli/src/lib/utils/__tests__/extension.test.ts +++ b/apps/cli/src/lib/utils/__tests__/extension.test.ts @@ -21,9 +21,26 @@ describe("getDefaultExtensionPath", () => { it("should return monorepo path when extension.js exists there", () => { const mockDirname = "/test/apps/cli/dist" - const expectedMonorepoPath = path.resolve(mockDirname, "../../../src/dist") + const expectedMonorepoPath = path.resolve("/test/apps/cli", "../../src/dist") - vi.mocked(fs.existsSync).mockReturnValue(true) + // Walk-up: dist/ has no package.json, apps/cli/ does + vi.mocked(fs.existsSync).mockImplementation((p) => { + const s = String(p) + + if (s === path.join(mockDirname, "package.json")) { + return false + } + + if (s === path.join("/test/apps/cli", "package.json")) { + return true + } + + if (s === path.join(expectedMonorepoPath, "extension.js")) { + return true + } + + return false + }) const result = getDefaultExtensionPath(mockDirname) @@ -33,9 +50,18 @@ describe("getDefaultExtensionPath", () => { it("should return package path when extension.js does not exist in monorepo path", () => { const mockDirname = "/test/apps/cli/dist" - const expectedPackagePath = path.resolve(mockDirname, "../extension") + const expectedPackagePath = path.resolve("/test/apps/cli", "extension") - vi.mocked(fs.existsSync).mockReturnValue(false) + // Walk-up finds package.json at apps/cli/, but no extension.js in monorepo path + vi.mocked(fs.existsSync).mockImplementation((p) => { + const s = String(p) + + if (s === path.join("/test/apps/cli", "package.json")) { + return true + } + + return false + }) const result = getDefaultExtensionPath(mockDirname) @@ -43,12 +69,45 @@ describe("getDefaultExtensionPath", () => { }) it("should check monorepo path first", () => { - const mockDirname = "/some/path" - vi.mocked(fs.existsSync).mockReturnValue(false) + const mockDirname = "/test/apps/cli/dist" + + vi.mocked(fs.existsSync).mockImplementation((p) => { + const s = String(p) + + if (s === path.join("/test/apps/cli", "package.json")) { + return true + } + + return false + }) getDefaultExtensionPath(mockDirname) - const expectedMonorepoPath = path.resolve(mockDirname, "../../../src/dist") + const expectedMonorepoPath = path.resolve("/test/apps/cli", "../../src/dist") expect(fs.existsSync).toHaveBeenCalledWith(path.join(expectedMonorepoPath, "extension.js")) }) + + it("should work when called from source directory (tsx dev)", () => { + const mockDirname = "/test/apps/cli/src/commands/cli" + const expectedMonorepoPath = path.resolve("/test/apps/cli", "../../src/dist") + + // Walk-up: no package.json in src subdirs, found at apps/cli/ + vi.mocked(fs.existsSync).mockImplementation((p) => { + const s = String(p) + + if (s === path.join("/test/apps/cli", "package.json")) { + return true + } + + if (s === path.join(expectedMonorepoPath, "extension.js")) { + return true + } + + return false + }) + + const result = getDefaultExtensionPath(mockDirname) + + expect(result).toBe(expectedMonorepoPath) + }) }) diff --git a/apps/cli/src/lib/utils/__tests__/guards.test.ts b/apps/cli/src/lib/utils/__tests__/guards.test.ts new file mode 100644 index 0000000000..f59eeb506d --- /dev/null +++ b/apps/cli/src/lib/utils/__tests__/guards.test.ts @@ -0,0 +1,27 @@ +import { isRecord } from "../guards.js" + +describe("isRecord", () => { + it("returns true for plain objects", () => { + expect(isRecord({})).toBe(true) + expect(isRecord({ a: 1 })).toBe(true) + }) + + it("returns true for arrays (arrays are objects)", () => { + expect(isRecord([])).toBe(true) + }) + + it("returns false for null", () => { + expect(isRecord(null)).toBe(false) + }) + + it("returns false for undefined", () => { + expect(isRecord(undefined)).toBe(false) + }) + + it("returns false for primitives", () => { + expect(isRecord("string")).toBe(false) + expect(isRecord(42)).toBe(false) + expect(isRecord(true)).toBe(false) + expect(isRecord(Symbol("s"))).toBe(false) + }) +}) diff --git a/apps/cli/src/lib/utils/context-window.ts b/apps/cli/src/lib/utils/context-window.ts index c1224c8b1e..df878e16b0 100644 --- a/apps/cli/src/lib/utils/context-window.ts +++ b/apps/cli/src/lib/utils/context-window.ts @@ -48,18 +48,10 @@ function getModelIdForProvider(config: ProviderSettings): string | undefined { return config.requestyModelId case "litellm": return config.litellmModelId - case "deepinfra": - return config.deepInfraModelId - case "huggingface": - return config.huggingFaceModelId - case "unbound": - return config.unboundModelId case "vercel-ai-gateway": return config.vercelAiGatewayModelId - case "io-intelligence": - return config.ioIntelligenceModelId default: - // For anthropic, bedrock, vertex, gemini, xai, groq, etc. + // For anthropic, bedrock, vertex, gemini, xai, etc. return config.apiModelId } } diff --git a/apps/cli/src/lib/utils/extension.ts b/apps/cli/src/lib/utils/extension.ts index 904940ec00..f49b2df865 100644 --- a/apps/cli/src/lib/utils/extension.ts +++ b/apps/cli/src/lib/utils/extension.ts @@ -17,17 +17,26 @@ export function getDefaultExtensionPath(dirname: string): string { } } - // __dirname is apps/cli/dist when bundled - // The extension is at src/dist (relative to monorepo root) - // So from apps/cli/dist, we need to go ../../../src/dist - const monorepoPath = path.resolve(dirname, "../../../src/dist") + // Find the CLI package root (apps/cli) by walking up to the nearest package.json. + // This works whether called from dist/ (bundled) or src/commands/cli/ (tsx dev). + let packageRoot = dirname + + while (packageRoot !== path.dirname(packageRoot)) { + if (fs.existsSync(path.join(packageRoot, "package.json"))) { + break + } + + packageRoot = path.dirname(packageRoot) + } + + // The extension is at ../../src/dist relative to apps/cli (monorepo/src/dist) + const monorepoPath = path.resolve(packageRoot, "../../src/dist") - // Try monorepo path first (for development) if (fs.existsSync(path.join(monorepoPath, "extension.js"))) { return monorepoPath } - // Fallback: when installed via curl script, extension is at ../extension - const packagePath = path.resolve(dirname, "../extension") + // Fallback: when installed via curl script, extension is at apps/cli/extension + const packagePath = path.resolve(packageRoot, "extension") return packagePath } diff --git a/apps/cli/src/lib/utils/guards.ts b/apps/cli/src/lib/utils/guards.ts new file mode 100644 index 0000000000..a901f1a658 --- /dev/null +++ b/apps/cli/src/lib/utils/guards.ts @@ -0,0 +1,3 @@ +export function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null +} diff --git a/apps/cli/src/lib/utils/version.ts b/apps/cli/src/lib/utils/version.ts index e4f2ce59b2..c599963bdc 100644 --- a/apps/cli/src/lib/utils/version.ts +++ b/apps/cli/src/lib/utils/version.ts @@ -1,6 +1,24 @@ -import { createRequire } from "module" +import fs from "fs" +import path from "path" +import { fileURLToPath } from "url" -const require = createRequire(import.meta.url) -const packageJson = require("../package.json") +// Walk up from the current file to find the nearest package.json. +// This works whether running from source (tsx src/lib/utils/) or bundle (dist/). +function findVersion(): string { + let dir = path.dirname(fileURLToPath(import.meta.url)) -export const VERSION = packageJson.version + while (dir !== path.dirname(dir)) { + const candidate = path.join(dir, "package.json") + + if (fs.existsSync(candidate)) { + const packageJson = JSON.parse(fs.readFileSync(candidate, "utf-8")) + return packageJson.version + } + + dir = path.dirname(dir) + } + + return "0.0.0" +} + +export const VERSION = findVersion() diff --git a/apps/cli/src/types/constants.ts b/apps/cli/src/types/constants.ts index 5b3dc57778..6c54348a9c 100644 --- a/apps/cli/src/types/constants.ts +++ b/apps/cli/src/types/constants.ts @@ -3,7 +3,7 @@ import { reasoningEffortsExtended } from "@roo-code/types" export const DEFAULT_FLAGS = { mode: "code", reasoningEffort: "medium" as const, - model: "anthropic/claude-opus-4.5", + model: "anthropic/claude-opus-4.6", } export const REASONING_EFFORTS = [...reasoningEffortsExtended, "unspecified", "disabled"] diff --git a/apps/cli/src/types/json-events.ts b/apps/cli/src/types/json-events.ts index f18f3b2768..048a303a0f 100644 --- a/apps/cli/src/types/json-events.ts +++ b/apps/cli/src/types/json-events.ts @@ -27,6 +27,8 @@ export function isValidOutputFormat(format: string): format is OutputFormat { */ export type JsonEventType = | "system" // System messages (init, ready, shutdown) + | "control" // Transport/control protocol events + | "queue" // Message queue telemetry from extension state | "assistant" // Assistant text messages | "user" // User messages (echoed input) | "tool_use" // Tool invocations (file ops, commands, browser, MCP) @@ -35,6 +37,17 @@ export type JsonEventType = | "error" // Errors | "result" // Final task result +export interface JsonEventQueueItem { + /** Queue item id generated by MessageQueueService */ + id: string + /** Queued text prompt preview */ + text?: string + /** Number of attached images in the queued message */ + imageCount?: number + /** Queue insertion/update timestamp (ms epoch) */ + timestamp?: number +} + /** * Tool use information for tool_use events. */ @@ -84,14 +97,32 @@ export interface JsonEventCost { export interface JsonEvent { /** Event type discriminator */ type: JsonEventType + /** Protocol schema version (included on system.init) */ + schemaVersion?: number + /** Transport protocol identifier (included on system.init) */ + protocol?: string + /** Capability names supported by the current process */ + capabilities?: string[] /** Message ID - included on first delta and final message */ id?: number + /** Active task ID when available */ + taskId?: string + /** Request ID for correlating streamed output to stdin commands */ + requestId?: string + /** Command name for control events */ + command?: string /** Content text (for text-based events) */ content?: string /** True when this is the final message (stream complete) */ done?: boolean /** Optional subtype for more specific categorization */ subtype?: string + /** Optional machine-readable status/error code */ + code?: string + /** Current queue depth (for queue events) */ + queueDepth?: number + /** Queue item snapshots (for queue events) */ + queue?: JsonEventQueueItem[] /** Tool use information (for tool_use events) */ tool_use?: JsonEventToolUse /** Tool result information (for tool_result events) */ diff --git a/apps/cli/src/types/types.ts b/apps/cli/src/types/types.ts index 05392ccca8..fbd132bfdc 100644 --- a/apps/cli/src/types/types.ts +++ b/apps/cli/src/types/types.ts @@ -22,10 +22,11 @@ export type FlagOptions = { promptFile?: string workspace?: string print: boolean + stdinPromptStream: boolean extension?: string debug: boolean - yes: boolean - dangerouslySkipPermissions: boolean + requireApproval: boolean + exitOnError: boolean apiKey?: string provider?: SupportedProvider model?: string @@ -57,7 +58,9 @@ export interface CliSettings { model?: string /** Default reasoning effort level */ reasoningEffort?: ReasoningEffortFlagOptions - /** Auto-approve all prompts (use with caution) */ + /** Require manual approval for tools/commands/browser/MCP actions */ + requireApproval?: boolean + /** @deprecated Legacy inverse setting kept for backward compatibility */ dangerouslySkipPermissions?: boolean /** Exit upon task completion */ oneshot?: boolean diff --git a/apps/cli/src/ui/components/ChatHistoryItem.tsx b/apps/cli/src/ui/components/ChatHistoryItem.tsx index c51b0faddb..e5bbc79366 100644 --- a/apps/cli/src/ui/components/ChatHistoryItem.tsx +++ b/apps/cli/src/ui/components/ChatHistoryItem.tsx @@ -10,14 +10,13 @@ import { getToolRenderer } from "./tools/index.js" /** * Tool categories for styling */ -type ToolCategory = "file" | "directory" | "search" | "command" | "browser" | "mode" | "completion" | "other" +type ToolCategory = "file" | "directory" | "search" | "command" | "mode" | "completion" | "other" function getToolCategory(toolName: string): ToolCategory { const fileTools = ["readFile", "read_file", "writeToFile", "write_to_file", "applyDiff", "apply_diff"] const dirTools = ["listFiles", "list_files", "listFilesRecursive", "listFilesTopLevel"] const searchTools = ["searchFiles", "search_files"] const commandTools = ["executeCommand", "execute_command"] - const browserTools = ["browserAction", "browser_action"] const modeTools = ["switchMode", "switch_mode", "newTask", "new_task"] const completionTools = ["attemptCompletion", "attempt_completion", "askFollowupQuestion", "ask_followup_question"] @@ -25,7 +24,6 @@ function getToolCategory(toolName: string): ToolCategory { if (dirTools.includes(toolName)) return "directory" if (searchTools.includes(toolName)) return "search" if (commandTools.includes(toolName)) return "command" - if (browserTools.includes(toolName)) return "browser" if (modeTools.includes(toolName)) return "mode" if (completionTools.includes(toolName)) return "completion" return "other" @@ -39,7 +37,6 @@ const CATEGORY_COLORS: Record = { directory: theme.toolHeader, search: theme.warningColor, command: theme.successColor, - browser: theme.focusColor, mode: theme.userHeader, completion: theme.successColor, other: theme.toolHeader, diff --git a/apps/cli/src/ui/components/tools/BrowserTool.tsx b/apps/cli/src/ui/components/tools/BrowserTool.tsx deleted file mode 100644 index 5e6d51857a..0000000000 --- a/apps/cli/src/ui/components/tools/BrowserTool.tsx +++ /dev/null @@ -1,87 +0,0 @@ -import { Box, Text } from "ink" - -import * as theme from "../../theme.js" -import { Icon } from "../Icon.js" - -import type { ToolRendererProps } from "./types.js" -import { getToolDisplayName, getToolIconName } from "./utils.js" - -const ACTION_LABELS: Record = { - launch: "Launch Browser", - click: "Click", - hover: "Hover", - type: "Type Text", - press: "Press Key", - scroll_down: "Scroll Down", - scroll_up: "Scroll Up", - resize: "Resize Window", - close: "Close Browser", - screenshot: "Take Screenshot", -} - -export function BrowserTool({ toolData }: ToolRendererProps) { - const iconName = getToolIconName(toolData.tool) - const displayName = getToolDisplayName(toolData.tool) - const action = toolData.action || "" - const url = toolData.url || "" - const coordinate = toolData.coordinate || "" - const content = toolData.content || "" // May contain text for type action. - - const actionLabel = ACTION_LABELS[action] || action - - return ( - - {/* Header */} - - - - {" "} - {displayName} - - {action && ( - - {" "} - → {actionLabel} - - )} - - - {/* Action details */} - - {/* URL for launch action */} - {url && ( - - url: - - {url} - - - )} - - {/* Coordinates for click/hover actions */} - {coordinate && ( - - at: - {coordinate} - - )} - - {/* Text content for type action */} - {content && action === "type" && ( - - text: - "{content}" - - )} - - {/* Key for press action */} - {content && action === "press" && ( - - key: - {content} - - )} - - - ) -} diff --git a/apps/cli/src/ui/components/tools/index.ts b/apps/cli/src/ui/components/tools/index.ts index c628432002..e5f5527c2f 100644 --- a/apps/cli/src/ui/components/tools/index.ts +++ b/apps/cli/src/ui/components/tools/index.ts @@ -15,7 +15,6 @@ import { FileReadTool } from "./FileReadTool.js" import { FileWriteTool } from "./FileWriteTool.js" import { SearchTool } from "./SearchTool.js" import { CommandTool } from "./CommandTool.js" -import { BrowserTool } from "./BrowserTool.js" import { ModeTool } from "./ModeTool.js" import { CompletionTool } from "./CompletionTool.js" import { GenericTool } from "./GenericTool.js" @@ -32,7 +31,6 @@ export { FileReadTool } from "./FileReadTool.js" export { FileWriteTool } from "./FileWriteTool.js" export { SearchTool } from "./SearchTool.js" export { CommandTool } from "./CommandTool.js" -export { BrowserTool } from "./BrowserTool.js" export { ModeTool } from "./ModeTool.js" export { CompletionTool } from "./CompletionTool.js" export { GenericTool } from "./GenericTool.js" @@ -45,7 +43,6 @@ const CATEGORY_RENDERERS: Record> = { "file-write": FileWriteTool, search: SearchTool, command: CommandTool, - browser: BrowserTool, mode: ModeTool, completion: CompletionTool, other: GenericTool, diff --git a/apps/cli/src/ui/components/tools/types.ts b/apps/cli/src/ui/components/tools/types.ts index a16fbd60ea..29c8444af1 100644 --- a/apps/cli/src/ui/components/tools/types.ts +++ b/apps/cli/src/ui/components/tools/types.ts @@ -5,15 +5,7 @@ export interface ToolRendererProps { rawContent?: string } -export type ToolCategory = - | "file-read" - | "file-write" - | "search" - | "command" - | "browser" - | "mode" - | "completion" - | "other" +export type ToolCategory = "file-read" | "file-write" | "search" | "command" | "mode" | "completion" | "other" export function getToolCategory(toolName: string): ToolCategory { const fileReadTools = ["readFile", "read_file", "skill", "listFilesTopLevel", "listFilesRecursive", "list_files"] @@ -29,7 +21,6 @@ export function getToolCategory(toolName: string): ToolCategory { const searchTools = ["searchFiles", "search_files", "codebaseSearch", "codebase_search"] const commandTools = ["execute_command", "executeCommand"] - const browserTools = ["browser_action", "browserAction"] const modeTools = ["switchMode", "switch_mode", "newTask", "new_task", "finishTask"] const completionTools = ["attempt_completion", "attemptCompletion", "ask_followup_question", "askFollowupQuestion"] @@ -37,7 +28,6 @@ export function getToolCategory(toolName: string): ToolCategory { if (fileWriteTools.includes(toolName)) return "file-write" if (searchTools.includes(toolName)) return "search" if (commandTools.includes(toolName)) return "command" - if (browserTools.includes(toolName)) return "browser" if (modeTools.includes(toolName)) return "mode" if (completionTools.includes(toolName)) return "completion" return "other" diff --git a/apps/cli/src/ui/components/tools/utils.ts b/apps/cli/src/ui/components/tools/utils.ts index 31acf2cccb..484125dbb2 100644 --- a/apps/cli/src/ui/components/tools/utils.ts +++ b/apps/cli/src/ui/components/tools/utils.ts @@ -73,10 +73,6 @@ export function getToolDisplayName(toolName: string): string { execute_command: "Execute Command", executeCommand: "Execute Command", - // Browser operations - browser_action: "Browser Action", - browserAction: "Browser Action", - // Mode operations switchMode: "Switch Mode", switch_mode: "Switch Mode", @@ -129,10 +125,6 @@ export function getToolIconName(toolName: string): IconName { execute_command: "terminal", executeCommand: "terminal", - // Browser operations - browser_action: "browser", - browserAction: "browser", - // Mode operations switchMode: "switch", switch_mode: "switch", diff --git a/apps/cli/src/ui/types.ts b/apps/cli/src/ui/types.ts index c2187fb2b6..3c45377c67 100644 --- a/apps/cli/src/ui/types.ts +++ b/apps/cli/src/ui/types.ts @@ -40,14 +40,6 @@ export interface ToolData { /** Command output */ output?: string - // Browser operation fields - /** Browser action type */ - action?: string - /** Browser URL */ - url?: string - /** Click/hover coordinates */ - coordinate?: string - // Batch operation fields /** Batch file reads */ batchFiles?: Array<{ diff --git a/apps/cli/src/ui/utils/tools.ts b/apps/cli/src/ui/utils/tools.ts index be3ff9484d..b79a506571 100644 --- a/apps/cli/src/ui/utils/tools.ts +++ b/apps/cli/src/ui/utils/tools.ts @@ -57,17 +57,6 @@ export function extractToolData(toolInfo: Record): ToolData { toolData.output = toolInfo.output as string } - // Extract browser-related fields - if (toolInfo.action !== undefined) { - toolData.action = toolInfo.action as string - } - if (toolInfo.url !== undefined) { - toolData.url = toolInfo.url as string - } - if (toolInfo.coordinate !== undefined) { - toolData.coordinate = toolInfo.coordinate as string - } - // Extract batch file operations if (Array.isArray(toolInfo.files)) { toolData.batchFiles = (toolInfo.files as Array>).map((f) => ({ @@ -165,12 +154,6 @@ export function formatToolOutput(toolInfo: Record): string { return `📁 ${listPath || "."}${recursive ? " (recursive)" : ""}` } - case "browser_action": { - const action = toolInfo.action as string - const url = toolInfo.url as string - return `🌐 ${action || "action"}${url ? `: ${url}` : ""}` - } - case "attempt_completion": { const result = toolInfo.result as string if (result) { @@ -248,12 +231,6 @@ export function formatToolAskMessage(toolInfo: Record): string return `Apply changes to: ${diffPath || "(no path)"}` } - case "browser_action": { - const action = toolInfo.action as string - const url = toolInfo.url as string - return `Browser: ${action || "action"}${url ? ` - ${url}` : ""}` - } - default: { const params = Object.entries(toolInfo) .filter(([key]) => key !== "tool") diff --git a/apps/vscode-e2e/src/suite/tools/read-file.test.ts b/apps/vscode-e2e/src/suite/tools/read-file.test.ts index 00aca7f58a..6f3e28f60f 100644 --- a/apps/vscode-e2e/src/suite/tools/read-file.test.ts +++ b/apps/vscode-e2e/src/suite/tools/read-file.test.ts @@ -376,7 +376,7 @@ suite.skip("Roo Code read_file Tool", function () { } }) - test("Should read file with line range", async function () { + test("Should read file with slice offset/limit", async function () { const api = globalThis.api const messages: ClineMessage[] = [] let taskCompleted = false @@ -446,7 +446,7 @@ suite.skip("Roo Code read_file Tool", function () { alwaysAllowReadOnly: true, alwaysAllowReadOnlyOutsideWorkspace: true, }, - text: `Use the read_file tool to read the file "${fileName}" and show me what's on lines 2, 3, and 4. The file contains lines like "Line 1", "Line 2", etc. Assume the file exists and you can read it directly.`, + text: `Use the read_file tool to read the file "${fileName}" using slice mode with offset=2 and limit=3 (1-based offset). The file contains lines like "Line 1", "Line 2", etc. After reading, show me the three lines you read.`, }) // Wait for task completion @@ -455,9 +455,8 @@ suite.skip("Roo Code read_file Tool", function () { // Verify tool was executed assert.ok(toolExecuted, "The read_file tool should have been executed") - // Verify the tool returned the correct lines (when line range is used) + // Verify the tool returned the correct lines (offset=2, limit=3 -> lines 2-4) if (toolResult && (toolResult as string).includes(" | ")) { - // The result includes line numbers assert.ok( (toolResult as string).includes("2 | Line 2"), "Tool result should include line 2 with line number", diff --git a/apps/web-evals/next-env.d.ts b/apps/web-evals/next-env.d.ts index 1b3be0840f..7506fe6afb 100644 --- a/apps/web-evals/next-env.d.ts +++ b/apps/web-evals/next-env.d.ts @@ -1,5 +1,6 @@ /// /// +import "./.next/dev/types/routes.d.ts" // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/apps/web-evals/next.config.ts b/apps/web-evals/next.config.ts index 08ed853fc3..b5f54a87be 100644 --- a/apps/web-evals/next.config.ts +++ b/apps/web-evals/next.config.ts @@ -1,10 +1,7 @@ import type { NextConfig } from "next" const nextConfig: NextConfig = { - webpack: (config) => { - config.resolve.extensionAlias = { ".js": [".ts", ".tsx", ".js", ".jsx"] } - return config - }, + turbopack: {}, } export default nextConfig diff --git a/apps/web-evals/package.json b/apps/web-evals/package.json index 9ba2c98c2c..83d69edd59 100644 --- a/apps/web-evals/package.json +++ b/apps/web-evals/package.json @@ -3,7 +3,7 @@ "version": "0.0.0", "type": "module", "scripts": { - "lint": "next lint --max-warnings 0", + "lint": "eslint src --ext=ts,tsx --max-warnings=0", "check-types": "tsc -b", "dev": "scripts/check-services.sh && next dev -p 3446", "format": "prettier --write src", @@ -35,7 +35,7 @@ "cmdk": "^1.1.0", "fuzzysort": "^3.1.0", "lucide-react": "^0.518.0", - "next": "~15.2.8", + "next": "^16.1.6", "next-themes": "^0.4.6", "p-map": "^7.0.3", "react": "^18.3.1", diff --git a/apps/web-roo-code/next-sitemap.config.cjs b/apps/web-roo-code/next-sitemap.config.cjs index e9b0ca3c47..e2b1e47e2c 100644 --- a/apps/web-roo-code/next-sitemap.config.cjs +++ b/apps/web-roo-code/next-sitemap.config.cjs @@ -1,3 +1,68 @@ +const path = require('path'); +const fs = require('fs'); +const matter = require('gray-matter'); + +/** + * Get published blog posts for sitemap + * Note: This runs at build time, so recently-scheduled posts may lag + */ +function getPublishedBlogPosts() { + const BLOG_DIR = path.join(process.cwd(), 'src/content/blog'); + + if (!fs.existsSync(BLOG_DIR)) { + return []; + } + + const files = fs.readdirSync(BLOG_DIR).filter(f => f.endsWith('.md')); + const posts = []; + + // Get current time in PT for publish check + const formatter = new Intl.DateTimeFormat('en-US', { + timeZone: 'America/Los_Angeles', + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + hour12: false, + }); + + const parts = formatter.formatToParts(new Date()); + const get = (type) => parts.find(p => p.type === type)?.value ?? ''; + const nowDate = `${get('year')}-${get('month')}-${get('day')}`; + const nowMinutes = parseInt(get('hour')) * 60 + parseInt(get('minute')); + + for (const file of files) { + const filepath = path.join(BLOG_DIR, file); + const raw = fs.readFileSync(filepath, 'utf8'); + const { data } = matter(raw); + + // Check if post is published + if (data.status !== 'published') continue; + + // Parse publish time + const timeMatch = data.publish_time_pt?.match(/^(1[0-2]|[1-9]):([0-5][0-9])(am|pm)$/i); + if (!timeMatch) continue; + + let hours = parseInt(timeMatch[1]); + const mins = parseInt(timeMatch[2]); + const isPm = timeMatch[3].toLowerCase() === 'pm'; + if (hours === 12) hours = isPm ? 12 : 0; + else if (isPm) hours += 12; + const postMinutes = hours * 60 + mins; + + // Check if post is past publish date/time + const isPublished = nowDate > data.publish_date || + (nowDate === data.publish_date && nowMinutes >= postMinutes); + + if (isPublished && data.slug) { + posts.push(data.slug); + } + } + + return posts; +} + /** @type {import('next-sitemap').IConfig} */ module.exports = { siteUrl: process.env.NEXT_PUBLIC_SITE_URL || 'https://roocode.com', @@ -39,6 +104,12 @@ module.exports = { } else if (path === '/privacy' || path === '/terms') { priority = 0.5; changefreq = 'yearly'; + } else if (path === '/blog') { + priority = 0.8; + changefreq = 'weekly'; + } else if (path.startsWith('/blog/')) { + priority = 0.7; + changefreq = 'monthly'; } return { @@ -50,15 +121,7 @@ module.exports = { }; }, additionalPaths: async (config) => { - // Add any additional paths that might not be automatically discovered - // This is useful for dynamic routes or API-generated pages - // Add the /evals page since it's a dynamic route - return [{ - loc: '/evals', - changefreq: 'monthly', - priority: 0.8, - lastmod: new Date().toISOString(), - }]; + const result = []; // Add the /evals page since it's a dynamic route result.push({ @@ -68,6 +131,29 @@ module.exports = { lastmod: new Date().toISOString(), }); + // Add /blog index + result.push({ + loc: '/blog', + changefreq: 'weekly', + priority: 0.8, + lastmod: new Date().toISOString(), + }); + + // Add published blog posts + try { + const slugs = getPublishedBlogPosts(); + for (const slug of slugs) { + result.push({ + loc: `/blog/${slug}`, + changefreq: 'monthly', + priority: 0.7, + lastmod: new Date().toISOString(), + }); + } + } catch (e) { + console.warn('Could not load blog posts for sitemap:', e.message); + } + return result; }, -}; \ No newline at end of file +}; diff --git a/apps/web-roo-code/next.config.ts b/apps/web-roo-code/next.config.ts index a2591c1a30..0aaf2849d5 100644 --- a/apps/web-roo-code/next.config.ts +++ b/apps/web-roo-code/next.config.ts @@ -1,9 +1,9 @@ +import path from "path" import type { NextConfig } from "next" const nextConfig: NextConfig = { - webpack: (config) => { - config.resolve.extensionAlias = { ".js": [".ts", ".tsx", ".js", ".jsx"] } - return config + turbopack: { + root: path.join(__dirname, "../.."), }, async redirects() { return [ diff --git a/apps/web-roo-code/package.json b/apps/web-roo-code/package.json index d82cad56ab..8ad1a95051 100644 --- a/apps/web-roo-code/package.json +++ b/apps/web-roo-code/package.json @@ -3,31 +3,35 @@ "version": "0.0.0", "type": "module", "scripts": { - "lint": "next lint --max-warnings 0", + "lint": "eslint src --ext=ts,tsx --max-warnings=0", "check-types": "tsc --noEmit", "dev": "next dev", "build": "next build", "postbuild": "next-sitemap --config next-sitemap.config.cjs", "start": "next start", - "clean": "rimraf .next .turbo" + "clean": "rimraf .next .turbo", + "test": "vitest run", + "test:watch": "vitest" }, "dependencies": { - "@radix-ui/react-dialog": "^1.1.14", - "@radix-ui/react-slot": "^1.2.3", + "@radix-ui/react-dialog": "^1.1.15", + "@radix-ui/react-navigation-menu": "^1.2.14", + "@radix-ui/react-slot": "^1.2.4", "@roo-code/evals": "workspace:^", - "@roo-code/types": "workspace:^", - "@tanstack/react-query": "^5.79.0", - "@vercel/og": "^0.6.2", + "@roo-code/types": "^1.108.0", + "@tanstack/react-query": "^5.90.20", + "@vercel/og": "^0.8.6", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "embla-carousel-auto-scroll": "^8.6.0", "embla-carousel-autoplay": "^8.6.0", "embla-carousel-react": "^8.6.0", - "framer-motion": "12.15.0", - "lucide-react": "^0.518.0", - "next": "~15.2.8", + "framer-motion": "^12.29.2", + "gray-matter": "^4.0.3", + "lucide-react": "^0.563.0", + "next": "^16.1.6", "next-themes": "^0.4.6", - "posthog-js": "^1.248.1", + "posthog-js": "^1.336.4", "react": "^18.3.1", "react-cookie-consent": "^9.0.0", "react-dom": "^18.3.1", @@ -36,7 +40,7 @@ "recharts": "^2.15.3", "rehype-raw": "^7.0.0", "remark-gfm": "^4.0.1", - "tailwind-merge": "^3.3.0", + "tailwind-merge": "^3.4.0", "tailwindcss-animate": "^1.0.7", "tldts": "^6.1.86", "zod": "^3.25.61" @@ -44,13 +48,14 @@ "devDependencies": { "@roo-code/config-eslint": "workspace:^", "@roo-code/config-typescript": "workspace:^", - "@tailwindcss/typography": "^0.5.16", + "@tailwindcss/typography": "^0.5.19", "@types/node": "20.x", "@types/react": "^18.3.23", "@types/react-dom": "^18.3.7", - "autoprefixer": "^10.4.21", + "autoprefixer": "^10.4.23", "next-sitemap": "^4.2.3", - "postcss": "^8.5.4", - "tailwindcss": "^3.4.17" + "postcss": "^8.5.6", + "tailwindcss": "^3.4.17", + "vitest": "^4.0.18" } } diff --git a/apps/web-roo-code/src/app/blog/[slug]/page.tsx b/apps/web-roo-code/src/app/blog/[slug]/page.tsx new file mode 100644 index 0000000000..985ee95b2b --- /dev/null +++ b/apps/web-roo-code/src/app/blog/[slug]/page.tsx @@ -0,0 +1,337 @@ +/** + * Blog Post Page + * MKT-69: Blog Post Page + * + * Renders a single blog post from Markdown. + * Uses dynamic rendering (force-dynamic) for request-time publish gating. + * Does NOT use generateStaticParams to avoid static generation. + * + * AEO Enhancement: Parses FAQ sections from markdown, renders as accordion, + * and generates FAQPage JSON-LD schema for AI search optimization. + */ + +import type { Metadata } from "next" +import Link from "next/link" +import { notFound } from "next/navigation" +import Script from "next/script" +import { ChevronLeft, ChevronRight, Clock } from "lucide-react" +import { + getBlogPostBySlug, + getAdjacentPosts, + formatPostDatePt, + calculateReadingTime, + formatReadingTime, +} from "@/lib/blog" +import { SEO } from "@/lib/seo" +import { ogImageUrl } from "@/lib/og" +import { BlogPostAnalytics } from "@/components/blog/BlogAnalytics" +import { BlogContent } from "@/components/blog/BlogContent" +import { BlogFAQ, type FAQItem } from "@/components/blog/BlogFAQ" +import { BlogPostCTA } from "@/components/blog/BlogPostCTA" + +// Force dynamic rendering for request-time publish gating +export const dynamic = "force-dynamic" +export const runtime = "nodejs" + +interface Props { + params: Promise<{ slug: string }> +} + +/** + * Parse FAQ section from markdown content + * + * Looks for a section starting with "## Frequently asked questions" + * and extracts H3 questions with their content as answers. + * + * Returns the FAQ items and the content with FAQ section removed. + */ +function parseFAQFromMarkdown(content: string): { + faqItems: FAQItem[] + contentWithoutFAQ: string +} { + // Match FAQ section: ## Frequently asked questions (case-insensitive) + const faqSectionRegex = /^## Frequently asked questions\s*$/im + const faqMatch = content.match(faqSectionRegex) + + if (!faqMatch || faqMatch.index === undefined) { + return { faqItems: [], contentWithoutFAQ: content } + } + + const faqStartIndex = faqMatch.index + const beforeFAQ = content.slice(0, faqStartIndex).trim() + const faqSection = content.slice(faqStartIndex) + + // Find where FAQ section ends (next H2 or end of content) + const nextH2Match = faqSection.slice(faqMatch[0].length).match(/^## /m) + const faqContent = + nextH2Match && nextH2Match.index !== undefined + ? faqSection.slice(0, faqMatch[0].length + nextH2Match.index) + : faqSection + + const afterFAQ = + nextH2Match && nextH2Match.index !== undefined ? faqSection.slice(faqMatch[0].length + nextH2Match.index) : "" + + // Parse individual FAQ items (### Question followed by content) + const faqItems: FAQItem[] = [] + const questionRegex = /^### (.+?)$\s*([\s\S]*?)(?=^### |$(?![\s\S]))/gm + let match + + while ((match = questionRegex.exec(faqContent)) !== null) { + const question = match[1]?.trim() + const answer = match[2]?.trim() + if (question && answer) { + faqItems.push({ question, answer }) + } + } + + const contentWithoutFAQ = (beforeFAQ + "\n\n" + afterFAQ).trim() + + return { faqItems, contentWithoutFAQ } +} + +export async function generateMetadata({ params }: Props): Promise { + const { slug } = await params + const post = getBlogPostBySlug(slug) + + if (!post) { + return {} + } + + const path = `/blog/${post.slug}` + + return { + title: post.title, + description: post.description, + alternates: { + canonical: `${SEO.url}${path}`, + }, + openGraph: { + title: post.title, + description: post.description, + url: `${SEO.url}${path}`, + siteName: SEO.name, + images: [ + { + url: ogImageUrl(post.title, post.description), + width: 1200, + height: 630, + alt: post.title, + }, + ], + locale: SEO.locale, + type: "article", + publishedTime: post.publish_date, + }, + twitter: { + card: SEO.twitterCard, + title: post.title, + description: post.description, + images: [ogImageUrl(post.title, post.description)], + }, + keywords: [...SEO.keywords, ...post.tags], + } +} + +export default async function BlogPostPage({ params }: Props) { + const { slug } = await params + const post = getBlogPostBySlug(slug) + + if (!post) { + notFound() + } + + const { previous, next } = getAdjacentPosts(slug) + + // Calculate reading time + const readingTime = calculateReadingTime(post.content) + const readingTimeDisplay = formatReadingTime(readingTime) + + // Parse FAQ section from markdown content + const { faqItems, contentWithoutFAQ } = parseFAQFromMarkdown(post.content) + const hasFAQ = faqItems.length > 0 + + // BlogPosting JSON-LD schema (more specific than Article for SEO) + const articleSchema = { + "@context": "https://schema.org", + "@type": "BlogPosting", + headline: post.title, + description: post.description, + datePublished: post.publish_date, + image: ogImageUrl(post.title, post.description), + wordCount: post.content.split(/\s+/).filter(Boolean).length, + mainEntityOfPage: { + "@type": "WebPage", + "@id": `${SEO.url}/blog/${post.slug}`, + }, + url: `${SEO.url}/blog/${post.slug}`, + author: { + "@type": "Organization", + "@id": `${SEO.url}#org`, + name: SEO.name, + }, + publisher: { + "@type": "Organization", + "@id": `${SEO.url}#org`, + name: SEO.name, + logo: { + "@type": "ImageObject", + url: `${SEO.url}/android-chrome-512x512.png`, + }, + }, + } + + // Breadcrumb schema + const breadcrumbSchema = { + "@context": "https://schema.org", + "@type": "BreadcrumbList", + itemListElement: [ + { + "@type": "ListItem", + position: 1, + name: "Home", + item: SEO.url, + }, + { + "@type": "ListItem", + position: 2, + name: "Blog", + item: `${SEO.url}/blog`, + }, + { + "@type": "ListItem", + position: 3, + name: post.title, + item: `${SEO.url}/blog/${post.slug}`, + }, + ], + } + + // FAQPage schema (only if post has FAQ section) - AEO optimization + const faqSchema = hasFAQ + ? { + "@context": "https://schema.org", + "@type": "FAQPage", + mainEntity: faqItems.map((item) => ({ + "@type": "Question", + name: item.question, + acceptedAnswer: { + "@type": "Answer", + text: item.answer, + }, + })), + } + : null + + return ( + <> + - ` - - const csp = [ - "default-src 'none'", - `font-src ${webview.cspSource} data:`, - `style-src ${webview.cspSource} 'unsafe-inline' https://* http://${localServerUrl}`, - `img-src ${webview.cspSource} data:`, - `script-src 'unsafe-eval' ${webview.cspSource} http://${localServerUrl} 'nonce-${nonce}'`, - `connect-src ${webview.cspSource} ws://${localServerUrl} http://${localServerUrl}`, - ] - - return ` - - - - - - - - - Browser Session - - -

- ${reactRefresh} - - - - ` - } - - private getHtmlContent(webview: vscode.Webview, extensionUri: vscode.Uri): string { - const stylesUri = getUri(webview, extensionUri, ["webview-ui", "build", "assets", "index.css"]) - const scriptUri = getUri(webview, extensionUri, ["webview-ui", "build", "assets", "browser-panel.js"]) - const codiconsUri = getUri(webview, extensionUri, ["assets", "codicons", "codicon.css"]) - - const nonce = getNonce() - - const csp = [ - "default-src 'none'", - `font-src ${webview.cspSource} data:`, - `style-src ${webview.cspSource} 'unsafe-inline'`, - `img-src ${webview.cspSource} data:`, - `script-src ${webview.cspSource} 'wasm-unsafe-eval' 'nonce-${nonce}'`, - `connect-src ${webview.cspSource}`, - ] - - return ` - - - - - - - - - Browser Session - - -
- - - - ` - } -} diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index e722ce37f8..b9da4b4c60 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -45,10 +45,11 @@ import { DEFAULT_MODES, DEFAULT_CHECKPOINT_TIMEOUT_SECONDS, getModelId, + isRetiredProvider, } from "@roo-code/types" import { aggregateTaskCostsRecursive, type AggregatedCosts } from "./aggregateTaskCosts" import { TelemetryService } from "@roo-code/telemetry" -import { CloudService, BridgeOrchestrator, getRooCodeApiUrl } from "@roo-code/cloud" +import { CloudService, getRooCodeApiUrl } from "@roo-code/cloud" import { Package } from "../../shared/package" import { findLast } from "../../shared/array" @@ -93,11 +94,10 @@ import { ContextProxy } from "../config/ContextProxy" import { ProviderSettingsManager } from "../config/ProviderSettingsManager" import { CustomModesManager } from "../config/CustomModesManager" import { Task } from "../task/Task" -import { getSystemPromptFilePath } from "../prompts/sections/custom-system-prompt" import { webviewMessageHandler } from "./webviewMessageHandler" import type { ClineMessage, TodoItem } from "@roo-code/types" -import { readApiMessages, saveApiMessages, saveTaskMessages } from "../task-persistence" +import { readApiMessages, saveApiMessages, saveTaskMessages, TaskHistoryStore } from "../task-persistence" import { readTaskMessages } from "../task-persistence/taskMessages" import { getNonce } from "./getNonce" import { getUri } from "./getUri" @@ -147,8 +147,13 @@ export class ClineProvider private taskCreationCallback: (task: Task) => void private taskEventListeners: WeakMap void>> = new WeakMap() private currentWorkspacePath: string | undefined + private _disposed = false private recentTasksCache?: string[] + public readonly taskHistoryStore: TaskHistoryStore + private taskHistoryStoreInitialized = false + private globalStateWriteThroughTimer: ReturnType | null = null + private static readonly GLOBAL_STATE_WRITE_THROUGH_DEBOUNCE_MS = 5000 // 5 seconds private pendingOperations: Map = new Map() private static readonly PENDING_OPERATION_TIMEOUT_MS = 30000 // 30 seconds @@ -156,9 +161,15 @@ export class ClineProvider private cloudOrganizationsCacheTimestamp: number | null = null private static readonly CLOUD_ORGANIZATIONS_CACHE_DURATION_MS = 5 * 1000 // 5 seconds + /** + * Monotonically increasing sequence number for clineMessages state pushes. + * Used by the frontend to reject stale state that arrives out-of-order. + */ + private clineMessagesSeq = 0 + public isViewLaunched = false public settingsImportedAt?: number - public readonly latestAnnouncementId = "jan-2026-v3.45.0-smart-code-folding" // v3.45.0 Smart Code Folding + public readonly latestAnnouncementId = "feb-2026-v3.50.0-gemini-31-pro-cli-ndjson-cli-v010" // v3.50.0 Gemini 3.1 Pro Support, CLI NDJSON Protocol, CLI v0.1.0 public readonly providerSettingsManager: ProviderSettingsManager public readonly customModesManager: CustomModesManager @@ -177,6 +188,18 @@ export class ClineProvider this.mdmService = mdmService this.updateGlobalState("codebaseIndexModels", EMBEDDING_MODEL_PROFILES) + // Initialize the per-task file-based history store. + // The globalState write-through is debounced separately (not on every mutation) + // since per-task files are authoritative and globalState is only for downgrade compat. + this.taskHistoryStore = new TaskHistoryStore(this.contextProxy.globalStorageUri.fsPath, { + onWrite: async () => { + this.scheduleGlobalStateWriteThrough() + }, + }) + this.initializeTaskHistoryStore().catch((error) => { + this.log(`Failed to initialize TaskHistoryStore: ${error}`) + }) + // Start configuration loading (which might trigger indexing) in the background. // Don't await, allowing activation to continue immediately. @@ -189,7 +212,7 @@ export class ClineProvider this.providerSettingsManager = new ProviderSettingsManager(this.context) this.customModesManager = new CustomModesManager(this.context, async () => { - await this.postStateToWebview() + await this.postStateToWebviewWithoutClineMessages() }) // Initialize MCP Hub through the singleton manager @@ -306,6 +329,35 @@ export class ClineProvider } } + /** + * Initialize the TaskHistoryStore and migrate from globalState if needed. + */ + private async initializeTaskHistoryStore(): Promise { + try { + await this.taskHistoryStore.initialize() + + // Migration: backfill per-task files from globalState on first run + const migrationKey = "taskHistoryMigratedToFiles" + const alreadyMigrated = this.context.globalState.get(migrationKey) + + if (!alreadyMigrated) { + const legacyHistory = this.context.globalState.get("taskHistory") ?? [] + + if (legacyHistory.length > 0) { + this.log(`[initializeTaskHistoryStore] Migrating ${legacyHistory.length} entries from globalState`) + await this.taskHistoryStore.migrateFromGlobalState(legacyHistory) + } + + await this.context.globalState.update(migrationKey, true) + this.log("[initializeTaskHistoryStore] Migration complete") + } + + this.taskHistoryStoreInitialized = true + } catch (error) { + this.log(`[initializeTaskHistoryStore] Error: ${error instanceof Error ? error.message : String(error)}`) + } + } + /** * Override EventEmitter's on method to match TaskProviderLike interface */ @@ -386,7 +438,7 @@ export class ClineProvider await this.activateProviderProfile({ name: profile.name }) } - await this.postStateToWebview() + await this.postStateToWebviewWithoutClineMessages() } } catch (error) { this.log(`Error syncing cloud profiles: ${error}`) @@ -453,7 +505,7 @@ export class ClineProvider // Removes and destroys the top Cline instance (the current finished task), // activating the previous one (resuming the parent task). - async removeClineFromStack() { + async removeClineFromStack(options?: { skipDelegationRepair?: boolean }) { if (this.clineStack.length === 0) { return } @@ -462,6 +514,11 @@ export class ClineProvider let task = this.clineStack.pop() if (task) { + // Capture delegation metadata before abort/dispose, since abortTask(true) + // is async and the task reference is cleared afterwards. + const childTaskId = task.taskId + const parentTaskId = task.parentTaskId + task.emit(RooCodeEventName.TaskUnfocused) try { @@ -485,6 +542,37 @@ export class ClineProvider // Make sure no reference kept, once promises end it will be // garbage collected. task = undefined + + // Delegation-aware parent metadata repair: + // If the popped task was a delegated child, repair the parent's metadata + // so it transitions from "delegated" back to "active" and becomes resumable + // from the task history list. + // Skip when called from delegateParentAndOpenChild() during nested delegation + // transitions (A→B→C), where the caller intentionally replaces the active + // child and will update the parent to point at the new child. + if (parentTaskId && childTaskId && !options?.skipDelegationRepair) { + try { + const { historyItem: parentHistory } = await this.getTaskWithId(parentTaskId) + + if (parentHistory.status === "delegated" && parentHistory.awaitingChildId === childTaskId) { + await this.updateTaskHistory({ + ...parentHistory, + status: "active", + awaitingChildId: undefined, + }) + this.log( + `[ClineProvider#removeClineFromStack] Repaired parent ${parentTaskId} metadata: delegated → active (child ${childTaskId} removed)`, + ) + } + } catch (err) { + // Non-fatal: log but do not block the pop operation. + this.log( + `[ClineProvider#removeClineFromStack] Failed to repair parent metadata for ${parentTaskId} (non-fatal): ${ + err instanceof Error ? err.message : String(err) + }`, + ) + } + } } } @@ -577,6 +665,11 @@ export class ClineProvider } async dispose() { + if (this._disposed) { + return + } + + this._disposed = true this.log("Disposing ClineProvider...") // Clear all tasks from the stack. @@ -618,6 +711,8 @@ export class ClineProvider this.skillsManager = undefined this.marketplaceManager?.cleanup() this.customModesManager?.dispose() + this.taskHistoryStore.dispose() + this.flushGlobalStateWriteThrough() this.log("Disposed all disposables") ClineProvider.activeInstances.delete(this) @@ -757,6 +852,8 @@ export class ClineProvider terminalZshP10k = false, terminalPowershellCounter = false, terminalZdotdir = false, + ttsEnabled, + ttsSpeed, }) => { Terminal.setShellIntegrationTimeout(terminalShellIntegrationTimeout) Terminal.setShellIntegrationDisabled(terminalShellIntegrationDisabled) @@ -766,17 +863,11 @@ export class ClineProvider Terminal.setTerminalZshP10k(terminalZshP10k) Terminal.setPowershellCounter(terminalPowershellCounter) Terminal.setTerminalZdotdir(terminalZdotdir) + setTtsEnabled(ttsEnabled ?? false) + setTtsSpeed(ttsSpeed ?? 1) }, ) - this.getState().then(({ ttsEnabled }) => { - setTtsEnabled(ttsEnabled ?? false) - }) - - this.getState().then(({ ttsSpeed }) => { - setTtsSpeed(ttsSpeed ?? 1) - }) - // Set up webview options with proper resource roots const resourceRoots = [this.contextProxy.extensionUri] @@ -899,7 +990,8 @@ export class ClineProvider // Load the saved API config for the restored mode if it exists. // Skip mode-based profile activation if historyItem.apiConfigName exists, // since the task's specific provider profile will override it anyway. - if (!historyItem.apiConfigName) { + const lockApiConfigAcrossModes = this.context.workspaceState.get("lockApiConfigAcrossModes", false) + if (!historyItem.apiConfigName && !lockApiConfigAcrossModes) { const savedConfigId = await this.providerSettingsManager.getModeConfigId(historyItem.mode) const listApiConfig = await this.providerSettingsManager.listConfig() @@ -986,7 +1078,6 @@ export class ClineProvider workspacePath: historyItem.workspace, onCreated: this.taskCreationCallback, startTask: options?.startTask ?? true, - enableBridge: BridgeOrchestrator.isEnabled(cloudUserInfo, taskSyncEnabled), // Preserve the status from the history item to avoid overwriting it when the task saves messages initialStatus: historyItem.status, }) @@ -1079,7 +1170,15 @@ export class ClineProvider } public async postMessageToWebview(message: ExtensionMessage) { - await this.view?.webview.postMessage(message) + if (this._disposed) { + return + } + + try { + await this.view?.webview.postMessage(message) + } catch { + // View disposed, drop message silently + } } private async getHMRHtmlContent(webview: vscode.Webview): Promise { @@ -1290,12 +1389,12 @@ export class ClineProvider try { // Update the task history with the new mode first. - const history = this.getGlobalState("taskHistory") ?? [] - const taskHistoryItem = history.find((item) => item.id === task.taskId) + const taskHistoryItem = + this.taskHistoryStore.get(task.taskId) ?? + (this.getGlobalState("taskHistory") ?? []).find((item) => item.id === task.taskId) if (taskHistoryItem) { - taskHistoryItem.mode = newMode - await this.updateTaskHistory(taskHistoryItem) + await this.updateTaskHistory({ ...taskHistoryItem, mode: newMode }) } // Only update the task's mode after successful persistence. @@ -1316,6 +1415,13 @@ export class ClineProvider this.emit(RooCodeEventName.ModeChanged, newMode) + // If workspace lock is on, keep the current API config — don't load mode-specific config + const lockApiConfigAcrossModes = this.context.workspaceState.get("lockApiConfigAcrossModes", false) + if (lockApiConfigAcrossModes) { + await this.postStateToWebview() + return + } + // Load the saved API config for the new mode if it exists. const savedConfigId = await this.providerSettingsManager.getModeConfigId(newMode) const listApiConfig = await this.providerSettingsManager.listConfig() @@ -1502,8 +1608,9 @@ export class ClineProvider // been persisted into taskHistory (it will be captured on the next save). task.setTaskApiConfigName(apiConfigName) - const history = this.getGlobalState("taskHistory") ?? [] - const taskHistoryItem = history.find((item) => item.id === task.taskId) + const taskHistoryItem = + this.taskHistoryStore.get(task.taskId) ?? + (this.getGlobalState("taskHistory") ?? []).find((item) => item.id === task.taskId) if (taskHistoryItem) { await this.updateTaskHistory({ ...taskHistoryItem, apiConfigName }) @@ -1662,34 +1769,43 @@ export class ClineProvider uiMessagesFilePath: string apiConversationHistory: Anthropic.MessageParam[] }> { - const history = this.getGlobalState("taskHistory") ?? [] - const historyItem = history.find((item) => item.id === id) + const historyItem = + this.taskHistoryStore.get(id) ?? (this.getGlobalState("taskHistory") ?? []).find((item) => item.id === id) - if (historyItem) { - const { getTaskDirectoryPath } = await import("../../utils/storage") - const globalStoragePath = this.contextProxy.globalStorageUri.fsPath - const taskDirPath = await getTaskDirectoryPath(globalStoragePath, id) - const apiConversationHistoryFilePath = path.join(taskDirPath, GlobalFileNames.apiConversationHistory) - const uiMessagesFilePath = path.join(taskDirPath, GlobalFileNames.uiMessages) - const fileExists = await fileExistsAtPath(apiConversationHistoryFilePath) - - if (fileExists) { - const apiConversationHistory = JSON.parse(await fs.readFile(apiConversationHistoryFilePath, "utf8")) - - return { - historyItem, - taskDirPath, - apiConversationHistoryFilePath, - uiMessagesFilePath, - apiConversationHistory, - } - } + if (!historyItem) { + throw new Error("Task not found") } - // if we tried to get a task that doesn't exist, remove it from state - // FIXME: this seems to happen sometimes when the json file doesnt save to disk for some reason - await this.deleteTaskFromState(id) - throw new Error("Task not found") + const { getTaskDirectoryPath } = await import("../../utils/storage") + const globalStoragePath = this.contextProxy.globalStorageUri.fsPath + const taskDirPath = await getTaskDirectoryPath(globalStoragePath, id) + const apiConversationHistoryFilePath = path.join(taskDirPath, GlobalFileNames.apiConversationHistory) + const uiMessagesFilePath = path.join(taskDirPath, GlobalFileNames.uiMessages) + const fileExists = await fileExistsAtPath(apiConversationHistoryFilePath) + + let apiConversationHistory: Anthropic.MessageParam[] = [] + + if (fileExists) { + try { + apiConversationHistory = JSON.parse(await fs.readFile(apiConversationHistoryFilePath, "utf8")) + } catch (error) { + console.warn( + `[getTaskWithId] api_conversation_history.json corrupted for task ${id}, returning empty history: ${error instanceof Error ? error.message : String(error)}`, + ) + } + } else { + console.warn( + `[getTaskWithId] api_conversation_history.json missing for task ${id}, returning empty history`, + ) + } + + return { + historyItem, + taskDirPath, + apiConversationHistoryFilePath, + uiMessagesFilePath, + apiConversationHistory, + } } async getTaskWithAggregatedCosts(taskId: string): Promise<{ @@ -1786,9 +1902,7 @@ export class ClineProvider } // Delete all tasks from state in one batch - const taskHistory = this.getGlobalState("taskHistory") ?? [] - const updatedTaskHistory = taskHistory.filter((task) => !allIdsToDelete.includes(task.id)) - await this.updateGlobalState("taskHistory", updatedTaskHistory) + await this.taskHistoryStore.deleteMany(allIdsToDelete) this.recentTasksCache = undefined // Delete associated shadow repositories or branches and task directories @@ -1830,10 +1944,9 @@ export class ClineProvider } async deleteTaskFromState(id: string) { - const taskHistory = this.getGlobalState("taskHistory") ?? [] - const updatedTaskHistory = taskHistory.filter((task) => task.id !== id) - await this.updateGlobalState("taskHistory", updatedTaskHistory) + await this.taskHistoryStore.delete(id) this.recentTasksCache = undefined + await this.postStateToWebview() } @@ -1844,6 +1957,8 @@ export class ClineProvider async postStateToWebview() { const state = await this.getStateToPostToWebview() + this.clineMessagesSeq++ + state.clineMessagesSeq = this.clineMessagesSeq this.postMessageToWebview({ type: "state", state }) // Check MDM compliance and send user to account tab if not compliant @@ -1863,6 +1978,8 @@ export class ClineProvider */ async postStateToWebviewWithoutTaskHistory(): Promise { const state = await this.getStateToPostToWebview() + this.clineMessagesSeq++ + state.clineMessagesSeq = this.clineMessagesSeq const { taskHistory: _omit, ...rest } = state this.postMessageToWebview({ type: "state", state: rest }) @@ -1872,6 +1989,28 @@ export class ClineProvider } } + /** + * Like postStateToWebview but intentionally omits both clineMessages and taskHistory. + * + * Rationale: + * - Cloud event handlers (auth, settings, user-info) and mode changes trigger state pushes + * that have nothing to do with chat messages. Including clineMessages in these pushes + * creates race conditions where a stale snapshot of clineMessages (captured during async + * getStateToPostToWebview) overwrites newer messages the task has streamed in the meantime. + * - This method ensures cloud/mode events only push the state fields they actually affect + * (cloud auth, org settings, profiles, etc.) without interfering with task message streaming. + */ + async postStateToWebviewWithoutClineMessages(): Promise { + const state = await this.getStateToPostToWebview() + const { clineMessages: _omitMessages, taskHistory: _omitHistory, ...rest } = state + this.postMessageToWebview({ type: "state", state: rest }) + + // Preserve existing MDM redirect behavior + if (this.mdmService?.requiresCloudAuth() && !this.checkMdmCompliance()) { + await this.postMessageToWebview({ type: "action", action: "cloudButtonClicked" }) + } + } + /** * Fetches marketplace data on demand to avoid blocking main state updates */ @@ -1917,14 +2056,6 @@ export class ClineProvider } } - /** - * Checks if there is a file-based system prompt override for the given mode - */ - async hasFileBasedSystemPromptOverride(mode: Mode): Promise { - const promptFilePath = getSystemPromptFilePath(this.cwd, mode) - return await fileExistsAtPath(promptFilePath) - } - /** * Merges allowed commands from global state and workspace configuration * with proper validation and deduplication @@ -1982,6 +2113,9 @@ export class ClineProvider } async getStateToPostToWebview(): Promise { + // Ensure the store is initialized before reading task history + await this.taskHistoryStore.initialized + const { apiConfiguration, lastShownAnnouncementId, @@ -1994,7 +2128,6 @@ export class ClineProvider alwaysAllowExecute, allowedCommands, deniedCommands, - alwaysAllowBrowser, alwaysAllowMcp, alwaysAllowModeSwitch, alwaysAllowSubtasks, @@ -2009,11 +2142,6 @@ export class ClineProvider checkpointTimeout, taskHistory, soundVolume, - browserViewportSize, - screenshotQuality, - remoteBrowserHost, - remoteBrowserEnabled, - cachedChromeHostUrl, writeDelayMs, terminalShellIntegrationTimeout, terminalShellIntegrationDisabled, @@ -2036,12 +2164,11 @@ export class ClineProvider experiments, maxOpenTabsContext, maxWorkspaceFiles, - browserToolEnabled, + disabledTools, telemetrySetting, showRooIgnoredFiles, enableSubfolderRules, language, - maxReadFileLine, maxImageFileSize, maxTotalImageSize, historyPreviewCollapsed, @@ -2053,7 +2180,6 @@ export class ClineProvider publicSharingEnabled, organizationAllowList, organizationSettingsVersion, - maxConcurrentFileReads, customCondensingPrompt, codebaseIndexConfig, codebaseIndexModels, @@ -2067,12 +2193,10 @@ export class ClineProvider includeCurrentCost, maxGitStatusFiles, taskSyncEnabled, - remoteControlEnabled, imageGenerationProvider, openRouterImageApiKey, openRouterImageGenerationSelectedModel, - featureRoomoteControlEnabled, - isBrowserSessionActive, + lockApiConfigAcrossModes, } = await this.getState() let cloudOrganizations: CloudOrganizationMembership[] = [] @@ -2103,10 +2227,6 @@ export class ClineProvider const mergedDeniedCommands = this.mergeDeniedCommands(deniedCommands) const cwd = this.cwd - // Check if there's a system prompt override for the current mode - const currentMode = mode ?? defaultModeSlug - const hasSystemPromptOverride = await this.hasFileBasedSystemPromptOverride(currentMode) - return { version: this.context.extension?.packageJSON?.version ?? "", apiConfiguration, @@ -2117,25 +2237,21 @@ export class ClineProvider alwaysAllowWriteOutsideWorkspace: alwaysAllowWriteOutsideWorkspace ?? false, alwaysAllowWriteProtected: alwaysAllowWriteProtected ?? false, alwaysAllowExecute: alwaysAllowExecute ?? false, - alwaysAllowBrowser: alwaysAllowBrowser ?? false, alwaysAllowMcp: alwaysAllowMcp ?? false, alwaysAllowModeSwitch: alwaysAllowModeSwitch ?? false, alwaysAllowSubtasks: alwaysAllowSubtasks ?? false, - isBrowserSessionActive, allowedMaxRequests, allowedMaxCost, autoCondenseContext: autoCondenseContext ?? true, autoCondenseContextPercent: autoCondenseContextPercent ?? 100, uriScheme: vscode.env.uriScheme, currentTaskItem: this.getCurrentTask()?.taskId - ? (taskHistory || []).find((item: HistoryItem) => item.id === this.getCurrentTask()?.taskId) + ? this.taskHistoryStore.get(this.getCurrentTask()!.taskId) : undefined, clineMessages: this.getCurrentTask()?.clineMessages || [], currentTaskTodos: this.getCurrentTask()?.todoList || [], messageQueue: this.getCurrentTask()?.messageQueueService?.messages, - taskHistory: (taskHistory || []) - .filter((item: HistoryItem) => item.ts && item.task) - .sort((a: HistoryItem, b: HistoryItem) => b.ts - a.ts), + taskHistory: this.taskHistoryStore.getAll().filter((item: HistoryItem) => item.ts && item.task), soundEnabled: soundEnabled ?? false, ttsEnabled: ttsEnabled ?? false, ttsSpeed: ttsSpeed ?? 1.0, @@ -2146,11 +2262,6 @@ export class ClineProvider allowedCommands: mergedAllowedCommands, deniedCommands: mergedDeniedCommands, soundVolume: soundVolume ?? 0.5, - browserViewportSize: browserViewportSize ?? "900x600", - screenshotQuality: screenshotQuality ?? 75, - remoteBrowserHost, - remoteBrowserEnabled: remoteBrowserEnabled ?? false, - cachedChromeHostUrl: cachedChromeHostUrl, writeDelayMs: writeDelayMs ?? DEFAULT_WRITE_DELAY_MS, terminalShellIntegrationTimeout: terminalShellIntegrationTimeout ?? Terminal.defaultShellIntegrationTimeout, terminalShellIntegrationDisabled: terminalShellIntegrationDisabled ?? true, @@ -2175,7 +2286,7 @@ export class ClineProvider maxOpenTabsContext: maxOpenTabsContext ?? 20, maxWorkspaceFiles: maxWorkspaceFiles ?? 200, cwd, - browserToolEnabled: browserToolEnabled ?? true, + disabledTools, telemetrySetting, telemetryKey, machineId, @@ -2183,12 +2294,9 @@ export class ClineProvider enableSubfolderRules: enableSubfolderRules ?? false, language: language ?? formatLanguage(vscode.env.language), renderContext: this.renderContext, - maxReadFileLine: maxReadFileLine ?? -1, maxImageFileSize: maxImageFileSize ?? 5, maxTotalImageSize: maxTotalImageSize ?? 20, - maxConcurrentFileReads: maxConcurrentFileReads ?? 5, settingsImportedAt: this.settingsImportedAt, - hasSystemPromptOverride, historyPreviewCollapsed: historyPreviewCollapsed ?? false, reasoningBlockCollapsed: reasoningBlockCollapsed ?? true, enterBehavior: enterBehavior ?? "send", @@ -2222,6 +2330,7 @@ export class ClineProvider profileThresholds: profileThresholds ?? {}, cloudApiUrl: getRooCodeApiUrl(), hasOpenedModeSelector: this.getGlobalState("hasOpenedModeSelector") ?? false, + lockApiConfigAcrossModes: lockApiConfigAcrossModes ?? false, alwaysAllowFollowupQuestions: alwaysAllowFollowupQuestions ?? false, followupAutoApproveTimeoutMs: followupAutoApproveTimeoutMs ?? 60000, includeDiagnosticMessages: includeDiagnosticMessages ?? true, @@ -2231,11 +2340,9 @@ export class ClineProvider includeCurrentCost: includeCurrentCost ?? true, maxGitStatusFiles: maxGitStatusFiles ?? 0, taskSyncEnabled, - remoteControlEnabled, imageGenerationProvider, openRouterImageApiKey, openRouterImageGenerationSelectedModel, - featureRoomoteControlEnabled, openAiCodexIsAuthenticated: await (async () => { try { const { openAiCodexOAuthManager } = await import("../../integrations/openai-codex/oauth") @@ -2257,19 +2364,17 @@ export class ClineProvider async getState(): Promise< Omit< ExtensionState, - | "clineMessages" - | "renderContext" - | "hasOpenedModeSelector" - | "version" - | "shouldShowAnnouncement" - | "hasSystemPromptOverride" + "clineMessages" | "renderContext" | "hasOpenedModeSelector" | "version" | "shouldShowAnnouncement" > > { const stateValues = this.contextProxy.getValues() const customModes = await this.customModesManager.getCustomModes() - // Determine apiProvider with the same logic as before. - const apiProvider: ProviderName = stateValues.apiProvider ? stateValues.apiProvider : "anthropic" + // Determine apiProvider with the same logic as before, while filtering retired providers. + const apiProvider: ProviderName = + stateValues.apiProvider && !isRetiredProvider(stateValues.apiProvider) + ? stateValues.apiProvider + : "anthropic" // Build the apiConfiguration object combining state values and secrets. const providerSettings = this.contextProxy.getProviderSettings() @@ -2352,9 +2457,6 @@ export class ClineProvider ) } - // Get actual browser session state - const isBrowserSessionActive = this.getCurrentTask()?.browserSession?.isSessionActive() ?? false - // Return the same structure as before. return { apiConfiguration: providerSettings, @@ -2367,19 +2469,17 @@ export class ClineProvider alwaysAllowWriteOutsideWorkspace: stateValues.alwaysAllowWriteOutsideWorkspace ?? false, alwaysAllowWriteProtected: stateValues.alwaysAllowWriteProtected ?? false, alwaysAllowExecute: stateValues.alwaysAllowExecute ?? false, - alwaysAllowBrowser: stateValues.alwaysAllowBrowser ?? false, alwaysAllowMcp: stateValues.alwaysAllowMcp ?? false, alwaysAllowModeSwitch: stateValues.alwaysAllowModeSwitch ?? false, alwaysAllowSubtasks: stateValues.alwaysAllowSubtasks ?? false, alwaysAllowFollowupQuestions: stateValues.alwaysAllowFollowupQuestions ?? false, - isBrowserSessionActive, followupAutoApproveTimeoutMs: stateValues.followupAutoApproveTimeoutMs ?? 60000, diagnosticsEnabled: stateValues.diagnosticsEnabled ?? true, allowedMaxRequests: stateValues.allowedMaxRequests, allowedMaxCost: stateValues.allowedMaxCost, autoCondenseContext: stateValues.autoCondenseContext ?? true, autoCondenseContextPercent: stateValues.autoCondenseContextPercent ?? 100, - taskHistory: stateValues.taskHistory ?? [], + taskHistory: this.taskHistoryStore.getAll(), allowedCommands: stateValues.allowedCommands, deniedCommands: stateValues.deniedCommands, soundEnabled: stateValues.soundEnabled ?? false, @@ -2388,11 +2488,6 @@ export class ClineProvider enableCheckpoints: stateValues.enableCheckpoints ?? true, checkpointTimeout: stateValues.checkpointTimeout ?? DEFAULT_CHECKPOINT_TIMEOUT_SECONDS, soundVolume: stateValues.soundVolume, - browserViewportSize: stateValues.browserViewportSize ?? "900x600", - screenshotQuality: stateValues.screenshotQuality ?? 75, - remoteBrowserHost: stateValues.remoteBrowserHost, - remoteBrowserEnabled: stateValues.remoteBrowserEnabled ?? false, - cachedChromeHostUrl: stateValues.cachedChromeHostUrl as string | undefined, writeDelayMs: stateValues.writeDelayMs ?? DEFAULT_WRITE_DELAY_MS, terminalShellIntegrationTimeout: stateValues.terminalShellIntegrationTimeout ?? Terminal.defaultShellIntegrationTimeout, @@ -2419,14 +2514,12 @@ export class ClineProvider customModes, maxOpenTabsContext: stateValues.maxOpenTabsContext ?? 20, maxWorkspaceFiles: stateValues.maxWorkspaceFiles ?? 200, - browserToolEnabled: stateValues.browserToolEnabled ?? true, + disabledTools: stateValues.disabledTools, telemetrySetting: stateValues.telemetrySetting || "unset", showRooIgnoredFiles: stateValues.showRooIgnoredFiles ?? false, enableSubfolderRules: stateValues.enableSubfolderRules ?? false, - maxReadFileLine: stateValues.maxReadFileLine ?? -1, maxImageFileSize: stateValues.maxImageFileSize ?? 5, maxTotalImageSize: stateValues.maxTotalImageSize ?? 20, - maxConcurrentFileReads: stateValues.maxConcurrentFileReads ?? 5, historyPreviewCollapsed: stateValues.historyPreviewCollapsed ?? false, reasoningBlockCollapsed: stateValues.reasoningBlockCollapsed ?? true, enterBehavior: stateValues.enterBehavior ?? "send", @@ -2458,6 +2551,7 @@ export class ClineProvider stateValues.codebaseIndexConfig?.codebaseIndexOpenRouterSpecificProvider, }, profileThresholds: stateValues.profileThresholds ?? {}, + lockApiConfigAcrossModes: this.context.workspaceState.get("lockApiConfigAcrossModes", false), includeDiagnosticMessages: stateValues.includeDiagnosticMessages ?? true, maxDiagnosticMessages: stateValues.maxDiagnosticMessages ?? 50, includeTaskHistoryInEnhance: stateValues.includeTaskHistoryInEnhance ?? true, @@ -2465,83 +2559,85 @@ export class ClineProvider includeCurrentCost: stateValues.includeCurrentCost ?? true, maxGitStatusFiles: stateValues.maxGitStatusFiles ?? 0, taskSyncEnabled, - remoteControlEnabled: (() => { - try { - const cloudSettings = CloudService.instance.getUserSettings() - return cloudSettings?.settings?.extensionBridgeEnabled ?? false - } catch (error) { - console.error( - `[getState] failed to get remote control setting from cloud: ${error instanceof Error ? error.message : String(error)}`, - ) - return false - } - })(), imageGenerationProvider: stateValues.imageGenerationProvider, openRouterImageApiKey: stateValues.openRouterImageApiKey, openRouterImageGenerationSelectedModel: stateValues.openRouterImageGenerationSelectedModel, - featureRoomoteControlEnabled: (() => { - try { - const userSettings = CloudService.instance.getUserSettings() - const hasOrganization = cloudUserInfo?.organizationId != null - return hasOrganization || (userSettings?.features?.roomoteControlEnabled ?? false) - } catch (error) { - console.error( - `[getState] failed to get featureRoomoteControlEnabled: ${error instanceof Error ? error.message : String(error)}`, - ) - return false - } - })(), } } /** * Updates a task in the task history and optionally broadcasts the updated history to the webview. + * Now delegates to TaskHistoryStore for per-task file persistence. + * * @param item The history item to update or add * @param options.broadcast Whether to broadcast the updated history to the webview (default: true) * @returns The updated task history array */ async updateTaskHistory(item: HistoryItem, options: { broadcast?: boolean } = {}): Promise { const { broadcast = true } = options - const history = (this.getGlobalState("taskHistory") as HistoryItem[] | undefined) || [] - const existingItemIndex = history.findIndex((h) => h.id === item.id) - const wasExisting = existingItemIndex !== -1 - if (wasExisting) { - // Preserve existing metadata (e.g., delegation fields) unless explicitly overwritten. - // This prevents loss of status/awaitingChildId/delegatedToId when tasks are reopened, - // terminated, or when routine message persistence occurs. - history[existingItemIndex] = { - ...history[existingItemIndex], - ...item, - } - } else { - history.push(item) - } - - await this.updateGlobalState("taskHistory", history) + const history = await this.taskHistoryStore.upsert(item) this.recentTasksCache = undefined // Broadcast the updated history to the webview if requested. // Prefer per-item updates to avoid repeatedly cloning/sending the full history. if (broadcast && this.isViewLaunched) { - const updatedItem = wasExisting ? history[existingItemIndex] : item + const updatedItem = this.taskHistoryStore.get(item.id) ?? item await this.postMessageToWebview({ type: "taskHistoryItemUpdated", taskHistoryItem: updatedItem }) } return history } + /** + * Schedule a debounced write-through of task history to globalState. + * Only used for backward compatibility during the transition period. + * Per-task files are authoritative; globalState is the downgrade fallback. + */ + private scheduleGlobalStateWriteThrough(): void { + if (this.globalStateWriteThroughTimer) { + clearTimeout(this.globalStateWriteThroughTimer) + } + + this.globalStateWriteThroughTimer = setTimeout(async () => { + this.globalStateWriteThroughTimer = null + try { + const items = this.taskHistoryStore.getAll() + await this.updateGlobalState("taskHistory", items) + } catch (err) { + this.log( + `[scheduleGlobalStateWriteThrough] Failed: ${err instanceof Error ? err.message : String(err)}`, + ) + } + }, ClineProvider.GLOBAL_STATE_WRITE_THROUGH_DEBOUNCE_MS) + } + + /** + * Flush any pending debounced globalState write-through immediately. + */ + private flushGlobalStateWriteThrough(): void { + if (this.globalStateWriteThroughTimer) { + clearTimeout(this.globalStateWriteThroughTimer) + this.globalStateWriteThroughTimer = null + } + + const items = this.taskHistoryStore.getAll() + this.updateGlobalState("taskHistory", items).catch((err) => { + this.log(`[flushGlobalStateWriteThrough] Failed: ${err instanceof Error ? err.message : String(err)}`) + }) + } + /** * Broadcasts a task history update to the webview. * This sends a lightweight message with just the task history, rather than the full state. - * @param history The task history to broadcast (if not provided, reads from global state) + * @param history The task history to broadcast (if not provided, reads from the store) */ public async broadcastTaskHistoryUpdate(history?: HistoryItem[]): Promise { if (!this.isViewLaunched) { return } - const taskHistory = history ?? (this.getGlobalState("taskHistory") as HistoryItem[] | undefined) ?? [] + const taskHistory = history ?? this.taskHistoryStore.getAll() // Sort and filter the history the same way as getStateToPostToWebview const sortedHistory = taskHistory @@ -2662,64 +2758,6 @@ export class ClineProvider return true } - public async remoteControlEnabled(enabled: boolean) { - if (!enabled) { - await BridgeOrchestrator.disconnect() - return - } - - const userInfo = CloudService.instance.getUserInfo() - - if (!userInfo) { - this.log("[ClineProvider#remoteControlEnabled] Failed to get user info, disconnecting") - await BridgeOrchestrator.disconnect() - return - } - - const config = await CloudService.instance.cloudAPI?.bridgeConfig().catch(() => undefined) - - if (!config) { - this.log("[ClineProvider#remoteControlEnabled] Failed to get bridge config") - return - } - - await BridgeOrchestrator.connectOrDisconnect(userInfo, enabled, { - ...config, - provider: this, - sessionId: vscode.env.sessionId, - isCloudAgent: CloudService.instance.isCloudAgent, - }) - - const bridge = BridgeOrchestrator.getInstance() - - if (bridge) { - const currentTask = this.getCurrentTask() - - if (currentTask && !currentTask.enableBridge) { - try { - currentTask.enableBridge = true - await BridgeOrchestrator.subscribeToTask(currentTask) - } catch (error) { - const message = `[ClineProvider#remoteControlEnabled] BridgeOrchestrator.subscribeToTask() failed: ${error instanceof Error ? error.message : String(error)}` - this.log(message) - console.error(message) - } - } - } else { - for (const task of this.clineStack) { - if (task.enableBridge) { - try { - await BridgeOrchestrator.getInstance()?.unsubscribeFromTask(task.taskId) - } catch (error) { - const message = `[ClineProvider#remoteControlEnabled] BridgeOrchestrator#unsubscribeFromTask() failed: ${error instanceof Error ? error.message : String(error)}` - this.log(message) - console.error(message) - } - } - } - } - } - /** * Gets the CodeIndexManager for the current active workspace * @returns CodeIndexManager instance for the current workspace or the default one @@ -2792,7 +2830,7 @@ export class ClineProvider return this.recentTasksCache } - const history = this.getGlobalState("taskHistory") ?? [] + const history = this.taskHistoryStore.getAll() const workspaceTasks: HistoryItem[] = [] for (const item of history) { @@ -2875,15 +2913,8 @@ export class ClineProvider } } - const { - apiConfiguration, - organizationAllowList, - enableCheckpoints, - checkpointTimeout, - experiments, - cloudUserInfo, - remoteControlEnabled, - } = await this.getState() + const { apiConfiguration, organizationAllowList, enableCheckpoints, checkpointTimeout, experiments } = + await this.getState() // Single-open-task invariant: always enforce for user-initiated top-level tasks if (!parentTask) { @@ -2911,7 +2942,6 @@ export class ClineProvider parentTask, taskNumber: this.clineStack.length + 1, onCreated: this.taskCreationCallback, - enableBridge: BridgeOrchestrator.isEnabled(cloudUserInfo, remoteControlEnabled), initialTodos: options.initialTodos, ...options, }) @@ -3108,12 +3138,14 @@ export class ClineProvider } } + const apiProvider = apiConfiguration?.apiProvider + return { language, mode, taskId: task?.taskId, parentTaskId: task?.parentTaskId, - apiProvider: apiConfiguration?.apiProvider, + apiProvider: apiProvider && !isRetiredProvider(apiProvider) ? apiProvider : undefined, modelId: task?.api?.getModel().id, diffStrategy: task?.diffStrategy?.getName(), isSubtask: task ? !!task.parentTaskId : undefined, @@ -3185,7 +3217,21 @@ export class ClineProvider // recursivelyMakeClineRequests BEFORE tools start executing. We only need to // flush the pending user message with tool_results. try { - await parent.flushPendingToolResultsToHistory() + const flushSuccess = await parent.flushPendingToolResultsToHistory() + + if (!flushSuccess) { + console.warn(`[delegateParentAndOpenChild] Flush failed for parent ${parentTaskId}, retrying...`) + const retrySuccess = await parent.retrySaveApiConversationHistory() + + if (!retrySuccess) { + console.error( + `[delegateParentAndOpenChild] CRITICAL: Parent ${parentTaskId} API history not persisted to disk. Child return may produce stale state.`, + ) + vscode.window.showWarningMessage( + "Warning: Parent task state could not be saved. The parent task may lose recent context when resumed.", + ) + } + } } catch (error) { this.log( `[delegateParentAndOpenChild] Error flushing pending tool results (non-fatal): ${ @@ -3198,7 +3244,7 @@ export class ClineProvider // This ensures we never have >1 tasks open at any time during delegation. // Await abort completion to ensure clean disposal and prevent unhandled rejections. try { - await this.removeClineFromStack() + await this.removeClineFromStack({ skipDelegationRepair: true }) } catch (error) { this.log( `[delegateParentAndOpenChild] Error during parent disposal (non-fatal): ${ @@ -3226,12 +3272,20 @@ export class ClineProvider // Pass initialStatus: "active" to ensure the child task's historyItem is created // with status from the start, avoiding race conditions where the task might // call attempt_completion before status is persisted separately. + // + // Pass startTask: false to prevent the child from beginning its task loop + // (and writing to globalState via saveClineMessages → updateTaskHistory) + // before we persist the parent's delegation metadata in step 5. + // Without this, the child's fire-and-forget startTask() races with step 5, + // and the last writer to globalState overwrites the other's changes— + // causing the parent's delegation fields to be lost. const child = await this.createTask(message, undefined, parent as any, { initialTodos, initialStatus: "active", + startTask: false, }) - // 5) Persist parent delegation metadata + // 5) Persist parent delegation metadata BEFORE the child starts writing. try { const { historyItem } = await this.getTaskWithId(parentTaskId) const childIds = Array.from(new Set([...(historyItem.childIds ?? []), child.taskId])) @@ -3251,7 +3305,10 @@ export class ClineProvider ) } - // 6) Emit TaskDelegated (provider-level) + // 6) Start the child task now that parent metadata is safely persisted. + child.start() + + // 7) Emit TaskDelegated (provider-level) try { this.emit(RooCodeEventName.TaskDelegated, parentTaskId, child.taskId) } catch { @@ -3385,7 +3442,19 @@ export class ClineProvider await saveApiMessages({ messages: parentApiMessages as any, taskId: parentTaskId, globalStoragePath }) - // 3) Update child metadata to "completed" status + // 3) Close child instance if still open (single-open-task invariant). + // This MUST happen BEFORE updating the child's status to "completed" because + // removeClineFromStack() → abortTask(true) → saveClineMessages() writes + // the historyItem with initialStatus (typically "active"), which would + // overwrite a "completed" status set earlier. + const current = this.getCurrentTask() + if (current?.taskId === childTaskId) { + await this.removeClineFromStack() + } + + // 4) Update child metadata to "completed" status. + // This runs after the abort so it overwrites the stale "active" status + // that saveClineMessages() may have written during step 3. try { const { historyItem: childHistory } = await this.getTaskWithId(childTaskId) await this.updateTaskHistory({ @@ -3400,7 +3469,7 @@ export class ClineProvider ) } - // 4) Update parent metadata and persist BEFORE emitting completion event + // 5) Update parent metadata and persist BEFORE emitting completion event const childIds = Array.from(new Set([...(historyItem.childIds ?? []), childTaskId])) const updatedHistory: typeof historyItem = { ...historyItem, @@ -3412,19 +3481,13 @@ export class ClineProvider } await this.updateTaskHistory(updatedHistory) - // 5) Emit TaskDelegationCompleted (provider-level) + // 6) Emit TaskDelegationCompleted (provider-level) try { this.emit(RooCodeEventName.TaskDelegationCompleted, parentTaskId, childTaskId, completionResultSummary) } catch { // non-fatal } - // 6) Close child instance if still open (single-open-task invariant) - const current = this.getCurrentTask() - if (current?.taskId === childTaskId) { - await this.removeClineFromStack() - } - // 7) Reopen the parent from history as the sole active task (restores saved mode) // IMPORTANT: startTask=false to suppress resume-from-history ask scheduling const parentInstance = await this.createTaskWithHistoryItem(updatedHistory, { startTask: false }) diff --git a/src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts b/src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts index 04f5d57792..87c6ea968c 100644 --- a/src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts @@ -129,9 +129,6 @@ vi.mock("@roo-code/cloud", () => ({ } }, }, - BridgeOrchestrator: { - isEnabled: vi.fn().mockReturnValue(false), - }, getRooCodeApiUrl: vi.fn().mockReturnValue("https://app.roocode.com"), })) @@ -171,6 +168,11 @@ describe("ClineProvider - API Handler Rebuild Guard", () => { store: vi.fn().mockImplementation((key: string, value: string | undefined) => (secrets[key] = value)), delete: vi.fn().mockImplementation((key: string) => delete secrets[key]), }, + workspaceState: { + get: vi.fn().mockReturnValue(undefined), + update: vi.fn().mockResolvedValue(undefined), + keys: vi.fn().mockReturnValue([]), + }, subscriptions: [], extension: { packageJSON: { version: "1.0.0" }, diff --git a/src/core/webview/__tests__/ClineProvider.flicker-free-cancel.spec.ts b/src/core/webview/__tests__/ClineProvider.flicker-free-cancel.spec.ts index 8533865031..4bb01347a3 100644 --- a/src/core/webview/__tests__/ClineProvider.flicker-free-cancel.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.flicker-free-cancel.spec.ts @@ -78,9 +78,6 @@ vi.mock("@roo-code/cloud", () => ({ isAuthenticated: vi.fn().mockReturnValue(false), }, }, - BridgeOrchestrator: { - isEnabled: vi.fn().mockReturnValue(false), - }, getRooCodeApiUrl: vi.fn().mockReturnValue("https://api.roo-code.com"), })) diff --git a/src/core/webview/__tests__/ClineProvider.lockApiConfig.spec.ts b/src/core/webview/__tests__/ClineProvider.lockApiConfig.spec.ts new file mode 100644 index 0000000000..2cf9d4cae8 --- /dev/null +++ b/src/core/webview/__tests__/ClineProvider.lockApiConfig.spec.ts @@ -0,0 +1,369 @@ +// npx vitest run core/webview/__tests__/ClineProvider.lockApiConfig.spec.ts + +import * as vscode from "vscode" +import { TelemetryService } from "@roo-code/telemetry" +import { ClineProvider } from "../ClineProvider" +import { ContextProxy } from "../../config/ContextProxy" + +vi.mock("vscode", () => ({ + ExtensionContext: vi.fn(), + OutputChannel: vi.fn(), + WebviewView: vi.fn(), + Uri: { + joinPath: vi.fn(), + file: vi.fn(), + }, + CodeActionKind: { + QuickFix: { value: "quickfix" }, + RefactorRewrite: { value: "refactor.rewrite" }, + }, + commands: { + executeCommand: vi.fn().mockResolvedValue(undefined), + }, + window: { + showInformationMessage: vi.fn(), + showWarningMessage: vi.fn(), + showErrorMessage: vi.fn(), + onDidChangeActiveTextEditor: vi.fn(() => ({ dispose: vi.fn() })), + }, + workspace: { + getConfiguration: vi.fn().mockReturnValue({ + get: vi.fn().mockReturnValue([]), + update: vi.fn(), + }), + onDidChangeConfiguration: vi.fn().mockImplementation(() => ({ + dispose: vi.fn(), + })), + onDidSaveTextDocument: vi.fn(() => ({ dispose: vi.fn() })), + onDidChangeTextDocument: vi.fn(() => ({ dispose: vi.fn() })), + onDidOpenTextDocument: vi.fn(() => ({ dispose: vi.fn() })), + onDidCloseTextDocument: vi.fn(() => ({ dispose: vi.fn() })), + }, + env: { + uriScheme: "vscode", + language: "en", + appName: "Visual Studio Code", + }, + ExtensionMode: { + Production: 1, + Development: 2, + Test: 3, + }, + version: "1.85.0", +})) + +vi.mock("../../task/Task", () => ({ + Task: vi.fn().mockImplementation((options) => ({ + taskId: options.taskId || "test-task-id", + saveClineMessages: vi.fn(), + clineMessages: [], + apiConversationHistory: [], + overwriteClineMessages: vi.fn(), + overwriteApiConversationHistory: vi.fn(), + abortTask: vi.fn(), + handleWebviewAskResponse: vi.fn(), + getTaskNumber: vi.fn().mockReturnValue(0), + setTaskNumber: vi.fn(), + setParentTask: vi.fn(), + setRootTask: vi.fn(), + emit: vi.fn(), + parentTask: options.parentTask, + updateApiConfiguration: vi.fn(), + setTaskApiConfigName: vi.fn(), + _taskApiConfigName: options.historyItem?.apiConfigName, + taskApiConfigName: options.historyItem?.apiConfigName, + })), +})) + +vi.mock("../../prompts/sections/custom-instructions") + +vi.mock("../../../utils/safeWriteJson") + +vi.mock("../../../api", () => ({ + buildApiHandler: vi.fn().mockReturnValue({ + getModel: vi.fn().mockReturnValue({ + id: "claude-3-sonnet", + }), + }), +})) + +vi.mock("../../../integrations/workspace/WorkspaceTracker", () => ({ + default: vi.fn().mockImplementation(() => ({ + initializeFilePaths: vi.fn(), + dispose: vi.fn(), + })), +})) + +vi.mock("../../diff/strategies/multi-search-replace", () => ({ + MultiSearchReplaceDiffStrategy: vi.fn().mockImplementation(() => ({ + getName: () => "test-strategy", + applyDiff: vi.fn(), + })), +})) + +vi.mock("@roo-code/cloud", () => ({ + CloudService: { + hasInstance: vi.fn().mockReturnValue(true), + get instance() { + return { + isAuthenticated: vi.fn().mockReturnValue(false), + } + }, + }, + getRooCodeApiUrl: vi.fn().mockReturnValue("https://app.roocode.com"), +})) + +vi.mock("../../../shared/modes", () => { + const mockModes = [ + { + slug: "code", + name: "Code Mode", + roleDefinition: "You are a code assistant", + groups: ["read", "edit"], + }, + { + slug: "architect", + name: "Architect Mode", + roleDefinition: "You are an architect", + groups: ["read", "edit"], + }, + { + slug: "ask", + name: "Ask Mode", + roleDefinition: "You are an assistant", + groups: ["read"], + }, + { + slug: "debug", + name: "Debug Mode", + roleDefinition: "You are a debugger", + groups: ["read", "edit"], + }, + { + slug: "orchestrator", + name: "Orchestrator Mode", + roleDefinition: "You are an orchestrator", + groups: [], + }, + ] + + return { + modes: mockModes, + getAllModes: vi.fn((customModes?: Array<{ slug: string }>) => { + if (!customModes?.length) { + return [...mockModes] + } + const allModes = [...mockModes] + customModes.forEach((cm) => { + const idx = allModes.findIndex((m) => m.slug === cm.slug) + if (idx !== -1) { + allModes[idx] = cm as (typeof mockModes)[number] + } else { + allModes.push(cm as (typeof mockModes)[number]) + } + }) + return allModes + }), + getModeBySlug: vi.fn().mockReturnValue({ + slug: "code", + name: "Code Mode", + roleDefinition: "You are a code assistant", + groups: ["read", "edit"], + }), + defaultModeSlug: "code", + } +}) + +vi.mock("../../prompts/system", () => ({ + SYSTEM_PROMPT: vi.fn().mockResolvedValue("mocked system prompt"), + codeMode: "code", +})) + +vi.mock("../../../api/providers/fetchers/modelCache", () => ({ + getModels: vi.fn().mockResolvedValue({}), + flushModels: vi.fn(), +})) + +vi.mock("../../../integrations/misc/extract-text", () => ({ + extractTextFromFile: vi.fn().mockResolvedValue("Mock file content"), +})) + +vi.mock("p-wait-for", () => ({ + default: vi.fn().mockImplementation(async () => Promise.resolve()), +})) + +vi.mock("fs/promises", () => ({ + mkdir: vi.fn().mockResolvedValue(undefined), + writeFile: vi.fn().mockResolvedValue(undefined), + readFile: vi.fn().mockResolvedValue(""), + unlink: vi.fn().mockResolvedValue(undefined), + rmdir: vi.fn().mockResolvedValue(undefined), +})) + +vi.mock("@roo-code/telemetry", () => ({ + TelemetryService: { + hasInstance: vi.fn().mockReturnValue(true), + createInstance: vi.fn(), + get instance() { + return { + trackEvent: vi.fn(), + trackError: vi.fn(), + setProvider: vi.fn(), + captureModeSwitch: vi.fn(), + } + }, + }, +})) + +describe("ClineProvider - Lock API Config Across Modes", () => { + let provider: ClineProvider + let mockContext: vscode.ExtensionContext + let mockOutputChannel: vscode.OutputChannel + let mockWebviewView: vscode.WebviewView + + beforeEach(() => { + vi.clearAllMocks() + + if (!TelemetryService.hasInstance()) { + TelemetryService.createInstance([]) + } + + const globalState: Record = { + mode: "code", + currentApiConfigName: "default-profile", + } + + const workspaceState: Record = {} + + const secrets: Record = {} + + mockContext = { + extensionPath: "/test/path", + extensionUri: {} as vscode.Uri, + globalState: { + get: vi.fn().mockImplementation((key: string) => globalState[key]), + update: vi.fn().mockImplementation((key: string, value: unknown) => { + globalState[key] = value + return Promise.resolve() + }), + keys: vi.fn().mockImplementation(() => Object.keys(globalState)), + }, + secrets: { + get: vi.fn().mockImplementation((key: string) => secrets[key]), + store: vi.fn().mockImplementation((key: string, value: string | undefined) => { + secrets[key] = value + return Promise.resolve() + }), + delete: vi.fn().mockImplementation((key: string) => { + delete secrets[key] + return Promise.resolve() + }), + }, + workspaceState: { + get: vi.fn().mockImplementation((key: string, defaultValue?: unknown) => { + return key in workspaceState ? workspaceState[key] : defaultValue + }), + update: vi.fn().mockImplementation((key: string, value: unknown) => { + workspaceState[key] = value + return Promise.resolve() + }), + keys: vi.fn().mockImplementation(() => Object.keys(workspaceState)), + }, + subscriptions: [], + extension: { + packageJSON: { version: "1.0.0" }, + }, + globalStorageUri: { + fsPath: "/test/storage/path", + }, + } as unknown as vscode.ExtensionContext + + mockOutputChannel = { + appendLine: vi.fn(), + clear: vi.fn(), + dispose: vi.fn(), + } as unknown as vscode.OutputChannel + + const mockPostMessage = vi.fn() + + mockWebviewView = { + webview: { + postMessage: mockPostMessage, + html: "", + options: {}, + onDidReceiveMessage: vi.fn(), + asWebviewUri: vi.fn(), + cspSource: "vscode-webview://test-csp-source", + }, + visible: true, + onDidDispose: vi.fn().mockImplementation((callback) => { + callback() + return { dispose: vi.fn() } + }), + onDidChangeVisibility: vi.fn().mockImplementation(() => ({ dispose: vi.fn() })), + } as unknown as vscode.WebviewView + + provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + + // Mock getMcpHub method + provider.getMcpHub = vi.fn().mockReturnValue({ + listTools: vi.fn().mockResolvedValue([]), + callTool: vi.fn().mockResolvedValue({ content: [] }), + listResources: vi.fn().mockResolvedValue([]), + readResource: vi.fn().mockResolvedValue({ contents: [] }), + getAllServers: vi.fn().mockReturnValue([]), + }) + }) + + describe("handleModeSwitch honors lockApiConfigAcrossModes as a read-time override", () => { + beforeEach(async () => { + await provider.resolveWebviewView(mockWebviewView) + }) + + it("skips mode-specific config lookup/load when lockApiConfigAcrossModes is true", async () => { + await mockContext.workspaceState.update("lockApiConfigAcrossModes", true) + + const getModeConfigIdSpy = vi + .spyOn(provider.providerSettingsManager, "getModeConfigId") + .mockResolvedValue("architect-profile-id") + const listConfigSpy = vi + .spyOn(provider.providerSettingsManager, "listConfig") + .mockResolvedValue([ + { name: "architect-profile", id: "architect-profile-id", apiProvider: "anthropic" }, + ]) + const activateProviderProfileSpy = vi + .spyOn(provider, "activateProviderProfile") + .mockResolvedValue(undefined) + + await provider.handleModeSwitch("architect") + + expect(getModeConfigIdSpy).not.toHaveBeenCalled() + expect(listConfigSpy).not.toHaveBeenCalled() + expect(activateProviderProfileSpy).not.toHaveBeenCalled() + }) + + it("keeps normal mode-specific lookup/load behavior when lockApiConfigAcrossModes is false", async () => { + await mockContext.workspaceState.update("lockApiConfigAcrossModes", false) + + const getModeConfigIdSpy = vi + .spyOn(provider.providerSettingsManager, "getModeConfigId") + .mockResolvedValue("architect-profile-id") + vi.spyOn(provider.providerSettingsManager, "listConfig").mockResolvedValue([ + { name: "architect-profile", id: "architect-profile-id", apiProvider: "anthropic" }, + ]) + vi.spyOn(provider.providerSettingsManager, "getProfile").mockResolvedValue({ + name: "architect-profile", + apiProvider: "anthropic", + }) + + const activateProviderProfileSpy = vi + .spyOn(provider, "activateProviderProfile") + .mockResolvedValue(undefined) + + await provider.handleModeSwitch("architect") + + expect(getModeConfigIdSpy).toHaveBeenCalledWith("architect") + expect(activateProviderProfileSpy).toHaveBeenCalledWith({ name: "architect-profile" }) + }) + }) +}) diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index c08ff8cad9..1e26cd45be 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -78,34 +78,6 @@ vi.mock("@modelcontextprotocol/sdk/types.js", () => ({ }, })) -vi.mock("../../../services/browser/BrowserSession", () => ({ - BrowserSession: vi.fn().mockImplementation(() => ({ - testConnection: vi.fn().mockImplementation(async (url) => { - if (url === "http://localhost:9222") { - return { - success: true, - message: "Successfully connected to Chrome", - endpoint: "ws://localhost:9222/devtools/browser/123", - } - } else { - return { - success: false, - message: "Failed to connect to Chrome", - endpoint: undefined, - } - } - }), - })), -})) - -vi.mock("../../../services/browser/browserDiscovery", () => ({ - discoverChromeHostUrl: vi.fn().mockResolvedValue("http://localhost:9222"), - tryChromeHostUrl: vi.fn().mockImplementation(async (url) => { - return url === "http://localhost:9222" - }), - testBrowserConnection: vi.fn(), -})) - // Remove duplicate mock - it's already defined below. const mockAddCustomInstructions = vi.fn().mockResolvedValue("Combined instructions") @@ -247,7 +219,7 @@ vi.mock("../../../shared/modes", () => ({ slug: "code", name: "Code Mode", roleDefinition: "You are a code assistant", - groups: ["read", "edit", "browser"], + groups: ["read", "edit"], }, { slug: "architect", @@ -266,7 +238,7 @@ vi.mock("../../../shared/modes", () => ({ slug: "code", name: "Code Mode", roleDefinition: "You are a code assistant", - groups: ["read", "edit", "browser"], + groups: ["read", "edit"], }), getGroupName: vi.fn().mockImplementation((group: string) => { // Return appropriate group names for different tool groups @@ -275,8 +247,6 @@ vi.mock("../../../shared/modes", () => ({ return "Read Tools" case "edit": return "Edit Tools" - case "browser": - return "Browser Tools" case "mcp": return "MCP Tools" default: @@ -327,12 +297,10 @@ vi.mock("@roo-code/cloud", () => ({ get instance() { return { isAuthenticated: vi.fn().mockReturnValue(false), + off: vi.fn(), } }, }, - BridgeOrchestrator: { - isEnabled: vi.fn().mockReturnValue(false), - }, getRooCodeApiUrl: vi.fn().mockReturnValue("https://app.roocode.com"), })) @@ -405,6 +373,11 @@ describe("ClineProvider", () => { store: vi.fn().mockImplementation((key: string, value: string | undefined) => (secrets[key] = value)), delete: vi.fn().mockImplementation((key: string) => delete secrets[key]), }, + workspaceState: { + get: vi.fn().mockReturnValue(undefined), + update: vi.fn().mockResolvedValue(undefined), + keys: vi.fn().mockReturnValue([]), + }, subscriptions: [], extension: { packageJSON: { version: "1.0.0" }, @@ -529,7 +502,6 @@ describe("ClineProvider", () => { const mockState: ExtensionState = { version: "1.0.0", - isBrowserSessionActive: false, clineMessages: [], taskHistory: [], shouldShowAnnouncement: false, @@ -549,26 +521,22 @@ describe("ClineProvider", () => { }, alwaysAllowWriteOutsideWorkspace: false, alwaysAllowExecute: false, - alwaysAllowBrowser: false, alwaysAllowMcp: false, uriScheme: "vscode", soundEnabled: false, ttsEnabled: false, enableCheckpoints: false, writeDelayMs: 1000, - browserViewportSize: "900x600", mcpEnabled: true, mode: defaultModeSlug, customModes: [], experiments: experimentDefault, maxOpenTabsContext: 20, maxWorkspaceFiles: 200, - browserToolEnabled: true, telemetrySetting: "unset", showRooIgnoredFiles: false, enableSubfolderRules: false, renderContext: "sidebar", - maxReadFileLine: 500, maxImageFileSize: 5, maxTotalImageSize: 20, cloudUserInfo: null, @@ -583,9 +551,7 @@ describe("ClineProvider", () => { diagnosticsEnabled: true, openRouterImageApiKey: undefined, openRouterImageGenerationSelectedModel: undefined, - remoteControlEnabled: false, taskSyncEnabled: false, - featureRoomoteControlEnabled: false, checkpointTimeout: DEFAULT_CHECKPOINT_TIMEOUT_SECONDS, } @@ -598,6 +564,43 @@ describe("ClineProvider", () => { expect(mockPostMessage).toHaveBeenCalledWith(message) }) + test("postMessageToWebview does not throw when webview is disposed", async () => { + await provider.resolveWebviewView(mockWebviewView) + + // Simulate postMessage throwing after webview disposal + mockPostMessage.mockRejectedValueOnce(new Error("Webview is disposed")) + + const message: ExtensionMessage = { type: "action", action: "chatButtonClicked" } + + // Should not throw + await expect(provider.postMessageToWebview(message)).resolves.toBeUndefined() + }) + + test("postMessageToWebview skips postMessage after dispose", async () => { + await provider.resolveWebviewView(mockWebviewView) + + await provider.dispose() + mockPostMessage.mockClear() + + const message: ExtensionMessage = { type: "action", action: "chatButtonClicked" } + await provider.postMessageToWebview(message) + + expect(mockPostMessage).not.toHaveBeenCalled() + }) + + test("dispose is idempotent — second call is a no-op", async () => { + await provider.resolveWebviewView(mockWebviewView) + + await provider.dispose() + await provider.dispose() + + // dispose body runs only once: log "Disposing ClineProvider..." appears once + const disposeCalls = (mockOutputChannel.appendLine as ReturnType).mock.calls.filter( + ([msg]) => typeof msg === "string" && msg.includes("Disposing ClineProvider..."), + ) + expect(disposeCalls).toHaveLength(1) + }) + test("handles webviewDidLaunch message", async () => { await provider.resolveWebviewView(mockWebviewView) @@ -760,7 +763,6 @@ describe("ClineProvider", () => { expect(state).toHaveProperty("alwaysAllowReadOnly") expect(state).toHaveProperty("alwaysAllowWrite") expect(state).toHaveProperty("alwaysAllowExecute") - expect(state).toHaveProperty("alwaysAllowBrowser") expect(state).toHaveProperty("taskHistory") expect(state).toHaveProperty("soundEnabled") expect(state).toHaveProperty("ttsEnabled") @@ -962,21 +964,6 @@ describe("ClineProvider", () => { expect(provider.providerSettingsManager.activateProfile).toHaveBeenCalledWith({ id: "config-id-123" }) }) - test("handles browserToolEnabled setting", async () => { - await provider.resolveWebviewView(mockWebviewView) - const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as any).mock.calls[0][0] - - // Test browserToolEnabled - await messageHandler({ type: "updateSettings", updatedSettings: { browserToolEnabled: true } }) - expect(mockContext.globalState.update).toHaveBeenCalledWith("browserToolEnabled", true) - expect(mockPostMessage).toHaveBeenCalled() - - // Verify state includes browserToolEnabled - const state = await provider.getState() - expect(state).toHaveProperty("browserToolEnabled") - expect(state.browserToolEnabled).toBe(true) // Default value should be true - }) - test("handles showRooIgnoredFiles setting", async () => { await provider.resolveWebviewView(mockWebviewView) const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as any).mock.calls[0][0] @@ -1161,7 +1148,7 @@ describe("ClineProvider", () => { { ts: 1000, type: "say", say: "user_feedback" }, // User message 1 { ts: 2000, type: "say", say: "tool" }, // Tool message { ts: 3000, type: "say", say: "text" }, // Message before delete - { ts: 4000, type: "say", say: "browser_action" }, // Message to delete + { ts: 4000, type: "say", say: "tool" }, // Message to delete { ts: 5000, type: "say", say: "user_feedback" }, // Next user message { ts: 6000, type: "say", say: "user_feedback" }, // Final message ] as ClineMessage[] @@ -1249,7 +1236,7 @@ describe("ClineProvider", () => { { ts: 1000, type: "say", say: "user_feedback" }, // User message 1 { ts: 2000, type: "say", say: "tool" }, // Tool message { ts: 3000, type: "say", say: "text" }, // Message before edit - { ts: 4000, type: "say", say: "browser_action" }, // Message to edit + { ts: 4000, type: "say", say: "tool" }, // Message to edit { ts: 5000, type: "say", say: "user_feedback" }, // Next user message { ts: 6000, type: "say", say: "user_feedback" }, // Final message ] as ClineMessage[] @@ -1442,7 +1429,6 @@ describe("ClineProvider", () => { }, mode: "architect", mcpEnabled: false, - browserViewportSize: "900x600", experiments: experimentDefault, } as any) @@ -1459,54 +1445,6 @@ describe("ClineProvider", () => { }), ) }) - - // Tests for browser tool support - simplified to focus on behavior - test("generates system prompt with different browser tool configurations", async () => { - await provider.resolveWebviewView(mockWebviewView) - const handler = getMessageHandler() - - // Test 1: Browser tools enabled with compatible model and mode - vi.spyOn(provider, "getState").mockResolvedValueOnce({ - apiConfiguration: { - apiProvider: "openrouter", - }, - browserToolEnabled: true, - mode: "code", // code mode includes browser tool group - experiments: experimentDefault, - } as any) - - await handler({ type: "getSystemPrompt", mode: "code" }) - - expect(mockPostMessage).toHaveBeenCalledWith( - expect.objectContaining({ - type: "systemPrompt", - text: expect.any(String), - mode: "code", - }), - ) - - mockPostMessage.mockClear() - - // Test 2: Browser tools disabled - vi.spyOn(provider, "getState").mockResolvedValueOnce({ - apiConfiguration: { - apiProvider: "openrouter", - }, - browserToolEnabled: false, - mode: "code", - experiments: experimentDefault, - } as any) - - await handler({ type: "getSystemPrompt", mode: "code" }) - - expect(mockPostMessage).toHaveBeenCalledWith( - expect.objectContaining({ - type: "systemPrompt", - text: expect.any(String), - mode: "code", - }), - ) - }) }) describe("handleModeSwitch", () => { @@ -1602,7 +1540,7 @@ describe("ClineProvider", () => { slug: "code", name: "Code Mode", roleDefinition: "You are a code assistant", - groups: ["read", "edit", "browser"], + groups: ["read", "edit"], }) // Subsequent calls return default mode // Mock provider settings manager @@ -1801,7 +1739,7 @@ describe("ClineProvider", () => { slug: "code", name: "Code Mode", roleDefinition: "You are a code assistant", - groups: ["read", "edit", "browser"], + groups: ["read", "edit"], }) // Mock provider settings manager to throw error @@ -2052,77 +1990,6 @@ describe("ClineProvider", () => { ]) }) }) - - describe("browser connection features", () => { - beforeEach(async () => { - // Reset mocks - vi.clearAllMocks() - await provider.resolveWebviewView(mockWebviewView) - }) - - // These mocks are already defined at the top of the file - - test("handles testBrowserConnection with provided URL", async () => { - // Get the message handler - const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as any).mock.calls[0][0] - - // Test with valid URL - await messageHandler({ - type: "testBrowserConnection", - text: "http://localhost:9222", - }) - - // Verify postMessage was called with success result - expect(mockPostMessage).toHaveBeenCalledWith( - expect.objectContaining({ - type: "browserConnectionResult", - success: true, - text: expect.stringContaining("Successfully connected to Chrome"), - }), - ) - - // Reset mock - mockPostMessage.mockClear() - - // Test with invalid URL - await messageHandler({ - type: "testBrowserConnection", - text: "http://inlocalhost:9222", - }) - - // Verify postMessage was called with failure result - expect(mockPostMessage).toHaveBeenCalledWith( - expect.objectContaining({ - type: "browserConnectionResult", - success: false, - text: expect.stringContaining("Failed to connect to Chrome"), - }), - ) - }) - - test("handles testBrowserConnection with auto-discovery", async () => { - // Get the message handler - const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as any).mock.calls[0][0] - - // Test auto-discovery (no URL provided) - await messageHandler({ - type: "testBrowserConnection", - }) - - // Verify discoverChromeHostUrl was called - const { discoverChromeHostUrl } = await import("../../../services/browser/browserDiscovery") - expect(discoverChromeHostUrl).toHaveBeenCalled() - - // Verify postMessage was called with success result - expect(mockPostMessage).toHaveBeenCalledWith( - expect.objectContaining({ - type: "browserConnectionResult", - success: true, - text: expect.stringContaining("Auto-discovered and tested connection to Chrome"), - }), - ) - }) - }) }) describe("Project MCP Settings", () => { @@ -2148,6 +2015,11 @@ describe("Project MCP Settings", () => { store: vi.fn(), delete: vi.fn(), }, + workspaceState: { + get: vi.fn().mockReturnValue(undefined), + update: vi.fn().mockResolvedValue(undefined), + keys: vi.fn().mockReturnValue([]), + }, subscriptions: [], extension: { packageJSON: { version: "1.0.0" }, @@ -2278,6 +2150,11 @@ describe.skip("ContextProxy integration", () => { update: vi.fn(), keys: vi.fn().mockReturnValue([]), }, + workspaceState: { + get: vi.fn().mockReturnValue(undefined), + update: vi.fn().mockResolvedValue(undefined), + keys: vi.fn().mockReturnValue([]), + }, secrets: { get: vi.fn(), store: vi.fn(), delete: vi.fn() }, extensionUri: {} as vscode.Uri, globalStorageUri: { fsPath: "/test/path" }, @@ -2343,6 +2220,11 @@ describe("getTelemetryProperties", () => { update: vi.fn(), keys: vi.fn().mockReturnValue([]), }, + workspaceState: { + get: vi.fn().mockReturnValue(undefined), + update: vi.fn().mockResolvedValue(undefined), + keys: vi.fn().mockReturnValue([]), + }, secrets: { get: vi.fn(), store: vi.fn(), delete: vi.fn() }, extensionUri: {} as vscode.Uri, globalStorageUri: { fsPath: "/test/path" }, @@ -2505,6 +2387,11 @@ describe("ClineProvider - Router Models", () => { store: vi.fn().mockImplementation((key: string, value: string | undefined) => (secrets[key] = value)), delete: vi.fn().mockImplementation((key: string) => delete secrets[key]), }, + workspaceState: { + get: vi.fn().mockReturnValue(undefined), + update: vi.fn().mockResolvedValue(undefined), + keys: vi.fn().mockReturnValue([]), + }, subscriptions: [], extension: { packageJSON: { version: "1.0.0" }, @@ -2553,7 +2440,6 @@ describe("ClineProvider - Router Models", () => { apiConfiguration: { openRouterApiKey: "openrouter-key", requestyApiKey: "requesty-key", - unboundApiKey: "unbound-key", litellmApiKey: "litellm-key", litellmBaseUrl: "http://localhost:4000", }, @@ -2582,9 +2468,7 @@ describe("ClineProvider - Router Models", () => { // Verify getModels was called for each provider with correct options expect(getModels).toHaveBeenCalledWith({ provider: "openrouter" }) expect(getModels).toHaveBeenCalledWith({ provider: "requesty", apiKey: "requesty-key" }) - expect(getModels).toHaveBeenCalledWith({ provider: "unbound", apiKey: "unbound-key" }) expect(getModels).toHaveBeenCalledWith({ provider: "vercel-ai-gateway" }) - expect(getModels).toHaveBeenCalledWith({ provider: "deepinfra" }) expect(getModels).toHaveBeenCalledWith( expect.objectContaining({ provider: "roo", @@ -2596,24 +2480,18 @@ describe("ClineProvider - Router Models", () => { apiKey: "litellm-key", baseUrl: "http://localhost:4000", }) - expect(getModels).toHaveBeenCalledWith({ provider: "chutes" }) // Verify response was sent expect(mockPostMessage).toHaveBeenCalledWith({ type: "routerModels", routerModels: { - deepinfra: mockModels, openrouter: mockModels, requesty: mockModels, - unbound: mockModels, roo: mockModels, - chutes: mockModels, litellm: mockModels, ollama: {}, lmstudio: {}, "vercel-ai-gateway": mockModels, - huggingface: {}, - "io-intelligence": {}, }, values: undefined, }) @@ -2627,7 +2505,6 @@ describe("ClineProvider - Router Models", () => { apiConfiguration: { openRouterApiKey: "openrouter-key", requestyApiKey: "requesty-key", - unboundApiKey: "unbound-key", litellmApiKey: "litellm-key", litellmBaseUrl: "http://localhost:4000", }, @@ -2642,11 +2519,8 @@ describe("ClineProvider - Router Models", () => { vi.mocked(getModels) .mockResolvedValueOnce(mockModels) // openrouter success .mockRejectedValueOnce(new Error("Requesty API error")) // requesty fail - .mockRejectedValueOnce(new Error("Unbound API error")) // unbound fail .mockResolvedValueOnce(mockModels) // vercel-ai-gateway success - .mockResolvedValueOnce(mockModels) // deepinfra success .mockResolvedValueOnce(mockModels) // roo success - .mockRejectedValueOnce(new Error("Chutes API error")) // chutes fail .mockRejectedValueOnce(new Error("LiteLLM connection failed")) // litellm fail await messageHandler({ type: "requestRouterModels" }) @@ -2655,18 +2529,13 @@ describe("ClineProvider - Router Models", () => { expect(mockPostMessage).toHaveBeenCalledWith({ type: "routerModels", routerModels: { - deepinfra: mockModels, openrouter: mockModels, requesty: {}, - unbound: {}, roo: mockModels, - chutes: {}, ollama: {}, lmstudio: {}, litellm: {}, "vercel-ai-gateway": mockModels, - huggingface: {}, - "io-intelligence": {}, }, values: undefined, }) @@ -2679,27 +2548,6 @@ describe("ClineProvider - Router Models", () => { values: { provider: "requesty" }, }) - expect(mockPostMessage).toHaveBeenCalledWith({ - type: "singleRouterModelFetchResponse", - success: false, - error: "Unbound API error", - values: { provider: "unbound" }, - }) - - expect(mockPostMessage).toHaveBeenCalledWith({ - type: "singleRouterModelFetchResponse", - success: false, - error: "Unbound API error", - values: { provider: "unbound" }, - }) - - expect(mockPostMessage).toHaveBeenCalledWith({ - type: "singleRouterModelFetchResponse", - success: false, - error: "Chutes API error", - values: { provider: "chutes" }, - }) - expect(mockPostMessage).toHaveBeenCalledWith({ type: "singleRouterModelFetchResponse", success: false, @@ -2717,7 +2565,6 @@ describe("ClineProvider - Router Models", () => { apiConfiguration: { openRouterApiKey: "openrouter-key", requestyApiKey: "requesty-key", - unboundApiKey: "unbound-key", // No litellm config }, } as any) @@ -2752,7 +2599,6 @@ describe("ClineProvider - Router Models", () => { apiConfiguration: { openRouterApiKey: "openrouter-key", requestyApiKey: "requesty-key", - unboundApiKey: "unbound-key", // No litellm config }, } as any) @@ -2776,18 +2622,13 @@ describe("ClineProvider - Router Models", () => { expect(mockPostMessage).toHaveBeenCalledWith({ type: "routerModels", routerModels: { - deepinfra: mockModels, openrouter: mockModels, requesty: mockModels, - unbound: mockModels, roo: mockModels, - chutes: mockModels, litellm: {}, ollama: {}, lmstudio: {}, "vercel-ai-gateway": mockModels, - huggingface: {}, - "io-intelligence": {}, }, values: undefined, }) @@ -2858,6 +2699,11 @@ describe("ClineProvider - Comprehensive Edit/Delete Edge Cases", () => { store: vi.fn().mockImplementation((key: string, value: string | undefined) => (secrets[key] = value)), delete: vi.fn().mockImplementation((key: string) => delete secrets[key]), }, + workspaceState: { + get: vi.fn().mockReturnValue(undefined), + update: vi.fn().mockResolvedValue(undefined), + keys: vi.fn().mockReturnValue([]), + }, subscriptions: [], extension: { packageJSON: { version: "1.0.0" }, @@ -3771,4 +3617,53 @@ describe("ClineProvider - Comprehensive Edit/Delete Edge Cases", () => { }) }) }) + + describe("getTaskWithId", () => { + it("returns empty apiConversationHistory when file is missing", async () => { + const historyItem = { id: "missing-api-file-task", task: "test task", ts: Date.now() } + vi.mocked(mockContext.globalState.get).mockImplementation((key: string) => { + if (key === "taskHistory") { + return [historyItem] + } + return undefined + }) + + const deleteTaskSpy = vi.spyOn(provider, "deleteTaskFromState") + + const result = await (provider as any).getTaskWithId("missing-api-file-task") + + expect(result.historyItem).toEqual(historyItem) + expect(result.apiConversationHistory).toEqual([]) + expect(deleteTaskSpy).not.toHaveBeenCalled() + }) + + it("returns empty apiConversationHistory when file contains invalid JSON", async () => { + const historyItem = { id: "corrupt-api-task", task: "test task", ts: Date.now() } + vi.mocked(mockContext.globalState.get).mockImplementation((key: string) => { + if (key === "taskHistory") { + return [historyItem] + } + return undefined + }) + + // Make fileExistsAtPath return true so the read path is exercised + const fsUtils = await import("../../../utils/fs") + vi.spyOn(fsUtils, "fileExistsAtPath").mockResolvedValue(true) + + // Make readFile return corrupted JSON + const fsp = await import("fs/promises") + vi.mocked(fsp.readFile).mockResolvedValueOnce("{not valid json!!!" as never) + + const deleteTaskSpy = vi.spyOn(provider, "deleteTaskFromState") + + const result = await (provider as any).getTaskWithId("corrupt-api-task") + + expect(result.historyItem).toEqual(historyItem) + expect(result.apiConversationHistory).toEqual([]) + expect(deleteTaskSpy).not.toHaveBeenCalled() + + // Restore the spy + vi.mocked(fsUtils.fileExistsAtPath).mockRestore() + }) + }) }) diff --git a/src/core/webview/__tests__/ClineProvider.sticky-mode.spec.ts b/src/core/webview/__tests__/ClineProvider.sticky-mode.spec.ts index 27aab0b7da..abef31af89 100644 --- a/src/core/webview/__tests__/ClineProvider.sticky-mode.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.sticky-mode.spec.ts @@ -112,9 +112,6 @@ vi.mock("@roo-code/cloud", () => ({ } }, }, - BridgeOrchestrator: { - isEnabled: vi.fn().mockReturnValue(false), - }, getRooCodeApiUrl: vi.fn().mockReturnValue("https://app.roocode.com"), })) @@ -124,7 +121,7 @@ vi.mock("../../../shared/modes", () => ({ slug: "code", name: "Code Mode", roleDefinition: "You are a code assistant", - groups: ["read", "edit", "browser"], + groups: ["read", "edit"], }, { slug: "architect", @@ -137,7 +134,7 @@ vi.mock("../../../shared/modes", () => ({ slug: "code", name: "Code Mode", roleDefinition: "You are a code assistant", - groups: ["read", "edit", "browser"], + groups: ["read", "edit"], }), defaultModeSlug: "code", })) @@ -165,10 +162,23 @@ vi.mock("fs/promises", () => ({ mkdir: vi.fn().mockResolvedValue(undefined), writeFile: vi.fn().mockResolvedValue(undefined), readFile: vi.fn().mockResolvedValue(""), + readdir: vi.fn().mockResolvedValue([]), unlink: vi.fn().mockResolvedValue(undefined), rmdir: vi.fn().mockResolvedValue(undefined), + access: vi.fn().mockResolvedValue(undefined), + rm: vi.fn().mockResolvedValue(undefined), })) +vi.mock("../../../utils/storage", async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + getStorageBasePath: vi.fn().mockImplementation((defaultPath: string) => defaultPath), + getSettingsDirectoryPath: vi.fn().mockResolvedValue("/test/settings/path"), + getTaskDirectoryPath: vi.fn().mockResolvedValue("/test/task/path"), + } +}) + vi.mock("@roo-code/telemetry", () => ({ TelemetryService: { hasInstance: vi.fn().mockReturnValue(true), @@ -191,7 +201,7 @@ describe("ClineProvider - Sticky Mode", () => { let mockWebviewView: vscode.WebviewView let mockPostMessage: any - beforeEach(() => { + beforeEach(async () => { vi.clearAllMocks() if (!TelemetryService.hasInstance()) { @@ -227,6 +237,11 @@ describe("ClineProvider - Sticky Mode", () => { return Promise.resolve() }), }, + workspaceState: { + get: vi.fn().mockReturnValue(undefined), + update: vi.fn().mockResolvedValue(undefined), + keys: vi.fn().mockReturnValue([]), + }, subscriptions: [], extension: { packageJSON: { version: "1.0.0" }, @@ -263,6 +278,9 @@ describe("ClineProvider - Sticky Mode", () => { provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + // Wait for the async TaskHistoryStore initialization to complete + await new Promise((resolve) => setTimeout(resolve, 10)) + // Mock getMcpHub method provider.getMcpHub = vi.fn().mockReturnValue({ listTools: vi.fn().mockResolvedValue([]), diff --git a/src/core/webview/__tests__/ClineProvider.sticky-profile.spec.ts b/src/core/webview/__tests__/ClineProvider.sticky-profile.spec.ts index 80b14746a7..da2734de87 100644 --- a/src/core/webview/__tests__/ClineProvider.sticky-profile.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.sticky-profile.spec.ts @@ -114,9 +114,6 @@ vi.mock("@roo-code/cloud", () => ({ } }, }, - BridgeOrchestrator: { - isEnabled: vi.fn().mockReturnValue(false), - }, getRooCodeApiUrl: vi.fn().mockReturnValue("https://app.roocode.com"), })) @@ -126,7 +123,7 @@ vi.mock("../../../shared/modes", () => ({ slug: "code", name: "Code Mode", roleDefinition: "You are a code assistant", - groups: ["read", "edit", "browser"], + groups: ["read", "edit"], }, { slug: "architect", @@ -139,7 +136,7 @@ vi.mock("../../../shared/modes", () => ({ slug: "code", name: "Code Mode", roleDefinition: "You are a code assistant", - groups: ["read", "edit", "browser"], + groups: ["read", "edit"], }), defaultModeSlug: "code", })) @@ -166,10 +163,23 @@ vi.mock("fs/promises", () => ({ mkdir: vi.fn().mockResolvedValue(undefined), writeFile: vi.fn().mockResolvedValue(undefined), readFile: vi.fn().mockResolvedValue(""), + readdir: vi.fn().mockResolvedValue([]), unlink: vi.fn().mockResolvedValue(undefined), rmdir: vi.fn().mockResolvedValue(undefined), + access: vi.fn().mockResolvedValue(undefined), + rm: vi.fn().mockResolvedValue(undefined), })) +vi.mock("../../../utils/storage", async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + getStorageBasePath: vi.fn().mockImplementation((defaultPath: string) => defaultPath), + getSettingsDirectoryPath: vi.fn().mockResolvedValue("/test/settings/path"), + getTaskDirectoryPath: vi.fn().mockResolvedValue("/test/task/path"), + } +}) + vi.mock("@roo-code/telemetry", () => ({ TelemetryService: { hasInstance: vi.fn().mockReturnValue(true), @@ -192,7 +202,7 @@ describe("ClineProvider - Sticky Provider Profile", () => { let mockWebviewView: vscode.WebviewView let mockPostMessage: any - beforeEach(() => { + beforeEach(async () => { vi.clearAllMocks() taskIdCounter = 0 @@ -229,6 +239,11 @@ describe("ClineProvider - Sticky Provider Profile", () => { return Promise.resolve() }), }, + workspaceState: { + get: vi.fn().mockReturnValue(undefined), + update: vi.fn().mockResolvedValue(undefined), + keys: vi.fn().mockReturnValue([]), + }, subscriptions: [], extension: { packageJSON: { version: "1.0.0" }, @@ -265,6 +280,9 @@ describe("ClineProvider - Sticky Provider Profile", () => { provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + // Wait for the async TaskHistoryStore initialization to complete + await new Promise((resolve) => setTimeout(resolve, 10)) + // Mock getMcpHub method provider.getMcpHub = vi.fn().mockReturnValue({ listTools: vi.fn().mockResolvedValue([]), @@ -296,20 +314,16 @@ describe("ClineProvider - Sticky Provider Profile", () => { // Add task to provider stack await provider.addClineToStack(mockTask as any) - // Mock getGlobalState to return task history - vi.spyOn(provider as any, "getGlobalState").mockReturnValue([ - { - id: mockTask.taskId, - ts: Date.now(), - task: "Test task", - number: 1, - tokensIn: 0, - tokensOut: 0, - cacheWrites: 0, - cacheReads: 0, - totalCost: 0, - }, - ]) + // Populate the store so persistStickyProviderProfileToCurrentTask finds the task + await provider.taskHistoryStore.upsert({ + id: mockTask.taskId, + ts: Date.now(), + task: "Test task", + number: 1, + tokensIn: 0, + tokensOut: 0, + totalCost: 0, + }) // Mock updateTaskHistory to track calls const updateTaskHistorySpy = vi @@ -603,20 +617,16 @@ describe("ClineProvider - Sticky Provider Profile", () => { updateApiConfiguration: vi.fn(), } - // Mock getGlobalState to return task history with our task - vi.spyOn(provider as any, "getGlobalState").mockReturnValue([ - { - id: mockTask.taskId, - ts: Date.now(), - task: "Test task", - number: 1, - tokensIn: 0, - tokensOut: 0, - cacheWrites: 0, - cacheReads: 0, - totalCost: 0, - }, - ]) + // Populate the store so persistStickyProviderProfileToCurrentTask finds the task + await provider.taskHistoryStore.upsert({ + id: mockTask.taskId, + ts: Date.now(), + task: "Test task", + number: 1, + tokensIn: 0, + tokensOut: 0, + totalCost: 0, + }) // Mock updateTaskHistory to capture the updated history item let updatedHistoryItem: any @@ -715,7 +725,10 @@ describe("ClineProvider - Sticky Provider Profile", () => { }, ] - vi.spyOn(provider as any, "getGlobalState").mockReturnValue(taskHistory) + // Populate the store + for (const item of taskHistory) { + await provider.taskHistoryStore.upsert(item as any) + } // Mock updateTaskHistory vi.spyOn(provider, "updateTaskHistory").mockImplementation((item) => { @@ -771,20 +784,16 @@ describe("ClineProvider - Sticky Provider Profile", () => { // Add task to provider stack await provider.addClineToStack(mockTask as any) - // Mock getGlobalState - vi.spyOn(provider as any, "getGlobalState").mockReturnValue([ - { - id: mockTask.taskId, - ts: Date.now(), - task: "Test task", - number: 1, - tokensIn: 0, - tokensOut: 0, - cacheWrites: 0, - cacheReads: 0, - totalCost: 0, - }, - ]) + // Populate the store + await provider.taskHistoryStore.upsert({ + id: mockTask.taskId, + ts: Date.now(), + task: "Test task", + number: 1, + tokensIn: 0, + tokensOut: 0, + totalCost: 0, + }) // Mock updateTaskHistory to throw error vi.spyOn(provider, "updateTaskHistory").mockRejectedValue(new Error("Save failed")) diff --git a/src/core/webview/__tests__/ClineProvider.taskHistory.spec.ts b/src/core/webview/__tests__/ClineProvider.taskHistory.spec.ts index f5e6afa7f0..d1bbd9bca6 100644 --- a/src/core/webview/__tests__/ClineProvider.taskHistory.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.taskHistory.spec.ts @@ -17,8 +17,11 @@ vi.mock("fs/promises", () => ({ mkdir: vi.fn().mockResolvedValue(undefined), writeFile: vi.fn().mockResolvedValue(undefined), readFile: vi.fn().mockResolvedValue(""), + readdir: vi.fn().mockResolvedValue([]), unlink: vi.fn().mockResolvedValue(undefined), rmdir: vi.fn().mockResolvedValue(undefined), + access: vi.fn().mockResolvedValue(undefined), + rm: vi.fn().mockResolvedValue(undefined), })) vi.mock("axios", () => ({ @@ -44,6 +47,11 @@ vi.mock("../../../utils/storage", () => ({ getSettingsDirectoryPath: vi.fn().mockResolvedValue("/test/settings/path"), getTaskDirectoryPath: vi.fn().mockResolvedValue("/test/task/path"), getGlobalStoragePath: vi.fn().mockResolvedValue("/test/storage/path"), + getStorageBasePath: vi.fn().mockImplementation((defaultPath: string) => defaultPath), +})) + +vi.mock("../../../utils/safeWriteJson", () => ({ + safeWriteJson: vi.fn().mockResolvedValue(undefined), })) vi.mock("@modelcontextprotocol/sdk/types.js", () => ({ @@ -67,18 +75,6 @@ vi.mock("@modelcontextprotocol/sdk/types.js", () => ({ }, })) -vi.mock("../../../services/browser/BrowserSession", () => ({ - BrowserSession: vi.fn().mockImplementation(() => ({ - testConnection: vi.fn().mockResolvedValue({ success: false }), - })), -})) - -vi.mock("../../../services/browser/browserDiscovery", () => ({ - discoverChromeHostUrl: vi.fn().mockResolvedValue("http://localhost:9222"), - tryChromeHostUrl: vi.fn().mockResolvedValue(false), - testBrowserConnection: vi.fn(), -})) - vi.mock("@modelcontextprotocol/sdk/client/index.js", () => ({ Client: vi.fn().mockImplementation(() => ({ connect: vi.fn().mockResolvedValue(undefined), @@ -233,9 +229,6 @@ vi.mock("@roo-code/cloud", () => ({ } }, }, - BridgeOrchestrator: { - isEnabled: vi.fn().mockReturnValue(false), - }, getRooCodeApiUrl: vi.fn().mockReturnValue("https://app.roocode.com"), })) @@ -251,7 +244,7 @@ describe("ClineProvider Task History Synchronization", () => { let mockPostMessage: ReturnType let taskHistoryState: HistoryItem[] - beforeEach(() => { + beforeEach(async () => { vi.clearAllMocks() if (!TelemetryService.hasInstance()) { @@ -287,6 +280,11 @@ describe("ClineProvider Task History Synchronization", () => { store: vi.fn().mockImplementation((key: string, value: string | undefined) => (secrets[key] = value)), delete: vi.fn().mockImplementation((key: string) => delete secrets[key]), }, + workspaceState: { + get: vi.fn().mockReturnValue(undefined), + update: vi.fn().mockResolvedValue(undefined), + keys: vi.fn().mockReturnValue([]), + }, subscriptions: [], extension: { packageJSON: { version: "1.0.0" }, @@ -323,6 +321,10 @@ describe("ClineProvider Task History Synchronization", () => { provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + // Wait for the async TaskHistoryStore initialization to complete + // (fire-and-forget from the constructor; microtasks need to flush) + await new Promise((resolve) => setTimeout(resolve, 10)) + // Mock the custom modes manager ;(provider as any).customModesManager = { updateCustomMode: vi.fn().mockResolvedValue(undefined), @@ -415,6 +417,74 @@ describe("ClineProvider Task History Synchronization", () => { expect(taskHistoryItemUpdatedCalls.length).toBe(0) }) + it("preserves delegated metadata on partial update unless explicitly overwritten (UTH-02)", async () => { + await provider.resolveWebviewView(mockWebviewView) + provider.isViewLaunched = true + + const initial = createHistoryItem({ + id: "task-delegated-metadata", + task: "Delegated task", + status: "delegated", + delegatedToId: "child-1", + awaitingChildId: "child-1", + childIds: ["child-1"], + }) + + await provider.updateTaskHistory(initial, { broadcast: false }) + + // Partial update intentionally omits delegated metadata fields. + const partialUpdate: HistoryItem = { + ...createHistoryItem({ id: "task-delegated-metadata", task: "Delegated task (updated)" }), + status: "active", + } + + const updatedHistory = await provider.updateTaskHistory(partialUpdate, { broadcast: false }) + const updatedItem = updatedHistory.find((item) => item.id === "task-delegated-metadata") + + expect(updatedItem).toBeDefined() + expect(updatedItem?.status).toBe("active") + expect(updatedItem?.delegatedToId).toBe("child-1") + expect(updatedItem?.awaitingChildId).toBe("child-1") + expect(updatedItem?.childIds).toEqual(["child-1"]) + }) + + it("invalidates recentTasksCache on updateTaskHistory (UTH-04)", async () => { + const workspace = provider.cwd + const tsBase = Date.now() + + await provider.updateTaskHistory( + createHistoryItem({ + id: "cache-seed", + task: "Cache seed", + workspace, + ts: tsBase, + }), + { broadcast: false }, + ) + + const initialRecent = provider.getRecentTasks() + expect(initialRecent).toContain("cache-seed") + + // Prime cache and verify internal cache is set. + expect((provider as unknown as { recentTasksCache?: string[] }).recentTasksCache).toEqual(initialRecent) + + await provider.updateTaskHistory( + createHistoryItem({ + id: "cache-new", + task: "Cache new", + workspace, + ts: tsBase + 1, + }), + { broadcast: false }, + ) + + // Direct assertion for invalidation side-effect. + expect((provider as unknown as { recentTasksCache?: string[] }).recentTasksCache).toBeUndefined() + + const recomputedRecent = provider.getRecentTasks() + expect(recomputedRecent).toContain("cache-new") + }) + it("updates existing task in history", async () => { await provider.resolveWebviewView(mockWebviewView) provider.isViewLaunched = true @@ -435,18 +505,15 @@ describe("ClineProvider Task History Synchronization", () => { await provider.updateTaskHistory(updatedItem) - // Verify the update was persisted - expect(mockContext.globalState.update).toHaveBeenCalledWith( - "taskHistory", + // Verify the update was persisted in the store + const storeHistory = provider.taskHistoryStore.getAll() + expect(storeHistory).toEqual( expect.arrayContaining([expect.objectContaining({ id: "task-update", task: "Updated task" })]), ) // Should not have duplicates - const allCalls = (mockContext.globalState.update as ReturnType).mock.calls - const lastUpdateCall = allCalls.find((call: any[]) => call[0] === "taskHistory") - const historyArray = lastUpdateCall?.[1] as HistoryItem[] - const matchingItems = historyArray?.filter((item: HistoryItem) => item.id === "task-update") - expect(matchingItems?.length).toBe(1) + const matchingItems = storeHistory.filter((item: HistoryItem) => item.id === "task-update") + expect(matchingItems.length).toBe(1) }) it("returns the updated task history array", async () => { @@ -521,18 +588,14 @@ describe("ClineProvider Task History Synchronization", () => { expect(sentHistory[0].id).toBe("valid") }) - it("reads from global state when no history is provided", async () => { + it("reads from store when no history is provided", async () => { await provider.resolveWebviewView(mockWebviewView) provider.isViewLaunched = true - // Set up task history in global state + // Populate the store with an item const now = Date.now() - const stateHistory: HistoryItem[] = [createHistoryItem({ id: "from-state", ts: now, task: "State task" })] - - // Update the mock to return our history - ;(mockContext.globalState.get as ReturnType).mockImplementation((key: string) => { - if (key === "taskHistory") return stateHistory - return undefined + await provider.updateTaskHistory(createHistoryItem({ id: "from-store", ts: now, task: "Store task" }), { + broadcast: false, }) // Clear previous calls @@ -544,8 +607,8 @@ describe("ClineProvider Task History Synchronization", () => { const call = calls.find((c) => c[0]?.type === "taskHistoryUpdated") const sentHistory = call?.[0]?.taskHistory as HistoryItem[] - expect(sentHistory.length).toBe(1) - expect(sentHistory[0].id).toBe("from-state") + expect(sentHistory.length).toBeGreaterThanOrEqual(1) + expect(sentHistory.some((item) => item.id === "from-store")).toBe(true) }) }) @@ -554,13 +617,18 @@ describe("ClineProvider Task History Synchronization", () => { await provider.resolveWebviewView(mockWebviewView) const now = Date.now() - const multiWorkspaceHistory: HistoryItem[] = [ + + // Populate the store with multi-workspace items + await provider.updateTaskHistory( createHistoryItem({ id: "ws1-task", ts: now, task: "Workspace 1 task", workspace: "/path/to/workspace1", }), + { broadcast: false }, + ) + await provider.updateTaskHistory( createHistoryItem({ id: "ws2-task", ts: now - 1000, @@ -568,6 +636,9 @@ describe("ClineProvider Task History Synchronization", () => { workspace: "/path/to/workspace2", number: 2, }), + { broadcast: false }, + ) + await provider.updateTaskHistory( createHistoryItem({ id: "ws3-task", ts: now - 2000, @@ -575,13 +646,8 @@ describe("ClineProvider Task History Synchronization", () => { workspace: "/different/workspace", number: 3, }), - ] - - // Update the mock to return multi-workspace history - ;(mockContext.globalState.get as ReturnType).mockImplementation((key: string) => { - if (key === "taskHistory") return multiWorkspaceHistory - return undefined - }) + { broadcast: false }, + ) const state = await provider.getStateToPostToWebview() @@ -592,4 +658,100 @@ describe("ClineProvider Task History Synchronization", () => { expect(state.taskHistory.some((item: HistoryItem) => item.workspace === "/different/workspace")).toBe(true) }) }) + + describe("taskHistory write lock (mutex)", () => { + it("serializes concurrent updateTaskHistory calls so no entries are lost", async () => { + await provider.resolveWebviewView(mockWebviewView) + + // Fire 5 concurrent updateTaskHistory calls + const items = Array.from({ length: 5 }, (_, i) => + createHistoryItem({ id: `concurrent-${i}`, task: `Task ${i}` }), + ) + + await Promise.all(items.map((item) => provider.updateTaskHistory(item, { broadcast: false }))) + + // All 5 entries must survive (read from store, not debounced globalState) + const history = provider.taskHistoryStore.getAll() + const ids = history.map((h: HistoryItem) => h.id) + for (const item of items) { + expect(ids).toContain(item.id) + } + expect(history.length).toBe(5) + }) + + it("serializes concurrent update and deleteTaskFromState so they don't corrupt each other", async () => { + await provider.resolveWebviewView(mockWebviewView) + + // Seed with two items + const keep = createHistoryItem({ id: "keep-me", task: "Keep" }) + const remove = createHistoryItem({ id: "remove-me", task: "Remove" }) + await provider.updateTaskHistory(keep, { broadcast: false }) + await provider.updateTaskHistory(remove, { broadcast: false }) + + // Concurrently: add a new item AND delete "remove-me" + const newItem = createHistoryItem({ id: "new-item", task: "New" }) + await Promise.all([ + provider.updateTaskHistory(newItem, { broadcast: false }), + provider.deleteTaskFromState("remove-me"), + ]) + + const history = provider.taskHistoryStore.getAll() + const ids = history.map((h: HistoryItem) => h.id) + expect(ids).toContain("keep-me") + expect(ids).toContain("new-item") + expect(ids).not.toContain("remove-me") + }) + + it("does not block subsequent writes when a previous store write errors", async () => { + await provider.resolveWebviewView(mockWebviewView) + + // Temporarily make the store's safeWriteJson throw + const { safeWriteJson } = await import("../../../utils/safeWriteJson") + const mockSafeWriteJson = vi.mocked(safeWriteJson) + let callCount = 0 + mockSafeWriteJson.mockImplementation(async () => { + callCount++ + if (callCount === 1) { + throw new Error("simulated write failure") + } + }) + + // First call should fail (store write failure) + const item1 = createHistoryItem({ id: "fail-item", task: "Fail" }) + await expect(provider.updateTaskHistory(item1, { broadcast: false })).rejects.toThrow( + "simulated write failure", + ) + + // Restore mock + mockSafeWriteJson.mockResolvedValue(undefined) + + // Second call should still succeed (store lock not stuck) + const item2 = createHistoryItem({ id: "ok-item", task: "OK" }) + const result = await provider.updateTaskHistory(item2, { broadcast: false }) + expect(result.some((h) => h.id === "ok-item")).toBe(true) + }) + + it("serializes concurrent updates to the same item preserving the last write", async () => { + await provider.resolveWebviewView(mockWebviewView) + + const base = createHistoryItem({ id: "race-item", task: "Original" }) + await provider.updateTaskHistory(base, { broadcast: false }) + + // Fire two concurrent updates to the same item + await Promise.all([ + provider.updateTaskHistory(createHistoryItem({ id: "race-item", task: "Original", tokensIn: 111 }), { + broadcast: false, + }), + provider.updateTaskHistory(createHistoryItem({ id: "race-item", task: "Original", tokensIn: 222 }), { + broadcast: false, + }), + ]) + + const history = provider.taskHistoryStore.getAll() + const item = history.find((h: HistoryItem) => h.id === "race-item") + expect(item).toBeDefined() + // The second write (tokensIn: 222) should be the last one since writes are serialized + expect(item!.tokensIn).toBe(222) + }) + }) }) diff --git a/src/core/webview/__tests__/generateSystemPrompt.browser-capability.spec.ts b/src/core/webview/__tests__/generateSystemPrompt.browser-capability.spec.ts deleted file mode 100644 index 3b521c0f14..0000000000 --- a/src/core/webview/__tests__/generateSystemPrompt.browser-capability.spec.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { describe, test, expect, vi } from "vitest" - -// Module under test -import { generateSystemPrompt } from "../generateSystemPrompt" - -// Mock SYSTEM_PROMPT to capture its third argument (browser capability flag) -vi.mock("../../prompts/system", () => ({ - SYSTEM_PROMPT: vi.fn(async (_ctx, _cwd, canUseBrowserTool: boolean) => { - // return a simple string to satisfy return type - return `SYSTEM_PROMPT:${canUseBrowserTool}` - }), -})) - -// Mock API handler so we control model.info flags -vi.mock("../../../api", () => ({ - buildApiHandler: vi.fn((_config) => ({ - getModel: () => ({ - id: "mock-model", - info: { - supportsImages: true, - contextWindow: 200_000, - maxTokens: 8192, - supportsPromptCache: false, - }, - }), - })), -})) - -// Minimal mode utilities: provide a custom mode that includes the "browser" group -const mockCustomModes = [ - { - slug: "test-mode", - name: "Test Mode", - roleDefinition: "Test role", - description: "", - groups: ["browser"], // critical: include browser group - }, -] - -// Minimal ClineProvider stub -function makeProviderStub() { - return { - cwd: "/tmp", - context: {} as any, - customModesManager: { - getCustomModes: async () => mockCustomModes, - }, - getCurrentTask: () => ({ - rooIgnoreController: { getInstructions: () => undefined }, - }), - getMcpHub: () => undefined, - getSkillsManager: () => undefined, - // State must enable browser tool and provide apiConfiguration - getState: async () => ({ - apiConfiguration: { - apiProvider: "openrouter", // not used by the test beyond handler creation - }, - customModePrompts: undefined, - customInstructions: undefined, - browserViewportSize: "900x600", - mcpEnabled: false, - experiments: {}, - browserToolEnabled: true, // critical: enabled in settings - language: "en", - maxReadFileLine: -1, - maxConcurrentFileReads: 5, - }), - } as any -} - -describe("generateSystemPrompt browser capability (supportsImages=true)", () => { - test("passes canUseBrowserTool=true when mode has browser group and setting enabled", async () => { - const provider = makeProviderStub() - const message = { mode: "test-mode" } as any - - const result = await generateSystemPrompt(provider, message) - - // SYSTEM_PROMPT mock encodes the boolean into the returned string - expect(result).toBe("SYSTEM_PROMPT:true") - }) -}) diff --git a/src/core/webview/__tests__/skillsMessageHandler.spec.ts b/src/core/webview/__tests__/skillsMessageHandler.spec.ts new file mode 100644 index 0000000000..4aac692911 --- /dev/null +++ b/src/core/webview/__tests__/skillsMessageHandler.spec.ts @@ -0,0 +1,415 @@ +// npx vitest run src/core/webview/__tests__/skillsMessageHandler.spec.ts + +import type { SkillMetadata, WebviewMessage } from "@roo-code/types" +import type { ClineProvider } from "../ClineProvider" + +// Mock vscode first +vi.mock("vscode", () => { + const showErrorMessage = vi.fn() + + return { + window: { + showErrorMessage, + }, + } +}) + +// Mock open-file +vi.mock("../../../integrations/misc/open-file", () => ({ + openFile: vi.fn(), +})) + +// Mock i18n +vi.mock("../../../i18n", () => ({ + t: (key: string, params?: Record) => { + const translations: Record = { + "skills:errors.missing_create_fields": "Missing required fields: skillName, source, or skillDescription", + "skills:errors.manager_unavailable": "Skills manager not available", + "skills:errors.missing_delete_fields": "Missing required fields: skillName or source", + "skills:errors.missing_move_fields": "Missing required fields: skillName or source", + "skills:errors.skill_not_found": `Skill "${params?.name}" not found`, + } + return translations[key] || key + }, +})) + +import * as vscode from "vscode" +import { openFile } from "../../../integrations/misc/open-file" +import { + handleRequestSkills, + handleCreateSkill, + handleDeleteSkill, + handleMoveSkill, + handleOpenSkillFile, +} from "../skillsMessageHandler" + +describe("skillsMessageHandler", () => { + const mockLog = vi.fn() + const mockPostMessageToWebview = vi.fn() + const mockGetSkillsMetadata = vi.fn() + const mockCreateSkill = vi.fn() + const mockDeleteSkill = vi.fn() + const mockMoveSkill = vi.fn() + const mockGetSkill = vi.fn() + const mockFindSkillByNameAndSource = vi.fn() + + const createMockProvider = (hasSkillsManager: boolean = true): ClineProvider => { + const skillsManager = hasSkillsManager + ? { + getSkillsMetadata: mockGetSkillsMetadata, + createSkill: mockCreateSkill, + deleteSkill: mockDeleteSkill, + moveSkill: mockMoveSkill, + getSkill: mockGetSkill, + findSkillByNameAndSource: mockFindSkillByNameAndSource, + } + : undefined + + return { + log: mockLog, + postMessageToWebview: mockPostMessageToWebview, + getSkillsManager: () => skillsManager, + } as unknown as ClineProvider + } + + const mockSkills: SkillMetadata[] = [ + { + name: "test-skill", + description: "Test skill description", + path: "/path/to/test-skill/SKILL.md", + source: "global", + }, + { + name: "project-skill", + description: "Project skill description", + path: "/project/.roo/skills/project-skill/SKILL.md", + source: "project", + mode: "code", + }, + ] + + beforeEach(() => { + vi.clearAllMocks() + }) + + describe("handleRequestSkills", () => { + it("returns skills when skills manager is available", async () => { + const provider = createMockProvider(true) + mockGetSkillsMetadata.mockReturnValue(mockSkills) + + const result = await handleRequestSkills(provider) + + expect(result).toEqual(mockSkills) + expect(mockPostMessageToWebview).toHaveBeenCalledWith({ type: "skills", skills: mockSkills }) + }) + + it("returns empty skills when skills manager is not available", async () => { + const provider = createMockProvider(false) + + const result = await handleRequestSkills(provider) + + expect(result).toEqual([]) + expect(mockPostMessageToWebview).toHaveBeenCalledWith({ type: "skills", skills: [] }) + }) + + it("handles errors and returns empty skills", async () => { + const provider = createMockProvider(true) + mockGetSkillsMetadata.mockImplementation(() => { + throw new Error("Test error") + }) + + const result = await handleRequestSkills(provider) + + expect(result).toEqual([]) + expect(mockLog).toHaveBeenCalled() + expect(mockPostMessageToWebview).toHaveBeenCalledWith({ type: "skills", skills: [] }) + }) + }) + + describe("handleCreateSkill", () => { + it("creates a skill successfully", async () => { + const provider = createMockProvider(true) + mockCreateSkill.mockResolvedValue("/path/to/new-skill/SKILL.md") + mockGetSkillsMetadata.mockReturnValue(mockSkills) + + const result = await handleCreateSkill(provider, { + type: "createSkill", + skillName: "new-skill", + source: "global", + skillDescription: "New skill description", + } as WebviewMessage) + + expect(result).toEqual(mockSkills) + expect(mockCreateSkill).toHaveBeenCalledWith("new-skill", "global", "New skill description", undefined) + expect(openFile).toHaveBeenCalledWith("/path/to/new-skill/SKILL.md") + expect(mockPostMessageToWebview).toHaveBeenCalledWith({ type: "skills", skills: mockSkills }) + }) + + it("creates a skill with mode restriction", async () => { + const provider = createMockProvider(true) + mockCreateSkill.mockResolvedValue("/path/to/new-skill/SKILL.md") + mockGetSkillsMetadata.mockReturnValue(mockSkills) + + const result = await handleCreateSkill(provider, { + type: "createSkill", + skillName: "new-skill", + source: "project", + skillDescription: "New skill description", + skillMode: "code", + } as WebviewMessage) + + expect(result).toEqual(mockSkills) + expect(mockCreateSkill).toHaveBeenCalledWith("new-skill", "project", "New skill description", ["code"]) + }) + + it("returns undefined when required fields are missing", async () => { + const provider = createMockProvider(true) + + const result = await handleCreateSkill(provider, { + type: "createSkill", + skillName: "new-skill", + // missing source and skillDescription + } as WebviewMessage) + + expect(result).toBeUndefined() + expect(mockLog).toHaveBeenCalledWith( + "Error creating skill: Missing required fields: skillName, source, or skillDescription", + ) + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith( + "Failed to create skill: Missing required fields: skillName, source, or skillDescription", + ) + }) + + it("returns undefined when skills manager is not available", async () => { + const provider = createMockProvider(false) + + const result = await handleCreateSkill(provider, { + type: "createSkill", + skillName: "new-skill", + source: "global", + skillDescription: "New skill description", + } as WebviewMessage) + + expect(result).toBeUndefined() + expect(mockLog).toHaveBeenCalledWith("Error creating skill: Skills manager not available") + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith( + "Failed to create skill: Skills manager not available", + ) + }) + }) + + describe("handleDeleteSkill", () => { + it("deletes a skill successfully", async () => { + const provider = createMockProvider(true) + mockDeleteSkill.mockResolvedValue(undefined) + mockGetSkillsMetadata.mockReturnValue([mockSkills[1]]) + + const result = await handleDeleteSkill(provider, { + type: "deleteSkill", + skillName: "test-skill", + source: "global", + } as WebviewMessage) + + expect(result).toEqual([mockSkills[1]]) + expect(mockDeleteSkill).toHaveBeenCalledWith("test-skill", "global", undefined) + expect(mockPostMessageToWebview).toHaveBeenCalledWith({ type: "skills", skills: [mockSkills[1]] }) + }) + + it("deletes a skill with mode restriction", async () => { + const provider = createMockProvider(true) + mockDeleteSkill.mockResolvedValue(undefined) + mockGetSkillsMetadata.mockReturnValue([mockSkills[0]]) + + const result = await handleDeleteSkill(provider, { + type: "deleteSkill", + skillName: "project-skill", + source: "project", + skillMode: "code", + } as WebviewMessage) + + expect(result).toEqual([mockSkills[0]]) + expect(mockDeleteSkill).toHaveBeenCalledWith("project-skill", "project", "code") + }) + + it("returns undefined when required fields are missing", async () => { + const provider = createMockProvider(true) + + const result = await handleDeleteSkill(provider, { + type: "deleteSkill", + skillName: "test-skill", + // missing source + } as WebviewMessage) + + expect(result).toBeUndefined() + expect(mockLog).toHaveBeenCalledWith("Error deleting skill: Missing required fields: skillName or source") + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith( + "Failed to delete skill: Missing required fields: skillName or source", + ) + }) + + it("returns undefined when skills manager is not available", async () => { + const provider = createMockProvider(false) + + const result = await handleDeleteSkill(provider, { + type: "deleteSkill", + skillName: "test-skill", + source: "global", + } as WebviewMessage) + + expect(result).toBeUndefined() + expect(mockLog).toHaveBeenCalledWith("Error deleting skill: Skills manager not available") + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith( + "Failed to delete skill: Skills manager not available", + ) + }) + }) + + describe("handleMoveSkill", () => { + it("moves a skill successfully", async () => { + const provider = createMockProvider(true) + mockMoveSkill.mockResolvedValue(undefined) + mockGetSkillsMetadata.mockReturnValue([mockSkills[0]]) + + const result = await handleMoveSkill(provider, { + type: "moveSkill", + skillName: "test-skill", + source: "global", + skillMode: undefined, + newSkillMode: "code", + } as WebviewMessage) + + expect(result).toEqual([mockSkills[0]]) + expect(mockMoveSkill).toHaveBeenCalledWith("test-skill", "global", undefined, "code") + expect(mockPostMessageToWebview).toHaveBeenCalledWith({ type: "skills", skills: [mockSkills[0]] }) + }) + + it("moves a skill from one mode to another", async () => { + const provider = createMockProvider(true) + mockMoveSkill.mockResolvedValue(undefined) + mockGetSkillsMetadata.mockReturnValue([mockSkills[1]]) + + const result = await handleMoveSkill(provider, { + type: "moveSkill", + skillName: "project-skill", + source: "project", + skillMode: "code", + newSkillMode: "architect", + } as WebviewMessage) + + expect(result).toEqual([mockSkills[1]]) + expect(mockMoveSkill).toHaveBeenCalledWith("project-skill", "project", "code", "architect") + }) + + it("returns undefined when required fields are missing", async () => { + const provider = createMockProvider(true) + + const result = await handleMoveSkill(provider, { + type: "moveSkill", + skillName: "test-skill", + // missing source + } as WebviewMessage) + + expect(result).toBeUndefined() + expect(mockLog).toHaveBeenCalledWith("Error moving skill: Missing required fields: skillName or source") + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith( + "Failed to move skill: Missing required fields: skillName or source", + ) + }) + + it("returns undefined when skills manager is not available", async () => { + const provider = createMockProvider(false) + + const result = await handleMoveSkill(provider, { + type: "moveSkill", + skillName: "test-skill", + source: "global", + newSkillMode: "code", + } as WebviewMessage) + + expect(result).toBeUndefined() + expect(mockLog).toHaveBeenCalledWith("Error moving skill: Skills manager not available") + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith( + "Failed to move skill: Skills manager not available", + ) + }) + }) + + describe("handleOpenSkillFile", () => { + it("opens a skill file successfully", async () => { + const provider = createMockProvider(true) + mockFindSkillByNameAndSource.mockReturnValue(mockSkills[0]) + + await handleOpenSkillFile(provider, { + type: "openSkillFile", + skillName: "test-skill", + source: "global", + } as WebviewMessage) + + expect(mockFindSkillByNameAndSource).toHaveBeenCalledWith("test-skill", "global") + expect(openFile).toHaveBeenCalledWith("/path/to/test-skill/SKILL.md") + }) + + it("opens a skill file with mode restriction", async () => { + const provider = createMockProvider(true) + mockFindSkillByNameAndSource.mockReturnValue(mockSkills[1]) + + await handleOpenSkillFile(provider, { + type: "openSkillFile", + skillName: "project-skill", + source: "project", + skillMode: "code", + } as WebviewMessage) + + expect(mockFindSkillByNameAndSource).toHaveBeenCalledWith("project-skill", "project") + expect(openFile).toHaveBeenCalledWith("/project/.roo/skills/project-skill/SKILL.md") + }) + + it("shows error when required fields are missing", async () => { + const provider = createMockProvider(true) + + await handleOpenSkillFile(provider, { + type: "openSkillFile", + skillName: "test-skill", + // missing source + } as WebviewMessage) + + expect(mockLog).toHaveBeenCalledWith( + "Error opening skill file: Missing required fields: skillName or source", + ) + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith( + "Failed to open skill file: Missing required fields: skillName or source", + ) + }) + + it("shows error when skills manager is not available", async () => { + const provider = createMockProvider(false) + + await handleOpenSkillFile(provider, { + type: "openSkillFile", + skillName: "test-skill", + source: "global", + } as WebviewMessage) + + expect(mockLog).toHaveBeenCalledWith("Error opening skill file: Skills manager not available") + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith( + "Failed to open skill file: Skills manager not available", + ) + }) + + it("shows error when skill is not found", async () => { + const provider = createMockProvider(true) + mockFindSkillByNameAndSource.mockReturnValue(undefined) + + await handleOpenSkillFile(provider, { + type: "openSkillFile", + skillName: "nonexistent-skill", + source: "global", + } as WebviewMessage) + + expect(mockLog).toHaveBeenCalledWith('Error opening skill file: Skill "nonexistent-skill" not found') + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith( + 'Failed to open skill file: Skill "nonexistent-skill" not found', + ) + }) + }) +}) diff --git a/src/core/webview/__tests__/webviewMessageHandler.imageMentions.integration.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.imageMentions.integration.spec.ts deleted file mode 100644 index 277e56626a..0000000000 --- a/src/core/webview/__tests__/webviewMessageHandler.imageMentions.integration.spec.ts +++ /dev/null @@ -1,130 +0,0 @@ -import * as fs from "fs/promises" -import * as path from "path" -import * as os from "os" - -// Must mock dependencies before importing the handler module. -vi.mock("../../../api/providers/fetchers/modelCache") - -import { webviewMessageHandler } from "../webviewMessageHandler" -import type { ClineProvider } from "../ClineProvider" - -vi.mock("vscode", () => ({ - window: { - showInformationMessage: vi.fn(), - showErrorMessage: vi.fn(), - }, - workspace: { - workspaceFolders: [{ uri: { fsPath: "/mock/workspace" } }], - }, -})) - -// Mock imageHelpers - use actual implementations for functions that need real file access -vi.mock("../../tools/helpers/imageHelpers", async (importOriginal) => { - const actual = await importOriginal() - return { - ...actual, - validateImageForProcessing: vi.fn().mockResolvedValue({ isValid: true, sizeInMB: 0.001 }), - ImageMemoryTracker: vi.fn().mockImplementation(() => ({ - getTotalMemoryUsed: vi.fn().mockReturnValue(0), - addMemoryUsage: vi.fn(), - })), - } -}) - -describe("webviewMessageHandler - image mentions (integration)", () => { - it("resolves image mentions for newTask and passes images to createTask", async () => { - const tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), "roo-image-mentions-")) - try { - const imgBytes = Buffer.from("png-bytes") - await fs.writeFile(path.join(tmpRoot, "cat.png"), imgBytes) - - const mockProvider = { - cwd: tmpRoot, - getCurrentTask: vi.fn().mockReturnValue(undefined), - createTask: vi.fn().mockResolvedValue(undefined), - postMessageToWebview: vi.fn().mockResolvedValue(undefined), - getState: vi.fn().mockResolvedValue({ - maxImageFileSize: 5, - maxTotalImageSize: 20, - }), - } as unknown as ClineProvider - - await webviewMessageHandler(mockProvider, { - type: "newTask", - text: "Please look at @/cat.png", - images: [], - } as any) - - expect(mockProvider.createTask).toHaveBeenCalledWith("Please look at @/cat.png", [ - `data:image/png;base64,${imgBytes.toString("base64")}`, - ]) - } finally { - await fs.rm(tmpRoot, { recursive: true, force: true }) - } - }) - - it("resolves image mentions for askResponse and passes images to handleWebviewAskResponse", async () => { - const tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), "roo-image-mentions-")) - try { - const imgBytes = Buffer.from("jpg-bytes") - await fs.writeFile(path.join(tmpRoot, "cat.jpg"), imgBytes) - - const handleWebviewAskResponse = vi.fn() - const mockProvider = { - cwd: tmpRoot, - getCurrentTask: vi.fn().mockReturnValue({ - cwd: tmpRoot, - handleWebviewAskResponse, - }), - getState: vi.fn().mockResolvedValue({ - maxImageFileSize: 5, - maxTotalImageSize: 20, - }), - } as unknown as ClineProvider - - await webviewMessageHandler(mockProvider, { - type: "askResponse", - askResponse: "messageResponse", - text: "Please look at @/cat.jpg", - images: [], - } as any) - - expect(handleWebviewAskResponse).toHaveBeenCalledWith("messageResponse", "Please look at @/cat.jpg", [ - `data:image/jpeg;base64,${imgBytes.toString("base64")}`, - ]) - } finally { - await fs.rm(tmpRoot, { recursive: true, force: true }) - } - }) - - it("resolves gif image mentions (matching read_file behavior)", async () => { - const tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), "roo-image-mentions-")) - try { - const imgBytes = Buffer.from("gif-bytes") - await fs.writeFile(path.join(tmpRoot, "animation.gif"), imgBytes) - - const mockProvider = { - cwd: tmpRoot, - getCurrentTask: vi.fn().mockReturnValue(undefined), - createTask: vi.fn().mockResolvedValue(undefined), - postMessageToWebview: vi.fn().mockResolvedValue(undefined), - getState: vi.fn().mockResolvedValue({ - maxImageFileSize: 5, - maxTotalImageSize: 20, - }), - } as unknown as ClineProvider - - await webviewMessageHandler(mockProvider, { - type: "newTask", - text: "See @/animation.gif", - images: [], - } as any) - - expect(mockProvider.createTask).toHaveBeenCalledWith("See @/animation.gif", [ - `data:image/gif;base64,${imgBytes.toString("base64")}`, - ]) - } finally { - await fs.rm(tmpRoot, { recursive: true, force: true }) - } - }) -}) diff --git a/src/core/webview/__tests__/webviewMessageHandler.lockApiConfig.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.lockApiConfig.spec.ts new file mode 100644 index 0000000000..fd9b4a7740 --- /dev/null +++ b/src/core/webview/__tests__/webviewMessageHandler.lockApiConfig.spec.ts @@ -0,0 +1,68 @@ +// npx vitest run core/webview/__tests__/webviewMessageHandler.lockApiConfig.spec.ts + +import { webviewMessageHandler } from "../webviewMessageHandler" +import type { ClineProvider } from "../ClineProvider" + +describe("webviewMessageHandler - lockApiConfigAcrossModes", () => { + let mockProvider: { + context: { + workspaceState: { + get: ReturnType + update: ReturnType + } + } + getState: ReturnType + postStateToWebview: ReturnType + providerSettingsManager: { + setModeConfig: ReturnType + } + postMessageToWebview: ReturnType + getCurrentTask: ReturnType + } + + beforeEach(() => { + vi.clearAllMocks() + + mockProvider = { + context: { + workspaceState: { + get: vi.fn(), + update: vi.fn().mockResolvedValue(undefined), + }, + }, + getState: vi.fn().mockResolvedValue({ + currentApiConfigName: "test-config", + listApiConfigMeta: [{ name: "test-config", id: "config-123" }], + customModes: [], + }), + postStateToWebview: vi.fn(), + providerSettingsManager: { + setModeConfig: vi.fn(), + }, + postMessageToWebview: vi.fn(), + getCurrentTask: vi.fn(), + } + }) + + it("sets lockApiConfigAcrossModes to true and posts state without mode config fan-out", async () => { + await webviewMessageHandler(mockProvider as unknown as ClineProvider, { + type: "lockApiConfigAcrossModes", + bool: true, + }) + + expect(mockProvider.context.workspaceState.update).toHaveBeenCalledWith("lockApiConfigAcrossModes", true) + expect(mockProvider.providerSettingsManager.setModeConfig).not.toHaveBeenCalled() + expect(mockProvider.postStateToWebview).toHaveBeenCalled() + }) + + it("sets lockApiConfigAcrossModes to false without applying to all modes", async () => { + await webviewMessageHandler(mockProvider as unknown as ClineProvider, { + type: "lockApiConfigAcrossModes", + bool: false, + }) + + expect(mockProvider.context.workspaceState.update).toHaveBeenCalledWith("lockApiConfigAcrossModes", false) + expect(mockProvider.providerSettingsManager.setModeConfig).not.toHaveBeenCalled() + expect(mockProvider.postStateToWebview).toHaveBeenCalled() + }) +}) diff --git a/src/core/webview/__tests__/webviewMessageHandler.routerModels.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.routerModels.spec.ts index df2616a842..111b6c745d 100644 --- a/src/core/webview/__tests__/webviewMessageHandler.routerModels.spec.ts +++ b/src/core/webview/__tests__/webviewMessageHandler.routerModels.spec.ts @@ -74,14 +74,8 @@ describe("webviewMessageHandler - requestRouterModels provider filter", () => { return { "openrouter/qwen2.5": { contextWindow: 32768, supportsPromptCache: false } } case "requesty": return { "requesty/model": { contextWindow: 8192, supportsPromptCache: false } } - case "deepinfra": - return { "deepinfra/model": { contextWindow: 8192, supportsPromptCache: false } } - case "unbound": - return { "unbound/model": { contextWindow: 8192, supportsPromptCache: false } } case "vercel-ai-gateway": return { "vercel/model": { contextWindow: 8192, supportsPromptCache: false } } - case "io-intelligence": - return { "io/model": { contextWindow: 8192, supportsPromptCache: false } } case "litellm": return { "litellm/model": { contextWindow: 8192, supportsPromptCache: false } } default: diff --git a/src/core/webview/__tests__/webviewMessageHandler.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.spec.ts index faa8e92682..420d309fb7 100644 --- a/src/core/webview/__tests__/webviewMessageHandler.spec.ts +++ b/src/core/webview/__tests__/webviewMessageHandler.spec.ts @@ -265,7 +265,6 @@ describe("webviewMessageHandler - requestRouterModels", () => { apiConfiguration: { openRouterApiKey: "openrouter-key", requestyApiKey: "requesty-key", - unboundApiKey: "unbound-key", litellmApiKey: "litellm-key", litellmBaseUrl: "http://localhost:4000", }, @@ -297,9 +296,7 @@ describe("webviewMessageHandler - requestRouterModels", () => { // Verify getModels was called for each provider expect(mockGetModels).toHaveBeenCalledWith({ provider: "openrouter" }) expect(mockGetModels).toHaveBeenCalledWith({ provider: "requesty", apiKey: "requesty-key" }) - expect(mockGetModels).toHaveBeenCalledWith({ provider: "unbound", apiKey: "unbound-key" }) expect(mockGetModels).toHaveBeenCalledWith({ provider: "vercel-ai-gateway" }) - expect(mockGetModels).toHaveBeenCalledWith({ provider: "deepinfra" }) expect(mockGetModels).toHaveBeenCalledWith( expect.objectContaining({ provider: "roo", @@ -311,25 +308,18 @@ describe("webviewMessageHandler - requestRouterModels", () => { apiKey: "litellm-key", baseUrl: "http://localhost:4000", }) - // Note: huggingface is not fetched in requestRouterModels - it has its own handler - // Note: io-intelligence is not fetched because no API key is provided in the mock state // Verify response was sent expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({ type: "routerModels", routerModels: { - deepinfra: mockModels, openrouter: mockModels, requesty: mockModels, - unbound: mockModels, litellm: mockModels, roo: mockModels, - chutes: mockModels, ollama: {}, lmstudio: {}, "vercel-ai-gateway": mockModels, - huggingface: {}, - "io-intelligence": {}, }, values: undefined, }) @@ -340,7 +330,6 @@ describe("webviewMessageHandler - requestRouterModels", () => { apiConfiguration: { openRouterApiKey: "openrouter-key", requestyApiKey: "requesty-key", - unboundApiKey: "unbound-key", // Missing litellm config }, }) @@ -377,7 +366,6 @@ describe("webviewMessageHandler - requestRouterModels", () => { apiConfiguration: { openRouterApiKey: "openrouter-key", requestyApiKey: "requesty-key", - unboundApiKey: "unbound-key", // Missing litellm config }, }) @@ -409,18 +397,13 @@ describe("webviewMessageHandler - requestRouterModels", () => { expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({ type: "routerModels", routerModels: { - deepinfra: mockModels, openrouter: mockModels, requesty: mockModels, - unbound: mockModels, roo: mockModels, - chutes: mockModels, litellm: {}, ollama: {}, lmstudio: {}, "vercel-ai-gateway": mockModels, - huggingface: {}, - "io-intelligence": {}, }, values: undefined, }) @@ -440,11 +423,8 @@ describe("webviewMessageHandler - requestRouterModels", () => { mockGetModels .mockResolvedValueOnce(mockModels) // openrouter .mockRejectedValueOnce(new Error("Requesty API error")) // requesty - .mockRejectedValueOnce(new Error("Unbound API error")) // unbound .mockResolvedValueOnce(mockModels) // vercel-ai-gateway - .mockResolvedValueOnce(mockModels) // deepinfra .mockResolvedValueOnce(mockModels) // roo - .mockRejectedValueOnce(new Error("Chutes API error")) // chutes .mockRejectedValueOnce(new Error("LiteLLM connection failed")) // litellm await webviewMessageHandler(mockClineProvider, { @@ -459,20 +439,6 @@ describe("webviewMessageHandler - requestRouterModels", () => { values: { provider: "requesty" }, }) - expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({ - type: "singleRouterModelFetchResponse", - success: false, - error: "Unbound API error", - values: { provider: "unbound" }, - }) - - expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({ - type: "singleRouterModelFetchResponse", - success: false, - error: "Chutes API error", - values: { provider: "chutes" }, - }) - expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({ type: "singleRouterModelFetchResponse", success: false, @@ -484,18 +450,13 @@ describe("webviewMessageHandler - requestRouterModels", () => { expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({ type: "routerModels", routerModels: { - deepinfra: mockModels, openrouter: mockModels, requesty: {}, - unbound: {}, roo: mockModels, - chutes: {}, litellm: {}, ollama: {}, lmstudio: {}, "vercel-ai-gateway": mockModels, - huggingface: {}, - "io-intelligence": {}, }, values: undefined, }) @@ -506,11 +467,8 @@ describe("webviewMessageHandler - requestRouterModels", () => { mockGetModels .mockRejectedValueOnce(new Error("Structured error message")) // openrouter .mockRejectedValueOnce(new Error("Requesty API error")) // requesty - .mockRejectedValueOnce(new Error("Unbound API error")) // unbound .mockRejectedValueOnce(new Error("Vercel AI Gateway error")) // vercel-ai-gateway - .mockRejectedValueOnce(new Error("DeepInfra API error")) // deepinfra .mockRejectedValueOnce(new Error("Roo API error")) // roo - .mockRejectedValueOnce(new Error("Chutes API error")) // chutes .mockRejectedValueOnce(new Error("LiteLLM connection failed")) // litellm await webviewMessageHandler(mockClineProvider, { @@ -532,20 +490,6 @@ describe("webviewMessageHandler - requestRouterModels", () => { values: { provider: "requesty" }, }) - expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({ - type: "singleRouterModelFetchResponse", - success: false, - error: "Unbound API error", - values: { provider: "unbound" }, - }) - - expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({ - type: "singleRouterModelFetchResponse", - success: false, - error: "DeepInfra API error", - values: { provider: "deepinfra" }, - }) - expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({ type: "singleRouterModelFetchResponse", success: false, @@ -560,13 +504,6 @@ describe("webviewMessageHandler - requestRouterModels", () => { values: { provider: "roo" }, }) - expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({ - type: "singleRouterModelFetchResponse", - success: false, - error: "Chutes API error", - values: { provider: "chutes" }, - }) - expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({ type: "singleRouterModelFetchResponse", success: false, diff --git a/src/core/webview/generateSystemPrompt.ts b/src/core/webview/generateSystemPrompt.ts index b6f77d3842..8af2f5ff5d 100644 --- a/src/core/webview/generateSystemPrompt.ts +++ b/src/core/webview/generateSystemPrompt.ts @@ -1,6 +1,6 @@ import * as vscode from "vscode" import { WebviewMessage } from "../../shared/WebviewMessage" -import { defaultModeSlug, getModeBySlug, getGroupName } from "../../shared/modes" +import { defaultModeSlug } from "../../shared/modes" import { buildApiHandler } from "../../api" import { SYSTEM_PROMPT } from "../prompts/system" @@ -14,13 +14,9 @@ export const generateSystemPrompt = async (provider: ClineProvider, message: Web apiConfiguration, customModePrompts, customInstructions, - browserViewportSize, mcpEnabled, experiments, - browserToolEnabled, language, - maxReadFileLine, - maxConcurrentFileReads, enableSubfolderRules, } = await provider.getState() @@ -33,36 +29,22 @@ export const generateSystemPrompt = async (provider: ClineProvider, message: Web const rooIgnoreInstructions = provider.getCurrentTask()?.rooIgnoreController?.getInstructions() - // Determine if browser tools can be used based on model support, mode, and user settings - let modelInfo: any = undefined - - // Create a temporary API handler to check if the model supports browser capability - // This avoids relying on an active Cline instance which might not exist during preview + // Create a temporary API handler to check model info for stealth mode. + // This avoids relying on an active Cline instance which might not exist during preview. + let modelInfo: { isStealthModel?: boolean } | undefined try { const tempApiHandler = buildApiHandler(apiConfiguration) modelInfo = tempApiHandler.getModel().info } catch (error) { - console.error("Error checking if model supports browser capability:", error) + console.error("Error fetching model info for system prompt preview:", error) } - // Check if the current mode includes the browser tool group - const modeConfig = getModeBySlug(mode, customModes) - const modeSupportsBrowser = modeConfig?.groups.some((group) => getGroupName(group) === "browser") ?? false - - // Check if model supports browser capability (images) - const modelSupportsBrowser = modelInfo && (modelInfo as any)?.supportsImages === true - - // Only enable browser tools if the model supports it, the mode includes browser tools, - // and browser tools are enabled in settings - const canUseBrowserTool = modelSupportsBrowser && modeSupportsBrowser && (browserToolEnabled ?? true) - const systemPrompt = await SYSTEM_PROMPT( provider.context, cwd, - canUseBrowserTool, + false, // supportsComputerUse — browser removed mcpEnabled ? provider.getMcpHub() : undefined, diffStrategy, - browserViewportSize ?? "900x600", mode, customModePrompts, customModes, @@ -70,9 +52,7 @@ export const generateSystemPrompt = async (provider: ClineProvider, message: Web experiments, language, rooIgnoreInstructions, - maxReadFileLine !== -1, { - maxConcurrentFileReads: maxConcurrentFileReads ?? 5, todoListEnabled: apiConfiguration?.todoListEnabled ?? true, useAgentRules: vscode.workspace.getConfiguration(Package.name).get("useAgentRules") ?? true, enableSubfolderRules: enableSubfolderRules ?? false, diff --git a/src/core/webview/skillsMessageHandler.ts b/src/core/webview/skillsMessageHandler.ts new file mode 100644 index 0000000000..496ff70c24 --- /dev/null +++ b/src/core/webview/skillsMessageHandler.ts @@ -0,0 +1,208 @@ +import * as vscode from "vscode" + +import type { SkillMetadata, WebviewMessage } from "@roo-code/types" + +import type { ClineProvider } from "./ClineProvider" +import { openFile } from "../../integrations/misc/open-file" +import { t } from "../../i18n" + +type SkillSource = SkillMetadata["source"] + +/** + * Handles the requestSkills message - returns all skills metadata + */ +export async function handleRequestSkills(provider: ClineProvider): Promise { + try { + const skillsManager = provider.getSkillsManager() + if (skillsManager) { + const skills = skillsManager.getSkillsMetadata() + await provider.postMessageToWebview({ type: "skills", skills }) + return skills + } else { + await provider.postMessageToWebview({ type: "skills", skills: [] }) + return [] + } + } catch (error) { + provider.log(`Error fetching skills: ${JSON.stringify(error, Object.getOwnPropertyNames(error), 2)}`) + await provider.postMessageToWebview({ type: "skills", skills: [] }) + return [] + } +} + +/** + * Handles the createSkill message - creates a new skill + */ +export async function handleCreateSkill( + provider: ClineProvider, + message: WebviewMessage, +): Promise { + try { + const skillName = message.skillName + const source = message.source as SkillSource + const skillDescription = message.skillDescription + // Support new modeSlugs array or fall back to legacy skillMode + const modeSlugs = message.skillModeSlugs ?? (message.skillMode ? [message.skillMode] : undefined) + + if (!skillName || !source || !skillDescription) { + throw new Error(t("skills:errors.missing_create_fields")) + } + + const skillsManager = provider.getSkillsManager() + if (!skillsManager) { + throw new Error(t("skills:errors.manager_unavailable")) + } + + const createdPath = await skillsManager.createSkill(skillName, source, skillDescription, modeSlugs) + + // Open the created file in the editor + openFile(createdPath) + + // Send updated skills list + const skills = skillsManager.getSkillsMetadata() + await provider.postMessageToWebview({ type: "skills", skills }) + return skills + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error) + provider.log(`Error creating skill: ${errorMessage}`) + vscode.window.showErrorMessage(`Failed to create skill: ${errorMessage}`) + return undefined + } +} + +/** + * Handles the deleteSkill message - deletes a skill + */ +export async function handleDeleteSkill( + provider: ClineProvider, + message: WebviewMessage, +): Promise { + try { + const skillName = message.skillName + const source = message.source as SkillSource + // Support new skillModeSlugs array or fall back to legacy skillMode + const skillMode = message.skillModeSlugs?.[0] ?? message.skillMode + + if (!skillName || !source) { + throw new Error(t("skills:errors.missing_delete_fields")) + } + + const skillsManager = provider.getSkillsManager() + if (!skillsManager) { + throw new Error(t("skills:errors.manager_unavailable")) + } + + await skillsManager.deleteSkill(skillName, source, skillMode) + + // Send updated skills list + const skills = skillsManager.getSkillsMetadata() + await provider.postMessageToWebview({ type: "skills", skills }) + return skills + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error) + provider.log(`Error deleting skill: ${errorMessage}`) + vscode.window.showErrorMessage(`Failed to delete skill: ${errorMessage}`) + return undefined + } +} + +/** + * Handles the moveSkill message - moves a skill to a different mode + */ +export async function handleMoveSkill( + provider: ClineProvider, + message: WebviewMessage, +): Promise { + try { + const skillName = message.skillName + const source = message.source as SkillSource + const currentMode = message.skillMode + const newMode = message.newSkillMode + + if (!skillName || !source) { + throw new Error(t("skills:errors.missing_move_fields")) + } + + const skillsManager = provider.getSkillsManager() + if (!skillsManager) { + throw new Error(t("skills:errors.manager_unavailable")) + } + + await skillsManager.moveSkill(skillName, source, currentMode, newMode) + + // Send updated skills list + const skills = skillsManager.getSkillsMetadata() + await provider.postMessageToWebview({ type: "skills", skills }) + return skills + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error) + provider.log(`Error moving skill: ${errorMessage}`) + vscode.window.showErrorMessage(`Failed to move skill: ${errorMessage}`) + return undefined + } +} + +/** + * Handles the updateSkillModes message - updates the mode associations for a skill + */ +export async function handleUpdateSkillModes( + provider: ClineProvider, + message: WebviewMessage, +): Promise { + try { + const skillName = message.skillName + const source = message.source as SkillSource + const newModeSlugs = message.newSkillModeSlugs + + if (!skillName || !source) { + throw new Error(t("skills:errors.missing_update_modes_fields")) + } + + const skillsManager = provider.getSkillsManager() + if (!skillsManager) { + throw new Error(t("skills:errors.manager_unavailable")) + } + + await skillsManager.updateSkillModes(skillName, source, newModeSlugs) + + // Send updated skills list + const skills = skillsManager.getSkillsMetadata() + await provider.postMessageToWebview({ type: "skills", skills }) + return skills + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error) + provider.log(`Error updating skill modes: ${errorMessage}`) + vscode.window.showErrorMessage(`Failed to update skill modes: ${errorMessage}`) + return undefined + } +} + +/** + * Handles the openSkillFile message - opens a skill file in the editor + */ +export async function handleOpenSkillFile(provider: ClineProvider, message: WebviewMessage): Promise { + try { + const skillName = message.skillName + const source = message.source as SkillSource + + if (!skillName || !source) { + throw new Error(t("skills:errors.missing_delete_fields")) + } + + const skillsManager = provider.getSkillsManager() + if (!skillsManager) { + throw new Error(t("skills:errors.manager_unavailable")) + } + + // Find skill by name and source (skills may have modeSlugs arrays now) + const skill = skillsManager.findSkillByNameAndSource(skillName, source) + if (!skill) { + throw new Error(t("skills:errors.skill_not_found", { name: skillName })) + } + + openFile(skill.path) + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error) + provider.log(`Error opening skill file: ${errorMessage}`) + vscode.window.showErrorMessage(`Failed to open skill file: ${errorMessage}`) + } +} diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index 242cc4bf94..fce2baf45d 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -29,14 +29,22 @@ import { type ApiMessage } from "../task-persistence/apiMessages" import { saveTaskMessages } from "../task-persistence" import { ClineProvider } from "./ClineProvider" -import { BrowserSessionPanelManager } from "./BrowserSessionPanelManager" import { handleCheckpointRestoreOperation } from "./checkpointRestoreHandler" import { generateErrorDiagnostics } from "./diagnosticsHandler" +import { + handleRequestSkills, + handleCreateSkill, + handleDeleteSkill, + handleMoveSkill, + handleUpdateSkillModes, + handleOpenSkillFile, +} from "./skillsMessageHandler" import { changeLanguage, t } from "../../i18n" import { Package } from "../../shared/package" import { type RouterName, toRouterName } from "../../shared/api" import { MessageEnhancer } from "./messageEnhancer" +import { CodeIndexManager } from "../../services/code-index/manager" import { checkExistKey } from "../../shared/checkExistApiConfig" import { experimentDefault } from "../../shared/experiments" import { Terminal } from "../../integrations/terminal/Terminal" @@ -44,7 +52,6 @@ import { openFile } from "../../integrations/misc/open-file" import { openImage, saveImage } from "../../integrations/misc/image-handler" import { selectImages } from "../../integrations/misc/process-images" import { getTheme } from "../../integrations/theme/getTheme" -import { discoverChromeHostUrl, tryChromeHostUrl } from "../../services/browser/browserDiscovery" import { searchWorkspaceFiles } from "../../services/search/file-search" import { fileExistsAtPath } from "../../utils/fs" import { playTts, setTtsEnabled, setTtsSpeed, stopTts } from "../../utils/tts" @@ -491,12 +498,18 @@ export const webviewMessageHandler = async ( if (!checkExistKey(listApiConfig[0])) { const { apiConfiguration } = await provider.getState() - await provider.providerSettingsManager.saveConfig( - listApiConfig[0].name ?? "default", - apiConfiguration, - ) + // Only save if the current configuration has meaningful settings + // (e.g., API keys). This prevents saving a default "anthropic" + // fallback when no real config exists, which can happen during + // CLI initialization before provider settings are applied. + if (checkExistKey(apiConfiguration)) { + await provider.providerSettingsManager.saveConfig( + listApiConfig[0].name ?? "default", + apiConfiguration, + ) - listApiConfig[0].apiProvider = apiConfiguration.apiProvider + listApiConfig[0].apiProvider = apiConfiguration.apiProvider + } } } @@ -861,16 +874,11 @@ export const webviewMessageHandler = async ( : { openrouter: {}, "vercel-ai-gateway": {}, - huggingface: {}, litellm: {}, - deepinfra: {}, - "io-intelligence": {}, requesty: {}, - unbound: {}, ollama: {}, lmstudio: {}, roo: {}, - chutes: {}, } const safeGetModels = async (options: GetModelsOptions): Promise => { @@ -897,16 +905,7 @@ export const webviewMessageHandler = async ( baseUrl: apiConfiguration.requestyBaseUrl, }, }, - { key: "unbound", options: { provider: "unbound", apiKey: apiConfiguration.unboundApiKey } }, { key: "vercel-ai-gateway", options: { provider: "vercel-ai-gateway" } }, - { - key: "deepinfra", - options: { - provider: "deepinfra", - apiKey: apiConfiguration.deepInfraApiKey, - baseUrl: apiConfiguration.deepInfraBaseUrl, - }, - }, { key: "roo", options: { @@ -917,20 +916,8 @@ export const webviewMessageHandler = async ( : undefined, }, }, - { - key: "chutes", - options: { provider: "chutes", apiKey: apiConfiguration.chutesApiKey }, - }, ] - // IO Intelligence is conditional on api key - if (apiConfiguration.ioIntelligenceApiKey) { - candidates.push({ - key: "io-intelligence", - options: { provider: "io-intelligence", apiKey: apiConfiguration.ioIntelligenceApiKey }, - }) - } - // LiteLLM is conditional on baseUrl+apiKey const litellmApiKey = apiConfiguration.litellmApiKey || message?.values?.litellmApiKey const litellmBaseUrl = apiConfiguration.litellmBaseUrl || message?.values?.litellmBaseUrl @@ -1259,21 +1246,6 @@ export const webviewMessageHandler = async ( // TODO: Cache like we do for OpenRouter, etc? provider.postMessageToWebview({ type: "vsCodeLmModels", vsCodeLmModels }) break - case "requestHuggingFaceModels": - // TODO: Why isn't this handled by `requestRouterModels` above? - try { - const { getHuggingFaceModelsWithMetadata } = await import("../../api/providers/fetchers/huggingface") - const huggingFaceModelsResponse = await getHuggingFaceModelsWithMetadata() - - provider.postMessageToWebview({ - type: "huggingFaceModels", - huggingFaceModels: huggingFaceModelsResponse.models, - }) - } catch (error) { - console.error("Failed to fetch Hugging Face models:", error) - provider.postMessageToWebview({ type: "huggingFaceModels", huggingFaceModels: [] }) - } - break case "openImage": openImage(message.text!, { values: message.values }) break @@ -1356,69 +1328,6 @@ export const webviewMessageHandler = async ( // Cancel any pending auto-approval timeout for the current task provider.getCurrentTask()?.cancelAutoApprovalTimeout() break - case "killBrowserSession": - { - const task = provider.getCurrentTask() - if (task?.browserSession) { - await task.browserSession.closeBrowser() - await provider.postStateToWebview() - } - } - break - case "openBrowserSessionPanel": - { - // Toggle the Browser Session panel (open if closed, close if open) - const panelManager = BrowserSessionPanelManager.getInstance(provider) - await panelManager.toggle() - } - break - case "showBrowserSessionPanelAtStep": - { - const panelManager = BrowserSessionPanelManager.getInstance(provider) - - // If this is a launch action, reset the manual close flag - if (message.isLaunchAction) { - panelManager.resetManualCloseFlag() - } - - // Show panel if: - // 1. Manual click (forceShow) - always show - // 2. Launch action - always show and reset flag - // 3. Auto-open for non-launch action - only if user hasn't manually closed - if (message.forceShow || message.isLaunchAction || panelManager.shouldAllowAutoOpen()) { - // Ensure panel is shown and populated - await panelManager.show() - - // Navigate to a specific step if provided - // For launch actions: navigate to step 0 - // For manual clicks: navigate to the clicked step - // For auto-opens of regular actions: don't navigate, let BrowserSessionRow's - // internal auto-advance logic handle it (only advances if user is on most recent step) - if (typeof message.stepIndex === "number" && message.stepIndex >= 0) { - await panelManager.navigateToStep(message.stepIndex) - } - } - } - break - case "refreshBrowserSessionPanel": - { - // Re-send the latest browser session snapshot to the panel - const panelManager = BrowserSessionPanelManager.getInstance(provider) - const task = provider.getCurrentTask() - if (task) { - const messages = task.clineMessages || [] - const browserSessionStartIndex = messages.findIndex( - (m) => - m.ask === "browser_action_launch" || - (m.say === "browser_session_status" && m.text?.includes("opened")), - ) - const browserSessionMessages = - browserSessionStartIndex !== -1 ? messages.slice(browserSessionStartIndex) : [] - const isBrowserSessionActive = task.browserSession?.isSessionActive() ?? false - await panelManager.updateBrowserSession(browserSessionMessages, isBrowserSessionActive) - } - } - break case "allowedCommands": { // Validate and sanitize the commands array const commands = message.commands ?? [] @@ -1585,25 +1494,10 @@ export const webviewMessageHandler = async ( } break } - case "remoteControlEnabled": - try { - await CloudService.instance.updateUserSettings({ extensionBridgeEnabled: message.bool ?? false }) - } catch (error) { - provider.log( - `CloudService#updateUserSettings failed: ${error instanceof Error ? error.message : String(error)}`, - ) - } - break - case "taskSyncEnabled": const enabled = message.bool ?? false const updatedSettings: Partial = { taskSyncEnabled: enabled } - // If disabling task sync, also disable remote control. - if (!enabled) { - updatedSettings.extensionBridgeEnabled = false - } - try { await CloudService.instance.updateUserSettings(updatedSettings) } catch (error) { @@ -1647,43 +1541,6 @@ export const webviewMessageHandler = async ( stopTts() break - case "testBrowserConnection": - // If no text is provided, try auto-discovery - if (!message.text) { - // Use testBrowserConnection for auto-discovery - const chromeHostUrl = await discoverChromeHostUrl() - - if (chromeHostUrl) { - // Send the result back to the webview - await provider.postMessageToWebview({ - type: "browserConnectionResult", - success: !!chromeHostUrl, - text: `Auto-discovered and tested connection to Chrome: ${chromeHostUrl}`, - values: { endpoint: chromeHostUrl }, - }) - } else { - await provider.postMessageToWebview({ - type: "browserConnectionResult", - success: false, - text: "No Chrome instances found on the network. Make sure Chrome is running with remote debugging enabled (--remote-debugging-port=9222).", - }) - } - } else { - // Test the provided URL - const customHostUrl = message.text - const hostIsValid = await tryChromeHostUrl(message.text) - - // Send the result back to the webview - await provider.postMessageToWebview({ - type: "browserConnectionResult", - success: hostIsValid, - text: hostIsValid - ? `Successfully connected to Chrome: ${customHostUrl}` - : "Failed to connect to Chrome", - }) - } - break - case "updateVSCodeSetting": { const { setting, value } = message @@ -1789,6 +1646,14 @@ export const webviewMessageHandler = async ( await provider.postStateToWebview() break + case "lockApiConfigAcrossModes": { + const enabled = message.bool ?? false + await provider.context.workspaceState.update("lockApiConfigAcrossModes", enabled) + + await provider.postStateToWebview() + break + } + case "toggleApiConfigPin": if (message.text) { const currentPinned = getGlobalState("pinnedApiConfigs") ?? {} @@ -2871,7 +2736,6 @@ export const webviewMessageHandler = async ( try { const manager = provider.getCurrentWorkspaceCodeIndexManager() if (!manager) { - // No workspace open - send error status provider.postMessageToWebview({ type: "indexingStatusUpdate", values: { @@ -2885,23 +2749,19 @@ export const webviewMessageHandler = async ( provider.log("Cannot start indexing: No workspace folder open") return } + + // "Start Indexing" implicitly enables the workspace + await manager.setWorkspaceEnabled(true) + if (manager.isFeatureEnabled && manager.isFeatureConfigured) { - // Mimic extension startup behavior: initialize first, which will - // check if Qdrant container is active and reuse existing collection await manager.initialize(provider.contextProxy) - // Only call startIndexing if we're in a state that requires it - // (e.g., Standby or Error). If already Indexed or Indexing, the - // initialize() call above will have already started the watcher. const currentState = manager.state if (currentState === "Standby" || currentState === "Error") { - // startIndexing now handles error recovery internally manager.startIndexing() - // If startIndexing recovered from error, we need to reinitialize if (!manager.isInitialized) { await manager.initialize(provider.contextProxy) - // Try starting again after initialization if (manager.state === "Standby" || manager.state === "Error") { manager.startIndexing() } @@ -2913,6 +2773,82 @@ export const webviewMessageHandler = async ( } break } + case "stopIndexing": { + try { + const manager = provider.getCurrentWorkspaceCodeIndexManager() + if (!manager) { + provider.log("Cannot stop indexing: No workspace folder open") + return + } + manager.stopIndexing() + provider.postMessageToWebview({ + type: "indexingStatusUpdate", + values: manager.getCurrentStatus(), + }) + } catch (error) { + provider.log(`Error stopping indexing: ${error instanceof Error ? error.message : String(error)}`) + } + break + } + case "toggleWorkspaceIndexing": { + try { + const manager = provider.getCurrentWorkspaceCodeIndexManager() + if (!manager) { + provider.log("Cannot toggle workspace indexing: No workspace folder open") + return + } + const enabled = message.bool ?? false + await manager.setWorkspaceEnabled(enabled) + if (enabled && manager.isFeatureEnabled && manager.isFeatureConfigured) { + await manager.initialize(provider.contextProxy) + manager.startIndexing() + } else if (!enabled) { + manager.stopIndexing() + } + provider.postMessageToWebview({ + type: "indexingStatusUpdate", + values: manager.getCurrentStatus(), + }) + } catch (error) { + provider.log( + `Error toggling workspace indexing: ${error instanceof Error ? error.message : String(error)}`, + ) + } + break + } + case "setAutoEnableDefault": { + try { + const manager = provider.getCurrentWorkspaceCodeIndexManager() + if (!manager) { + provider.log("Cannot set auto-enable default: No workspace folder open") + return + } + // Capture prior state for every manager before persisting the global change + const allManagers = CodeIndexManager.getAllInstances() + const priorStates = new Map(allManagers.map((m) => [m, m.isWorkspaceEnabled])) + await manager.setAutoEnableDefault(message.bool ?? true) + // Apply stop/start to every affected manager + for (const m of allManagers) { + const wasEnabled = priorStates.get(m)! + const isNowEnabled = m.isWorkspaceEnabled + if (wasEnabled && !isNowEnabled) { + m.stopIndexing() + } else if (!wasEnabled && isNowEnabled && m.isFeatureEnabled && m.isFeatureConfigured) { + await m.initialize(provider.contextProxy) + m.startIndexing() + } + } + provider.postMessageToWebview({ + type: "indexingStatusUpdate", + values: manager.getCurrentStatus(), + }) + } catch (error) { + provider.log( + `Error setting auto-enable default: ${error instanceof Error ? error.message : String(error)}`, + ) + } + break + } case "clearIndexData": { try { const manager = provider.getCurrentWorkspaceCodeIndexManager() @@ -3112,6 +3048,30 @@ export const webviewMessageHandler = async ( } break } + case "requestSkills": { + await handleRequestSkills(provider) + break + } + case "createSkill": { + await handleCreateSkill(provider, message) + break + } + case "deleteSkill": { + await handleDeleteSkill(provider, message) + break + } + case "moveSkill": { + await handleMoveSkill(provider, message) + break + } + case "updateSkillModes": { + await handleUpdateSkillModes(provider, message) + break + } + case "openSkillFile": { + await handleOpenSkillFile(provider, message) + break + } case "openCommandFile": { try { if (message.text) { diff --git a/src/extension.ts b/src/extension.ts index bcfbe33993..19c0d70585 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -1,19 +1,24 @@ import * as vscode from "vscode" import * as dotenvx from "@dotenvx/dotenvx" +import * as fs from "fs" import * as path from "path" // Load environment variables from .env file -try { - // Specify path to .env file in the project root directory - const envPath = path.join(__dirname, "..", ".env") - dotenvx.config({ path: envPath }) -} catch (e) { - // Silently handle environment loading errors - console.warn("Failed to load environment variables:", e) +// The extension-level .env is optional (not shipped in production builds). +// Avoid calling dotenvx when the file doesn't exist, otherwise dotenvx emits +// a noisy [MISSING_ENV_FILE] error to the extension host console. +const envPath = path.join(__dirname, "..", ".env") +if (fs.existsSync(envPath)) { + try { + dotenvx.config({ path: envPath }) + } catch (e) { + // Best-effort only: never fail extension activation due to optional env loading. + console.warn("Failed to load environment variables:", e) + } } import type { CloudUserInfo, AuthState } from "@roo-code/types" -import { CloudService, BridgeOrchestrator } from "@roo-code/cloud" +import { CloudService } from "@roo-code/cloud" import { TelemetryService, PostHogTelemetryClient } from "@roo-code/telemetry" import { customToolRegistry } from "@roo-code/core" @@ -190,21 +195,11 @@ export async function activate(context: vscode.ExtensionContext) { const provider = new ClineProvider(context, outputChannel, "sidebar", contextProxy, mdmService) // Initialize Roo Code Cloud service. - const postStateListener = () => ClineProvider.getVisibleInstance()?.postStateToWebview() + const postStateListener = () => ClineProvider.getVisibleInstance()?.postStateToWebviewWithoutClineMessages() authStateChangedHandler = async (data: { state: AuthState; previousState: AuthState }) => { postStateListener() - if (data.state === "logged-out") { - try { - await provider.remoteControlEnabled(false) - } catch (error) { - cloudLogger( - `[authStateChangedHandler] remoteControlEnabled(false) failed: ${error instanceof Error ? error.message : String(error)}`, - ) - } - } - // Handle Roo models cache based on auth state (ROO-202) const handleRooModelsCache = async () => { try { @@ -260,36 +255,11 @@ export async function activate(context: vscode.ExtensionContext) { } settingsUpdatedHandler = async () => { - const userInfo = CloudService.instance.getUserInfo() - - if (userInfo && CloudService.instance.cloudAPI) { - try { - provider.remoteControlEnabled(CloudService.instance.isTaskSyncEnabled()) - } catch (error) { - cloudLogger( - `[settingsUpdatedHandler] remoteControlEnabled failed: ${error instanceof Error ? error.message : String(error)}`, - ) - } - } - postStateListener() } userInfoHandler = async ({ userInfo }: { userInfo: CloudUserInfo }) => { postStateListener() - - if (!CloudService.instance.cloudAPI) { - cloudLogger("[userInfoHandler] CloudAPI is not initialized") - return - } - - try { - provider.remoteControlEnabled(CloudService.instance.isTaskSyncEnabled()) - } catch (error) { - cloudLogger( - `[userInfoHandler] remoteControlEnabled failed: ${error instanceof Error ? error.message : String(error)}`, - ) - } } cloudService = await CloudService.createInstance(context, cloudLogger, { @@ -476,12 +446,6 @@ export async function deactivate() { } } - const bridge = BridgeOrchestrator.getInstance() - - if (bridge) { - await bridge.disconnect() - } - await McpServerManager.cleanup(extensionContext) TelemetryService.instance.shutdown() TerminalRegistry.cleanup() diff --git a/src/extension/__tests__/api-delete-queued-message.spec.ts b/src/extension/__tests__/api-delete-queued-message.spec.ts new file mode 100644 index 0000000000..6bf6014bf8 --- /dev/null +++ b/src/extension/__tests__/api-delete-queued-message.spec.ts @@ -0,0 +1,70 @@ +import { describe, it, expect, vi, beforeEach } from "vitest" +import * as vscode from "vscode" + +import { API } from "../api" +import { ClineProvider } from "../../core/webview/ClineProvider" + +vi.mock("vscode") +vi.mock("../../core/webview/ClineProvider") + +describe("API - DeleteQueuedMessage Command", () => { + let api: API + let mockOutputChannel: vscode.OutputChannel + let mockProvider: ClineProvider + let mockRemoveMessage: ReturnType + let mockLog: ReturnType + + beforeEach(() => { + mockOutputChannel = { + appendLine: vi.fn(), + } as unknown as vscode.OutputChannel + + mockRemoveMessage = vi.fn().mockReturnValue(true) + + mockProvider = { + context: {} as vscode.ExtensionContext, + postMessageToWebview: vi.fn().mockResolvedValue(undefined), + on: vi.fn(), + getCurrentTaskStack: vi.fn().mockReturnValue([]), + getCurrentTask: vi.fn().mockReturnValue({ + messageQueueService: { + removeMessage: mockRemoveMessage, + }, + }), + viewLaunched: true, + } as unknown as ClineProvider + + mockLog = vi.fn() + + api = new API(mockOutputChannel, mockProvider, undefined, true) + ;(api as any).log = mockLog + }) + + it("should remove a queued message by id", () => { + const messageId = "msg-abc-123" + + api.deleteQueuedMessage(messageId) + + expect(mockRemoveMessage).toHaveBeenCalledWith(messageId) + expect(mockRemoveMessage).toHaveBeenCalledTimes(1) + }) + + it("should handle missing current task gracefully and log a message", () => { + ;(mockProvider.getCurrentTask as ReturnType).mockReturnValue(undefined) + + // Should not throw + expect(() => api.deleteQueuedMessage("msg-abc-123")).not.toThrow() + expect(mockLog).toHaveBeenCalledWith( + "[API#deleteQueuedMessage] no current task; ignoring delete for messageId msg-abc-123", + ) + expect(mockRemoveMessage).not.toHaveBeenCalled() + }) + + it("should handle non-existent message id gracefully", () => { + mockRemoveMessage.mockReturnValue(false) + + // Should not throw even when removeMessage returns false + expect(() => api.deleteQueuedMessage("non-existent-id")).not.toThrow() + expect(mockRemoveMessage).toHaveBeenCalledWith("non-existent-id") + }) +}) diff --git a/src/extension/__tests__/api-send-message.spec.ts b/src/extension/__tests__/api-send-message.spec.ts index ea1331f618..6d9895ade1 100644 --- a/src/extension/__tests__/api-send-message.spec.ts +++ b/src/extension/__tests__/api-send-message.spec.ts @@ -28,6 +28,7 @@ describe("API - SendMessage Command", () => { postMessageToWebview: mockPostMessageToWebview, on: vi.fn(), getCurrentTaskStack: vi.fn().mockReturnValue([]), + getCurrentTask: vi.fn().mockReturnValue(undefined), viewLaunched: true, } as unknown as ClineProvider diff --git a/src/extension/api.ts b/src/extension/api.ts index e9c35861c5..4a66b40078 100644 --- a/src/extension/api.ts +++ b/src/extension/api.ts @@ -4,6 +4,7 @@ import * as path from "path" import * as os from "os" import * as vscode from "vscode" +import pWaitFor from "p-wait-for" import { type RooCodeAPI, @@ -20,17 +21,19 @@ import { IpcMessageType, } from "@roo-code/types" import { IpcServer } from "@roo-code/ipc" +import { CloudService } from "@roo-code/cloud" import { Package } from "../shared/package" import { ClineProvider } from "../core/webview/ClineProvider" import { openClineInNewTab } from "../activate/registerCommands" +import { getCommands } from "../services/command/commands" +import { getModels } from "../api/providers/fetchers/modelCache" export class API extends EventEmitter implements RooCodeAPI { private readonly outputChannel: vscode.OutputChannel private readonly sidebarProvider: ClineProvider private readonly context: vscode.ExtensionContext private readonly ipc?: IpcServer - private readonly taskMap = new Map() private readonly log: (...args: unknown[]) => void private logfile?: string @@ -65,35 +68,97 @@ export class API extends EventEmitter implements RooCodeAPI { ipc.listen() this.log(`[API] ipc server started: socketPath=${socketPath}, pid=${process.pid}, ppid=${process.ppid}`) - ipc.on(IpcMessageType.TaskCommand, async (_clientId, { commandName, data }) => { - switch (commandName) { + ipc.on(IpcMessageType.TaskCommand, async (clientId, command) => { + const sendResponse = (eventName: RooCodeEventName, payload: unknown[]) => { + ipc.send(clientId, { + type: IpcMessageType.TaskEvent, + origin: IpcOrigin.Server, + data: { eventName, payload } as TaskEvent, + }) + } + + switch (command.commandName) { case TaskCommandName.StartNewTask: - this.log(`[API] StartNewTask -> ${data.text}, ${JSON.stringify(data.configuration)}`) - await this.startNewTask(data) + this.log( + `[API] StartNewTask -> ${command.data.text}, ${JSON.stringify(command.data.configuration)}`, + ) + await this.startNewTask(command.data) break case TaskCommandName.CancelTask: - this.log(`[API] CancelTask -> ${data}`) - await this.cancelTask(data) + this.log(`[API] CancelTask`) + await this.cancelCurrentTask() break case TaskCommandName.CloseTask: - this.log(`[API] CloseTask -> ${data}`) + this.log(`[API] CloseTask`) await vscode.commands.executeCommand("workbench.action.files.saveFiles") await vscode.commands.executeCommand("workbench.action.closeWindow") break case TaskCommandName.ResumeTask: - this.log(`[API] ResumeTask -> ${data}`) + this.log(`[API] ResumeTask -> ${command.data}`) try { - await this.resumeTask(data) + await this.resumeTask(command.data) } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error) - this.log(`[API] ResumeTask failed for taskId ${data}: ${errorMessage}`) - // Don't rethrow - we want to prevent IPC server crashes - // The error is logged for debugging purposes + this.log(`[API] ResumeTask failed for taskId ${command.data}: ${errorMessage}`) + // Don't rethrow - we want to prevent IPC server crashes. + // The error is logged for debugging purposes. } break case TaskCommandName.SendMessage: - this.log(`[API] SendMessage -> ${data.text}`) - await this.sendMessage(data.text, data.images) + this.log(`[API] SendMessage -> ${command.data.text}`) + await this.sendMessage(command.data.text, command.data.images) + break + case TaskCommandName.GetCommands: + try { + const commands = await getCommands(this.sidebarProvider.cwd) + + sendResponse(RooCodeEventName.CommandsResponse, [ + commands.map((cmd) => ({ + name: cmd.name, + source: cmd.source, + filePath: cmd.filePath, + description: cmd.description, + argumentHint: cmd.argumentHint, + })), + ]) + } catch (error) { + sendResponse(RooCodeEventName.CommandsResponse, [[]]) + } + + break + case TaskCommandName.GetModes: + try { + const modes = await this.sidebarProvider.getModes() + sendResponse(RooCodeEventName.ModesResponse, [modes]) + } catch (error) { + sendResponse(RooCodeEventName.ModesResponse, [[]]) + } + + break + case TaskCommandName.GetModels: + try { + const models = await getModels({ + provider: "roo" as const, + baseUrl: process.env.ROO_CODE_PROVIDER_URL ?? "https://api.roocode.com/proxy", + apiKey: CloudService.hasInstance() + ? CloudService.instance.authService?.getSessionToken() + : undefined, + }) + + sendResponse(RooCodeEventName.ModelsResponse, [models]) + } catch (error) { + sendResponse(RooCodeEventName.ModelsResponse, [{}]) + } + + break + case TaskCommandName.DeleteQueuedMessage: + this.log(`[API] DeleteQueuedMessage -> ${command.data}`) + try { + this.deleteQueuedMessage(command.data) + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error) + this.log(`[API] DeleteQueuedMessage failed for messageId ${command.data}: ${errorMessage}`) + } break } }) @@ -153,9 +218,19 @@ export class API extends EventEmitter implements RooCodeAPI { } public async resumeTask(taskId: string): Promise { + await vscode.commands.executeCommand(`${Package.name}.SidebarProvider.focus`) + await this.waitForWebviewLaunch(5_000) + const { historyItem } = await this.sidebarProvider.getTaskWithId(taskId) await this.sidebarProvider.createTaskWithHistoryItem(historyItem) - await this.sidebarProvider.postMessageToWebview({ type: "action", action: "chatButtonClicked" }) + + if (this.sidebarProvider.viewLaunched) { + await this.sidebarProvider.postMessageToWebview({ type: "action", action: "chatButtonClicked" }) + } else { + this.log( + `[API#resumeTask] webview not launched after resume for task ${taskId}; continuing in headless mode`, + ) + } } public async isTaskInHistory(taskId: string): Promise { @@ -181,17 +256,34 @@ export class API extends EventEmitter implements RooCodeAPI { await this.sidebarProvider.cancelTask() } - public async cancelTask(taskId: string) { - const provider = this.taskMap.get(taskId) + public async sendMessage(text?: string, images?: string[]) { + const currentTask = this.sidebarProvider.getCurrentTask() - if (provider) { - await provider.cancelTask() - this.taskMap.delete(taskId) + // In headless/sandbox flows the webview may not be launched, so routing + // through invoke=sendMessage drops the message. Deliver directly to the + // task ask-response channel instead. + if (!this.sidebarProvider.viewLaunched) { + if (!currentTask) { + this.log("[API#sendMessage] no current task in headless mode; message dropped") + return + } + + await currentTask.submitUserMessage(text ?? "", images) + return } + + await this.sidebarProvider.postMessageToWebview({ type: "invoke", invoke: "sendMessage", text, images }) } - public async sendMessage(text?: string, images?: string[]) { - await this.sidebarProvider.postMessageToWebview({ type: "invoke", invoke: "sendMessage", text, images }) + public deleteQueuedMessage(messageId: string) { + const currentTask = this.sidebarProvider.getCurrentTask() + + if (!currentTask) { + this.log(`[API#deleteQueuedMessage] no current task; ignoring delete for messageId ${messageId}`) + return + } + + currentTask.messageQueueService.removeMessage(messageId) } public async pressPrimaryButton() { @@ -206,13 +298,26 @@ export class API extends EventEmitter implements RooCodeAPI { return this.sidebarProvider.viewLaunched } + private async waitForWebviewLaunch(timeoutMs: number): Promise { + try { + await pWaitFor(() => this.sidebarProvider.viewLaunched, { + timeout: timeoutMs, + interval: 50, + }) + + return true + } catch { + this.log(`[API#waitForWebviewLaunch] webview did not launch within ${timeoutMs}ms`) + return false + } + } + private registerListeners(provider: ClineProvider) { provider.on(RooCodeEventName.TaskCreated, (task) => { // Task Lifecycle task.on(RooCodeEventName.TaskStarted, async () => { this.emit(RooCodeEventName.TaskStarted, task.taskId) - this.taskMap.set(task.taskId, provider) await this.fileLog(`[${new Date().toISOString()}] taskStarted -> ${task.taskId}\n`) }) @@ -221,8 +326,6 @@ export class API extends EventEmitter implements RooCodeAPI { isSubtask: !!task.parentTaskId, }) - this.taskMap.delete(task.taskId) - await this.fileLog( `[${new Date().toISOString()}] taskCompleted -> ${task.taskId} | ${JSON.stringify(tokenUsage, null, 2)} | ${JSON.stringify(toolUsage, null, 2)}\n`, ) @@ -230,7 +333,6 @@ export class API extends EventEmitter implements RooCodeAPI { task.on(RooCodeEventName.TaskAborted, () => { this.emit(RooCodeEventName.TaskAborted, task.taskId) - this.taskMap.delete(task.taskId) }) task.on(RooCodeEventName.TaskFocused, () => { @@ -301,6 +403,10 @@ export class API extends EventEmitter implements RooCodeAPI { this.emit(RooCodeEventName.TaskAskResponded, task.taskId) }) + task.on(RooCodeEventName.QueuedMessagesUpdated, (taskId, messages) => { + this.emit(RooCodeEventName.QueuedMessagesUpdated, taskId, messages) + }) + // Task Analytics task.on(RooCodeEventName.TaskToolFailed, (taskId, tool, error) => { diff --git a/src/i18n/locales/ca/common.json b/src/i18n/locales/ca/common.json index 9f8f961e73..33188fce19 100644 --- a/src/i18n/locales/ca/common.json +++ b/src/i18n/locales/ca/common.json @@ -114,15 +114,6 @@ "thinking_complete_safety": "(Pensament completat, però la sortida s'ha bloquejat a causa de la configuració de seguretat.)", "thinking_complete_recitation": "(Pensament completat, però la sortida s'ha bloquejat a causa de la comprovació de recitació.)" }, - "cerebras": { - "authenticationFailed": "Ha fallat l'autenticació de l'API de Cerebras. Comproveu que la vostra clau d'API sigui vàlida i no hagi caducat.", - "accessForbidden": "Accés denegat a l'API de Cerebras. La vostra clau d'API pot no tenir accés al model o funcionalitat sol·licitats.", - "rateLimitExceeded": "S'ha superat el límit de velocitat de l'API de Cerebras. Espereu abans de fer una altra sol·licitud.", - "serverError": "Error del servidor de l'API de Cerebras ({{status}}). Torneu-ho a provar més tard.", - "genericError": "Error de l'API de Cerebras ({{status}}): {{message}}", - "noResponseBody": "Error de l'API de Cerebras: No hi ha cos de resposta", - "completionError": "Error de finalització de Cerebras: {{error}}" - }, "roo": { "authenticationRequired": "El proveïdor Roo requereix autenticació al núvol. Si us plau, inicieu sessió a Roo Code Cloud." }, @@ -205,10 +196,7 @@ "enter_valid_path": "Introdueix una ruta vàlida" }, "settings": { - "providers": { - "groqApiKey": "Clau API de Groq", - "getGroqApiKey": "Obté la clau API de Groq" - } + "providers": {} }, "customModes": { "errors": { diff --git a/src/i18n/locales/ca/embeddings.json b/src/i18n/locales/ca/embeddings.json index 21a4a27ab4..9ceec7d05c 100644 --- a/src/i18n/locales/ca/embeddings.json +++ b/src/i18n/locales/ca/embeddings.json @@ -69,6 +69,8 @@ "fileWatcherStopped": "Monitor de fitxers aturat.", "failedDuringInitialScan": "Ha fallat durant l'escaneig inicial: {{errorMessage}}", "unknownError": "Error desconegut", - "indexingRequiresWorkspace": "Indexació requereix una carpeta de workspace oberta" + "indexingRequiresWorkspace": "Indexació requereix una carpeta de workspace oberta", + "indexingStopped": "Indexació aturada per l'usuari.", + "indexingStoppedPartial": "Indexació aturada. Dades d'índex parcials conservades." } } diff --git a/src/i18n/locales/ca/skills.json b/src/i18n/locales/ca/skills.json new file mode 100644 index 0000000000..1fb358a350 --- /dev/null +++ b/src/i18n/locales/ca/skills.json @@ -0,0 +1,16 @@ +{ + "errors": { + "name_length": "El nom de l'habilitat ha de tenir entre 1 i {{maxLength}} caràcters (s'han rebut {{length}})", + "name_format": "El nom de l'habilitat només pot contenir lletres minúscules, números i guions (sense guions inicials o finals, sense guions consecutius)", + "description_length": "La descripció de l'habilitat ha de tenir entre 1 i 1024 caràcters (s'han rebut {{length}})", + "no_workspace": "No es pot crear l'habilitat del projecte: no hi ha cap carpeta d'espai de treball oberta", + "already_exists": "L'habilitat \"{{name}}\" ja existeix a {{path}}", + "not_found": "No s'ha trobat l'habilitat \"{{name}}\" a {{source}}{{modeInfo}}", + "missing_create_fields": "Falten camps obligatoris: skillName, source o skillDescription", + "missing_move_fields": "Falten camps obligatoris: skillName o source", + "missing_update_modes_fields": "Falten camps obligatoris: skillName o source", + "manager_unavailable": "El gestor d'habilitats no està disponible", + "missing_delete_fields": "Falten camps obligatoris: skillName o source", + "skill_not_found": "No s'ha trobat l'habilitat \"{{name}}\"" + } +} diff --git a/src/i18n/locales/de/common.json b/src/i18n/locales/de/common.json index 086372dda8..861d9da576 100644 --- a/src/i18n/locales/de/common.json +++ b/src/i18n/locales/de/common.json @@ -111,15 +111,6 @@ "thinking_complete_safety": "(Denken abgeschlossen, aber die Ausgabe wurde aufgrund von Sicherheitseinstellungen blockiert.)", "thinking_complete_recitation": "(Denken abgeschlossen, aber die Ausgabe wurde aufgrund der Rezitationsprüfung blockiert.)" }, - "cerebras": { - "authenticationFailed": "Cerebras API-Authentifizierung fehlgeschlagen. Bitte überprüfe, ob dein API-Schlüssel gültig und nicht abgelaufen ist.", - "accessForbidden": "Cerebras API-Zugriff verweigert. Dein API-Schlüssel hat möglicherweise keinen Zugriff auf das angeforderte Modell oder die Funktion.", - "rateLimitExceeded": "Cerebras API-Ratenlimit überschritten. Bitte warte, bevor du eine weitere Anfrage stellst.", - "serverError": "Cerebras API-Serverfehler ({{status}}). Bitte versuche es später erneut.", - "genericError": "Cerebras API-Fehler ({{status}}): {{message}}", - "noResponseBody": "Cerebras API-Fehler: Kein Antworttext vorhanden", - "completionError": "Cerebras-Vervollständigungsfehler: {{error}}" - }, "roo": { "authenticationRequired": "Roo-Anbieter erfordert Cloud-Authentifizierung. Bitte melde dich bei Roo Code Cloud an." }, @@ -205,10 +196,7 @@ "task_placeholder": "Gib deine Aufgabe hier ein" }, "settings": { - "providers": { - "groqApiKey": "Groq API-Schlüssel", - "getGroqApiKey": "Groq API-Schlüssel erhalten" - } + "providers": {} }, "customModes": { "errors": { diff --git a/src/i18n/locales/de/embeddings.json b/src/i18n/locales/de/embeddings.json index 0297ec0309..766d31d5ba 100644 --- a/src/i18n/locales/de/embeddings.json +++ b/src/i18n/locales/de/embeddings.json @@ -69,6 +69,8 @@ "fileWatcherStopped": "Datei-Watcher gestoppt.", "failedDuringInitialScan": "Fehler während des ersten Scans: {{errorMessage}}", "unknownError": "Unbekannter Fehler", - "indexingRequiresWorkspace": "Indexierung erfordert einen offenen Workspace-Ordner" + "indexingRequiresWorkspace": "Indexierung erfordert einen offenen Workspace-Ordner", + "indexingStopped": "Indexierung vom Benutzer gestoppt.", + "indexingStoppedPartial": "Indexierung gestoppt. Teilweise Indexdaten beibehalten." } } diff --git a/src/i18n/locales/de/skills.json b/src/i18n/locales/de/skills.json new file mode 100644 index 0000000000..9c1107e9bf --- /dev/null +++ b/src/i18n/locales/de/skills.json @@ -0,0 +1,16 @@ +{ + "errors": { + "name_length": "Skill-Name muss 1-{{maxLength}} Zeichen lang sein (erhalten: {{length}})", + "name_format": "Skill-Name darf nur Kleinbuchstaben, Zahlen und Bindestriche enthalten (keine führenden oder nachgestellten Bindestriche, keine aufeinanderfolgenden Bindestriche)", + "description_length": "Skill-Beschreibung muss 1-1024 Zeichen lang sein (erhalten: {{length}})", + "no_workspace": "Projekt-Skill kann nicht erstellt werden: kein Workspace-Ordner ist geöffnet", + "already_exists": "Skill \"{{name}}\" existiert bereits unter {{path}}", + "not_found": "Skill \"{{name}}\" nicht gefunden in {{source}}{{modeInfo}}", + "missing_create_fields": "Erforderliche Felder fehlen: skillName, source oder skillDescription", + "missing_move_fields": "Erforderliche Felder fehlen: skillName oder source", + "missing_update_modes_fields": "Erforderliche Felder fehlen: skillName oder source", + "manager_unavailable": "Skill-Manager nicht verfügbar", + "missing_delete_fields": "Erforderliche Felder fehlen: skillName oder source", + "skill_not_found": "Skill \"{{name}}\" nicht gefunden" + } +} diff --git a/src/i18n/locales/en/common.json b/src/i18n/locales/en/common.json index 636d26f76c..d65fe18367 100644 --- a/src/i18n/locales/en/common.json +++ b/src/i18n/locales/en/common.json @@ -111,15 +111,6 @@ "thinking_complete_safety": "(Thinking complete, but output was blocked due to safety settings.)", "thinking_complete_recitation": "(Thinking complete, but output was blocked due to recitation check.)" }, - "cerebras": { - "authenticationFailed": "Cerebras API authentication failed. Please check your API key is valid and not expired.", - "accessForbidden": "Cerebras API access forbidden. Your API key may not have access to the requested model or feature.", - "rateLimitExceeded": "Cerebras API rate limit exceeded. Please wait before making another request.", - "serverError": "Cerebras API server error ({{status}}). Please try again later.", - "genericError": "Cerebras API Error ({{status}}): {{message}}", - "noResponseBody": "Cerebras API Error: No response body", - "completionError": "Cerebras completion error: {{error}}" - }, "roo": { "authenticationRequired": "Roo provider requires cloud authentication. Please sign in to Roo Code Cloud." }, diff --git a/src/i18n/locales/en/embeddings.json b/src/i18n/locales/en/embeddings.json index 5819e45c1a..7777af9027 100644 --- a/src/i18n/locales/en/embeddings.json +++ b/src/i18n/locales/en/embeddings.json @@ -69,6 +69,8 @@ "fileWatcherStopped": "File watcher stopped.", "failedDuringInitialScan": "Failed during initial scan: {{errorMessage}}", "unknownError": "Unknown error", - "indexingRequiresWorkspace": "Indexing requires an open workspace folder" + "indexingRequiresWorkspace": "Indexing requires an open workspace folder", + "indexingStopped": "Indexing stopped by user.", + "indexingStoppedPartial": "Indexing stopped. Partial index data preserved." } } diff --git a/src/i18n/locales/en/skills.json b/src/i18n/locales/en/skills.json new file mode 100644 index 0000000000..307b59d365 --- /dev/null +++ b/src/i18n/locales/en/skills.json @@ -0,0 +1,16 @@ +{ + "errors": { + "name_length": "Skill name must be 1-{{maxLength}} characters (got {{length}})", + "name_format": "Skill name must be lowercase letters/numbers/hyphens only (no leading/trailing hyphen, no consecutive hyphens)", + "description_length": "Skill description must be 1-1024 characters (got {{length}})", + "no_workspace": "Cannot create project skill: no workspace folder is open", + "already_exists": "Skill \"{{name}}\" already exists at {{path}}", + "not_found": "Skill \"{{name}}\" not found in {{source}}{{modeInfo}}", + "missing_create_fields": "Missing required fields: skillName, source, or skillDescription", + "missing_move_fields": "Missing required fields: skillName or source", + "missing_update_modes_fields": "Missing required fields: skillName or source", + "manager_unavailable": "Skills manager not available", + "missing_delete_fields": "Missing required fields: skillName or source", + "skill_not_found": "Skill \"{{name}}\" not found" + } +} diff --git a/src/i18n/locales/es/common.json b/src/i18n/locales/es/common.json index bc22040c6a..82be83956b 100644 --- a/src/i18n/locales/es/common.json +++ b/src/i18n/locales/es/common.json @@ -111,15 +111,6 @@ "thinking_complete_safety": "(Pensamiento completado, pero la salida fue bloqueada debido a la configuración de seguridad.)", "thinking_complete_recitation": "(Pensamiento completado, pero la salida fue bloqueada debido a la comprobación de recitación.)" }, - "cerebras": { - "authenticationFailed": "Falló la autenticación de la API de Cerebras. Verifica que tu clave de API sea válida y no haya expirado.", - "accessForbidden": "Acceso prohibido a la API de Cerebras. Tu clave de API puede no tener acceso al modelo o función solicitada.", - "rateLimitExceeded": "Se excedió el límite de velocidad de la API de Cerebras. Espera antes de hacer otra solicitud.", - "serverError": "Error del servidor de la API de Cerebras ({{status}}). Inténtalo de nuevo más tarde.", - "genericError": "Error de la API de Cerebras ({{status}}): {{message}}", - "noResponseBody": "Error de la API de Cerebras: Sin cuerpo de respuesta", - "completionError": "Error de finalización de Cerebras: {{error}}" - }, "roo": { "authenticationRequired": "El proveedor Roo requiere autenticación en la nube. Por favor, inicia sesión en Roo Code Cloud." }, @@ -205,10 +196,7 @@ "task_placeholder": "Escribe tu tarea aquí" }, "settings": { - "providers": { - "groqApiKey": "Clave API de Groq", - "getGroqApiKey": "Obtener clave API de Groq" - } + "providers": {} }, "customModes": { "errors": { diff --git a/src/i18n/locales/es/embeddings.json b/src/i18n/locales/es/embeddings.json index eca9efcc07..930404de1f 100644 --- a/src/i18n/locales/es/embeddings.json +++ b/src/i18n/locales/es/embeddings.json @@ -69,6 +69,8 @@ "fileWatcherStopped": "Monitor de archivos detenido.", "failedDuringInitialScan": "Falló durante el escaneo inicial: {{errorMessage}}", "unknownError": "Error desconocido", - "indexingRequiresWorkspace": "La indexación requiere una carpeta de workspace abierta" + "indexingRequiresWorkspace": "La indexación requiere una carpeta de workspace abierta", + "indexingStopped": "Indexación detenida por el usuario.", + "indexingStoppedPartial": "Indexación detenida. Datos de índice parciales conservados." } } diff --git a/src/i18n/locales/es/skills.json b/src/i18n/locales/es/skills.json new file mode 100644 index 0000000000..6e10006eff --- /dev/null +++ b/src/i18n/locales/es/skills.json @@ -0,0 +1,16 @@ +{ + "errors": { + "name_length": "El nombre de la habilidad debe tener entre 1 y {{maxLength}} caracteres (se recibieron {{length}})", + "name_format": "El nombre de la habilidad solo puede contener letras minúsculas, números y guiones (sin guiones al inicio o al final, sin guiones consecutivos)", + "description_length": "La descripción de la habilidad debe tener entre 1 y 1024 caracteres (se recibieron {{length}})", + "no_workspace": "No se puede crear la habilidad del proyecto: no hay ninguna carpeta de espacio de trabajo abierta", + "already_exists": "La habilidad \"{{name}}\" ya existe en {{path}}", + "not_found": "No se encontró la habilidad \"{{name}}\" en {{source}}{{modeInfo}}", + "missing_create_fields": "Faltan campos obligatorios: skillName, source o skillDescription", + "missing_move_fields": "Faltan campos obligatorios: skillName o source", + "missing_update_modes_fields": "Faltan campos obligatorios: skillName o source", + "manager_unavailable": "El gestor de habilidades no está disponible", + "missing_delete_fields": "Faltan campos obligatorios: skillName o source", + "skill_not_found": "No se encontró la habilidad \"{{name}}\"" + } +} diff --git a/src/i18n/locales/fr/common.json b/src/i18n/locales/fr/common.json index f7a76a53c1..6fc05ff94a 100644 --- a/src/i18n/locales/fr/common.json +++ b/src/i18n/locales/fr/common.json @@ -111,15 +111,6 @@ "thinking_complete_safety": "(Réflexion terminée, mais la sortie a été bloquée en raison des paramètres de sécurité.)", "thinking_complete_recitation": "(Réflexion terminée, mais la sortie a été bloquée en raison de la vérification de récitation.)" }, - "cerebras": { - "authenticationFailed": "Échec de l'authentification de l'API Cerebras. Vérifiez que votre clé API est valide et n'a pas expiré.", - "accessForbidden": "Accès interdit à l'API Cerebras. Votre clé API peut ne pas avoir accès au modèle ou à la fonction demandée.", - "rateLimitExceeded": "Limite de débit de l'API Cerebras dépassée. Veuillez attendre avant de faire une autre demande.", - "serverError": "Erreur du serveur de l'API Cerebras ({{status}}). Veuillez réessayer plus tard.", - "genericError": "Erreur de l'API Cerebras ({{status}}) : {{message}}", - "noResponseBody": "Erreur de l'API Cerebras : Aucun corps de réponse", - "completionError": "Erreur d'achèvement de Cerebras : {{error}}" - }, "roo": { "authenticationRequired": "Le fournisseur Roo nécessite une authentification cloud. Veuillez vous connecter à Roo Code Cloud." }, @@ -205,10 +196,7 @@ "task_placeholder": "Écris ta tâche ici" }, "settings": { - "providers": { - "groqApiKey": "Clé API Groq", - "getGroqApiKey": "Obtenir la clé API Groq" - } + "providers": {} }, "customModes": { "errors": { diff --git a/src/i18n/locales/fr/embeddings.json b/src/i18n/locales/fr/embeddings.json index fa92217987..7de086307e 100644 --- a/src/i18n/locales/fr/embeddings.json +++ b/src/i18n/locales/fr/embeddings.json @@ -69,6 +69,8 @@ "fileWatcherStopped": "Surveillant de fichiers arrêté.", "failedDuringInitialScan": "Échec lors du scan initial : {{errorMessage}}", "unknownError": "Erreur inconnue", - "indexingRequiresWorkspace": "L'indexation nécessite l'ouverture d'un dossier workspace" + "indexingRequiresWorkspace": "L'indexation nécessite l'ouverture d'un dossier workspace", + "indexingStopped": "Indexation arrêtée par l'utilisateur.", + "indexingStoppedPartial": "Indexation arrêtée. Données d'index partielles conservées." } } diff --git a/src/i18n/locales/fr/skills.json b/src/i18n/locales/fr/skills.json new file mode 100644 index 0000000000..3f2b6ac529 --- /dev/null +++ b/src/i18n/locales/fr/skills.json @@ -0,0 +1,16 @@ +{ + "errors": { + "name_length": "Le nom de la compétence doit contenir entre 1 et {{maxLength}} caractères ({{length}} reçu)", + "name_format": "Le nom de la compétence ne peut contenir que des lettres minuscules, des chiffres et des traits d'union (pas de trait d'union initial ou final, pas de traits d'union consécutifs)", + "description_length": "La description de la compétence doit contenir entre 1 et 1024 caractères ({{length}} reçu)", + "no_workspace": "Impossible de créer la compétence de projet : aucun dossier d'espace de travail n'est ouvert", + "already_exists": "La compétence \"{{name}}\" existe déjà à {{path}}", + "not_found": "Compétence \"{{name}}\" introuvable dans {{source}}{{modeInfo}}", + "missing_create_fields": "Champs obligatoires manquants : skillName, source ou skillDescription", + "missing_move_fields": "Champs obligatoires manquants : skillName ou source", + "missing_update_modes_fields": "Champs obligatoires manquants : skillName ou source", + "manager_unavailable": "Le gestionnaire de compétences n'est pas disponible", + "missing_delete_fields": "Champs obligatoires manquants : skillName ou source", + "skill_not_found": "Compétence \"{{name}}\" introuvable" + } +} diff --git a/src/i18n/locales/hi/common.json b/src/i18n/locales/hi/common.json index e51d177d94..528ed6d45f 100644 --- a/src/i18n/locales/hi/common.json +++ b/src/i18n/locales/hi/common.json @@ -111,15 +111,6 @@ "thinking_complete_safety": "(सोचना पूरा हुआ, लेकिन सुरक्षा सेटिंग्स के कारण आउटपुट अवरुद्ध कर दिया गया।)", "thinking_complete_recitation": "(सोचना पूरा हुआ, लेकिन पाठ जाँच के कारण आउटपुट अवरुद्ध कर दिया गया।)" }, - "cerebras": { - "authenticationFailed": "Cerebras API प्रमाणीकरण विफल हुआ। कृपया जांचें कि आपकी API कुंजी वैध है और समाप्त नहीं हुई है।", - "accessForbidden": "Cerebras API पहुंच निषेध। आपकी API कुंजी का अनुरोधित मॉडल या सुविधा तक पहुंच नहीं हो सकती है।", - "rateLimitExceeded": "Cerebras API दर सीमा पार हो गई। कृपया दूसरा अनुरोध करने से पहले प्रतीक्षा करें।", - "serverError": "Cerebras API सर्वर त्रुटि ({{status}})। कृपया बाद में पुनः प्रयास करें।", - "genericError": "Cerebras API त्रुटि ({{status}}): {{message}}", - "noResponseBody": "Cerebras API त्रुटि: कोई प्रतिक्रिया मुख्य भाग नहीं", - "completionError": "Cerebras पूर्णता त्रुटि: {{error}}" - }, "roo": { "authenticationRequired": "Roo प्रदाता को क्लाउड प्रमाणीकरण की आवश्यकता है। कृपया Roo Code Cloud में साइन इन करें।" }, @@ -205,10 +196,7 @@ "task_placeholder": "अपना कार्य यहाँ लिखें" }, "settings": { - "providers": { - "groqApiKey": "ग्रोक एपीआई कुंजी", - "getGroqApiKey": "ग्रोक एपीआई कुंजी प्राप्त करें" - } + "providers": {} }, "customModes": { "errors": { diff --git a/src/i18n/locales/hi/embeddings.json b/src/i18n/locales/hi/embeddings.json index eb7f066c56..9c7f9ca50a 100644 --- a/src/i18n/locales/hi/embeddings.json +++ b/src/i18n/locales/hi/embeddings.json @@ -69,6 +69,8 @@ "fileWatcherStopped": "फाइल वॉचर रुक गया।", "failedDuringInitialScan": "प्रारंभिक स्कैन के दौरान असफल: {{errorMessage}}", "unknownError": "अज्ञात त्रुटि", - "indexingRequiresWorkspace": "इंडेक्सिंग के लिए एक खुला वर्कस्पेस फ़ोल्डर आवश्यक है" + "indexingRequiresWorkspace": "इंडेक्सिंग के लिए एक खुला वर्कस्पेस फ़ोल्डर आवश्यक है", + "indexingStopped": "उपयोगकर्ता द्वारा इंडेक्सिंग रोकी गई।", + "indexingStoppedPartial": "इंडेक्सिंग रोकी गई। आंशिक इंडेक्स डेटा संरक्षित।" } } diff --git a/src/i18n/locales/hi/skills.json b/src/i18n/locales/hi/skills.json new file mode 100644 index 0000000000..ed04e50b5e --- /dev/null +++ b/src/i18n/locales/hi/skills.json @@ -0,0 +1,16 @@ +{ + "errors": { + "name_length": "स्किल का नाम 1-{{maxLength}} वर्णों का होना चाहिए ({{length}} प्राप्त हुआ)", + "name_format": "स्किल के नाम में केवल छोटे अक्षर, संख्याएं और हाइफ़न हो सकते हैं (शुरुआत या अंत में हाइफ़न नहीं, लगातार हाइफ़न नहीं)", + "description_length": "स्किल का विवरण 1-1024 वर्णों का होना चाहिए ({{length}} प्राप्त हुआ)", + "no_workspace": "प्रोजेक्ट स्किल नहीं बनाया जा सकता: कोई वर्कस्पेस फ़ोल्डर खुला नहीं है", + "already_exists": "स्किल \"{{name}}\" पहले से {{path}} पर मौजूद है", + "not_found": "स्किल \"{{name}}\" {{source}}{{modeInfo}} में नहीं मिला", + "missing_create_fields": "आवश्यक फ़ील्ड गायब हैं: skillName, source, या skillDescription", + "missing_move_fields": "आवश्यक फ़ील्ड गायब हैं: skillName या source", + "missing_update_modes_fields": "आवश्यक फ़ील्ड गायब हैं: skillName या source", + "manager_unavailable": "स्किल मैनेजर उपलब्ध नहीं है", + "missing_delete_fields": "आवश्यक फ़ील्ड गायब हैं: skillName या source", + "skill_not_found": "स्किल \"{{name}}\" नहीं मिला" + } +} diff --git a/src/i18n/locales/id/common.json b/src/i18n/locales/id/common.json index cfb165979d..cb1c3231fb 100644 --- a/src/i18n/locales/id/common.json +++ b/src/i18n/locales/id/common.json @@ -111,15 +111,6 @@ "thinking_complete_safety": "(Berpikir selesai, tetapi output diblokir karena pengaturan keamanan.)", "thinking_complete_recitation": "(Berpikir selesai, tetapi output diblokir karena pemeriksaan resitasi.)" }, - "cerebras": { - "authenticationFailed": "Autentikasi API Cerebras gagal. Silakan periksa apakah kunci API Anda valid dan belum kedaluwarsa.", - "accessForbidden": "Akses API Cerebras ditolak. Kunci API Anda mungkin tidak memiliki akses ke model atau fitur yang diminta.", - "rateLimitExceeded": "Batas kecepatan API Cerebras terlampaui. Silakan tunggu sebelum membuat permintaan lain.", - "serverError": "Kesalahan server API Cerebras ({{status}}). Silakan coba lagi nanti.", - "genericError": "Kesalahan API Cerebras ({{status}}): {{message}}", - "noResponseBody": "Kesalahan API Cerebras: Tidak ada isi respons", - "completionError": "Kesalahan penyelesaian Cerebras: {{error}}" - }, "roo": { "authenticationRequired": "Penyedia Roo memerlukan autentikasi cloud. Silakan masuk ke Roo Code Cloud." }, @@ -205,10 +196,7 @@ "task_placeholder": "Ketik tugas kamu di sini" }, "settings": { - "providers": { - "groqApiKey": "Kunci API Groq", - "getGroqApiKey": "Dapatkan Kunci API Groq" - } + "providers": {} }, "customModes": { "errors": { diff --git a/src/i18n/locales/id/embeddings.json b/src/i18n/locales/id/embeddings.json index cceb965430..955a039eff 100644 --- a/src/i18n/locales/id/embeddings.json +++ b/src/i18n/locales/id/embeddings.json @@ -69,6 +69,8 @@ "fileWatcherStopped": "Pemantau file dihentikan.", "failedDuringInitialScan": "Gagal selama pemindaian awal: {{errorMessage}}", "unknownError": "Kesalahan tidak diketahui", - "indexingRequiresWorkspace": "Pengindeksan memerlukan folder workspace yang terbuka" + "indexingRequiresWorkspace": "Pengindeksan memerlukan folder workspace yang terbuka", + "indexingStopped": "Pengindeksan dihentikan oleh pengguna.", + "indexingStoppedPartial": "Pengindeksan dihentikan. Data indeks parsial dipertahankan." } } diff --git a/src/i18n/locales/id/skills.json b/src/i18n/locales/id/skills.json new file mode 100644 index 0000000000..433fe0b0c4 --- /dev/null +++ b/src/i18n/locales/id/skills.json @@ -0,0 +1,16 @@ +{ + "errors": { + "name_length": "Nama skill harus 1-{{maxLength}} karakter (diterima {{length}})", + "name_format": "Nama skill hanya boleh berisi huruf kecil, angka, dan tanda hubung (tanpa tanda hubung di awal atau akhir, tanpa tanda hubung berturut-turut)", + "description_length": "Deskripsi skill harus 1-1024 karakter (diterima {{length}})", + "no_workspace": "Tidak dapat membuat skill proyek: tidak ada folder workspace yang terbuka", + "already_exists": "Skill \"{{name}}\" sudah ada di {{path}}", + "not_found": "Skill \"{{name}}\" tidak ditemukan di {{source}}{{modeInfo}}", + "missing_create_fields": "Bidang wajib tidak ada: skillName, source, atau skillDescription", + "missing_move_fields": "Bidang wajib tidak ada: skillName atau source", + "missing_update_modes_fields": "Bidang wajib tidak ada: skillName atau source", + "manager_unavailable": "Manajer skill tidak tersedia", + "missing_delete_fields": "Bidang wajib tidak ada: skillName atau source", + "skill_not_found": "Skill \"{{name}}\" tidak ditemukan" + } +} diff --git a/src/i18n/locales/it/common.json b/src/i18n/locales/it/common.json index e5fa6d68db..b4e522cb73 100644 --- a/src/i18n/locales/it/common.json +++ b/src/i18n/locales/it/common.json @@ -111,15 +111,6 @@ "thinking_complete_safety": "(Pensiero completato, ma l'output è stato bloccato a causa delle impostazioni di sicurezza.)", "thinking_complete_recitation": "(Pensiero completato, ma l'output è stato bloccato a causa del controllo di recitazione.)" }, - "cerebras": { - "authenticationFailed": "Autenticazione API Cerebras fallita. Verifica che la tua chiave API sia valida e non scaduta.", - "accessForbidden": "Accesso API Cerebras negato. La tua chiave API potrebbe non avere accesso al modello o alla funzione richiesta.", - "rateLimitExceeded": "Limite di velocità API Cerebras superato. Attendi prima di fare un'altra richiesta.", - "serverError": "Errore del server API Cerebras ({{status}}). Riprova più tardi.", - "genericError": "Errore API Cerebras ({{status}}): {{message}}", - "noResponseBody": "Errore API Cerebras: Nessun corpo di risposta", - "completionError": "Errore di completamento Cerebras: {{error}}" - }, "roo": { "authenticationRequired": "Il provider Roo richiede l'autenticazione cloud. Accedi a Roo Code Cloud." }, @@ -205,10 +196,7 @@ "task_placeholder": "Scrivi il tuo compito qui" }, "settings": { - "providers": { - "groqApiKey": "Chiave API Groq", - "getGroqApiKey": "Ottieni chiave API Groq" - } + "providers": {} }, "customModes": { "errors": { diff --git a/src/i18n/locales/it/embeddings.json b/src/i18n/locales/it/embeddings.json index 2e339ef5d8..b7314c244d 100644 --- a/src/i18n/locales/it/embeddings.json +++ b/src/i18n/locales/it/embeddings.json @@ -69,6 +69,8 @@ "fileWatcherStopped": "Monitoraggio file fermato.", "failedDuringInitialScan": "Fallito durante la scansione iniziale: {{errorMessage}}", "unknownError": "Errore sconosciuto", - "indexingRequiresWorkspace": "L'indicizzazione richiede una cartella di workspace aperta" + "indexingRequiresWorkspace": "L'indicizzazione richiede una cartella di workspace aperta", + "indexingStopped": "Indicizzazione interrotta dall'utente.", + "indexingStoppedPartial": "Indicizzazione interrotta. Dati di indice parziali conservati." } } diff --git a/src/i18n/locales/it/skills.json b/src/i18n/locales/it/skills.json new file mode 100644 index 0000000000..2f363a6cd0 --- /dev/null +++ b/src/i18n/locales/it/skills.json @@ -0,0 +1,16 @@ +{ + "errors": { + "name_length": "Il nome della skill deve essere di 1-{{maxLength}} caratteri (ricevuti {{length}})", + "name_format": "Il nome della skill può contenere solo lettere minuscole, numeri e trattini (senza trattini iniziali o finali, senza trattini consecutivi)", + "description_length": "La descrizione della skill deve essere di 1-1024 caratteri (ricevuti {{length}})", + "no_workspace": "Impossibile creare la skill del progetto: nessuna cartella di workspace aperta", + "already_exists": "La skill \"{{name}}\" esiste già in {{path}}", + "not_found": "Skill \"{{name}}\" non trovata in {{source}}{{modeInfo}}", + "missing_create_fields": "Campi obbligatori mancanti: skillName, source o skillDescription", + "missing_move_fields": "Campi obbligatori mancanti: skillName o source", + "missing_update_modes_fields": "Campi obbligatori mancanti: skillName o source", + "manager_unavailable": "Il gestore delle skill non è disponibile", + "missing_delete_fields": "Campi obbligatori mancanti: skillName o source", + "skill_not_found": "Skill \"{{name}}\" non trovata" + } +} diff --git a/src/i18n/locales/ja/common.json b/src/i18n/locales/ja/common.json index 7ebe0de597..7b63b6f729 100644 --- a/src/i18n/locales/ja/common.json +++ b/src/i18n/locales/ja/common.json @@ -111,15 +111,6 @@ "thinking_complete_safety": "(思考完了、安全設定により出力ブロック)", "thinking_complete_recitation": "(思考完了、引用チェックにより出力ブロック)" }, - "cerebras": { - "authenticationFailed": "Cerebras API認証が失敗しました。APIキーが有効で期限切れではないことを確認してください。", - "accessForbidden": "Cerebras APIアクセスが禁止されています。あなたのAPIキーは要求されたモデルや機能にアクセスできない可能性があります。", - "rateLimitExceeded": "Cerebras APIレート制限を超過しました。別のリクエストを行う前にお待ちください。", - "serverError": "Cerebras APIサーバーエラー ({{status}})。しばらくしてからもう一度お試しください。", - "genericError": "Cerebras APIエラー ({{status}}): {{message}}", - "noResponseBody": "Cerebras APIエラー: レスポンスボディなし", - "completionError": "Cerebras完了エラー: {{error}}" - }, "roo": { "authenticationRequired": "Rooプロバイダーはクラウド認証が必要です。Roo Code Cloudにサインインしてください。" }, @@ -205,10 +196,7 @@ "task_placeholder": "タスクをここに入力してください" }, "settings": { - "providers": { - "groqApiKey": "Groq APIキー", - "getGroqApiKey": "Groq APIキーを取得" - } + "providers": {} }, "customModes": { "errors": { diff --git a/src/i18n/locales/ja/embeddings.json b/src/i18n/locales/ja/embeddings.json index 5223c204e0..ce7150cf1c 100644 --- a/src/i18n/locales/ja/embeddings.json +++ b/src/i18n/locales/ja/embeddings.json @@ -69,6 +69,8 @@ "fileWatcherStopped": "ファイルウォッチャーが停止されました。", "failedDuringInitialScan": "初期スキャン中に失敗しました:{{errorMessage}}", "unknownError": "不明なエラー", - "indexingRequiresWorkspace": "インデックス作成には、開かれたワークスペースフォルダーが必要です" + "indexingRequiresWorkspace": "インデックス作成には、開かれたワークスペースフォルダーが必要です", + "indexingStopped": "ユーザーによりインデックス作成が停止されました。", + "indexingStoppedPartial": "インデックス作成が停止されました。部分的なインデックスデータは保持されています。" } } diff --git a/src/i18n/locales/ja/skills.json b/src/i18n/locales/ja/skills.json new file mode 100644 index 0000000000..90b44d9c95 --- /dev/null +++ b/src/i18n/locales/ja/skills.json @@ -0,0 +1,16 @@ +{ + "errors": { + "name_length": "スキル名は1-{{maxLength}}文字である必要があります({{length}}文字を受信)", + "name_format": "スキル名には小文字、数字、ハイフンのみ使用できます(先頭または末尾のハイフン、連続するハイフンは不可)", + "description_length": "スキルの説明は1-1024文字である必要があります({{length}}文字を受信)", + "no_workspace": "プロジェクトスキルを作成できません:ワークスペースフォルダが開かれていません", + "already_exists": "スキル「{{name}}」は既に{{path}}に存在します", + "not_found": "スキル「{{name}}」が{{source}}{{modeInfo}}に見つかりません", + "missing_create_fields": "必須フィールドが不足しています:skillName、source、またはskillDescription", + "missing_move_fields": "必須フィールドが不足しています:skillNameまたはsource", + "missing_update_modes_fields": "必須フィールドが不足しています:skillNameまたはsource", + "manager_unavailable": "スキルマネージャーが利用できません", + "missing_delete_fields": "必須フィールドが不足しています:skillNameまたはsource", + "skill_not_found": "スキル「{{name}}」が見つかりません" + } +} diff --git a/src/i18n/locales/ko/common.json b/src/i18n/locales/ko/common.json index 0c1ed5ba51..fbde3225bb 100644 --- a/src/i18n/locales/ko/common.json +++ b/src/i18n/locales/ko/common.json @@ -111,15 +111,6 @@ "thinking_complete_safety": "(생각 완료, 안전 설정으로 출력 차단됨)", "thinking_complete_recitation": "(생각 완료, 암송 확인으로 출력 차단됨)" }, - "cerebras": { - "authenticationFailed": "Cerebras API 인증에 실패했습니다. API 키가 유효하고 만료되지 않았는지 확인하세요.", - "accessForbidden": "Cerebras API 액세스가 금지되었습니다. API 키가 요청된 모델이나 기능에 액세스할 수 없을 수 있습니다.", - "rateLimitExceeded": "Cerebras API 속도 제한을 초과했습니다. 다른 요청을 하기 전에 기다리세요.", - "serverError": "Cerebras API 서버 오류 ({{status}}). 나중에 다시 시도하세요.", - "genericError": "Cerebras API 오류 ({{status}}): {{message}}", - "noResponseBody": "Cerebras API 오류: 응답 본문 없음", - "completionError": "Cerebras 완료 오류: {{error}}" - }, "roo": { "authenticationRequired": "Roo 제공업체는 클라우드 인증이 필요합니다. Roo Code Cloud에 로그인하세요." }, @@ -205,10 +196,7 @@ "task_placeholder": "여기에 작업을 입력하세요" }, "settings": { - "providers": { - "groqApiKey": "Groq API 키", - "getGroqApiKey": "Groq API 키 받기" - } + "providers": {} }, "customModes": { "errors": { diff --git a/src/i18n/locales/ko/embeddings.json b/src/i18n/locales/ko/embeddings.json index 236662eea2..436fa985c0 100644 --- a/src/i18n/locales/ko/embeddings.json +++ b/src/i18n/locales/ko/embeddings.json @@ -69,6 +69,8 @@ "fileWatcherStopped": "파일 감시자가 중지되었습니다.", "failedDuringInitialScan": "초기 스캔 중 실패: {{errorMessage}}", "unknownError": "알 수 없는 오류", - "indexingRequiresWorkspace": "인덱싱에는 열린 워크스페이스 폴더가 필요합니다" + "indexingRequiresWorkspace": "인덱싱에는 열린 워크스페이스 폴더가 필요합니다", + "indexingStopped": "사용자에 의해 인덱싱이 중지되었습니다.", + "indexingStoppedPartial": "인덱싱이 중지되었습니다. 부분 인덱스 데이터가 보존되었습니다." } } diff --git a/src/i18n/locales/ko/skills.json b/src/i18n/locales/ko/skills.json new file mode 100644 index 0000000000..5e4d59f92c --- /dev/null +++ b/src/i18n/locales/ko/skills.json @@ -0,0 +1,16 @@ +{ + "errors": { + "name_length": "스킬 이름은 1-{{maxLength}}자여야 합니다({{length}}자 수신됨)", + "name_format": "스킬 이름은 소문자, 숫자, 하이픈만 포함할 수 있습니다(앞뒤 하이픈 없음, 연속 하이픈 없음)", + "description_length": "스킬 설명은 1-1024자여야 합니다({{length}}자 수신됨)", + "no_workspace": "프로젝트 스킬을 생성할 수 없습니다: 열린 작업 공간 폴더가 없습니다", + "already_exists": "스킬 \"{{name}}\"이(가) 이미 {{path}}에 존재합니다", + "not_found": "{{source}}{{modeInfo}}에서 스킬 \"{{name}}\"을(를) 찾을 수 없습니다", + "missing_create_fields": "필수 필드 누락: skillName, source 또는 skillDescription", + "missing_move_fields": "필수 필드 누락: skillName 또는 source", + "missing_update_modes_fields": "필수 필드 누락: skillName 또는 source", + "manager_unavailable": "스킬 관리자를 사용할 수 없습니다", + "missing_delete_fields": "필수 필드 누락: skillName 또는 source", + "skill_not_found": "스킬 \"{{name}}\"을(를) 찾을 수 없습니다" + } +} diff --git a/src/i18n/locales/nl/common.json b/src/i18n/locales/nl/common.json index 0bbf569536..eba274c96e 100644 --- a/src/i18n/locales/nl/common.json +++ b/src/i18n/locales/nl/common.json @@ -111,15 +111,6 @@ "thinking_complete_safety": "(Nadenken voltooid, maar uitvoer is geblokkeerd vanwege veiligheidsinstellingen.)", "thinking_complete_recitation": "(Nadenken voltooid, maar uitvoer is geblokkeerd vanwege recitatiecontrole.)" }, - "cerebras": { - "authenticationFailed": "Cerebras API-authenticatie mislukt. Controleer of je API-sleutel geldig is en niet verlopen.", - "accessForbidden": "Cerebras API-toegang geweigerd. Je API-sleutel heeft mogelijk geen toegang tot het gevraagde model of de functie.", - "rateLimitExceeded": "Cerebras API-snelheidslimiet overschreden. Wacht voordat je een ander verzoek doet.", - "serverError": "Cerebras API-serverfout ({{status}}). Probeer het later opnieuw.", - "genericError": "Cerebras API-fout ({{status}}): {{message}}", - "noResponseBody": "Cerebras API-fout: Geen responslichaam", - "completionError": "Cerebras-voltooiingsfout: {{error}}" - }, "roo": { "authenticationRequired": "Roo provider vereist cloud authenticatie. Log in bij Roo Code Cloud." }, @@ -205,10 +196,7 @@ "task_placeholder": "Typ hier je taak" }, "settings": { - "providers": { - "groqApiKey": "Groq API-sleutel", - "getGroqApiKey": "Groq API-sleutel ophalen" - } + "providers": {} }, "customModes": { "errors": { diff --git a/src/i18n/locales/nl/embeddings.json b/src/i18n/locales/nl/embeddings.json index cce3f05c62..01e68683d3 100644 --- a/src/i18n/locales/nl/embeddings.json +++ b/src/i18n/locales/nl/embeddings.json @@ -69,6 +69,8 @@ "fileWatcherStopped": "Bestandsmonitor gestopt.", "failedDuringInitialScan": "Mislukt tijdens initiële scan: {{errorMessage}}", "unknownError": "Onbekende fout", - "indexingRequiresWorkspace": "Indexering vereist een geopende workspace map" + "indexingRequiresWorkspace": "Indexering vereist een geopende workspace map", + "indexingStopped": "Indexering gestopt door gebruiker.", + "indexingStoppedPartial": "Indexering gestopt. Gedeeltelijke indexgegevens bewaard." } } diff --git a/src/i18n/locales/nl/skills.json b/src/i18n/locales/nl/skills.json new file mode 100644 index 0000000000..4ca83f1a35 --- /dev/null +++ b/src/i18n/locales/nl/skills.json @@ -0,0 +1,16 @@ +{ + "errors": { + "name_length": "Vaardigheidsnaam moet 1-{{maxLength}} tekens lang zijn ({{length}} ontvangen)", + "name_format": "Vaardigheidsnaam mag alleen kleine letters, cijfers en koppeltekens bevatten (geen voorloop- of achterloop-koppeltekens, geen opeenvolgende koppeltekens)", + "description_length": "Vaardigheidsbeschrijving moet 1-1024 tekens lang zijn ({{length}} ontvangen)", + "no_workspace": "Kan projectvaardigheid niet aanmaken: geen werkruimtemap geopend", + "already_exists": "Vaardigheid \"{{name}}\" bestaat al op {{path}}", + "not_found": "Vaardigheid \"{{name}}\" niet gevonden in {{source}}{{modeInfo}}", + "missing_create_fields": "Vereiste velden ontbreken: skillName, source of skillDescription", + "missing_move_fields": "Vereiste velden ontbreken: skillName of source", + "missing_update_modes_fields": "Vereiste velden ontbreken: skillName of source", + "manager_unavailable": "Vaardigheidenbeheerder niet beschikbaar", + "missing_delete_fields": "Vereiste velden ontbreken: skillName of source", + "skill_not_found": "Vaardigheid \"{{name}}\" niet gevonden" + } +} diff --git a/src/i18n/locales/pl/common.json b/src/i18n/locales/pl/common.json index 23bc09e4d7..20b568281b 100644 --- a/src/i18n/locales/pl/common.json +++ b/src/i18n/locales/pl/common.json @@ -111,15 +111,6 @@ "thinking_complete_safety": "(Myślenie zakończone, ale dane wyjściowe zostały zablokowane przez ustawienia bezpieczeństwa.)", "thinking_complete_recitation": "(Myślenie zakończone, ale dane wyjściowe zostały zablokowane przez kontrolę recytacji.)" }, - "cerebras": { - "authenticationFailed": "Uwierzytelnianie API Cerebras nie powiodło się. Sprawdź, czy twój klucz API jest ważny i nie wygasł.", - "accessForbidden": "Dostęp do API Cerebras zabroniony. Twój klucz API może nie mieć dostępu do żądanego modelu lub funkcji.", - "rateLimitExceeded": "Przekroczono limit szybkości API Cerebras. Poczekaj przed wykonaniem kolejnego żądania.", - "serverError": "Błąd serwera API Cerebras ({{status}}). Spróbuj ponownie później.", - "genericError": "Błąd API Cerebras ({{status}}): {{message}}", - "noResponseBody": "Błąd API Cerebras: Brak treści odpowiedzi", - "completionError": "Błąd uzupełniania Cerebras: {{error}}" - }, "roo": { "authenticationRequired": "Dostawca Roo wymaga uwierzytelnienia w chmurze. Zaloguj się do Roo Code Cloud." }, @@ -205,10 +196,7 @@ "task_placeholder": "Wpisz swoje zadanie tutaj" }, "settings": { - "providers": { - "groqApiKey": "Klucz API Groq", - "getGroqApiKey": "Uzyskaj klucz API Groq" - } + "providers": {} }, "customModes": { "errors": { diff --git a/src/i18n/locales/pl/embeddings.json b/src/i18n/locales/pl/embeddings.json index 133f9f40da..0ef846b2cc 100644 --- a/src/i18n/locales/pl/embeddings.json +++ b/src/i18n/locales/pl/embeddings.json @@ -69,6 +69,8 @@ "fileWatcherStopped": "Monitor plików zatrzymany.", "failedDuringInitialScan": "Niepowodzenie podczas początkowego skanowania: {{errorMessage}}", "unknownError": "Nieznany błąd", - "indexingRequiresWorkspace": "Indeksowanie wymaga otwartego folderu workspace" + "indexingRequiresWorkspace": "Indeksowanie wymaga otwartego folderu workspace", + "indexingStopped": "Indeksowanie zatrzymane przez użytkownika.", + "indexingStoppedPartial": "Indeksowanie zatrzymane. Częściowe dane indeksu zachowane." } } diff --git a/src/i18n/locales/pl/skills.json b/src/i18n/locales/pl/skills.json new file mode 100644 index 0000000000..93927d1d14 --- /dev/null +++ b/src/i18n/locales/pl/skills.json @@ -0,0 +1,16 @@ +{ + "errors": { + "name_length": "Nazwa umiejętności musi mieć 1-{{maxLength}} znaków (otrzymano {{length}})", + "name_format": "Nazwa umiejętności może zawierać tylko małe litery, cyfry i myślniki (bez myślników na początku lub końcu, bez następujących po sobie myślników)", + "description_length": "Opis umiejętności musi mieć 1-1024 znaków (otrzymano {{length}})", + "no_workspace": "Nie można utworzyć umiejętności projektu: nie otwarto folderu obszaru roboczego", + "already_exists": "Umiejętność \"{{name}}\" już istnieje w {{path}}", + "not_found": "Nie znaleziono umiejętności \"{{name}}\" w {{source}}{{modeInfo}}", + "missing_create_fields": "Brakuje wymaganych pól: skillName, source lub skillDescription", + "missing_move_fields": "Brakuje wymaganych pól: skillName lub source", + "missing_update_modes_fields": "Brakuje wymaganych pól: skillName lub source", + "manager_unavailable": "Menedżer umiejętności niedostępny", + "missing_delete_fields": "Brakuje wymaganych pól: skillName lub source", + "skill_not_found": "Nie znaleziono umiejętności \"{{name}}\"" + } +} diff --git a/src/i18n/locales/pt-BR/common.json b/src/i18n/locales/pt-BR/common.json index 737b322f78..38abc8c804 100644 --- a/src/i18n/locales/pt-BR/common.json +++ b/src/i18n/locales/pt-BR/common.json @@ -115,15 +115,6 @@ "thinking_complete_safety": "(Pensamento concluído, mas a saída foi bloqueada devido às configurações de segurança.)", "thinking_complete_recitation": "(Pensamento concluído, mas a saída foi bloqueada devido à verificação de recitação.)" }, - "cerebras": { - "authenticationFailed": "Falha na autenticação da API Cerebras. Verifique se sua chave de API é válida e não expirou.", - "accessForbidden": "Acesso à API Cerebras negado. Sua chave de API pode não ter acesso ao modelo ou recurso solicitado.", - "rateLimitExceeded": "Limite de taxa da API Cerebras excedido. Aguarde antes de fazer outra solicitação.", - "serverError": "Erro do servidor da API Cerebras ({{status}}). Tente novamente mais tarde.", - "genericError": "Erro da API Cerebras ({{status}}): {{message}}", - "noResponseBody": "Erro da API Cerebras: Sem corpo de resposta", - "completionError": "Erro de conclusão do Cerebras: {{error}}" - }, "roo": { "authenticationRequired": "O provedor Roo requer autenticação na nuvem. Faça login no Roo Code Cloud." }, @@ -205,10 +196,7 @@ "enter_valid_path": "Por favor, digite um caminho válido" }, "settings": { - "providers": { - "groqApiKey": "Chave de API Groq", - "getGroqApiKey": "Obter chave de API Groq" - } + "providers": {} }, "customModes": { "errors": { diff --git a/src/i18n/locales/pt-BR/embeddings.json b/src/i18n/locales/pt-BR/embeddings.json index 09f4a55787..9cdf775e76 100644 --- a/src/i18n/locales/pt-BR/embeddings.json +++ b/src/i18n/locales/pt-BR/embeddings.json @@ -69,6 +69,8 @@ "fileWatcherStopped": "Monitor de arquivos parado.", "failedDuringInitialScan": "Falhou durante a varredura inicial: {{errorMessage}}", "unknownError": "Erro desconhecido", - "indexingRequiresWorkspace": "A indexação requer uma pasta de workspace aberta" + "indexingRequiresWorkspace": "A indexação requer uma pasta de workspace aberta", + "indexingStopped": "Indexação interrompida pelo usuário.", + "indexingStoppedPartial": "Indexação interrompida. Dados de índice parciais preservados." } } diff --git a/src/i18n/locales/pt-BR/skills.json b/src/i18n/locales/pt-BR/skills.json new file mode 100644 index 0000000000..2a0881bd8f --- /dev/null +++ b/src/i18n/locales/pt-BR/skills.json @@ -0,0 +1,16 @@ +{ + "errors": { + "name_length": "O nome da habilidade deve ter de 1 a {{maxLength}} caracteres (recebido {{length}})", + "name_format": "O nome da habilidade só pode conter letras minúsculas, números e hifens (sem hifens iniciais ou finais, sem hifens consecutivos)", + "description_length": "A descrição da habilidade deve ter de 1 a 1024 caracteres (recebido {{length}})", + "no_workspace": "Não é possível criar habilidade do projeto: nenhuma pasta de espaço de trabalho está aberta", + "already_exists": "A habilidade \"{{name}}\" já existe em {{path}}", + "not_found": "Habilidade \"{{name}}\" não encontrada em {{source}}{{modeInfo}}", + "missing_create_fields": "Campos obrigatórios ausentes: skillName, source ou skillDescription", + "missing_move_fields": "Campos obrigatórios ausentes: skillName ou source", + "missing_update_modes_fields": "Campos obrigatórios ausentes: skillName ou source", + "manager_unavailable": "Gerenciador de habilidades não disponível", + "missing_delete_fields": "Campos obrigatórios ausentes: skillName ou source", + "skill_not_found": "Habilidade \"{{name}}\" não encontrada" + } +} diff --git a/src/i18n/locales/ru/common.json b/src/i18n/locales/ru/common.json index 7ac53199ba..d124f59731 100644 --- a/src/i18n/locales/ru/common.json +++ b/src/i18n/locales/ru/common.json @@ -111,15 +111,6 @@ "thinking_complete_safety": "(Размышление завершено, но вывод заблокирован настройками безопасности.)", "thinking_complete_recitation": "(Размышление завершено, но вывод заблокирован проверкой цитирования.)" }, - "cerebras": { - "authenticationFailed": "Ошибка аутентификации Cerebras API. Убедитесь, что ваш API-ключ действителен и не истек.", - "accessForbidden": "Доступ к Cerebras API запрещен. Ваш API-ключ может не иметь доступа к запрашиваемой модели или функции.", - "rateLimitExceeded": "Превышен лимит скорости Cerebras API. Подождите перед отправкой следующего запроса.", - "serverError": "Ошибка сервера Cerebras API ({{status}}). Попробуйте позже.", - "genericError": "Ошибка Cerebras API ({{status}}): {{message}}", - "noResponseBody": "Ошибка Cerebras API: Нет тела ответа", - "completionError": "Ошибка завершения Cerebras: {{error}}" - }, "roo": { "authenticationRequired": "Провайдер Roo требует облачной аутентификации. Войдите в Roo Code Cloud." }, @@ -205,10 +196,7 @@ "task_placeholder": "Введите вашу задачу здесь" }, "settings": { - "providers": { - "groqApiKey": "Ключ API Groq", - "getGroqApiKey": "Получить ключ API Groq" - } + "providers": {} }, "customModes": { "errors": { diff --git a/src/i18n/locales/ru/embeddings.json b/src/i18n/locales/ru/embeddings.json index 9e94082bbf..873b1c0630 100644 --- a/src/i18n/locales/ru/embeddings.json +++ b/src/i18n/locales/ru/embeddings.json @@ -69,6 +69,8 @@ "fileWatcherStopped": "Наблюдатель файлов остановлен.", "failedDuringInitialScan": "Ошибка во время первоначального сканирования: {{errorMessage}}", "unknownError": "Неизвестная ошибка", - "indexingRequiresWorkspace": "Для индексации требуется открытая папка рабочего пространства" + "indexingRequiresWorkspace": "Для индексации требуется открытая папка рабочего пространства", + "indexingStopped": "Индексация остановлена пользователем.", + "indexingStoppedPartial": "Индексация остановлена. Частичные данные индекса сохранены." } } diff --git a/src/i18n/locales/ru/skills.json b/src/i18n/locales/ru/skills.json new file mode 100644 index 0000000000..c505d51de7 --- /dev/null +++ b/src/i18n/locales/ru/skills.json @@ -0,0 +1,16 @@ +{ + "errors": { + "name_length": "Имя навыка должно быть от 1 до {{maxLength}} символов (получено {{length}})", + "name_format": "Имя навыка может содержать только строчные буквы, цифры и дефисы (без начальных или конечных дефисов, без последовательных дефисов)", + "description_length": "Описание навыка должно быть от 1 до 1024 символов (получено {{length}})", + "no_workspace": "Невозможно создать навык проекта: не открыта папка рабочего пространства", + "already_exists": "Навык \"{{name}}\" уже существует в {{path}}", + "not_found": "Навык \"{{name}}\" не найден в {{source}}{{modeInfo}}", + "missing_create_fields": "Отсутствуют обязательные поля: skillName, source или skillDescription", + "missing_move_fields": "Отсутствуют обязательные поля: skillName или source", + "missing_update_modes_fields": "Отсутствуют обязательные поля: skillName или source", + "manager_unavailable": "Менеджер навыков недоступен", + "missing_delete_fields": "Отсутствуют обязательные поля: skillName или source", + "skill_not_found": "Навык \"{{name}}\" не найден" + } +} diff --git a/src/i18n/locales/tr/common.json b/src/i18n/locales/tr/common.json index fca268c0ff..00dcf6fc33 100644 --- a/src/i18n/locales/tr/common.json +++ b/src/i18n/locales/tr/common.json @@ -111,15 +111,6 @@ "thinking_complete_safety": "(Düşünme tamamlandı, ancak çıktı güvenlik ayarları nedeniyle engellendi.)", "thinking_complete_recitation": "(Düşünme tamamlandı, ancak çıktı okuma kontrolü nedeniyle engellendi.)" }, - "cerebras": { - "authenticationFailed": "Cerebras API kimlik doğrulama başarısız oldu. API anahtarınızın geçerli olduğunu ve süresi dolmadığını kontrol edin.", - "accessForbidden": "Cerebras API erişimi yasak. API anahtarınız istenen modele veya özelliğe erişimi olmayabilir.", - "rateLimitExceeded": "Cerebras API hız sınırı aşıldı. Başka bir istek yapmadan önce bekleyin.", - "serverError": "Cerebras API sunucu hatası ({{status}}). Lütfen daha sonra tekrar deneyin.", - "genericError": "Cerebras API Hatası ({{status}}): {{message}}", - "noResponseBody": "Cerebras API Hatası: Yanıt gövdesi yok", - "completionError": "Cerebras tamamlama hatası: {{error}}" - }, "roo": { "authenticationRequired": "Roo sağlayıcısı bulut kimlik doğrulaması gerektirir. Lütfen Roo Code Cloud'a giriş yapın." }, @@ -205,10 +196,7 @@ "task_placeholder": "Görevini buraya yaz" }, "settings": { - "providers": { - "groqApiKey": "Groq API Anahtarı", - "getGroqApiKey": "Groq API Anahtarı Al" - } + "providers": {} }, "customModes": { "errors": { diff --git a/src/i18n/locales/tr/embeddings.json b/src/i18n/locales/tr/embeddings.json index 411ed7ab52..30b703a93f 100644 --- a/src/i18n/locales/tr/embeddings.json +++ b/src/i18n/locales/tr/embeddings.json @@ -69,6 +69,8 @@ "fileWatcherStopped": "Dosya izleyici durduruldu.", "failedDuringInitialScan": "İlk tarama sırasında başarısız: {{errorMessage}}", "unknownError": "Bilinmeyen hata", - "indexingRequiresWorkspace": "İndeksleme açık bir workspace klasörü gerektirir" + "indexingRequiresWorkspace": "İndeksleme açık bir workspace klasörü gerektirir", + "indexingStopped": "İndeksleme kullanıcı tarafından durduruldu.", + "indexingStoppedPartial": "İndeksleme durduruldu. Kısmi indeks verileri korundu." } } diff --git a/src/i18n/locales/tr/skills.json b/src/i18n/locales/tr/skills.json new file mode 100644 index 0000000000..459d9c8f6d --- /dev/null +++ b/src/i18n/locales/tr/skills.json @@ -0,0 +1,16 @@ +{ + "errors": { + "name_length": "Beceri adı 1-{{maxLength}} karakter olmalıdır ({{length}} alındı)", + "name_format": "Beceri adı yalnızca küçük harfler, rakamlar ve tire içerebilir (başta veya sonda tire yok, ardışık tire yok)", + "description_length": "Beceri açıklaması 1-1024 karakter olmalıdır ({{length}} alındı)", + "no_workspace": "Proje becerisi oluşturulamıyor: açık çalışma alanı klasörü yok", + "already_exists": "\"{{name}}\" becerisi zaten {{path}} konumunda mevcut", + "not_found": "\"{{name}}\" becerisi {{source}}{{modeInfo}} içinde bulunamadı", + "missing_create_fields": "Gerekli alanlar eksik: skillName, source veya skillDescription", + "missing_move_fields": "Gerekli alanlar eksik: skillName veya source", + "missing_update_modes_fields": "Gerekli alanlar eksik: skillName veya source", + "manager_unavailable": "Beceri yöneticisi kullanılamıyor", + "missing_delete_fields": "Gerekli alanlar eksik: skillName veya source", + "skill_not_found": "\"{{name}}\" becerisi bulunamadı" + } +} diff --git a/src/i18n/locales/vi/common.json b/src/i18n/locales/vi/common.json index bd9bb72b47..decd4ff53e 100644 --- a/src/i18n/locales/vi/common.json +++ b/src/i18n/locales/vi/common.json @@ -111,15 +111,6 @@ "thinking_complete_safety": "(Đã suy nghĩ xong nhưng kết quả bị chặn do cài đặt an toàn.)", "thinking_complete_recitation": "(Đã suy nghĩ xong nhưng kết quả bị chặn do kiểm tra trích dẫn.)" }, - "cerebras": { - "authenticationFailed": "Xác thực API Cerebras thất bại. Vui lòng kiểm tra khóa API của bạn có hợp lệ và chưa hết hạn.", - "accessForbidden": "Truy cập API Cerebras bị từ chối. Khóa API của bạn có thể không có quyền truy cập vào mô hình hoặc tính năng được yêu cầu.", - "rateLimitExceeded": "Vượt quá giới hạn tốc độ API Cerebras. Vui lòng chờ trước khi thực hiện yêu cầu khác.", - "serverError": "Lỗi máy chủ API Cerebras ({{status}}). Vui lòng thử lại sau.", - "genericError": "Lỗi API Cerebras ({{status}}): {{message}}", - "noResponseBody": "Lỗi API Cerebras: Không có nội dung phản hồi", - "completionError": "Lỗi hoàn thành Cerebras: {{error}}" - }, "roo": { "authenticationRequired": "Nhà cung cấp Roo yêu cầu xác thực đám mây. Vui lòng đăng nhập vào Roo Code Cloud." }, @@ -205,10 +196,7 @@ "task_placeholder": "Nhập nhiệm vụ của bạn ở đây" }, "settings": { - "providers": { - "groqApiKey": "Khóa API Groq", - "getGroqApiKey": "Lấy khóa API Groq" - } + "providers": {} }, "customModes": { "errors": { diff --git a/src/i18n/locales/vi/embeddings.json b/src/i18n/locales/vi/embeddings.json index c9f9880df0..c92ebba276 100644 --- a/src/i18n/locales/vi/embeddings.json +++ b/src/i18n/locales/vi/embeddings.json @@ -69,6 +69,8 @@ "fileWatcherStopped": "Trình theo dõi tệp đã dừng.", "failedDuringInitialScan": "Thất bại trong quá trình quét ban đầu: {{errorMessage}}", "unknownError": "Lỗi không xác định", - "indexingRequiresWorkspace": "Lập chỉ mục yêu cầu một thư mục workspace đang mở" + "indexingRequiresWorkspace": "Lập chỉ mục yêu cầu một thư mục workspace đang mở", + "indexingStopped": "Lập chỉ mục đã bị dừng bởi người dùng.", + "indexingStoppedPartial": "Lập chỉ mục đã dừng. Dữ liệu chỉ mục một phần được bảo toàn." } } diff --git a/src/i18n/locales/vi/skills.json b/src/i18n/locales/vi/skills.json new file mode 100644 index 0000000000..3bd28a8c0b --- /dev/null +++ b/src/i18n/locales/vi/skills.json @@ -0,0 +1,16 @@ +{ + "errors": { + "name_length": "Tên kỹ năng phải từ 1-{{maxLength}} ký tự (nhận được {{length}})", + "name_format": "Tên kỹ năng chỉ có thể chứa chữ cái thường, số và dấu gạch ngang (không có dấu gạch ngang đầu hoặc cuối, không có dấu gạch ngang liên tiếp)", + "description_length": "Mô tả kỹ năng phải từ 1-1024 ký tự (nhận được {{length}})", + "no_workspace": "Không thể tạo kỹ năng dự án: không có thư mục vùng làm việc nào được mở", + "already_exists": "Kỹ năng \"{{name}}\" đã tồn tại tại {{path}}", + "not_found": "Không tìm thấy kỹ năng \"{{name}}\" trong {{source}}{{modeInfo}}", + "missing_create_fields": "Thiếu các trường bắt buộc: skillName, source hoặc skillDescription", + "missing_move_fields": "Thiếu các trường bắt buộc: skillName hoặc source", + "missing_update_modes_fields": "Thiếu các trường bắt buộc: skillName hoặc source", + "manager_unavailable": "Trình quản lý kỹ năng không khả dụng", + "missing_delete_fields": "Thiếu các trường bắt buộc: skillName hoặc source", + "skill_not_found": "Không tìm thấy kỹ năng \"{{name}}\"" + } +} diff --git a/src/i18n/locales/zh-CN/common.json b/src/i18n/locales/zh-CN/common.json index 494c246d65..6df1f78b16 100644 --- a/src/i18n/locales/zh-CN/common.json +++ b/src/i18n/locales/zh-CN/common.json @@ -116,15 +116,6 @@ "thinking_complete_safety": "(思考完成,但由于安全设置输出被阻止。)", "thinking_complete_recitation": "(思考完成,但由于引用检查输出被阻止。)" }, - "cerebras": { - "authenticationFailed": "Cerebras API 身份验证失败。请检查你的 API 密钥是否有效且未过期。", - "accessForbidden": "Cerebras API 访问被禁止。你的 API 密钥可能无法访问请求的模型或功能。", - "rateLimitExceeded": "Cerebras API 速率限制已超出。请稍等后再发起另一个请求。", - "serverError": "Cerebras API 服务器错误 ({{status}})。请稍后重试。", - "genericError": "Cerebras API 错误 ({{status}}):{{message}}", - "noResponseBody": "Cerebras API 错误:无响应主体", - "completionError": "Cerebras 完成错误:{{error}}" - }, "roo": { "authenticationRequired": "Roo 提供商需要云认证。请登录 Roo Code Cloud。" }, @@ -210,10 +201,7 @@ "task_placeholder": "在这里输入任务" }, "settings": { - "providers": { - "groqApiKey": "Groq API 密钥", - "getGroqApiKey": "获取 Groq API 密钥" - } + "providers": {} }, "customModes": { "errors": { diff --git a/src/i18n/locales/zh-CN/embeddings.json b/src/i18n/locales/zh-CN/embeddings.json index c27bc07801..b4f4eaad1d 100644 --- a/src/i18n/locales/zh-CN/embeddings.json +++ b/src/i18n/locales/zh-CN/embeddings.json @@ -69,6 +69,8 @@ "fileWatcherStopped": "文件监控已停止。", "failedDuringInitialScan": "初始扫描失败:{{errorMessage}}", "unknownError": "未知错误", - "indexingRequiresWorkspace": "索引需要打开的工作区文件夹" + "indexingRequiresWorkspace": "索引需要打开的工作区文件夹", + "indexingStopped": "用户已停止索引。", + "indexingStoppedPartial": "索引已停止。部分索引数据已保留。" } } diff --git a/src/i18n/locales/zh-CN/skills.json b/src/i18n/locales/zh-CN/skills.json new file mode 100644 index 0000000000..ade7833363 --- /dev/null +++ b/src/i18n/locales/zh-CN/skills.json @@ -0,0 +1,16 @@ +{ + "errors": { + "name_length": "技能名称必须为 1-{{maxLength}} 个字符(收到 {{length}} 个)", + "name_format": "技能名称只能包含小写字母、数字和连字符(不能有前导或尾随连字符,不能有连续连字符)", + "description_length": "技能描述必须为 1-1024 个字符(收到 {{length}} 个)", + "no_workspace": "无法创建项目技能:未打开工作区文件夹", + "already_exists": "技能 \"{{name}}\" 已存在于 {{path}}", + "not_found": "在 {{source}}{{modeInfo}} 中未找到技能 \"{{name}}\"", + "missing_create_fields": "缺少必填字段:skillName、source 或 skillDescription", + "missing_move_fields": "缺少必填字段:skillName 或 source", + "missing_update_modes_fields": "缺少必填字段:skillName 或 source", + "manager_unavailable": "技能管理器不可用", + "missing_delete_fields": "缺少必填字段:skillName 或 source", + "skill_not_found": "未找到技能 \"{{name}}\"" + } +} diff --git a/src/i18n/locales/zh-TW/common.json b/src/i18n/locales/zh-TW/common.json index 572cdb4651..be4a76fc5b 100644 --- a/src/i18n/locales/zh-TW/common.json +++ b/src/i18n/locales/zh-TW/common.json @@ -110,15 +110,6 @@ "thinking_complete_safety": "(思考完成,但由於安全設定輸出被阻止。)", "thinking_complete_recitation": "(思考完成,但由於引用檢查輸出被阻止。)" }, - "cerebras": { - "authenticationFailed": "Cerebras API 驗證失敗。請檢查您的 API 金鑰是否有效且未過期。", - "accessForbidden": "Cerebras API 存取被拒絕。您的 API 金鑰可能無法存取所請求的模型或功能。", - "rateLimitExceeded": "Cerebras API 速率限制已超出。請稍候再發出另一個請求。", - "serverError": "Cerebras API 伺服器錯誤 ({{status}})。請稍後重試。", - "genericError": "Cerebras API 錯誤 ({{status}}):{{message}}", - "noResponseBody": "Cerebras API 錯誤:無回應主體", - "completionError": "Cerebras 完成錯誤:{{error}}" - }, "roo": { "authenticationRequired": "Roo 提供者需要雲端認證。請登入 Roo Code Cloud。" }, @@ -205,10 +196,7 @@ "task_placeholder": "在這裡輸入工作" }, "settings": { - "providers": { - "groqApiKey": "Groq API 金鑰", - "getGroqApiKey": "取得 Groq API 金鑰" - } + "providers": {} }, "customModes": { "errors": { diff --git a/src/i18n/locales/zh-TW/embeddings.json b/src/i18n/locales/zh-TW/embeddings.json index 744e7022ea..26845ed948 100644 --- a/src/i18n/locales/zh-TW/embeddings.json +++ b/src/i18n/locales/zh-TW/embeddings.json @@ -69,6 +69,8 @@ "fileWatcherStopped": "檔案監控已停止。", "failedDuringInitialScan": "初始掃描失敗:{{errorMessage}}", "unknownError": "未知錯誤", - "indexingRequiresWorkspace": "索引需要開啟的工作區資料夾" + "indexingRequiresWorkspace": "索引需要開啟的工作區資料夾", + "indexingStopped": "使用者已停止索引。", + "indexingStoppedPartial": "索引已停止。部分索引資料已保留。" } } diff --git a/src/i18n/locales/zh-TW/skills.json b/src/i18n/locales/zh-TW/skills.json new file mode 100644 index 0000000000..e2c1fcf305 --- /dev/null +++ b/src/i18n/locales/zh-TW/skills.json @@ -0,0 +1,16 @@ +{ + "errors": { + "name_length": "技能名稱必須為 1-{{maxLength}} 個字元(收到 {{length}} 個)", + "name_format": "技能名稱只能包含小寫字母、數字和連字號(不能有前導或尾隨連字號,不能有連續連字號)", + "description_length": "技能描述必須為 1-1024 個字元(收到 {{length}} 個)", + "no_workspace": "無法建立專案技能:未開啟工作區資料夾", + "already_exists": "技能「{{name}}」已存在於 {{path}}", + "not_found": "在 {{source}}{{modeInfo}} 中找不到技能「{{name}}」", + "missing_create_fields": "缺少必填欄位:skillName、source 或 skillDescription", + "missing_move_fields": "缺少必填欄位:skillName 或 source", + "missing_update_modes_fields": "缺少必填欄位:skillName 或 source", + "manager_unavailable": "技能管理器無法使用", + "missing_delete_fields": "缺少必填欄位:skillName 或 source", + "skill_not_found": "找不到技能「{{name}}」" + } +} diff --git a/src/integrations/editor/DiffViewProvider.ts b/src/integrations/editor/DiffViewProvider.ts index 94a483706e..6ed4fd7553 100644 --- a/src/integrations/editor/DiffViewProvider.ts +++ b/src/integrations/editor/DiffViewProvider.ts @@ -98,7 +98,11 @@ export class DiffViewProvider { for (const tab of tabs) { if (!tab.isDirty) { - await vscode.window.tabGroups.close(tab) + try { + await vscode.window.tabGroups.close(tab) + } catch (err) { + console.error(`Failed to close tab ${tab.label}`, err) + } } this.documentWasOpen = true } diff --git a/src/integrations/misc/__tests__/extract-text-large-files.spec.ts b/src/integrations/misc/__tests__/extract-text-large-files.spec.ts deleted file mode 100644 index c9e2f181f5..0000000000 --- a/src/integrations/misc/__tests__/extract-text-large-files.spec.ts +++ /dev/null @@ -1,221 +0,0 @@ -// npx vitest run integrations/misc/__tests__/extract-text-large-files.spec.ts - -import * as fs from "fs/promises" - -import { extractTextFromFile } from "../extract-text" -import { countFileLines } from "../line-counter" -import { readLines } from "../read-lines" -import { isBinaryFile } from "isbinaryfile" - -// Mock all dependencies -vi.mock("fs/promises") -vi.mock("../line-counter") -vi.mock("../read-lines") -vi.mock("isbinaryfile") - -describe("extractTextFromFile - Large File Handling", () => { - // Type the mocks - const mockedFs = vi.mocked(fs) - const mockedCountFileLines = vi.mocked(countFileLines) - const mockedReadLines = vi.mocked(readLines) - const mockedIsBinaryFile = vi.mocked(isBinaryFile) - - beforeEach(() => { - vi.clearAllMocks() - // Set default mock behavior - mockedFs.access.mockResolvedValue(undefined) - mockedIsBinaryFile.mockResolvedValue(false) - }) - - it("should truncate files that exceed maxReadFileLine limit", async () => { - const largeFileContent = Array(150) - .fill(null) - .map((_, i) => `Line ${i + 1}: This is a test line with some content`) - .join("\n") - - mockedCountFileLines.mockResolvedValue(150) - mockedReadLines.mockResolvedValue( - Array(100) - .fill(null) - .map((_, i) => `Line ${i + 1}: This is a test line with some content`) - .join("\n"), - ) - - const result = await extractTextFromFile("/test/large-file.ts", 100) - - // Should only include first 100 lines with line numbers - expect(result).toContain(" 1 | Line 1: This is a test line with some content") - expect(result).toContain("100 | Line 100: This is a test line with some content") - expect(result).not.toContain("101 | Line 101: This is a test line with some content") - - // Should include truncation message - expect(result).toContain( - "[File truncated: showing 100 of 150 total lines. The file is too large and may exhaust the context window if read in full.]", - ) - }) - - it("should not truncate files within the maxReadFileLine limit", async () => { - const smallFileContent = Array(50) - .fill(null) - .map((_, i) => `Line ${i + 1}: This is a test line`) - .join("\n") - - mockedCountFileLines.mockResolvedValue(50) - mockedFs.readFile.mockResolvedValue(smallFileContent as any) - - const result = await extractTextFromFile("/test/small-file.ts", 100) - - // Should include all lines with line numbers - expect(result).toContain(" 1 | Line 1: This is a test line") - expect(result).toContain("50 | Line 50: This is a test line") - - // Should not include truncation message - expect(result).not.toContain("[File truncated:") - }) - - it("should handle files with exactly maxReadFileLine lines", async () => { - const exactFileContent = Array(100) - .fill(null) - .map((_, i) => `Line ${i + 1}`) - .join("\n") - - mockedCountFileLines.mockResolvedValue(100) - mockedFs.readFile.mockResolvedValue(exactFileContent as any) - - const result = await extractTextFromFile("/test/exact-file.ts", 100) - - // Should include all lines with line numbers - expect(result).toContain(" 1 | Line 1") - expect(result).toContain("100 | Line 100") - - // Should not include truncation message - expect(result).not.toContain("[File truncated:") - }) - - it("should handle undefined maxReadFileLine by not truncating", async () => { - const largeFileContent = Array(200) - .fill(null) - .map((_, i) => `Line ${i + 1}`) - .join("\n") - - mockedFs.readFile.mockResolvedValue(largeFileContent as any) - - const result = await extractTextFromFile("/test/large-file.ts", undefined) - - // Should include all lines with line numbers when maxReadFileLine is undefined - expect(result).toContain(" 1 | Line 1") - expect(result).toContain("200 | Line 200") - - // Should not include truncation message - expect(result).not.toContain("[File truncated:") - }) - - it("should handle empty files", async () => { - mockedFs.readFile.mockResolvedValue("" as any) - - const result = await extractTextFromFile("/test/empty-file.ts", 100) - - expect(result).toBe("") - expect(result).not.toContain("[File truncated:") - }) - - it("should handle files with only newlines", async () => { - const newlineOnlyContent = "\n\n\n\n\n" - - mockedCountFileLines.mockResolvedValue(6) // 5 newlines = 6 lines - mockedReadLines.mockResolvedValue("\n\n") - - const result = await extractTextFromFile("/test/newline-file.ts", 3) - - // Should truncate at line 3 - expect(result).toContain("[File truncated: showing 3 of 6 total lines") - }) - - it("should handle very large files efficiently", async () => { - // Simulate a 10,000 line file - mockedCountFileLines.mockResolvedValue(10000) - mockedReadLines.mockResolvedValue( - Array(500) - .fill(null) - .map((_, i) => `Line ${i + 1}: Some content here`) - .join("\n"), - ) - - const result = await extractTextFromFile("/test/very-large-file.ts", 500) - - // Should only include first 500 lines with line numbers - expect(result).toContain(" 1 | Line 1: Some content here") - expect(result).toContain("500 | Line 500: Some content here") - expect(result).not.toContain("501 | Line 501: Some content here") - - // Should show truncation message - expect(result).toContain("[File truncated: showing 500 of 10000 total lines") - }) - - it("should handle maxReadFileLine of 0 by throwing an error", async () => { - const fileContent = "Line 1\nLine 2\nLine 3" - - mockedFs.readFile.mockResolvedValue(fileContent as any) - - // maxReadFileLine of 0 should throw an error - await expect(extractTextFromFile("/test/file.ts", 0)).rejects.toThrow( - "Invalid maxReadFileLine: 0. Must be a positive integer or -1 for unlimited.", - ) - }) - - it("should handle negative maxReadFileLine by treating as undefined", async () => { - const fileContent = "Line 1\nLine 2\nLine 3" - - mockedFs.readFile.mockResolvedValue(fileContent as any) - - const result = await extractTextFromFile("/test/file.ts", -1) - - // Should include all content with line numbers when negative - expect(result).toContain("1 | Line 1") - expect(result).toContain("2 | Line 2") - expect(result).toContain("3 | Line 3") - expect(result).not.toContain("[File truncated:") - }) - - it("should preserve file content structure when truncating", async () => { - const structuredContent = [ - "function example() {", - " const x = 1;", - " const y = 2;", - " return x + y;", - "}", - "", - "// More code below", - ].join("\n") - - mockedCountFileLines.mockResolvedValue(7) - mockedReadLines.mockResolvedValue(["function example() {", " const x = 1;", " const y = 2;"].join("\n")) - - const result = await extractTextFromFile("/test/structured.ts", 3) - - // Should preserve the first 3 lines with line numbers - expect(result).toContain("1 | function example() {") - expect(result).toContain("2 | const x = 1;") - expect(result).toContain("3 | const y = 2;") - expect(result).not.toContain("4 | return x + y;") - - // Should include truncation info - expect(result).toContain("[File truncated: showing 3 of 7 total lines") - }) - - it("should handle binary files by throwing an error", async () => { - mockedIsBinaryFile.mockResolvedValue(true) - - await expect(extractTextFromFile("/test/binary.bin", 100)).rejects.toThrow( - "Cannot read text for file type: .bin", - ) - }) - - it("should handle file not found errors", async () => { - mockedFs.access.mockRejectedValue(new Error("ENOENT")) - - await expect(extractTextFromFile("/test/nonexistent.ts", 100)).rejects.toThrow( - "File not found: /test/nonexistent.ts", - ) - }) -}) diff --git a/src/integrations/misc/__tests__/indentation-reader.spec.ts b/src/integrations/misc/__tests__/indentation-reader.spec.ts new file mode 100644 index 0000000000..d46cb54277 --- /dev/null +++ b/src/integrations/misc/__tests__/indentation-reader.spec.ts @@ -0,0 +1,639 @@ +import { describe, it, expect } from "vitest" +import { + parseLines, + formatWithLineNumbers, + readWithIndentation, + readWithSlice, + computeEffectiveIndents, + type LineRecord, + type IndentationReadResult, +} from "../indentation-reader" + +// ─── Test Fixtures ──────────────────────────────────────────────────────────── + +const PYTHON_CODE = `#!/usr/bin/env python3 +"""Module docstring.""" +import os +import sys +from typing import List + +class Calculator: + """A simple calculator class.""" + + def __init__(self, value: int = 0): + self.value = value + + def add(self, n: int) -> int: + """Add a number.""" + self.value += n + return self.value + + def subtract(self, n: int) -> int: + """Subtract a number.""" + self.value -= n + return self.value + + def reset(self): + """Reset to zero.""" + self.value = 0 + +def main(): + calc = Calculator() + calc.add(5) + print(calc.value) + +if __name__ == "__main__": + main() +` + +const TYPESCRIPT_CODE = `import { something } from "./module" +import type { SomeType } from "./types" + +// Constants +const MAX_VALUE = 100 + +interface Config { + name: string + value: number +} + +class Handler { + private config: Config + + constructor(config: Config) { + this.config = config + } + + process(input: string): string { + // Process the input + const result = input.toUpperCase() + if (result.length > MAX_VALUE) { + return result.slice(0, MAX_VALUE) + } + return result + } + + validate(data: unknown): boolean { + if (typeof data !== "string") { + return false + } + return data.length > 0 + } +} + +export function createHandler(config: Config): Handler { + return new Handler(config) +} +` + +const SIMPLE_CODE = `function outer() { + function inner() { + console.log("hello") + } + inner() +} +` + +const CODE_WITH_BLANKS = `class Example: + def method_one(self): + x = 1 + + y = 2 + + return x + y + + def method_two(self): + return 42 +` + +// ─── parseLines Tests ───────────────────────────────────────────────────────── + +describe("parseLines", () => { + it("should parse lines with correct line numbers", () => { + const content = "line1\nline2\nline3" + const lines = parseLines(content) + + expect(lines).toHaveLength(3) + expect(lines[0].lineNumber).toBe(1) + expect(lines[1].lineNumber).toBe(2) + expect(lines[2].lineNumber).toBe(3) + }) + + it("should calculate indentation levels correctly", () => { + const content = "no indent\n one level\n two levels\n\t\ttab indent" + const lines = parseLines(content) + + expect(lines[0].indentLevel).toBe(0) + expect(lines[1].indentLevel).toBe(1) // 4 spaces = 1 level + expect(lines[2].indentLevel).toBe(2) // 8 spaces = 2 levels + expect(lines[3].indentLevel).toBe(2) // 2 tabs = 2 levels (tabs = 4 spaces each) + }) + + it("should identify blank lines", () => { + const content = "content\n\n \nmore content" + const lines = parseLines(content) + + expect(lines[0].isBlank).toBe(false) + expect(lines[1].isBlank).toBe(true) // empty + expect(lines[2].isBlank).toBe(true) // whitespace only + expect(lines[3].isBlank).toBe(false) + }) + + it("should identify block starts (Python style)", () => { + const content = "def foo():\n pass\nclass Bar:\n pass" + const lines = parseLines(content) + + expect(lines[0].isBlockStart).toBe(true) // def foo(): + expect(lines[1].isBlockStart).toBe(false) // pass + expect(lines[2].isBlockStart).toBe(true) // class Bar: + }) + + it("should identify block starts (C-style)", () => { + const content = "function foo() {\n return\n}\nif (x) {" + const lines = parseLines(content) + + expect(lines[0].isBlockStart).toBe(true) // function foo() { + expect(lines[1].isBlockStart).toBe(false) // return + expect(lines[2].isBlockStart).toBe(false) // } + expect(lines[3].isBlockStart).toBe(true) // if (x) { + }) + + it("should handle empty content", () => { + const lines = parseLines("") + expect(lines).toHaveLength(1) + expect(lines[0].isBlank).toBe(true) + }) +}) + +// ─── computeEffectiveIndents Tests ──────────────────────────────────────────── + +describe("computeEffectiveIndents", () => { + it("should return same indents for non-blank lines", () => { + const content = "line1\n line2\n line3" + const lines = parseLines(content) + const effective = computeEffectiveIndents(lines) + + expect(effective[0]).toBe(0) + expect(effective[1]).toBe(1) + expect(effective[2]).toBe(2) + }) + + it("should inherit previous indent for blank lines", () => { + const content = "line1\n line2\n\n line3" + const lines = parseLines(content) + const effective = computeEffectiveIndents(lines) + + expect(effective[0]).toBe(0) // line1 + expect(effective[1]).toBe(1) // line2 (indent 1) + expect(effective[2]).toBe(1) // blank line inherits from line2 + expect(effective[3]).toBe(1) // line3 + }) + + it("should handle multiple consecutive blank lines", () => { + const content = " start\n\n\n\n end" + const lines = parseLines(content) + const effective = computeEffectiveIndents(lines) + + expect(effective[0]).toBe(1) // start + expect(effective[1]).toBe(1) // blank inherits + expect(effective[2]).toBe(1) // blank inherits + expect(effective[3]).toBe(1) // blank inherits + expect(effective[4]).toBe(1) // end + }) + + it("should handle blank line at start", () => { + const content = "\n content" + const lines = parseLines(content) + const effective = computeEffectiveIndents(lines) + + expect(effective[0]).toBe(0) // blank at start has no previous, defaults to 0 + expect(effective[1]).toBe(1) // content + }) +}) + +// ─── formatWithLineNumbers Tests ────────────────────────────────────────────── + +describe("formatWithLineNumbers", () => { + it("should format lines with line numbers", () => { + const lines: LineRecord[] = [ + { lineNumber: 1, content: "first", indentLevel: 0, isBlank: false, isBlockStart: false }, + { lineNumber: 2, content: "second", indentLevel: 0, isBlank: false, isBlockStart: false }, + ] + + const result = formatWithLineNumbers(lines) + expect(result).toBe("1 | first\n2 | second") + }) + + it("should pad line numbers for alignment", () => { + const lines: LineRecord[] = [ + { lineNumber: 1, content: "a", indentLevel: 0, isBlank: false, isBlockStart: false }, + { lineNumber: 10, content: "b", indentLevel: 0, isBlank: false, isBlockStart: false }, + { lineNumber: 100, content: "c", indentLevel: 0, isBlank: false, isBlockStart: false }, + ] + + const result = formatWithLineNumbers(lines) + expect(result).toBe(" 1 | a\n 10 | b\n100 | c") + }) + + it("should truncate long lines", () => { + const longLine = "x".repeat(600) + const lines: LineRecord[] = [ + { lineNumber: 1, content: longLine, indentLevel: 0, isBlank: false, isBlockStart: false }, + ] + + const result = formatWithLineNumbers(lines, 100) + expect(result.length).toBeLessThan(longLine.length) + expect(result).toContain("...") + }) + + it("should handle empty array", () => { + const result = formatWithLineNumbers([]) + expect(result).toBe("") + }) +}) + +// ─── readWithSlice Tests ────────────────────────────────────────────────────── + +describe("readWithSlice", () => { + it("should read from beginning with default offset", () => { + const result = readWithSlice(SIMPLE_CODE, 0, 10) + + expect(result.totalLines).toBe(7) // 6 lines + empty trailing + expect(result.returnedLines).toBe(7) + expect(result.wasTruncated).toBe(false) + expect(result.content).toContain("1 | function outer()") + }) + + it("should respect offset parameter", () => { + const result = readWithSlice(SIMPLE_CODE, 2, 10) + + expect(result.content).not.toContain("function outer()") + expect(result.content).toContain("console.log") + expect(result.includedRanges[0][0]).toBe(3) // 1-based, offset 2 = line 3 + }) + + it("should respect limit parameter", () => { + const result = readWithSlice(TYPESCRIPT_CODE, 0, 5) + + expect(result.returnedLines).toBe(5) + expect(result.wasTruncated).toBe(true) + }) + + it("should handle offset beyond file end", () => { + const result = readWithSlice(SIMPLE_CODE, 1000, 10) + + expect(result.returnedLines).toBe(0) + expect(result.content).toContain("Error") + }) + + it("should handle negative offset", () => { + const result = readWithSlice(SIMPLE_CODE, -5, 10) + + // Should normalize to 0 + expect(result.includedRanges[0][0]).toBe(1) + }) +}) + +// ─── readWithIndentation Tests ──────────────────────────────────────────────── + +describe("readWithIndentation", () => { + describe("basic block extraction", () => { + it("should extract content around the anchor line", () => { + const result = readWithIndentation(PYTHON_CODE, { + anchorLine: 15, // Inside add() method + maxLevels: 0, // unlimited + includeHeader: false, + includeSiblings: false, + }) + + expect(result.content).toContain("def add") + expect(result.content).toContain("self.value += n") + expect(result.content).toContain("return self.value") + }) + + it("should handle anchor at first line", () => { + const result = readWithIndentation(SIMPLE_CODE, { + anchorLine: 1, + maxLevels: 0, + includeHeader: false, + }) + + expect(result.returnedLines).toBeGreaterThan(0) + expect(result.content).toContain("function outer()") + }) + + it("should handle anchor at last line", () => { + const lines = PYTHON_CODE.trim().split("\n").length + const result = readWithIndentation(PYTHON_CODE, { + anchorLine: lines, + maxLevels: 0, + includeHeader: false, + }) + + expect(result.returnedLines).toBeGreaterThan(0) + }) + }) + + describe("max_levels behavior", () => { + it("should include all content when maxLevels=0 (unlimited)", () => { + const result = readWithIndentation(SIMPLE_CODE, { + anchorLine: 3, // Inside inner() + maxLevels: 0, + includeHeader: false, + includeSiblings: false, + }) + + // With unlimited levels, should get the whole file + expect(result.content).toContain("function outer()") + expect(result.content).toContain("function inner()") + expect(result.content).toContain("console.log") + }) + + it("should limit expansion when maxLevels > 0", () => { + const result = readWithIndentation(SIMPLE_CODE, { + anchorLine: 3, // Inside inner() + maxLevels: 1, + includeHeader: false, + includeSiblings: false, + }) + + // With 1 level, should include inner() context but may not reach outer() + expect(result.content).toContain("console.log") + }) + + it("should handle deeply nested code with unlimited levels", () => { + const result = readWithIndentation(PYTHON_CODE, { + anchorLine: 15, // Inside add() method body + maxLevels: 0, // unlimited + includeHeader: false, + includeSiblings: false, + }) + + // Should expand to include class context + expect(result.content).toContain("class Calculator") + }) + }) + + describe("sibling blocks", () => { + it("should exclude siblings when includeSiblings is false", () => { + const result = readWithIndentation(PYTHON_CODE, { + anchorLine: 15, // Inside add() method + maxLevels: 1, + includeSiblings: false, + includeHeader: false, + }) + + // Should focus on add() but not include subtract() or other siblings + expect(result.content).toContain("def add") + }) + + it("should include siblings when includeSiblings is true", () => { + const result = readWithIndentation(PYTHON_CODE, { + anchorLine: 15, // Inside add() method + maxLevels: 1, + includeSiblings: true, + includeHeader: false, + }) + + // Should include sibling methods + expect(result.content).toContain("def add") + // May include other siblings depending on limit + }) + }) + + describe("file header (includeHeader option)", () => { + it("should allow comment lines at min indent when includeHeader is true", () => { + // The Codex algorithm's includeHeader option allows comment lines at the + // minimum indent level to be included during upward expansion. + // This is different from prepending the file's import header. + const result = readWithIndentation(PYTHON_CODE, { + anchorLine: 15, + maxLevels: 0, // unlimited - will expand to indent 0 + includeHeader: true, + includeSiblings: false, + }) + + // With unlimited levels, bidirectional expansion will include content + // at indent level 0. includeHeader allows comment lines to be included. + expect(result.returnedLines).toBeGreaterThan(0) + expect(result.content).toContain("def add") + }) + + it("should expand to top-level content with maxLevels=0", () => { + const result = readWithIndentation(PYTHON_CODE, { + anchorLine: 15, + maxLevels: 0, // unlimited + includeHeader: false, + includeSiblings: false, + }) + + // With unlimited levels, expansion goes to indent 0 + // which includes the class definition + expect(result.content).toContain("class Calculator") + }) + + it("should include class content when anchored inside a method", () => { + const result = readWithIndentation(TYPESCRIPT_CODE, { + anchorLine: 20, // Inside Handler class + maxLevels: 0, + includeHeader: true, + includeSiblings: false, + }) + + // Should include class context + expect(result.content).toContain("class Handler") + }) + }) + + describe("line limit and max_lines", () => { + it("should truncate output when exceeding limit", () => { + const result = readWithIndentation(TYPESCRIPT_CODE, { + anchorLine: 15, + maxLevels: 0, + includeHeader: true, + includeSiblings: true, + limit: 10, + }) + + expect(result.returnedLines).toBeLessThanOrEqual(10) + expect(result.wasTruncated).toBe(true) + }) + + it("should not truncate when under limit", () => { + const result = readWithIndentation(SIMPLE_CODE, { + anchorLine: 3, + maxLevels: 1, + includeHeader: false, + limit: 100, + }) + + expect(result.wasTruncated).toBe(false) + }) + + it("should respect maxLines as separate hard cap", () => { + const result = readWithIndentation(TYPESCRIPT_CODE, { + anchorLine: 20, + maxLevels: 0, + includeHeader: true, + includeSiblings: true, + limit: 100, + maxLines: 5, // Hard cap at 5 + }) + + expect(result.returnedLines).toBeLessThanOrEqual(5) + }) + + it("should use min of limit and maxLines", () => { + const result = readWithIndentation(TYPESCRIPT_CODE, { + anchorLine: 20, + maxLevels: 0, + includeHeader: true, + includeSiblings: true, + limit: 3, // More restrictive than maxLines + maxLines: 10, + }) + + expect(result.returnedLines).toBeLessThanOrEqual(3) + }) + }) + + describe("blank line handling", () => { + it("should treat blank lines with inherited indentation", () => { + const result = readWithIndentation(CODE_WITH_BLANKS, { + anchorLine: 4, // blank line inside method_one + maxLevels: 1, + includeHeader: false, + includeSiblings: false, + }) + + // Blank line should inherit previous indent and be included in expansion + expect(result.returnedLines).toBeGreaterThan(0) + }) + + it("should trim empty lines from edges of result", () => { + const result = readWithIndentation(CODE_WITH_BLANKS, { + anchorLine: 3, // x = 1 + maxLevels: 1, + includeHeader: false, + includeSiblings: false, + }) + + // Check that result doesn't start or end with blank lines + const lines = result.content.split("\n") + if (lines.length > 0) { + const firstLine = lines[0] + const lastLine = lines[lines.length - 1] + // Lines should have content after the line number prefix + expect(firstLine).toMatch(/\d+\s*\|/) + expect(lastLine).toMatch(/\d+\s*\|/) + } + }) + }) + + describe("error handling", () => { + it("should handle invalid anchor line (too low)", () => { + const result = readWithIndentation(PYTHON_CODE, { + anchorLine: 0, + maxLevels: 1, + }) + + expect(result.content).toContain("Error") + expect(result.returnedLines).toBe(0) + }) + + it("should handle invalid anchor line (too high)", () => { + const result = readWithIndentation(PYTHON_CODE, { + anchorLine: 9999, + maxLevels: 1, + }) + + expect(result.content).toContain("Error") + expect(result.returnedLines).toBe(0) + }) + }) + + describe("bidirectional expansion", () => { + it("should expand both up and down from anchor", () => { + const result = readWithIndentation(SIMPLE_CODE, { + anchorLine: 3, // console.log("hello") - in the middle + maxLevels: 0, + includeHeader: false, + includeSiblings: false, + limit: 10, + }) + + // Should include lines both before and after anchor + expect(result.content).toContain("function inner()") + expect(result.content).toContain("console.log") + }) + + it("should return single line when limit is 1", () => { + const result = readWithIndentation(SIMPLE_CODE, { + anchorLine: 3, + maxLevels: 0, + includeHeader: false, + includeSiblings: false, + limit: 1, + }) + + expect(result.returnedLines).toBe(1) + expect(result.content).toContain("console.log") + }) + + it("should stop expansion when hitting lower indent", () => { + const result = readWithIndentation(PYTHON_CODE, { + anchorLine: 15, // Inside add() method body (return self.value) + maxLevels: 2, // Only go up 2 levels from anchor indent + includeHeader: false, + includeSiblings: false, + }) + + // Should include method but respect maxLevels + expect(result.content).toContain("def add") + }) + }) + + describe("real-world scenarios", () => { + it("should extract a function with its context", () => { + const result = readWithIndentation(TYPESCRIPT_CODE, { + anchorLine: 37, // Inside createHandler function body (return statement) + maxLevels: 0, + includeHeader: true, + includeSiblings: false, + }) + + expect(result.content).toContain("export function createHandler") + expect(result.content).toContain("return new Handler") + }) + + it("should extract a class method with class context", () => { + const result = readWithIndentation(TYPESCRIPT_CODE, { + anchorLine: 19, // Inside process() method + maxLevels: 1, + includeHeader: false, + includeSiblings: false, + }) + + expect(result.content).toContain("process(input: string)") + }) + }) + + describe("includedRanges", () => { + it("should return correct contiguous range", () => { + const result = readWithIndentation(SIMPLE_CODE, { + anchorLine: 3, + maxLevels: 0, + includeHeader: false, + includeSiblings: false, + limit: 10, + }) + + expect(result.includedRanges.length).toBeGreaterThan(0) + // Each range should be [start, end] with start <= end + for (const [start, end] of result.includedRanges) { + expect(start).toBeLessThanOrEqual(end) + expect(start).toBeGreaterThan(0) + } + }) + }) +}) diff --git a/src/integrations/misc/__tests__/read-file-tool.spec.ts b/src/integrations/misc/__tests__/read-file-tool.spec.ts deleted file mode 100644 index fabc5bc829..0000000000 --- a/src/integrations/misc/__tests__/read-file-tool.spec.ts +++ /dev/null @@ -1,147 +0,0 @@ -// npx vitest run integrations/misc/__tests__/read-file-tool.spec.ts - -import type { Mock } from "vitest" -import * as path from "path" -import { countFileLines } from "../line-counter" -import { readLines } from "../read-lines" -import { extractTextFromFile, addLineNumbers } from "../extract-text" - -// Mock the required functions -vitest.mock("../line-counter") -vitest.mock("../read-lines") -vitest.mock("../extract-text") - -describe("read_file tool with maxReadFileLine setting", () => { - // Mock original implementation first to use in tests - let originalCountFileLines: any - let originalReadLines: any - let originalExtractTextFromFile: any - let originalAddLineNumbers: any - - beforeEach(async () => { - // Import actual implementations - originalCountFileLines = ((await vitest.importActual("../line-counter")) as any).countFileLines - originalReadLines = ((await vitest.importActual("../read-lines")) as any).readLines - originalExtractTextFromFile = ((await vitest.importActual("../extract-text")) as any).extractTextFromFile - originalAddLineNumbers = ((await vitest.importActual("../extract-text")) as any).addLineNumbers - - vitest.resetAllMocks() - // Reset mocks to simulate original behavior - ;(countFileLines as Mock).mockImplementation(originalCountFileLines) - ;(readLines as Mock).mockImplementation(originalReadLines) - ;(extractTextFromFile as Mock).mockImplementation(originalExtractTextFromFile) - ;(addLineNumbers as Mock).mockImplementation(originalAddLineNumbers) - }) - - // Test for the case when file size is smaller than maxReadFileLine - it("should read entire file when line count is less than maxReadFileLine", async () => { - // Mock necessary functions - ;(countFileLines as Mock).mockResolvedValue(100) - ;(extractTextFromFile as Mock).mockResolvedValue("Small file content") - - // Create mock implementation that would simulate the behavior - // Note: We're not testing the Cline class directly as it would be too complex - // We're testing the logic flow that would happen in the read_file implementation - - const filePath = path.resolve("/test", "smallFile.txt") - const maxReadFileLine = 500 - - // Check line count - const lineCount = await countFileLines(filePath) - expect(lineCount).toBeLessThan(maxReadFileLine) - - // Should use extractTextFromFile for small files - if (lineCount < maxReadFileLine) { - await extractTextFromFile(filePath) - } - - expect(extractTextFromFile).toHaveBeenCalledWith(filePath) - expect(readLines).not.toHaveBeenCalled() - }) - - // Test for the case when file size is larger than maxReadFileLine - it("should truncate file when line count exceeds maxReadFileLine", async () => { - // Mock necessary functions - ;(countFileLines as Mock).mockResolvedValue(5000) - ;(readLines as Mock).mockResolvedValue("First 500 lines of large file") - ;(addLineNumbers as Mock).mockReturnValue("1 | First line\n2 | Second line\n...") - - const filePath = path.resolve("/test", "largeFile.txt") - const maxReadFileLine = 500 - - // Check line count - const lineCount = await countFileLines(filePath) - expect(lineCount).toBeGreaterThan(maxReadFileLine) - - // Should use readLines for large files - if (lineCount > maxReadFileLine) { - const content = await readLines(filePath, maxReadFileLine - 1, 0) - const numberedContent = addLineNumbers(content) - - // Verify the truncation message is shown (simulated) - const truncationMsg = `\n\n[File truncated: showing ${maxReadFileLine} of ${lineCount} total lines]` - const fullResult = numberedContent + truncationMsg - - expect(fullResult).toContain("File truncated") - } - - expect(readLines).toHaveBeenCalledWith(filePath, maxReadFileLine - 1, 0) - expect(addLineNumbers).toHaveBeenCalled() - expect(extractTextFromFile).not.toHaveBeenCalled() - }) - - // Test for the case when the file is a source code file - it("should add source code file type info for large source code files", async () => { - // Mock necessary functions - ;(countFileLines as Mock).mockResolvedValue(5000) - ;(readLines as Mock).mockResolvedValue("First 500 lines of large JavaScript file") - ;(addLineNumbers as Mock).mockReturnValue('1 | const foo = "bar";\n2 | function test() {...') - - const filePath = path.resolve("/test", "largeFile.js") - const maxReadFileLine = 500 - - // Check line count - const lineCount = await countFileLines(filePath) - expect(lineCount).toBeGreaterThan(maxReadFileLine) - - // Check if the file is a source code file - const fileExt = path.extname(filePath).toLowerCase() - const isSourceCode = [ - ".js", - ".ts", - ".jsx", - ".tsx", - ".py", - ".java", - ".c", - ".cpp", - ".cs", - ".go", - ".rb", - ".php", - ".swift", - ".rs", - ].includes(fileExt) - expect(isSourceCode).toBeTruthy() - - // Should use readLines for large files - if (lineCount > maxReadFileLine) { - const content = await readLines(filePath, maxReadFileLine - 1, 0) - const numberedContent = addLineNumbers(content) - - // Verify the truncation message and source code message are shown (simulated) - let truncationMsg = `\n\n[File truncated: showing ${maxReadFileLine} of ${lineCount} total lines]` - if (isSourceCode) { - truncationMsg += - "\n\nThis appears to be a source code file. Consider using list_code_definition_names to understand its structure." - } - const fullResult = numberedContent + truncationMsg - - expect(fullResult).toContain("source code file") - expect(fullResult).toContain("list_code_definition_names") - } - - expect(readLines).toHaveBeenCalledWith(filePath, maxReadFileLine - 1, 0) - expect(addLineNumbers).toHaveBeenCalled() - }) -}) diff --git a/src/integrations/misc/__tests__/read-file-with-budget.spec.ts b/src/integrations/misc/__tests__/read-file-with-budget.spec.ts deleted file mode 100644 index 7a4e99ce69..0000000000 --- a/src/integrations/misc/__tests__/read-file-with-budget.spec.ts +++ /dev/null @@ -1,321 +0,0 @@ -import fs from "fs/promises" -import path from "path" -import os from "os" -import { readFileWithTokenBudget } from "../read-file-with-budget" - -describe("readFileWithTokenBudget", () => { - let tempDir: string - - beforeEach(async () => { - // Create a temporary directory for test files - tempDir = path.join(os.tmpdir(), `read-file-budget-test-${Date.now()}`) - await fs.mkdir(tempDir, { recursive: true }) - }) - - afterEach(async () => { - // Clean up temporary directory - await fs.rm(tempDir, { recursive: true, force: true }) - }) - - describe("Basic functionality", () => { - test("reads entire small file when within budget", async () => { - const filePath = path.join(tempDir, "small.txt") - const content = "Line 1\nLine 2\nLine 3" - await fs.writeFile(filePath, content) - - const result = await readFileWithTokenBudget(filePath, { - budgetTokens: 1000, // Large budget - }) - - expect(result.content).toBe(content) - expect(result.lineCount).toBe(3) - expect(result.complete).toBe(true) - expect(result.tokenCount).toBeGreaterThan(0) - expect(result.tokenCount).toBeLessThan(1000) - }) - - test("returns correct token count", async () => { - const filePath = path.join(tempDir, "token-test.txt") - const content = "This is a test file with some content." - await fs.writeFile(filePath, content) - - const result = await readFileWithTokenBudget(filePath, { - budgetTokens: 1000, - }) - - // Token count should be reasonable (rough estimate: 1 token per 3-4 chars) - expect(result.tokenCount).toBeGreaterThan(5) - expect(result.tokenCount).toBeLessThan(20) - }) - - test("returns complete: true for files within budget", async () => { - const filePath = path.join(tempDir, "within-budget.txt") - const lines = Array.from({ length: 10 }, (_, i) => `Line ${i + 1}`) - await fs.writeFile(filePath, lines.join("\n")) - - const result = await readFileWithTokenBudget(filePath, { - budgetTokens: 1000, - }) - - expect(result.complete).toBe(true) - expect(result.lineCount).toBe(10) - }) - }) - - describe("Truncation behavior", () => { - test("stops reading when token budget reached", async () => { - const filePath = path.join(tempDir, "large.txt") - // Create a file with many lines - const lines = Array.from({ length: 1000 }, (_, i) => `This is line number ${i + 1} with some content`) - await fs.writeFile(filePath, lines.join("\n")) - - const result = await readFileWithTokenBudget(filePath, { - budgetTokens: 50, // Small budget - }) - - expect(result.complete).toBe(false) - expect(result.lineCount).toBeLessThan(1000) - expect(result.lineCount).toBeGreaterThan(0) - expect(result.tokenCount).toBeLessThanOrEqual(50) - }) - - test("returns complete: false when truncated", async () => { - const filePath = path.join(tempDir, "truncated.txt") - const lines = Array.from({ length: 500 }, (_, i) => `Line ${i + 1}`) - await fs.writeFile(filePath, lines.join("\n")) - - const result = await readFileWithTokenBudget(filePath, { - budgetTokens: 20, - }) - - expect(result.complete).toBe(false) - expect(result.tokenCount).toBeLessThanOrEqual(20) - }) - - test("content ends at line boundary (no partial lines)", async () => { - const filePath = path.join(tempDir, "line-boundary.txt") - const lines = Array.from({ length: 100 }, (_, i) => `Line ${i + 1}`) - await fs.writeFile(filePath, lines.join("\n")) - - const result = await readFileWithTokenBudget(filePath, { - budgetTokens: 30, - }) - - // Content should not end mid-line - const contentLines = result.content.split("\n") - expect(contentLines.length).toBe(result.lineCount) - // Last line should be complete (not cut off) - expect(contentLines[contentLines.length - 1]).toMatch(/^Line \d+$/) - }) - - test("works with different chunk sizes", async () => { - const filePath = path.join(tempDir, "chunks.txt") - const lines = Array.from({ length: 1000 }, (_, i) => `Line ${i + 1}`) - await fs.writeFile(filePath, lines.join("\n")) - - // Test with small chunk size - const result1 = await readFileWithTokenBudget(filePath, { - budgetTokens: 50, - chunkLines: 10, - }) - - // Test with large chunk size - const result2 = await readFileWithTokenBudget(filePath, { - budgetTokens: 50, - chunkLines: 500, - }) - - // Both should truncate, but may differ slightly in exact line count - expect(result1.complete).toBe(false) - expect(result2.complete).toBe(false) - expect(result1.tokenCount).toBeLessThanOrEqual(50) - expect(result2.tokenCount).toBeLessThanOrEqual(50) - }) - }) - - describe("Edge cases", () => { - test("handles empty file", async () => { - const filePath = path.join(tempDir, "empty.txt") - await fs.writeFile(filePath, "") - - const result = await readFileWithTokenBudget(filePath, { - budgetTokens: 100, - }) - - expect(result.content).toBe("") - expect(result.lineCount).toBe(0) - expect(result.tokenCount).toBe(0) - expect(result.complete).toBe(true) - }) - - test("handles single line file", async () => { - const filePath = path.join(tempDir, "single-line.txt") - await fs.writeFile(filePath, "Single line content") - - const result = await readFileWithTokenBudget(filePath, { - budgetTokens: 100, - }) - - expect(result.content).toBe("Single line content") - expect(result.lineCount).toBe(1) - expect(result.complete).toBe(true) - }) - - test("handles budget of 0 tokens", async () => { - const filePath = path.join(tempDir, "zero-budget.txt") - await fs.writeFile(filePath, "Line 1\nLine 2\nLine 3") - - const result = await readFileWithTokenBudget(filePath, { - budgetTokens: 0, - }) - - expect(result.content).toBe("") - expect(result.lineCount).toBe(0) - expect(result.tokenCount).toBe(0) - expect(result.complete).toBe(false) - }) - - test("handles very small budget (fewer tokens than first line)", async () => { - const filePath = path.join(tempDir, "tiny-budget.txt") - const longLine = "This is a very long line with lots of content that will exceed a tiny token budget" - await fs.writeFile(filePath, `${longLine}\nLine 2\nLine 3`) - - const result = await readFileWithTokenBudget(filePath, { - budgetTokens: 2, // Very small budget - }) - - // Should return empty since first line exceeds budget - expect(result.content).toBe("") - expect(result.lineCount).toBe(0) - expect(result.complete).toBe(false) - }) - - test("throws error for non-existent file", async () => { - const filePath = path.join(tempDir, "does-not-exist.txt") - - await expect( - readFileWithTokenBudget(filePath, { - budgetTokens: 100, - }), - ).rejects.toThrow("File not found") - }) - - test("handles file with no trailing newline", async () => { - const filePath = path.join(tempDir, "no-trailing-newline.txt") - await fs.writeFile(filePath, "Line 1\nLine 2\nLine 3") - - const result = await readFileWithTokenBudget(filePath, { - budgetTokens: 1000, - }) - - expect(result.content).toBe("Line 1\nLine 2\nLine 3") - expect(result.lineCount).toBe(3) - expect(result.complete).toBe(true) - }) - - test("handles file with trailing newline", async () => { - const filePath = path.join(tempDir, "trailing-newline.txt") - await fs.writeFile(filePath, "Line 1\nLine 2\nLine 3\n") - - const result = await readFileWithTokenBudget(filePath, { - budgetTokens: 1000, - }) - - expect(result.content).toBe("Line 1\nLine 2\nLine 3") - expect(result.lineCount).toBe(3) - expect(result.complete).toBe(true) - }) - }) - - describe("Token counting accuracy", () => { - test("returned tokenCount matches actual tokens in content", async () => { - const filePath = path.join(tempDir, "accuracy.txt") - const content = "Hello world\nThis is a test\nWith some content" - await fs.writeFile(filePath, content) - - const result = await readFileWithTokenBudget(filePath, { - budgetTokens: 1000, - }) - - // Verify the token count is reasonable - // Rough estimate: 1 token per 3-4 characters - const minExpected = Math.floor(content.length / 5) - const maxExpected = Math.ceil(content.length / 2) - - expect(result.tokenCount).toBeGreaterThanOrEqual(minExpected) - expect(result.tokenCount).toBeLessThanOrEqual(maxExpected) - }) - - test("handles special characters correctly", async () => { - const filePath = path.join(tempDir, "special-chars.txt") - const content = "Special chars: @#$%^&*()\nUnicode: 你好世界\nEmoji: 😀🎉" - await fs.writeFile(filePath, content) - - const result = await readFileWithTokenBudget(filePath, { - budgetTokens: 1000, - }) - - expect(result.content).toBe(content) - expect(result.tokenCount).toBeGreaterThan(0) - expect(result.complete).toBe(true) - }) - - test("handles code content", async () => { - const filePath = path.join(tempDir, "code.ts") - const code = `function hello(name: string): string {\n return \`Hello, \${name}!\`\n}` - await fs.writeFile(filePath, code) - - const result = await readFileWithTokenBudget(filePath, { - budgetTokens: 1000, - }) - - expect(result.content).toBe(code) - expect(result.tokenCount).toBeGreaterThan(0) - expect(result.complete).toBe(true) - }) - }) - - describe("Performance", () => { - test("handles large files efficiently", async () => { - const filePath = path.join(tempDir, "large-file.txt") - // Create a 1MB file - const lines = Array.from({ length: 10000 }, (_, i) => `Line ${i + 1} with some additional content`) - await fs.writeFile(filePath, lines.join("\n")) - - const startTime = Date.now() - - const result = await readFileWithTokenBudget(filePath, { - budgetTokens: 100, - }) - - const endTime = Date.now() - const duration = endTime - startTime - - // Should complete in reasonable time (less than 5 seconds) - expect(duration).toBeLessThan(5000) - expect(result.complete).toBe(false) - expect(result.tokenCount).toBeLessThanOrEqual(100) - }) - - test("early exits when budget is reached", async () => { - const filePath = path.join(tempDir, "early-exit.txt") - // Create a very large file - const lines = Array.from({ length: 50000 }, (_, i) => `Line ${i + 1}`) - await fs.writeFile(filePath, lines.join("\n")) - - const startTime = Date.now() - - const result = await readFileWithTokenBudget(filePath, { - budgetTokens: 50, // Small budget should trigger early exit - }) - - const endTime = Date.now() - const duration = endTime - startTime - - // Should be much faster than reading entire file (less than 2 seconds) - expect(duration).toBeLessThan(2000) - expect(result.complete).toBe(false) - expect(result.lineCount).toBeLessThan(50000) - }) - }) -}) diff --git a/src/integrations/misc/extract-text.ts b/src/integrations/misc/extract-text.ts index bafa7a5bab..f29fa915d1 100644 --- a/src/integrations/misc/extract-text.ts +++ b/src/integrations/misc/extract-text.ts @@ -5,8 +5,8 @@ import mammoth from "mammoth" import fs from "fs/promises" import { isBinaryFile } from "isbinaryfile" import { extractTextFromXLSX } from "./extract-text-from-xlsx" -import { countFileLines } from "./line-counter" -import { readLines } from "./read-lines" +import { readWithSlice } from "./indentation-reader" +import { DEFAULT_LINE_LIMIT } from "../../core/prompts/tools/native-tools/read_file" async function extractTextFromPDF(filePath: string): Promise { const dataBuffer = await fs.readFile(filePath) @@ -51,26 +51,34 @@ export function getSupportedBinaryFormats(): string[] { } /** - * Extracts text content from a file, with support for various formats including PDF, DOCX, XLSX, and plain text. - * For large text files, can limit the number of lines read to prevent context exhaustion. + * Result of extracting text with metadata about truncation + */ +export interface ExtractTextResult { + /** The extracted content with line numbers */ + content: string + /** Total lines in the file */ + totalLines: number + /** Lines actually returned */ + returnedLines: number + /** Whether output was truncated */ + wasTruncated: boolean + /** Line range shown [start, end] (1-based) */ + linesShown?: [number, number] +} + +/** + * Extracts text content from a file with truncation support. + * Returns structured result with metadata about truncation. * * @param filePath - Path to the file to extract text from - * @param maxReadFileLine - Maximum number of lines to read from text files. - * Use UNLIMITED_LINES (-1) or undefined for no limit. - * Must be a positive integer or UNLIMITED_LINES. - * @returns Promise resolving to the extracted text content with line numbers - * @throws {Error} If file not found, unsupported format, or invalid parameters + * @param limit - Maximum lines to return (default: 2000) + * @returns Promise resolving to extracted text with metadata + * @throws {Error} If file not found or unsupported binary format */ -export async function extractTextFromFile(filePath: string, maxReadFileLine?: number): Promise { - // Validate maxReadFileLine parameter - if (maxReadFileLine !== undefined && maxReadFileLine !== -1) { - if (!Number.isInteger(maxReadFileLine) || maxReadFileLine < 1) { - throw new Error( - `Invalid maxReadFileLine: ${maxReadFileLine}. Must be a positive integer or -1 for unlimited.`, - ) - } - } - +export async function extractTextFromFileWithMetadata( + filePath: string, + limit: number = DEFAULT_LINE_LIMIT, +): Promise { try { await fs.access(filePath) } catch (error) { @@ -82,33 +90,49 @@ export async function extractTextFromFile(filePath: string, maxReadFileLine?: nu // Check if we have a specific extractor for this format const extractor = SUPPORTED_BINARY_FORMATS[fileExtension as keyof typeof SUPPORTED_BINARY_FORMATS] if (extractor) { - return extractor(filePath) + // For binary formats, extract and count lines + const content = await extractor(filePath) + const lines = content.split("\n") + return { + content, + totalLines: lines.length, + returnedLines: lines.length, + wasTruncated: false, + } } // Handle other files const isBinary = await isBinaryFile(filePath).catch(() => false) if (!isBinary) { - // Check if we need to apply line limit - if (maxReadFileLine !== undefined && maxReadFileLine !== -1) { - const totalLines = await countFileLines(filePath) - if (totalLines > maxReadFileLine) { - // Read only up to maxReadFileLine (endLine is 0-based and inclusive) - const content = await readLines(filePath, maxReadFileLine - 1, 0) - const numberedContent = addLineNumbers(content) - return ( - numberedContent + - `\n\n[File truncated: showing ${maxReadFileLine} of ${totalLines} total lines. The file is too large and may exhaust the context window if read in full.]` - ) - } + const rawContent = await fs.readFile(filePath, "utf8") + const result = readWithSlice(rawContent, 0, limit) + + return { + content: result.content, + totalLines: result.totalLines, + returnedLines: result.returnedLines, + wasTruncated: result.wasTruncated, + linesShown: result.includedRanges.length > 0 ? result.includedRanges[0] : undefined, } - // Read the entire file if no limit or file is within limit - return addLineNumbers(await fs.readFile(filePath, "utf8")) } else { throw new Error(`Cannot read text for file type: ${fileExtension}`) } } +/** + * Extracts text content from a file, with support for various formats including PDF, DOCX, XLSX, and plain text. + * Now uses truncation to limit large files to DEFAULT_LINE_LIMIT lines. + * + * @param filePath - Path to the file to extract text from + * @returns Promise resolving to the extracted text content with line numbers + * @throws {Error} If file not found or unsupported binary format + */ +export async function extractTextFromFile(filePath: string): Promise { + const result = await extractTextFromFileWithMetadata(filePath) + return result.content +} + export function addLineNumbers(content: string, startLine: number = 1): string { // If content is empty, return empty string - empty files should not have line numbers // If content is empty but startLine > 1, return "startLine | " because we know the file is not empty diff --git a/src/integrations/misc/indentation-reader.ts b/src/integrations/misc/indentation-reader.ts new file mode 100644 index 0000000000..aecabd5982 --- /dev/null +++ b/src/integrations/misc/indentation-reader.ts @@ -0,0 +1,469 @@ +/** + * Indentation-based semantic code block extraction. + * + * Inspired by Codex's indentation mode, this module extracts meaningful code blocks + * based on indentation hierarchy rather than arbitrary line ranges. + * + * The algorithm uses bidirectional expansion from an anchor line: + * 1. Parse the file to determine indentation level of each line + * 2. Compute effective indents (blank lines inherit previous non-blank line's indent) + * 3. Expand up and down from anchor simultaneously + * 4. Apply sibling exclusion counters to limit scope + * 5. Trim empty lines from edges + * 6. Apply line limit + */ + +import { + DEFAULT_LINE_LIMIT, + DEFAULT_MAX_LEVELS, + MAX_LINE_LENGTH, +} from "../../core/prompts/tools/native-tools/read_file" + +// ─── Types ──────────────────────────────────────────────────────────────────── + +export interface LineRecord { + /** 1-based line number */ + lineNumber: number + /** Original line content */ + content: string + /** Computed indentation level (number of leading whitespace units) */ + indentLevel: number + /** Whether this line is blank (empty or whitespace only) */ + isBlank: boolean + /** Whether this line starts a new block (has content followed by colon, brace, etc.) */ + isBlockStart: boolean +} + +export interface IndentationReadOptions { + /** 1-based anchor line number */ + anchorLine: number + /** Maximum indentation levels to include above anchor (0 = unlimited, default: 0) */ + maxLevels?: number + /** Include sibling blocks at the same indentation level (default: false) */ + includeSiblings?: boolean + /** Include file header content (imports, comments at top) (default: true) */ + includeHeader?: boolean + /** Maximum lines to return from bidirectional expansion (default: 2000) */ + limit?: number + /** Hard cap on lines returned, separate from limit (optional) */ + maxLines?: number +} + +export interface IndentationReadResult { + /** The extracted content with line numbers */ + content: string + /** Line ranges that were included [start, end] tuples (1-based) */ + includedRanges: Array<[number, number]> + /** Total lines in the file */ + totalLines: number + /** Lines actually returned */ + returnedLines: number + /** Whether output was truncated due to limit */ + wasTruncated: boolean +} + +// ─── Constants ──────────────────────────────────────────────────────────────── + +/** Indentation unit size (spaces) */ +const INDENT_SIZE = 4 + +/** Tab width for indent measurement (Codex standard) */ +const TAB_WIDTH = 4 + +/** Patterns that indicate a block start */ +const BLOCK_START_PATTERNS = [ + /:\s*$/, // Python-style (def foo():) + /\{\s*$/, // C-style opening brace + /=>\s*\{?\s*$/, // Arrow functions + /\bthen\s*$/, // Lua/some languages + /\bdo\s*$/, // Ruby, Lua +] + +/** Patterns for file header lines (imports, comments, etc.) */ +const HEADER_PATTERNS = [ + /^import\s/, // ES6 imports + /^from\s.*import/, // Python imports + /^const\s.*=\s*require/, // CommonJS requires + /^#!/, // Shebang + /^\/\*/, // Block comment start + /^\*/, // Block comment continuation + /^\s*\*\//, // Block comment end + /^\/\//, // Line comment + /^#(?!include)/, // Python/shell comment (not C #include) + /^"""/, // Python docstring + /^'''/, // Python docstring + /^use\s/, // Rust use + /^package\s/, // Go/Java package + /^require\s/, // Lua require + /^@/, // Decorators (Python, TypeScript) + /^"use\s/, // "use strict", "use client" +] + +/** Comment prefixes for header detection (Codex standard) */ +const COMMENT_PREFIXES = ["#", "//", "--", "/*", "*", "'''", '"""'] + +// ─── Core Functions ─────────────────────────────────────────────────────────── + +/** + * Parse a file's lines into LineRecord objects with indentation information. + */ +export function parseLines(content: string): LineRecord[] { + const lines = content.split("\n") + return lines.map((line, index) => { + const trimmed = line.trimStart() + const leadingWhitespace = line.length - trimmed.length + + // Calculate indent in spaces (tabs = TAB_WIDTH spaces each) + let indentSpaces = 0 + for (let i = 0; i < leadingWhitespace; i++) { + if (line[i] === "\t") { + indentSpaces += TAB_WIDTH + } else { + indentSpaces += 1 + } + } + // Convert to indent level (number of INDENT_SIZE units) + const indentLevel = Math.floor(indentSpaces / INDENT_SIZE) + + const isBlank = trimmed.length === 0 + const isBlockStart = !isBlank && BLOCK_START_PATTERNS.some((pattern) => pattern.test(line)) + + return { + lineNumber: index + 1, + content: line, + indentLevel, + isBlank, + isBlockStart, + } + }) +} + +/** + * Compute effective indents where blank lines inherit the previous non-blank line's indent. + * This matches the Codex algorithm behavior. + */ +export function computeEffectiveIndents(lines: LineRecord[]): number[] { + const effective: number[] = [] + let previousIndent = 0 + + for (const line of lines) { + if (line.isBlank) { + effective.push(previousIndent) + } else { + previousIndent = line.indentLevel + effective.push(previousIndent) + } + } + return effective +} + +/** + * Check if a line is a comment (for include_header behavior). + */ +function isComment(line: LineRecord): boolean { + const trimmed = line.content.trim() + return COMMENT_PREFIXES.some((prefix) => trimmed.startsWith(prefix)) +} + +/** + * Trim empty lines from the front and back of a line array. + */ +function trimEmptyLines(lines: LineRecord[]): void { + // Trim from front + while (lines.length > 0 && lines[0].isBlank) { + lines.shift() + } + // Trim from back + while (lines.length > 0 && lines[lines.length - 1].isBlank) { + lines.pop() + } +} + +/** + * Find the file header (imports, top-level comments, etc.). + * Returns the end index of the header section. + */ +function findHeaderEnd(lines: LineRecord[]): number { + let lastHeaderIdx = -1 + let inBlockComment = false + + for (let i = 0; i < lines.length; i++) { + const line = lines[i] + const trimmed = line.content.trim() + + // Track block comments + if (trimmed.startsWith("/*")) inBlockComment = true + if (trimmed.endsWith("*/")) { + inBlockComment = false + lastHeaderIdx = i + continue + } + if (inBlockComment) { + lastHeaderIdx = i + continue + } + + // Check if this is a header line + if (line.isBlank) { + // Blank lines are part of header if we haven't seen content yet + if (lastHeaderIdx === i - 1) { + lastHeaderIdx = i + } + continue + } + + const isHeader = HEADER_PATTERNS.some((pattern) => pattern.test(trimmed)) + if (isHeader) { + lastHeaderIdx = i + } else if (line.indentLevel === 0) { + // Hit first non-header top-level content + break + } + } + + return lastHeaderIdx +} + +/** + * Format lines with line numbers, applying truncation to long lines. + */ +export function formatWithLineNumbers(lines: LineRecord[], maxLineLength: number = MAX_LINE_LENGTH): string { + if (lines.length === 0) return "" + const maxLineNumWidth = String(lines[lines.length - 1]?.lineNumber || 1).length + + return lines + .map((line) => { + const lineNum = String(line.lineNumber).padStart(maxLineNumWidth, " ") + let content = line.content + + // Truncate long lines + if (content.length > maxLineLength) { + content = content.substring(0, maxLineLength - 3) + "..." + } + + return `${lineNum} | ${content}` + }) + .join("\n") +} + +/** + * Convert a contiguous array of LineRecords into merged ranges for output. + */ +function computeIncludedRanges(lines: LineRecord[]): Array<[number, number]> { + if (lines.length === 0) return [] + + const ranges: Array<[number, number]> = [] + let rangeStart = lines[0].lineNumber + let rangeEnd = lines[0].lineNumber + + for (let i = 1; i < lines.length; i++) { + const lineNum = lines[i].lineNumber + if (lineNum === rangeEnd + 1) { + // Contiguous + rangeEnd = lineNum + } else { + // Gap - save current range and start new one + ranges.push([rangeStart, rangeEnd]) + rangeStart = lineNum + rangeEnd = lineNum + } + } + // Don't forget the last range + ranges.push([rangeStart, rangeEnd]) + + return ranges +} + +// ─── Main Export ────────────────────────────────────────────────────────────── + +/** + * Read a file using indentation-based semantic extraction (Codex algorithm). + * + * Uses bidirectional expansion from the anchor line with sibling exclusion counters. + * + * @param content - The file content to process + * @param options - Extraction options + * @returns The extracted content with metadata + */ +export function readWithIndentation(content: string, options: IndentationReadOptions): IndentationReadResult { + const { + anchorLine, + maxLevels = DEFAULT_MAX_LEVELS, + includeSiblings = false, + includeHeader = true, + limit = DEFAULT_LINE_LIMIT, + maxLines, + } = options + + const lines = parseLines(content) + const totalLines = lines.length + + // Validate anchor line + if (anchorLine < 1 || anchorLine > totalLines) { + return { + content: `Error: anchor_line ${anchorLine} is out of range (1-${totalLines})`, + includedRanges: [], + totalLines, + returnedLines: 0, + wasTruncated: false, + } + } + + const anchorIdx = anchorLine - 1 // Convert to 0-based + const effectiveIndents = computeEffectiveIndents(lines) + const anchorIndent = effectiveIndents[anchorIdx] + + // Calculate minimum indent threshold + // maxLevels = 0 means unlimited (minIndent = 0) + // maxLevels > 0 means limit to that many levels above anchor + let minIndent: number + if (maxLevels === 0) { + minIndent = 0 + } else { + // Each "level" is INDENT_SIZE spaces worth of indentation + // We subtract maxLevels from the anchor's indent level + minIndent = Math.max(0, anchorIndent - maxLevels) + } + + // Calculate final limit (use maxLines as hard cap if provided) + const guardLimit = maxLines ?? limit + const finalLimit = Math.min(limit, guardLimit, totalLines) + + // Edge case: if limit is 1, just return the anchor line + if (finalLimit === 1) { + const singleLine = [lines[anchorIdx]] + return { + content: formatWithLineNumbers(singleLine), + includedRanges: [[anchorLine, anchorLine]], + totalLines, + returnedLines: 1, + wasTruncated: totalLines > 1, + } + } + + // Bidirectional expansion from anchor (Codex algorithm) + const result: LineRecord[] = [lines[anchorIdx]] + let i = anchorIdx - 1 // Up cursor + let j = anchorIdx + 1 // Down cursor + let iMinCount = 0 // Count of min-indent lines seen going up + let jMinCount = 0 // Count of min-indent lines seen going down + + while (result.length < finalLimit) { + let progressed = false + + // Expand upward + if (i >= 0 && effectiveIndents[i] >= minIndent) { + result.unshift(lines[i]) + progressed = true + + // Handle sibling exclusion at min indent + if (effectiveIndents[i] === minIndent && !includeSiblings) { + const allowHeader = includeHeader && isComment(lines[i]) + const canTake = allowHeader || iMinCount === 0 + + if (canTake) { + iMinCount++ + } else { + // Reject this line - remove it and stop expanding up + result.shift() + progressed = false + i = -1 // Stop expanding up + } + } + + if (i >= 0) i-- + } else if (i >= 0) { + i = -1 // Stop expanding up (hit lower indent) + } + + if (result.length >= finalLimit) break + + // Expand downward + if (j < lines.length && effectiveIndents[j] >= minIndent) { + result.push(lines[j]) + progressed = true + + // Handle sibling exclusion at min indent + if (effectiveIndents[j] === minIndent && !includeSiblings) { + if (jMinCount > 0) { + // Already saw one min-indent block going down, reject this + result.pop() + progressed = false + j = lines.length // Stop expanding down + } + jMinCount++ + } + + if (j < lines.length) j++ + } else if (j < lines.length) { + j = lines.length // Stop expanding down (hit lower indent) + } + + if (!progressed) break + } + + // Trim leading/trailing empty lines + trimEmptyLines(result) + + // Check if we were truncated + const wasTruncated = result.length >= finalLimit || i >= 0 || j < lines.length + + // Format output + const formattedContent = formatWithLineNumbers(result) + + // Compute included ranges + const includedRanges = computeIncludedRanges(result) + + return { + content: formattedContent, + includedRanges, + totalLines, + returnedLines: result.length, + wasTruncated: wasTruncated && result.length < totalLines, + } +} + +/** + * Simple slice mode reading - read lines with offset/limit. + * + * @param content - The file content to process + * @param offset - 0-based line offset to start from (default: 0) + * @param limit - Maximum lines to return (default: 2000) + * @returns The extracted content with metadata + */ +export function readWithSlice( + content: string, + offset: number = 0, + limit: number = DEFAULT_LINE_LIMIT, +): IndentationReadResult { + const lines = parseLines(content) + const totalLines = lines.length + + // Validate offset + if (offset < 0) offset = 0 + if (offset >= totalLines) { + return { + content: `Error: offset ${offset} is beyond file end (${totalLines} lines)`, + includedRanges: [], + totalLines, + returnedLines: 0, + wasTruncated: false, + } + } + + // Slice lines + const endIdx = Math.min(offset + limit, totalLines) + const selectedLines = lines.slice(offset, endIdx) + const wasTruncated = endIdx < totalLines + + // Format output + const formattedContent = formatWithLineNumbers(selectedLines) + + return { + content: formattedContent, + includedRanges: [[offset + 1, endIdx]], // 1-based + totalLines, + returnedLines: selectedLines.length, + wasTruncated, + } +} diff --git a/src/integrations/misc/read-file-with-budget.ts b/src/integrations/misc/read-file-with-budget.ts deleted file mode 100644 index 15aa4f1144..0000000000 --- a/src/integrations/misc/read-file-with-budget.ts +++ /dev/null @@ -1,182 +0,0 @@ -import { createReadStream } from "fs" -import fs from "fs/promises" -import { createInterface } from "readline" -import { countTokens } from "../../utils/countTokens" -import { Anthropic } from "@anthropic-ai/sdk" - -export interface ReadWithBudgetResult { - /** The content read up to the token budget */ - content: string - /** Actual token count of returned content */ - tokenCount: number - /** Total lines in the returned content */ - lineCount: number - /** Whether the entire file was read (false if truncated) */ - complete: boolean -} - -export interface ReadWithBudgetOptions { - /** Maximum tokens allowed. Required. */ - budgetTokens: number - /** Number of lines to buffer before token counting (default: 256) */ - chunkLines?: number -} - -/** - * Reads a file while incrementally counting tokens, stopping when budget is reached. - * - * Unlike validateFileTokenBudget + extractTextFromFile, this is a single-pass - * operation that returns the actual content up to the token limit. - * - * @param filePath - Path to the file to read - * @param options - Budget and chunking options - * @returns Content read, token count, and completion status - */ -export async function readFileWithTokenBudget( - filePath: string, - options: ReadWithBudgetOptions, -): Promise { - const { budgetTokens, chunkLines = 256 } = options - - // Verify file exists - try { - await fs.access(filePath) - } catch { - throw new Error(`File not found: ${filePath}`) - } - - return new Promise((resolve, reject) => { - let content = "" - let lineCount = 0 - let tokenCount = 0 - let lineBuffer: string[] = [] - let complete = true - let isProcessing = false - let shouldClose = false - - const readStream = createReadStream(filePath) - const rl = createInterface({ - input: readStream, - crlfDelay: Infinity, - }) - - const processBuffer = async (): Promise => { - if (lineBuffer.length === 0) return true - - const bufferText = lineBuffer.join("\n") - const currentBuffer = [...lineBuffer] - lineBuffer = [] - - // Count tokens for this chunk - let chunkTokens: number - try { - const contentBlocks: Anthropic.Messages.ContentBlockParam[] = [{ type: "text", text: bufferText }] - chunkTokens = await countTokens(contentBlocks) - } catch { - // Fallback: conservative estimate (2 chars per token) - chunkTokens = Math.ceil(bufferText.length / 2) - } - - // Check if adding this chunk would exceed budget - if (tokenCount + chunkTokens > budgetTokens) { - // Need to find cutoff within this chunk using binary search - let low = 0 - let high = currentBuffer.length - let bestFit = 0 - let bestTokens = 0 - - while (low < high) { - const mid = Math.floor((low + high + 1) / 2) - const testContent = currentBuffer.slice(0, mid).join("\n") - let testTokens: number - try { - const blocks: Anthropic.Messages.ContentBlockParam[] = [{ type: "text", text: testContent }] - testTokens = await countTokens(blocks) - } catch { - testTokens = Math.ceil(testContent.length / 2) - } - - if (tokenCount + testTokens <= budgetTokens) { - bestFit = mid - bestTokens = testTokens - low = mid - } else { - high = mid - 1 - } - } - - // Add best fit lines - if (bestFit > 0) { - const fitContent = currentBuffer.slice(0, bestFit).join("\n") - content += (content.length > 0 ? "\n" : "") + fitContent - tokenCount += bestTokens - lineCount += bestFit - } - complete = false - return false - } - - // Entire chunk fits - add it all - content += (content.length > 0 ? "\n" : "") + bufferText - tokenCount += chunkTokens - lineCount += currentBuffer.length - return true - } - - rl.on("line", (line) => { - lineBuffer.push(line) - - if (lineBuffer.length >= chunkLines && !isProcessing) { - isProcessing = true - rl.pause() - - processBuffer() - .then((continueReading) => { - isProcessing = false - if (!continueReading) { - shouldClose = true - rl.close() - readStream.destroy() - } else if (!shouldClose) { - rl.resume() - } - }) - .catch((err) => { - isProcessing = false - shouldClose = true - rl.close() - readStream.destroy() - reject(err) - }) - } - }) - - rl.on("close", async () => { - // Wait for any ongoing processing with timeout - const maxWaitTime = 30000 // 30 seconds - const startWait = Date.now() - while (isProcessing) { - if (Date.now() - startWait > maxWaitTime) { - reject(new Error("Timeout waiting for buffer processing to complete")) - return - } - await new Promise((r) => setTimeout(r, 10)) - } - - // Process remaining buffer - if (!shouldClose) { - try { - await processBuffer() - } catch (err) { - reject(err) - return - } - } - - resolve({ content, tokenCount, lineCount, complete }) - }) - - rl.on("error", reject) - readStream.on("error", reject) - }) -} diff --git a/src/package.json b/src/package.json index acea49056a..3b7b28ebf0 100644 --- a/src/package.json +++ b/src/package.json @@ -3,7 +3,7 @@ "displayName": "%extension.displayName%", "description": "%extension.description%", "publisher": "RooVeterinaryInc", - "version": "3.45.0", + "version": "3.50.0", "icon": "assets/icons/icon.png", "galleryBanner": { "color": "#617A91", @@ -439,8 +439,6 @@ "pretest": "turbo run bundle --cwd ..", "test": "vitest run", "format": "prettier --write .", - "generate:skills": "tsx services/skills/generate-built-in-skills.ts", - "prebundle": "pnpm generate:skills", "bundle": "node esbuild.mjs", "vscode:prepublish": "pnpm bundle --production", "vsix": "mkdirp ../bin && vsce package --no-dependencies --out ../bin", @@ -450,7 +448,14 @@ "clean": "rimraf README.md CHANGELOG.md LICENSE dist logs mock .turbo" }, "dependencies": { - "@anthropic-ai/bedrock-sdk": "^0.10.2", + "@ai-sdk/amazon-bedrock": "^4.0.51", + "@ai-sdk/baseten": "^1.0.31", + "@ai-sdk/deepseek": "^2.0.18", + "@ai-sdk/fireworks": "^2.0.32", + "@ai-sdk/google": "^3.0.22", + "@ai-sdk/google-vertex": "^4.0.45", + "@ai-sdk/mistral": "^3.0.19", + "@ai-sdk/xai": "^3.0.48", "@anthropic-ai/sdk": "^0.37.0", "@anthropic-ai/vertex-sdk": "^0.7.0", "@aws-sdk/client-bedrock-runtime": "^3.922.0", @@ -509,13 +514,13 @@ "puppeteer-core": "^23.4.0", "reconnecting-eventsource": "^1.6.4", "safe-stable-stringify": "^2.5.0", + "sambanova-ai-provider": "^1.2.2", "sanitize-filename": "^1.6.3", "say": "^0.16.0", "semver-compare": "^1.0.0", "serialize-error": "^12.0.0", "shell-quote": "^1.8.2", "simple-git": "^3.27.0", - "socket.io-client": "^4.8.1", "sound-play": "^1.1.0", "stream-json": "^1.8.0", "string-similarity": "^4.0.4", @@ -531,11 +536,12 @@ "web-tree-sitter": "^0.25.6", "workerpool": "^9.2.0", "yaml": "^2.8.0", + "zhipu-ai-provider": "^0.2.2", "zod": "3.25.76" }, "devDependencies": { - "@ai-sdk/openai-compatible": "^1.0.0", - "@openrouter/ai-sdk-provider": "^2.0.4", + "@ai-sdk/openai-compatible": "^2.0.28", + "@openrouter/ai-sdk-provider": "^2.1.1", "@roo-code/build": "workspace:^", "@roo-code/config-eslint": "workspace:^", "@roo-code/config-typescript": "workspace:^", @@ -560,7 +566,7 @@ "@types/vscode": "^1.84.0", "@vscode/test-electron": "^2.5.2", "@vscode/vsce": "3.3.2", - "ai": "^6.0.0", + "ai": "^6.0.75", "esbuild-wasm": "^0.25.0", "execa": "^9.5.2", "glob": "^11.1.0", diff --git a/src/services/browser/BrowserSession.ts b/src/services/browser/BrowserSession.ts deleted file mode 100644 index 7ab7e88cad..0000000000 --- a/src/services/browser/BrowserSession.ts +++ /dev/null @@ -1,913 +0,0 @@ -import * as vscode from "vscode" -import * as fs from "fs/promises" -import * as path from "path" -import { Browser, Page, ScreenshotOptions, TimeoutError, launch, connect, KeyInput } from "puppeteer-core" -// @ts-ignore -import PCR from "puppeteer-chromium-resolver" -import pWaitFor from "p-wait-for" -import delay from "delay" - -import { type BrowserActionResult } from "@roo-code/types" - -import { fileExistsAtPath } from "../../utils/fs" - -import { discoverChromeHostUrl, tryChromeHostUrl } from "./browserDiscovery" - -// Timeout constants -const BROWSER_NAVIGATION_TIMEOUT = 15_000 // 15 seconds - -interface PCRStats { - puppeteer: { launch: typeof launch } - executablePath: string -} - -export class BrowserSession { - private context: vscode.ExtensionContext - private browser?: Browser - private page?: Page - private currentMousePosition?: string - private lastConnectionAttempt?: number - private isUsingRemoteBrowser: boolean = false - private onStateChange?: (isActive: boolean) => void - - // Track last known viewport to surface in environment details - private lastViewportWidth?: number - private lastViewportHeight?: number - - constructor(context: vscode.ExtensionContext, onStateChange?: (isActive: boolean) => void) { - this.context = context - this.onStateChange = onStateChange - } - - private async ensureChromiumExists(): Promise { - const globalStoragePath = this.context?.globalStorageUri?.fsPath - if (!globalStoragePath) { - throw new Error("Global storage uri is invalid") - } - - const puppeteerDir = path.join(globalStoragePath, "puppeteer") - const dirExists = await fileExistsAtPath(puppeteerDir) - if (!dirExists) { - await fs.mkdir(puppeteerDir, { recursive: true }) - } - - // if chromium doesn't exist, this will download it to path.join(puppeteerDir, ".chromium-browser-snapshots") - // if it does exist it will return the path to existing chromium - const stats: PCRStats = await PCR({ - downloadPath: puppeteerDir, - }) - - return stats - } - - /** - * Gets the viewport size from global state or returns default - */ - private getViewport() { - const size = (this.context.globalState.get("browserViewportSize") as string | undefined) || "900x600" - const [width, height] = size.split("x").map(Number) - return { width, height } - } - - /** - * Launches a local browser instance - */ - private async launchLocalBrowser(): Promise { - console.log("Launching local browser") - const stats = await this.ensureChromiumExists() - this.browser = await stats.puppeteer.launch({ - args: [ - "--user-agent=Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36", - ], - executablePath: stats.executablePath, - defaultViewport: this.getViewport(), - // headless: false, - }) - this.isUsingRemoteBrowser = false - } - - /** - * Connects to a browser using a WebSocket URL - */ - private async connectWithChromeHostUrl(chromeHostUrl: string): Promise { - try { - this.browser = await connect({ - browserURL: chromeHostUrl, - defaultViewport: this.getViewport(), - }) - - // Cache the successful endpoint - console.log(`Connected to remote browser at ${chromeHostUrl}`) - this.context.globalState.update("cachedChromeHostUrl", chromeHostUrl) - this.lastConnectionAttempt = Date.now() - this.isUsingRemoteBrowser = true - - return true - } catch (error) { - console.log(`Failed to connect using WebSocket endpoint: ${error}`) - return false - } - } - - /** - * Attempts to connect to a remote browser using various methods - * Returns true if connection was successful, false otherwise - */ - private async connectToRemoteBrowser(): Promise { - let remoteBrowserHost = this.context.globalState.get("remoteBrowserHost") as string | undefined - let reconnectionAttempted = false - - // Try to connect with cached endpoint first if it exists and is recent (less than 1 hour old) - const cachedChromeHostUrl = this.context.globalState.get("cachedChromeHostUrl") as string | undefined - if (cachedChromeHostUrl && this.lastConnectionAttempt && Date.now() - this.lastConnectionAttempt < 3_600_000) { - console.log(`Attempting to connect using cached Chrome Host Url: ${cachedChromeHostUrl}`) - if (await this.connectWithChromeHostUrl(cachedChromeHostUrl)) { - return true - } - - console.log(`Failed to connect using cached Chrome Host Url: ${cachedChromeHostUrl}`) - // Clear the cached endpoint since it's no longer valid - this.context.globalState.update("cachedChromeHostUrl", undefined) - - // User wants to give up after one reconnection attempt - if (remoteBrowserHost) { - reconnectionAttempted = true - } - } - - // If user provided a remote browser host, try to connect to it - else if (remoteBrowserHost && !reconnectionAttempted) { - console.log(`Attempting to connect to remote browser at ${remoteBrowserHost}`) - try { - const hostIsValid = await tryChromeHostUrl(remoteBrowserHost) - - if (!hostIsValid) { - throw new Error("Could not find chromeHostUrl in the response") - } - - console.log(`Found WebSocket endpoint: ${remoteBrowserHost}`) - - if (await this.connectWithChromeHostUrl(remoteBrowserHost)) { - return true - } - } catch (error) { - console.error(`Failed to connect to remote browser: ${error}`) - // Fall back to auto-discovery if remote connection fails - } - } - - try { - console.log("Attempting browser auto-discovery...") - const chromeHostUrl = await discoverChromeHostUrl() - - if (chromeHostUrl && (await this.connectWithChromeHostUrl(chromeHostUrl))) { - return true - } - } catch (error) { - console.error(`Auto-discovery failed: ${error}`) - // Fall back to local browser if auto-discovery fails - } - - return false - } - - async launchBrowser(): Promise { - console.log("launch browser called") - - // Check if remote browser connection is enabled - const remoteBrowserEnabled = this.context.globalState.get("remoteBrowserEnabled") as boolean | undefined - - if (!remoteBrowserEnabled) { - console.log("Launching local browser") - if (this.browser) { - // throw new Error("Browser already launched") - await this.closeBrowser() // this may happen when the model launches a browser again after having used it already before - } else { - // If browser wasn't open, just reset the state - this.resetBrowserState() - } - await this.launchLocalBrowser() - } else { - console.log("Connecting to remote browser") - // Remote browser connection is enabled - const remoteConnected = await this.connectToRemoteBrowser() - - // If all remote connection attempts fail, fall back to local browser - if (!remoteConnected) { - console.log("Falling back to local browser") - await this.launchLocalBrowser() - } - } - - // Notify that browser session is now active - if (this.browser && this.onStateChange) { - this.onStateChange(true) - } - } - - /** - * Closes the browser and resets browser state - */ - async closeBrowser(): Promise { - const wasActive = !!(this.browser || this.page) - - if (wasActive) { - if (this.isUsingRemoteBrowser && this.browser) { - await this.browser.disconnect().catch(() => {}) - } else { - await this.browser?.close().catch(() => {}) - } - this.resetBrowserState() - - // Notify that browser session is now inactive - if (this.onStateChange) { - this.onStateChange(false) - } - } - return {} - } - - /** - * Resets all browser state variables - */ - private resetBrowserState(): void { - this.browser = undefined - this.page = undefined - this.currentMousePosition = undefined - this.isUsingRemoteBrowser = false - this.lastViewportWidth = undefined - this.lastViewportHeight = undefined - } - - async doAction(action: (page: Page) => Promise): Promise { - if (!this.page) { - throw new Error( - "Cannot perform browser action: no active browser session. The browser must be launched first using the 'launch' action before other browser actions can be performed.", - ) - } - - const logs: string[] = [] - let lastLogTs = Date.now() - - const consoleListener = (msg: any) => { - if (msg.type() === "log") { - logs.push(msg.text()) - } else { - logs.push(`[${msg.type()}] ${msg.text()}`) - } - lastLogTs = Date.now() - } - - const errorListener = (err: Error) => { - logs.push(`[Page Error] ${err.toString()}`) - lastLogTs = Date.now() - } - - // Add the listeners - this.page.on("console", consoleListener) - this.page.on("pageerror", errorListener) - - try { - await action(this.page) - } catch (err) { - if (!(err instanceof TimeoutError)) { - logs.push(`[Error] ${err.toString()}`) - } - } - - // Wait for console inactivity, with a timeout - await pWaitFor(() => Date.now() - lastLogTs >= 500, { - timeout: 3_000, - interval: 100, - }).catch(() => {}) - - // Draw cursor indicator if we have a cursor position - if (this.currentMousePosition) { - await this.drawCursorIndicator(this.page, this.currentMousePosition) - } - - let options: ScreenshotOptions = { - encoding: "base64", - - // clip: { - // x: 0, - // y: 0, - // width: 900, - // height: 600, - // }, - } - - let screenshotBase64 = await this.page.screenshot({ - ...options, - type: "webp", - quality: ((await this.context.globalState.get("screenshotQuality")) as number | undefined) ?? 75, - }) - let screenshot = `data:image/webp;base64,${screenshotBase64}` - - if (!screenshotBase64) { - console.log("webp screenshot failed, trying png") - screenshotBase64 = await this.page.screenshot({ - ...options, - type: "png", - }) - screenshot = `data:image/png;base64,${screenshotBase64}` - } - - if (!screenshotBase64) { - throw new Error("Failed to take screenshot.") - } - - // Remove cursor indicator after taking screenshot - if (this.currentMousePosition) { - await this.removeCursorIndicator(this.page) - } - - // this.page.removeAllListeners() <- causes the page to crash! - this.page.off("console", consoleListener) - this.page.off("pageerror", errorListener) - - // Get actual viewport dimensions - const viewport = this.page.viewport() - - // Persist last known viewport dimensions - this.lastViewportWidth = viewport?.width - this.lastViewportHeight = viewport?.height - - return { - screenshot, - logs: logs.join("\n"), - currentUrl: this.page.url(), - currentMousePosition: this.currentMousePosition, - viewportWidth: viewport?.width, - viewportHeight: viewport?.height, - } - } - - /** - * Extract the root domain from a URL - * e.g., http://localhost:3000/path -> localhost:3000 - * e.g., https://example.com/path -> example.com - */ - private getRootDomain(url: string): string { - try { - const urlObj = new URL(url) - // Remove www. prefix if present - return urlObj.host.replace(/^www\./, "") - } catch (error) { - // If URL parsing fails, return the original URL - return url - } - } - - /** - * Navigate to a URL with standard loading options - */ - private async navigatePageToUrl(page: Page, url: string): Promise { - await page.goto(url, { timeout: BROWSER_NAVIGATION_TIMEOUT, waitUntil: ["domcontentloaded", "networkidle2"] }) - await this.waitTillHTMLStable(page) - } - - /** - * Creates a new tab and navigates to the specified URL - */ - private async createNewTab(url: string): Promise { - if (!this.browser) { - throw new Error("Browser is not launched") - } - - // Create a new page - const newPage = await this.browser.newPage() - - // Set the new page as the active page - this.page = newPage - - // Navigate to the URL - const result = await this.doAction(async (page) => { - await this.navigatePageToUrl(page, url) - }) - - return result - } - - async navigateToUrl(url: string): Promise { - if (!this.browser) { - throw new Error("Browser is not launched") - } - // Remove trailing slash for comparison - const normalizedNewUrl = url.replace(/\/$/, "") - - // Extract the root domain from the URL - const rootDomain = this.getRootDomain(normalizedNewUrl) - - // Get all current pages - const pages = await this.browser.pages() - - // Try to find a page with the same root domain - let existingPage: Page | undefined - - for (const page of pages) { - try { - const pageUrl = page.url() - if (pageUrl && this.getRootDomain(pageUrl) === rootDomain) { - existingPage = page - break - } - } catch (error) { - // Skip pages that might have been closed or have errors - console.log(`Error checking page URL: ${error}`) - continue - } - } - - if (existingPage) { - // Tab with the same root domain exists, switch to it - console.log(`Tab with domain ${rootDomain} already exists, switching to it`) - - // Update the active page - this.page = existingPage - existingPage.bringToFront() - - // Navigate to the new URL if it's different] - const currentUrl = existingPage.url().replace(/\/$/, "") // Remove trailing / if present - if (this.getRootDomain(currentUrl) === rootDomain && currentUrl !== normalizedNewUrl) { - console.log(`Navigating to new URL: ${normalizedNewUrl}`) - console.log(`Current URL: ${currentUrl}`) - console.log(`Root domain: ${this.getRootDomain(currentUrl)}`) - console.log(`New URL: ${normalizedNewUrl}`) - // Navigate to the new URL - return this.doAction(async (page) => { - await this.navigatePageToUrl(page, normalizedNewUrl) - }) - } else { - console.log(`Tab with domain ${rootDomain} already exists, and URL is the same: ${normalizedNewUrl}`) - // URL is the same, just reload the page to ensure it's up to date - console.log(`Reloading page: ${normalizedNewUrl}`) - console.log(`Current URL: ${currentUrl}`) - console.log(`Root domain: ${this.getRootDomain(currentUrl)}`) - console.log(`New URL: ${normalizedNewUrl}`) - return this.doAction(async (page) => { - await page.reload({ - timeout: BROWSER_NAVIGATION_TIMEOUT, - waitUntil: ["domcontentloaded", "networkidle2"], - }) - await this.waitTillHTMLStable(page) - }) - } - } else { - // No tab with this root domain exists, create a new one - console.log(`No tab with domain ${rootDomain} exists, creating a new one`) - return this.createNewTab(normalizedNewUrl) - } - } - - // page.goto { waitUntil: "networkidle0" } may not ever resolve, and not waiting could return page content too early before js has loaded - // https://stackoverflow.com/questions/52497252/puppeteer-wait-until-page-is-completely-loaded/61304202#61304202 - private async waitTillHTMLStable(page: Page, timeout = 5_000) { - const checkDurationMsecs = 500 // 1000 - const maxChecks = timeout / checkDurationMsecs - let lastHTMLSize = 0 - let checkCounts = 1 - let countStableSizeIterations = 0 - const minStableSizeIterations = 3 - - while (checkCounts++ <= maxChecks) { - let html = await page.content() - let currentHTMLSize = html.length - - // let bodyHTMLSize = await page.evaluate(() => document.body.innerHTML.length) - console.log("last: ", lastHTMLSize, " <> curr: ", currentHTMLSize) - - if (lastHTMLSize !== 0 && currentHTMLSize === lastHTMLSize) { - countStableSizeIterations++ - } else { - countStableSizeIterations = 0 //reset the counter - } - - if (countStableSizeIterations >= minStableSizeIterations) { - console.log("Page rendered fully...") - break - } - - lastHTMLSize = currentHTMLSize - await delay(checkDurationMsecs) - } - } - - /** - * Force links and window.open to navigate in the same tab. - * This makes clicks on anchors with target="_blank" stay in the current page - * and also intercepts window.open so SPA/open-in-new-tab patterns don't spawn popups. - */ - private async forceLinksToSameTab(page: Page): Promise { - try { - await page.evaluate(() => { - try { - // Ensure we only install once per document - if ((window as any).__ROO_FORCE_SAME_TAB__) return - ;(window as any).__ROO_FORCE_SAME_TAB__ = true - - // Override window.open to navigate current tab instead of creating a new one - const originalOpen = window.open - window.open = function (url: string | URL, target?: string, features?: string) { - try { - const href = typeof url === "string" ? url : String(url) - location.href = href - } catch { - // fall back to original if something unexpected occurs - try { - return originalOpen.apply(window, [url as any, "_self", features]) as any - } catch {} - } - return null as any - } as any - - // Rewrite anchors that explicitly open new tabs - document.querySelectorAll('a[target="_blank"]').forEach((a) => { - a.setAttribute("target", "_self") - }) - - // Defensive capture: if an element still tries to open in a new tab, force same-tab - document.addEventListener( - "click", - (ev) => { - const el = (ev.target as HTMLElement | null)?.closest?.( - 'a[target="_blank"]', - ) as HTMLAnchorElement | null - if (el && el.href) { - ev.preventDefault() - try { - location.href = el.href - } catch {} - } - }, - { capture: true, passive: false }, - ) - } catch { - // no-op; forcing same-tab is best-effort - } - }) - } catch { - // If evaluate fails (e.g., cross-origin/state), continue without breaking the action - } - } - - /** - * Handles mouse interaction with network activity monitoring - */ - private async handleMouseInteraction( - page: Page, - coordinate: string, - action: (x: number, y: number) => Promise, - ): Promise { - const [x, y] = coordinate.split(",").map(Number) - - // Force any new-tab behavior (target="_blank", window.open) to stay in the same tab - await this.forceLinksToSameTab(page) - - // Set up network request monitoring - let hasNetworkActivity = false - const requestListener = () => { - hasNetworkActivity = true - } - page.on("request", requestListener) - - // Perform the mouse action - await action(x, y) - this.currentMousePosition = coordinate - - // Small delay to check if action triggered any network activity - await delay(100) - - if (hasNetworkActivity) { - // If we detected network activity, wait for navigation/loading - await page - .waitForNavigation({ - waitUntil: ["domcontentloaded", "networkidle2"], - timeout: BROWSER_NAVIGATION_TIMEOUT, - }) - .catch(() => {}) - await this.waitTillHTMLStable(page) - } - - // Clean up listener - page.off("request", requestListener) - } - - async click(coordinate: string): Promise { - return this.doAction(async (page) => { - await this.handleMouseInteraction(page, coordinate, async (x, y) => { - await page.mouse.click(x, y) - }) - }) - } - - async type(text: string): Promise { - return this.doAction(async (page) => { - await page.keyboard.type(text) - }) - } - - async press(key: string): Promise { - return this.doAction(async (page) => { - // Parse key combinations (e.g., "Cmd+K", "Shift+Enter") - const parts = key.split("+").map((k) => k.trim()) - const modifiers: string[] = [] - let mainKey = parts[parts.length - 1] - - // Identify modifiers - for (let i = 0; i < parts.length - 1; i++) { - const part = parts[i].toLowerCase() - if (part === "cmd" || part === "command" || part === "meta") { - modifiers.push("Meta") - } else if (part === "ctrl" || part === "control") { - modifiers.push("Control") - } else if (part === "shift") { - modifiers.push("Shift") - } else if (part === "alt" || part === "option") { - modifiers.push("Alt") - } - } - - // Map common key aliases to Puppeteer KeyInput values - const mapping: Record = { - esc: "Escape", - return: "Enter", - escape: "Escape", - enter: "Enter", - tab: "Tab", - space: "Space", - arrowup: "ArrowUp", - arrowdown: "ArrowDown", - arrowleft: "ArrowLeft", - arrowright: "ArrowRight", - } - mainKey = (mapping[mainKey.toLowerCase()] ?? mainKey) as string - - // Avoid new-tab behavior from Enter on links/buttons - await this.forceLinksToSameTab(page) - - // Track inflight requests so we can detect brief network bursts - let inflight = 0 - const onRequest = () => { - inflight++ - } - const onRequestDone = () => { - inflight = Math.max(0, inflight - 1) - } - page.on("request", onRequest) - page.on("requestfinished", onRequestDone) - page.on("requestfailed", onRequestDone) - - // Start a short navigation wait in parallel; if no nav, it times out harmlessly - const HARD_CAP_MS = 3000 - const navPromise = page - .waitForNavigation({ - // domcontentloaded is enough to confirm a submit navigated - waitUntil: ["domcontentloaded"], - timeout: HARD_CAP_MS, - }) - .catch(() => undefined) - - // Press key combination - if (modifiers.length > 0) { - // Hold down modifiers - for (const modifier of modifiers) { - await page.keyboard.down(modifier as KeyInput) - } - - // Press main key - await page.keyboard.press(mainKey as KeyInput) - - // Release modifiers - for (const modifier of modifiers) { - await page.keyboard.up(modifier as KeyInput) - } - } else { - // Single key press - await page.keyboard.press(mainKey as KeyInput) - } - - // Give time for any requests to kick off - await delay(120) - - // Hard-cap the wait to avoid UI hangs - await Promise.race([ - navPromise, - pWaitFor(() => inflight === 0, { timeout: HARD_CAP_MS, interval: 100 }).catch(() => {}), - delay(HARD_CAP_MS), - ]) - - // Stabilize DOM briefly before capturing screenshot (shorter cap) - await this.waitTillHTMLStable(page, 2_000) - - // Cleanup - page.off("request", onRequest) - page.off("requestfinished", onRequestDone) - page.off("requestfailed", onRequestDone) - }) - } - - /** - * Scrolls the page by the specified amount - */ - private async scrollPage(page: Page, direction: "up" | "down"): Promise { - const { height } = this.getViewport() - const scrollAmount = direction === "down" ? height : -height - - await page.evaluate((scrollHeight) => { - window.scrollBy({ - top: scrollHeight, - behavior: "auto", - }) - }, scrollAmount) - - await delay(300) - } - - async scrollDown(): Promise { - return this.doAction(async (page) => { - await this.scrollPage(page, "down") - }) - } - - async scrollUp(): Promise { - return this.doAction(async (page) => { - await this.scrollPage(page, "up") - }) - } - - async hover(coordinate: string): Promise { - return this.doAction(async (page) => { - await this.handleMouseInteraction(page, coordinate, async (x, y) => { - await page.mouse.move(x, y) - // Small delay to allow any hover effects to appear - await delay(300) - }) - }) - } - - async resize(size: string): Promise { - return this.doAction(async (page) => { - const [width, height] = size.split(",").map(Number) - const session = await page.createCDPSession() - await page.setViewport({ width, height }) - const { windowId } = await session.send("Browser.getWindowForTarget") - await session.send("Browser.setWindowBounds", { - bounds: { width, height }, - windowId, - }) - }) - } - - /** - * Determines image type from file extension - */ - private getImageTypeFromPath(filePath: string): "png" | "jpeg" | "webp" { - const ext = path.extname(filePath).toLowerCase() - if (ext === ".jpg" || ext === ".jpeg") return "jpeg" - if (ext === ".webp") return "webp" - return "png" - } - - /** - * Takes a screenshot and saves it to the specified file path. - * @param filePath - The destination file path (relative to workspace) - * @param cwd - Current working directory for resolving relative paths - * @returns BrowserActionResult with screenshot data and saved file path - * @throws Error if the resolved path escapes the workspace directory - */ - async saveScreenshot(filePath: string, cwd: string): Promise { - // Always resolve the path against the workspace root - const normalizedCwd = path.resolve(cwd) - const fullPath = path.resolve(cwd, filePath) - - // Validate that the resolved path stays within the workspace (before calling doAction) - if (!fullPath.startsWith(normalizedCwd + path.sep) && fullPath !== normalizedCwd) { - throw new Error( - `Screenshot path "${filePath}" resolves to "${fullPath}" which is outside the workspace "${normalizedCwd}". ` + - `Paths must be relative to the workspace and cannot escape it.`, - ) - } - - return this.doAction(async (page) => { - // Ensure directory exists - await fs.mkdir(path.dirname(fullPath), { recursive: true }) - - // Determine image type from extension - const imageType = this.getImageTypeFromPath(filePath) - - // Take screenshot directly to file (more efficient than base64 for file saving) - await page.screenshot({ - path: fullPath, - type: imageType, - quality: - imageType === "png" - ? undefined - : ((this.context.globalState.get("screenshotQuality") as number | undefined) ?? 75), - }) - }) - } - - /** - * Draws a cursor indicator on the page at the specified position - */ - private async drawCursorIndicator(page: Page, coordinate: string): Promise { - const [x, y] = coordinate.split(",").map(Number) - - try { - await page.evaluate( - (cursorX: number, cursorY: number) => { - // Create a cursor indicator element - const cursor = document.createElement("div") - cursor.id = "__roo_cursor_indicator__" - cursor.style.cssText = ` - position: fixed; - left: ${cursorX}px; - top: ${cursorY}px; - width: 35px; - height: 35px; - pointer-events: none; - z-index: 2147483647; - ` - - // Create SVG cursor pointer - const svg = ` - - - - - ` - cursor.innerHTML = svg - - document.body.appendChild(cursor) - }, - x, - y, - ) - } catch (error) { - console.error("Failed to draw cursor indicator:", error) - } - } - - /** - * Removes the cursor indicator from the page - */ - private async removeCursorIndicator(page: Page): Promise { - try { - await page.evaluate(() => { - const cursor = document.getElementById("__roo_cursor_indicator__") - if (cursor) { - cursor.remove() - } - }) - } catch (error) { - console.error("Failed to remove cursor indicator:", error) - } - } - - /** - * Returns whether a browser session is currently active - */ - isSessionActive(): boolean { - return !!(this.browser && this.page) - } - - /** - * Returns the last known viewport size (if any) - * - * Prefer the live page viewport when available so we stay accurate after: - * - browser_action resize - * - manual window resizes (especially with remote browsers) - * - * Falls back to the configured default viewport when no prior information exists. - */ - getViewportSize(): { width?: number; height?: number } { - // If we have an active page, ask Puppeteer for the current viewport. - // This keeps us in sync with any resizes that happen outside of our own - // browser_action lifecycle (e.g. user dragging the window). - if (this.page) { - const vp = this.page.viewport() - if (vp?.width) this.lastViewportWidth = vp.width - if (vp?.height) this.lastViewportHeight = vp.height - } - - // If we've ever observed a viewport, use that. - if (this.lastViewportWidth && this.lastViewportHeight) { - return { - width: this.lastViewportWidth, - height: this.lastViewportHeight, - } - } - - // Otherwise fall back to the configured default so the tool can still - // operate before the first screenshot-based action has run. - const { width, height } = this.getViewport() - return { width, height } - } -} diff --git a/src/services/browser/UrlContentFetcher.ts b/src/services/browser/UrlContentFetcher.ts deleted file mode 100644 index 2d8e4a3de8..0000000000 --- a/src/services/browser/UrlContentFetcher.ts +++ /dev/null @@ -1,143 +0,0 @@ -import * as vscode from "vscode" -import * as fs from "fs/promises" -import * as path from "path" -import { Browser, Page, launch } from "puppeteer-core" -import * as cheerio from "cheerio" -import TurndownService from "turndown" -// @ts-ignore -import PCR from "puppeteer-chromium-resolver" -import { fileExistsAtPath } from "../../utils/fs" -import { serializeError } from "serialize-error" - -// Timeout constants -const URL_FETCH_TIMEOUT = 30_000 // 30 seconds -const URL_FETCH_FALLBACK_TIMEOUT = 20_000 // 20 seconds for fallback - -interface PCRStats { - puppeteer: { launch: typeof launch } - executablePath: string -} - -export class UrlContentFetcher { - private context: vscode.ExtensionContext - private browser?: Browser - private page?: Page - - constructor(context: vscode.ExtensionContext) { - this.context = context - } - - private async ensureChromiumExists(): Promise { - const globalStoragePath = this.context?.globalStorageUri?.fsPath - if (!globalStoragePath) { - throw new Error("Global storage uri is invalid") - } - const puppeteerDir = path.join(globalStoragePath, "puppeteer") - const dirExists = await fileExistsAtPath(puppeteerDir) - if (!dirExists) { - await fs.mkdir(puppeteerDir, { recursive: true }) - } - // if chromium doesn't exist, this will download it to path.join(puppeteerDir, ".chromium-browser-snapshots") - // if it does exist it will return the path to existing chromium - const stats: PCRStats = await PCR({ - downloadPath: puppeteerDir, - }) - return stats - } - - async launchBrowser(): Promise { - if (this.browser) { - return - } - const stats = await this.ensureChromiumExists() - const args = [ - "--user-agent=Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36", - "--disable-dev-shm-usage", - "--disable-accelerated-2d-canvas", - "--no-first-run", - "--disable-gpu", - "--disable-features=VizDisplayCompositor", - ] - if (process.platform === "linux") { - // Fixes network errors on Linux hosts (see https://github.com/puppeteer/puppeteer/issues/8246) - args.push("--no-sandbox") - } - this.browser = await stats.puppeteer.launch({ - args, - executablePath: stats.executablePath, - }) - // (latest version of puppeteer does not add headless to user agent) - this.page = await this.browser?.newPage() - - // Set additional page configurations to improve loading success - if (this.page) { - await this.page.setViewport({ width: 1280, height: 720 }) - await this.page.setExtraHTTPHeaders({ - "Accept-Language": "en-US,en;q=0.9", - }) - } - } - - async closeBrowser(): Promise { - await this.browser?.close() - this.browser = undefined - this.page = undefined - } - - // must make sure to call launchBrowser before and closeBrowser after using this - async urlToMarkdown(url: string): Promise { - if (!this.browser || !this.page) { - throw new Error("Browser not initialized") - } - /* - - In Puppeteer, "networkidle2" waits until there are no more than 2 network connections for at least 500 ms (roughly equivalent to Playwright's "networkidle"). - - "domcontentloaded" is when the basic DOM is loaded. - This should be sufficient for most doc sites. - */ - try { - await this.page.goto(url, { - timeout: URL_FETCH_TIMEOUT, - waitUntil: ["domcontentloaded", "networkidle2"], - }) - } catch (error) { - // Use serialize-error to safely extract error information - const serializedError = serializeError(error) - const errorMessage = serializedError.message || String(error) - const errorName = serializedError.name - - // Only retry for timeout or network-related errors - const shouldRetry = - errorMessage.includes("timeout") || - errorMessage.includes("net::") || - errorMessage.includes("NetworkError") || - errorMessage.includes("ERR_") || - errorName === "TimeoutError" - - if (shouldRetry) { - // If networkidle2 fails due to timeout/network issues, try with just domcontentloaded as fallback - console.warn( - `Failed to load ${url} with networkidle2, retrying with domcontentloaded only: ${errorMessage}`, - ) - await this.page.goto(url, { - timeout: URL_FETCH_FALLBACK_TIMEOUT, - waitUntil: ["domcontentloaded"], - }) - } else { - // For other errors, throw them as-is - throw error - } - } - - const content = await this.page.content() - - // use cheerio to parse and clean up the HTML - const $ = cheerio.load(content) - $("script, style, nav, footer, header").remove() - - // convert cleaned HTML to markdown - const turndownService = new TurndownService() - const markdown = turndownService.turndown($.html()) - - return markdown - } -} diff --git a/src/services/browser/__tests__/BrowserSession.spec.ts b/src/services/browser/__tests__/BrowserSession.spec.ts deleted file mode 100644 index 2291fade42..0000000000 --- a/src/services/browser/__tests__/BrowserSession.spec.ts +++ /dev/null @@ -1,628 +0,0 @@ -// npx vitest services/browser/__tests__/BrowserSession.spec.ts - -import * as path from "path" -import { BrowserSession } from "../BrowserSession" -import { discoverChromeHostUrl, tryChromeHostUrl } from "../browserDiscovery" - -// Mock dependencies -vi.mock("vscode", () => ({ - ExtensionContext: vi.fn(), - Uri: { - file: vi.fn((path) => ({ fsPath: path })), - }, -})) - -// Mock puppeteer-core -vi.mock("puppeteer-core", () => { - const mockBrowser = { - newPage: vi.fn().mockResolvedValue({ - goto: vi.fn().mockResolvedValue(undefined), - on: vi.fn(), - off: vi.fn(), - screenshot: vi.fn().mockResolvedValue("mockScreenshotBase64"), - url: vi.fn().mockReturnValue("https://example.com"), - }), - pages: vi.fn().mockResolvedValue([]), - close: vi.fn().mockResolvedValue(undefined), - disconnect: vi.fn().mockResolvedValue(undefined), - } - - return { - Browser: vi.fn(), - Page: vi.fn(), - TimeoutError: class TimeoutError extends Error {}, - launch: vi.fn().mockResolvedValue(mockBrowser), - connect: vi.fn().mockResolvedValue(mockBrowser), - } -}) - -// Mock PCR -vi.mock("puppeteer-chromium-resolver", () => { - return { - default: vi.fn().mockResolvedValue({ - puppeteer: { - launch: vi.fn().mockImplementation(async () => { - const { launch } = await import("puppeteer-core") - return launch() - }), - }, - executablePath: "/mock/path/to/chromium", - }), - } -}) - -// Mock fs -vi.mock("fs/promises", () => ({ - mkdir: vi.fn().mockResolvedValue(undefined), - readFile: vi.fn(), - writeFile: vi.fn(), - access: vi.fn(), -})) - -// Mock fileExistsAtPath -vi.mock("../../../utils/fs", () => ({ - fileExistsAtPath: vi.fn().mockResolvedValue(false), -})) - -// Mock browser discovery functions -vi.mock("../browserDiscovery", () => ({ - discoverChromeHostUrl: vi.fn().mockResolvedValue(null), - tryChromeHostUrl: vi.fn().mockResolvedValue(false), -})) - -// Mock delay -vi.mock("delay", () => ({ - default: vi.fn().mockResolvedValue(undefined), -})) - -// Mock p-wait-for -vi.mock("p-wait-for", () => ({ - default: vi.fn().mockResolvedValue(undefined), -})) - -describe("BrowserSession", () => { - let browserSession: BrowserSession - let mockContext: any - - beforeEach(() => { - vi.clearAllMocks() - - // Set up mock context - mockContext = { - globalState: { - get: vi.fn(), - update: vi.fn(), - }, - globalStorageUri: { - fsPath: "/mock/global/storage/path", - }, - extensionUri: { - fsPath: "/mock/extension/path", - }, - } - - // Create browser session - browserSession = new BrowserSession(mockContext) - }) - - describe("Remote browser disabled", () => { - it("should launch a local browser when remote browser is disabled", async () => { - // Mock context to indicate remote browser is disabled - mockContext.globalState.get.mockImplementation((key: string) => { - if (key === "remoteBrowserEnabled") return false - return undefined - }) - - await browserSession.launchBrowser() - - const puppeteerCore = await import("puppeteer-core") - - // Verify that a local browser was launched - expect(puppeteerCore.launch).toHaveBeenCalled() - - // Verify that remote browser connection was not attempted - expect(discoverChromeHostUrl).not.toHaveBeenCalled() - expect(tryChromeHostUrl).not.toHaveBeenCalled() - - expect((browserSession as any).isUsingRemoteBrowser).toBe(false) - }) - }) - - describe("Remote browser successfully connects", () => { - it("should connect to a remote browser when enabled and connection succeeds", async () => { - // Mock context to indicate remote browser is enabled - mockContext.globalState.get.mockImplementation((key: string) => { - if (key === "remoteBrowserEnabled") return true - if (key === "remoteBrowserHost") return "http://remote-browser:9222" - return undefined - }) - - // Mock successful remote browser connection - vi.mocked(tryChromeHostUrl).mockResolvedValue(true) - - await browserSession.launchBrowser() - - const puppeteerCore = await import("puppeteer-core") - - // Verify that connect was called - expect(puppeteerCore.connect).toHaveBeenCalled() - - // Verify that local browser was not launched - expect(puppeteerCore.launch).not.toHaveBeenCalled() - - expect((browserSession as any).isUsingRemoteBrowser).toBe(true) - }) - }) - - describe("Remote browser enabled but falls back to local", () => { - it("should fall back to local browser when remote connection fails", async () => { - // Mock context to indicate remote browser is enabled - mockContext.globalState.get.mockImplementation((key: string) => { - if (key === "remoteBrowserEnabled") return true - if (key === "remoteBrowserHost") return "http://remote-browser:9222" - return undefined - }) - - // Mock failed remote browser connection - vi.mocked(tryChromeHostUrl).mockResolvedValue(false) - vi.mocked(discoverChromeHostUrl).mockResolvedValue(null) - - await browserSession.launchBrowser() - - // Import puppeteer-core to check if launch was called - const puppeteerCore = await import("puppeteer-core") - - // Verify that local browser was launched as fallback - expect(puppeteerCore.launch).toHaveBeenCalled() - - // Verify that isUsingRemoteBrowser is false - expect((browserSession as any).isUsingRemoteBrowser).toBe(false) - }) - }) - - describe("closeBrowser", () => { - it("should close a local browser properly", async () => { - const puppeteerCore = await import("puppeteer-core") - - // Create a mock browser directly - const mockBrowser = { - newPage: vi.fn().mockResolvedValue({}), - pages: vi.fn().mockResolvedValue([]), - close: vi.fn().mockResolvedValue(undefined), - disconnect: vi.fn().mockResolvedValue(undefined), - } - - // Set browser and page on the session - ;(browserSession as any).browser = mockBrowser - ;(browserSession as any).page = {} - ;(browserSession as any).isUsingRemoteBrowser = false - - await browserSession.closeBrowser() - - // Verify that browser.close was called - expect(mockBrowser.close).toHaveBeenCalled() - expect(mockBrowser.disconnect).not.toHaveBeenCalled() - - // Verify that browser state was reset - expect((browserSession as any).browser).toBeUndefined() - expect((browserSession as any).page).toBeUndefined() - expect((browserSession as any).isUsingRemoteBrowser).toBe(false) - }) - - it("should disconnect from a remote browser properly", async () => { - // Create a mock browser directly - const mockBrowser = { - newPage: vi.fn().mockResolvedValue({}), - pages: vi.fn().mockResolvedValue([]), - close: vi.fn().mockResolvedValue(undefined), - disconnect: vi.fn().mockResolvedValue(undefined), - } - - // Set browser and page on the session - ;(browserSession as any).browser = mockBrowser - ;(browserSession as any).page = {} - ;(browserSession as any).isUsingRemoteBrowser = true - - await browserSession.closeBrowser() - - // Verify that browser.disconnect was called - expect(mockBrowser.disconnect).toHaveBeenCalled() - expect(mockBrowser.close).not.toHaveBeenCalled() - }) - }) - - it("forces same-tab behavior before click", async () => { - // Prepare a minimal mock page with required APIs - const page: any = { - on: vi.fn(), - off: vi.fn(), - screenshot: vi.fn().mockResolvedValue("mockScreenshotBase64"), - url: vi.fn().mockReturnValue("https://example.com"), - viewport: vi.fn().mockReturnValue({ width: 900, height: 600 }), - waitForNavigation: vi.fn().mockResolvedValue(undefined), - evaluate: vi.fn().mockResolvedValue(undefined), - mouse: { - click: vi.fn().mockResolvedValue(undefined), - move: vi.fn().mockResolvedValue(undefined), - }, - } - - ;(browserSession as any).page = page - - // Spy on the forceLinksToSameTab helper to ensure it's invoked - const forceSpy = vi.fn().mockResolvedValue(undefined) - ;(browserSession as any).forceLinksToSameTab = forceSpy - - await browserSession.click("10,20") - - expect(forceSpy).toHaveBeenCalledTimes(1) - expect(forceSpy).toHaveBeenCalledWith(page) - expect(page.mouse.click).toHaveBeenCalledWith(10, 20) - }) -}) - -describe("keyboard press", () => { - it("presses a keyboard key", async () => { - // Prepare a minimal mock page with required APIs - const page: any = { - on: vi.fn(), - off: vi.fn(), - screenshot: vi.fn().mockResolvedValue("mockScreenshotBase64"), - url: vi.fn().mockReturnValue("https://example.com"), - viewport: vi.fn().mockReturnValue({ width: 900, height: 600 }), - waitForNavigation: vi.fn().mockResolvedValue(undefined), - evaluate: vi.fn().mockResolvedValue(undefined), - keyboard: { - press: vi.fn().mockResolvedValue(undefined), - type: vi.fn().mockResolvedValue(undefined), - }, - } - - // Create a fresh BrowserSession with a mock context - const mockCtx: any = { - globalState: { get: vi.fn(), update: vi.fn() }, - globalStorageUri: { fsPath: "/mock/global/storage/path" }, - extensionUri: { fsPath: "/mock/extension/path" }, - } - const session = new BrowserSession(mockCtx) - - ;(session as any).page = page - - await session.press("Enter") - - expect(page.keyboard.press).toHaveBeenCalledTimes(1) - expect(page.keyboard.press).toHaveBeenCalledWith("Enter") - }) -}) - -describe("cursor visualization", () => { - it("should draw cursor indicator when cursor position exists", async () => { - // Prepare a minimal mock page with required APIs - const page: any = { - on: vi.fn(), - off: vi.fn(), - screenshot: vi.fn().mockResolvedValue("mockScreenshotBase64"), - url: vi.fn().mockReturnValue("https://example.com"), - viewport: vi.fn().mockReturnValue({ width: 900, height: 600 }), - evaluate: vi.fn().mockResolvedValue(undefined), - mouse: { - click: vi.fn().mockResolvedValue(undefined), - }, - } - - // Create a fresh BrowserSession with a mock context - const mockCtx: any = { - globalState: { get: vi.fn(), update: vi.fn() }, - globalStorageUri: { fsPath: "/mock/global/storage/path" }, - extensionUri: { fsPath: "/mock/extension/path" }, - } - const session = new BrowserSession(mockCtx) - - ;(session as any).page = page - - // Perform a click action which sets cursor position - const result = await session.click("100,200") - - // Verify cursor indicator was drawn and removed - // evaluate is called 3 times: 1 for forceLinksToSameTab, 1 for draw cursor, 1 for remove cursor - expect(page.evaluate).toHaveBeenCalled() - - // Verify the result includes cursor position - expect(result.currentMousePosition).toBe("100,200") - }) - - it("should include cursor position in action result", async () => { - // Prepare a minimal mock page with required APIs - const page: any = { - on: vi.fn(), - off: vi.fn(), - screenshot: vi.fn().mockResolvedValue("mockScreenshotBase64"), - url: vi.fn().mockReturnValue("https://example.com"), - viewport: vi.fn().mockReturnValue({ width: 900, height: 600 }), - evaluate: vi.fn().mockResolvedValue(undefined), - mouse: { - move: vi.fn().mockResolvedValue(undefined), - }, - } - - // Create a fresh BrowserSession with a mock context - const mockCtx: any = { - globalState: { get: vi.fn(), update: vi.fn() }, - globalStorageUri: { fsPath: "/mock/global/storage/path" }, - extensionUri: { fsPath: "/mock/extension/path" }, - } - const session = new BrowserSession(mockCtx) - - ;(session as any).page = page - - // Perform a hover action which sets cursor position - const result = await session.hover("150,250") - - // Verify the result includes cursor position - expect(result.currentMousePosition).toBe("150,250") - expect(result.viewportWidth).toBe(900) - expect(result.viewportHeight).toBe(600) - }) - - it("should not draw cursor indicator when no cursor position exists", async () => { - // Prepare a minimal mock page with required APIs - const page: any = { - on: vi.fn(), - off: vi.fn(), - screenshot: vi.fn().mockResolvedValue("mockScreenshotBase64"), - url: vi.fn().mockReturnValue("https://example.com"), - viewport: vi.fn().mockReturnValue({ width: 900, height: 600 }), - evaluate: vi.fn().mockResolvedValue(undefined), - } - - // Create a fresh BrowserSession with a mock context - const mockCtx: any = { - globalState: { get: vi.fn(), update: vi.fn() }, - globalStorageUri: { fsPath: "/mock/global/storage/path" }, - extensionUri: { fsPath: "/mock/extension/path" }, - } - const session = new BrowserSession(mockCtx) - - ;(session as any).page = page - - // Perform scroll action which doesn't set cursor position - const result = await session.scrollDown() - - // Verify evaluate was called only for scroll operation (not for cursor drawing/removal) - // scrollDown calls evaluate once for scrolling - expect(page.evaluate).toHaveBeenCalledTimes(1) - - // Verify no cursor position in result - expect(result.currentMousePosition).toBeUndefined() - }) - - describe("saveScreenshot", () => { - // Use a cross-platform workspace path for testing - const testWorkspace = path.resolve("/workspace") - - it("should save screenshot to specified path with png format", async () => { - const mockFs = await import("fs/promises") - const page: any = { - on: vi.fn(), - off: vi.fn(), - screenshot: vi.fn().mockResolvedValue("mockScreenshotBase64"), - url: vi.fn().mockReturnValue("https://example.com"), - viewport: vi.fn().mockReturnValue({ width: 900, height: 600 }), - evaluate: vi.fn().mockResolvedValue(undefined), - } - - const mockCtx: any = { - globalState: { get: vi.fn(), update: vi.fn() }, - globalStorageUri: { fsPath: "/mock/global/storage/path" }, - extensionUri: { fsPath: "/mock/extension/path" }, - } - const session = new BrowserSession(mockCtx) - ;(session as any).page = page - - await session.saveScreenshot("screenshots/test.png", testWorkspace) - - expect(mockFs.mkdir).toHaveBeenCalledWith(path.join(testWorkspace, "screenshots"), { recursive: true }) - expect(page.screenshot).toHaveBeenCalledWith( - expect.objectContaining({ - path: path.join(testWorkspace, "screenshots", "test.png"), - type: "png", - }), - ) - }) - - it("should save screenshot with jpeg format for .jpg extension", async () => { - const page: any = { - on: vi.fn(), - off: vi.fn(), - screenshot: vi.fn().mockResolvedValue("mockScreenshotBase64"), - url: vi.fn().mockReturnValue("https://example.com"), - viewport: vi.fn().mockReturnValue({ width: 900, height: 600 }), - evaluate: vi.fn().mockResolvedValue(undefined), - } - - const mockCtx: any = { - globalState: { get: vi.fn().mockReturnValue(80), update: vi.fn() }, - globalStorageUri: { fsPath: "/mock/global/storage/path" }, - extensionUri: { fsPath: "/mock/extension/path" }, - } - const session = new BrowserSession(mockCtx) - ;(session as any).page = page - - await session.saveScreenshot("screenshots/test.jpg", testWorkspace) - - expect(page.screenshot).toHaveBeenCalledWith( - expect.objectContaining({ - path: path.join(testWorkspace, "screenshots", "test.jpg"), - type: "jpeg", - quality: 80, - }), - ) - }) - - it("should save screenshot with webp format", async () => { - const page: any = { - on: vi.fn(), - off: vi.fn(), - screenshot: vi.fn().mockResolvedValue("mockScreenshotBase64"), - url: vi.fn().mockReturnValue("https://example.com"), - viewport: vi.fn().mockReturnValue({ width: 900, height: 600 }), - evaluate: vi.fn().mockResolvedValue(undefined), - } - - const mockCtx: any = { - globalState: { get: vi.fn().mockReturnValue(75), update: vi.fn() }, - globalStorageUri: { fsPath: "/mock/global/storage/path" }, - extensionUri: { fsPath: "/mock/extension/path" }, - } - const session = new BrowserSession(mockCtx) - ;(session as any).page = page - - await session.saveScreenshot("test.webp", testWorkspace) - - expect(page.screenshot).toHaveBeenCalledWith( - expect.objectContaining({ - path: path.join(testWorkspace, "test.webp"), - type: "webp", - quality: 75, - }), - ) - }) - - it("should reject absolute file paths outside workspace", async () => { - // Create a cross-platform absolute path for testing - const absolutePath = path.resolve("/absolute/path/screenshot.png") - const page: any = { - on: vi.fn(), - off: vi.fn(), - screenshot: vi.fn().mockResolvedValue("mockScreenshotBase64"), - url: vi.fn().mockReturnValue("https://example.com"), - viewport: vi.fn().mockReturnValue({ width: 900, height: 600 }), - evaluate: vi.fn().mockResolvedValue(undefined), - } - - const mockCtx: any = { - globalState: { get: vi.fn(), update: vi.fn() }, - globalStorageUri: { fsPath: "/mock/global/storage/path" }, - extensionUri: { fsPath: "/mock/extension/path" }, - } - const session = new BrowserSession(mockCtx) - ;(session as any).page = page - - await expect(session.saveScreenshot(absolutePath, testWorkspace)).rejects.toThrow(/outside the workspace/) - - expect(page.screenshot).not.toHaveBeenCalled() - }) - - it("should reject paths with .. that escape the workspace", async () => { - const page: any = { - on: vi.fn(), - off: vi.fn(), - screenshot: vi.fn().mockResolvedValue("mockScreenshotBase64"), - url: vi.fn().mockReturnValue("https://example.com"), - viewport: vi.fn().mockReturnValue({ width: 900, height: 600 }), - evaluate: vi.fn().mockResolvedValue(undefined), - } - - const mockCtx: any = { - globalState: { get: vi.fn(), update: vi.fn() }, - globalStorageUri: { fsPath: "/mock/global/storage/path" }, - extensionUri: { fsPath: "/mock/extension/path" }, - } - const session = new BrowserSession(mockCtx) - ;(session as any).page = page - - await expect(session.saveScreenshot("../../etc/passwd", testWorkspace)).rejects.toThrow( - /outside the workspace/, - ) - - expect(page.screenshot).not.toHaveBeenCalled() - }) - - it("should allow paths with .. that stay within workspace", async () => { - const mockFs = await import("fs/promises") - const page: any = { - on: vi.fn(), - off: vi.fn(), - screenshot: vi.fn().mockResolvedValue("mockScreenshotBase64"), - url: vi.fn().mockReturnValue("https://example.com"), - viewport: vi.fn().mockReturnValue({ width: 900, height: 600 }), - evaluate: vi.fn().mockResolvedValue(undefined), - } - - const mockCtx: any = { - globalState: { get: vi.fn(), update: vi.fn() }, - globalStorageUri: { fsPath: "/mock/global/storage/path" }, - extensionUri: { fsPath: "/mock/extension/path" }, - } - const session = new BrowserSession(mockCtx) - ;(session as any).page = page - - // Path like "subdir/../screenshot.png" should resolve to "screenshot.png" within workspace - await session.saveScreenshot("subdir/../screenshot.png", testWorkspace) - - expect(page.screenshot).toHaveBeenCalledWith( - expect.objectContaining({ - path: path.join(testWorkspace, "screenshot.png"), - type: "png", - }), - ) - }) - }) - - describe("getViewportSize", () => { - it("falls back to configured viewport when no page or last viewport is available", () => { - const localCtx: any = { - globalState: { - get: vi.fn((key: string) => { - if (key === "browserViewportSize") return "1024x768" - return undefined - }), - update: vi.fn(), - }, - globalStorageUri: { fsPath: "/mock/global/storage/path" }, - extensionUri: { fsPath: "/mock/extension/path" }, - } - - const session = new BrowserSession(localCtx) - const vp = (session as any).getViewportSize() - expect(vp).toEqual({ width: 1024, height: 768 }) - }) - - it("returns live page viewport when available and updates lastViewport cache", () => { - const localCtx: any = { - globalState: { - get: vi.fn(), - update: vi.fn(), - }, - globalStorageUri: { fsPath: "/mock/global/storage/path" }, - extensionUri: { fsPath: "/mock/extension/path" }, - } - const session = new BrowserSession(localCtx) - ;(session as any).page = { - viewport: vi.fn().mockReturnValue({ width: 1111, height: 555 }), - } - - const vp = (session as any).getViewportSize() - expect(vp).toEqual({ width: 1111, height: 555 }) - expect((session as any).lastViewportWidth).toBe(1111) - expect((session as any).lastViewportHeight).toBe(555) - }) - - it("returns cached last viewport when page no longer exists", () => { - const localCtx: any = { - globalState: { - get: vi.fn(), - update: vi.fn(), - }, - globalStorageUri: { fsPath: "/mock/global/storage/path" }, - extensionUri: { fsPath: "/mock/extension/path" }, - } - const session = new BrowserSession(localCtx) - ;(session as any).lastViewportWidth = 800 - ;(session as any).lastViewportHeight = 600 - - const vp = (session as any).getViewportSize() - expect(vp).toEqual({ width: 800, height: 600 }) - }) - }) -}) diff --git a/src/services/browser/__tests__/UrlContentFetcher.spec.ts b/src/services/browser/__tests__/UrlContentFetcher.spec.ts deleted file mode 100644 index b21456e379..0000000000 --- a/src/services/browser/__tests__/UrlContentFetcher.spec.ts +++ /dev/null @@ -1,369 +0,0 @@ -// npx vitest services/browser/__tests__/UrlContentFetcher.spec.ts - -import * as path from "path" - -import { UrlContentFetcher } from "../UrlContentFetcher" - -// Mock dependencies -vi.mock("vscode", () => ({ - ExtensionContext: vi.fn(), - Uri: { - file: vi.fn((path) => ({ fsPath: path })), - }, -})) - -// Mock fs/promises -vi.mock("fs/promises", () => ({ - default: { - mkdir: vi.fn().mockResolvedValue(undefined), - }, - mkdir: vi.fn().mockResolvedValue(undefined), -})) - -// Mock utils/fs -vi.mock("../../../utils/fs", () => ({ - fileExistsAtPath: vi.fn().mockResolvedValue(true), -})) - -// Mock cheerio -vi.mock("cheerio", () => ({ - load: vi.fn(() => { - const $ = vi.fn((selector) => ({ - remove: vi.fn().mockReturnThis(), - })) as any - $.html = vi.fn().mockReturnValue("Test content") - return $ - }), -})) - -// Mock turndown -vi.mock("turndown", () => { - return { - default: class MockTurndownService { - turndown = vi.fn().mockReturnValue("# Test content") - }, - } -}) - -// Mock puppeteer-chromium-resolver -vi.mock("puppeteer-chromium-resolver", () => ({ - default: vi.fn().mockResolvedValue({ - puppeteer: { - launch: vi.fn().mockResolvedValue({ - newPage: vi.fn().mockResolvedValue({ - goto: vi.fn(), - content: vi.fn().mockResolvedValue("Test content"), - setViewport: vi.fn().mockResolvedValue(undefined), - setExtraHTTPHeaders: vi.fn().mockResolvedValue(undefined), - }), - close: vi.fn().mockResolvedValue(undefined), - }), - }, - executablePath: "/path/to/chromium", - }), -})) - -// Mock serialize-error -vi.mock("serialize-error", () => ({ - serializeError: vi.fn((error) => { - if (error instanceof Error) { - return { message: error.message, name: error.name } - } else if (typeof error === "string") { - return { message: error } - } else if (error && typeof error === "object" && "message" in error) { - return { message: String(error.message), name: "name" in error ? String(error.name) : undefined } - } else { - return { message: String(error) } - } - }), -})) - -describe("UrlContentFetcher", () => { - let urlContentFetcher: UrlContentFetcher - let mockContext: any - let mockPage: any - let mockBrowser: any - let PCR: any - - beforeEach(async () => { - vi.clearAllMocks() - - mockContext = { - globalStorageUri: { - fsPath: "/test/storage", - }, - } - - mockPage = { - goto: vi.fn(), - content: vi.fn().mockResolvedValue("Test content"), - setViewport: vi.fn().mockResolvedValue(undefined), - setExtraHTTPHeaders: vi.fn().mockResolvedValue(undefined), - } - - mockBrowser = { - newPage: vi.fn().mockResolvedValue(mockPage), - close: vi.fn().mockResolvedValue(undefined), - } - - // Reset PCR mock - // @ts-ignore - PCR = (await import("puppeteer-chromium-resolver")).default - vi.mocked(PCR).mockResolvedValue({ - puppeteer: { - launch: vi.fn().mockResolvedValue(mockBrowser), - }, - executablePath: "/path/to/chromium", - }) - - urlContentFetcher = new UrlContentFetcher(mockContext) - }) - - afterEach(() => { - vi.restoreAllMocks() - }) - - describe("launchBrowser", () => { - it("should launch browser with correct arguments on non-Linux platforms", async () => { - // Ensure we're not on Linux for this test - const originalPlatform = process.platform - Object.defineProperty(process, "platform", { - value: "darwin", // macOS - }) - - try { - await urlContentFetcher.launchBrowser() - - expect(vi.mocked(PCR)).toHaveBeenCalledWith({ - downloadPath: path.join("/test/storage", "puppeteer"), - }) - - const stats = await vi.mocked(PCR).mock.results[0].value - expect(stats.puppeteer.launch).toHaveBeenCalledWith({ - args: [ - "--user-agent=Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36", - "--disable-dev-shm-usage", - "--disable-accelerated-2d-canvas", - "--no-first-run", - "--disable-gpu", - "--disable-features=VizDisplayCompositor", - ], - executablePath: "/path/to/chromium", - }) - } finally { - // Restore original platform - Object.defineProperty(process, "platform", { - value: originalPlatform, - }) - } - }) - - it("should launch browser with Linux-specific arguments", async () => { - // Mock process.platform to be linux - const originalPlatform = process.platform - Object.defineProperty(process, "platform", { - value: "linux", - }) - - try { - // Create a new instance to ensure fresh state - const linuxFetcher = new UrlContentFetcher(mockContext) - await linuxFetcher.launchBrowser() - - expect(vi.mocked(PCR)).toHaveBeenCalledWith({ - downloadPath: path.join("/test/storage", "puppeteer"), - }) - - const stats = await vi.mocked(PCR).mock.results[vi.mocked(PCR).mock.results.length - 1].value - expect(stats.puppeteer.launch).toHaveBeenCalledWith({ - args: [ - "--user-agent=Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36", - "--disable-dev-shm-usage", - "--disable-accelerated-2d-canvas", - "--no-first-run", - "--disable-gpu", - "--disable-features=VizDisplayCompositor", - "--no-sandbox", // Linux-specific argument - ], - executablePath: "/path/to/chromium", - }) - } finally { - // Restore original platform - Object.defineProperty(process, "platform", { - value: originalPlatform, - }) - } - }) - - it("should set viewport and headers after launching", async () => { - await urlContentFetcher.launchBrowser() - - expect(mockPage.setViewport).toHaveBeenCalledWith({ width: 1280, height: 720 }) - expect(mockPage.setExtraHTTPHeaders).toHaveBeenCalledWith({ - "Accept-Language": "en-US,en;q=0.9", - }) - }) - - it("should not launch browser if already launched", async () => { - await urlContentFetcher.launchBrowser() - const initialCallCount = vi.mocked(PCR).mock.calls.length - - await urlContentFetcher.launchBrowser() - expect(vi.mocked(PCR)).toHaveBeenCalledTimes(initialCallCount) - }) - }) - - describe("urlToMarkdown", () => { - beforeEach(async () => { - await urlContentFetcher.launchBrowser() - }) - - it("should successfully fetch and convert URL to markdown", async () => { - mockPage.goto.mockResolvedValueOnce(undefined) - - const result = await urlContentFetcher.urlToMarkdown("https://example.com") - - expect(mockPage.goto).toHaveBeenCalledWith("https://example.com", { - timeout: 30000, - waitUntil: ["domcontentloaded", "networkidle2"], - }) - expect(result).toBe("# Test content") - }) - - it("should retry with domcontentloaded only when networkidle2 fails", async () => { - const timeoutError = new Error("Navigation timeout of 30000 ms exceeded") - mockPage.goto.mockRejectedValueOnce(timeoutError).mockResolvedValueOnce(undefined) - - const result = await urlContentFetcher.urlToMarkdown("https://example.com") - - expect(mockPage.goto).toHaveBeenCalledTimes(2) - expect(mockPage.goto).toHaveBeenNthCalledWith(1, "https://example.com", { - timeout: 30000, - waitUntil: ["domcontentloaded", "networkidle2"], - }) - expect(mockPage.goto).toHaveBeenNthCalledWith(2, "https://example.com", { - timeout: 20000, - waitUntil: ["domcontentloaded"], - }) - expect(result).toBe("# Test content") - }) - - it("should retry for network errors", async () => { - const networkError = new Error("net::ERR_CONNECTION_REFUSED") - mockPage.goto.mockRejectedValueOnce(networkError).mockResolvedValueOnce(undefined) - - const result = await urlContentFetcher.urlToMarkdown("https://example.com") - - expect(mockPage.goto).toHaveBeenCalledTimes(2) - expect(result).toBe("# Test content") - }) - - it("should retry for TimeoutError", async () => { - const timeoutError = new Error("TimeoutError: Navigation timeout") - timeoutError.name = "TimeoutError" - mockPage.goto.mockRejectedValueOnce(timeoutError).mockResolvedValueOnce(undefined) - - const result = await urlContentFetcher.urlToMarkdown("https://example.com") - - expect(mockPage.goto).toHaveBeenCalledTimes(2) - expect(result).toBe("# Test content") - }) - - it("should not retry for non-network/timeout errors", async () => { - const otherError = new Error("Some other error") - mockPage.goto.mockRejectedValueOnce(otherError) - - await expect(urlContentFetcher.urlToMarkdown("https://example.com")).rejects.toThrow("Some other error") - expect(mockPage.goto).toHaveBeenCalledTimes(1) - }) - - it("should throw error if browser not initialized", async () => { - const newFetcher = new UrlContentFetcher(mockContext) - - await expect(newFetcher.urlToMarkdown("https://example.com")).rejects.toThrow("Browser not initialized") - }) - - it("should handle errors without message property", async () => { - const errorWithoutMessage = { code: "UNKNOWN_ERROR" } - mockPage.goto.mockRejectedValueOnce(errorWithoutMessage) - - // serialize-error will convert this to a proper error with the object stringified - await expect(urlContentFetcher.urlToMarkdown("https://example.com")).rejects.toThrow() - - // Should not retry for non-network errors - expect(mockPage.goto).toHaveBeenCalledTimes(1) - }) - - it("should handle error objects with message property", async () => { - const errorWithMessage = { message: "Custom error", code: "CUSTOM_ERROR" } - mockPage.goto.mockRejectedValueOnce(errorWithMessage) - - await expect(urlContentFetcher.urlToMarkdown("https://example.com")).rejects.toThrow("Custom error") - - // Should not retry for error objects with message property (they're treated as known errors) - expect(mockPage.goto).toHaveBeenCalledTimes(1) - }) - - it("should retry for error objects with network-related messages", async () => { - const errorWithNetworkMessage = { message: "net::ERR_CONNECTION_REFUSED", code: "NETWORK_ERROR" } - mockPage.goto.mockRejectedValueOnce(errorWithNetworkMessage).mockResolvedValueOnce(undefined) - - const result = await urlContentFetcher.urlToMarkdown("https://example.com") - - // Should retry for network-related errors even in non-Error objects - expect(mockPage.goto).toHaveBeenCalledTimes(2) - expect(result).toBe("# Test content") - }) - - it("should handle string errors", async () => { - const stringError = "Simple string error" - mockPage.goto.mockRejectedValueOnce(stringError) - - await expect(urlContentFetcher.urlToMarkdown("https://example.com")).rejects.toThrow("Simple string error") - expect(mockPage.goto).toHaveBeenCalledTimes(1) - }) - - it("should retry net::ERR_ABORTED like other network errors", async () => { - const abortedError = new Error("net::ERR_ABORTED at https://example.com") - mockPage.goto.mockRejectedValueOnce(abortedError).mockResolvedValueOnce(undefined) - - const result = await urlContentFetcher.urlToMarkdown("https://example.com") - - expect(mockPage.goto).toHaveBeenCalledTimes(2) - expect(mockPage.goto).toHaveBeenNthCalledWith(1, "https://example.com", { - timeout: 30000, - waitUntil: ["domcontentloaded", "networkidle2"], - }) - expect(mockPage.goto).toHaveBeenNthCalledWith(2, "https://example.com", { - timeout: 20000, - waitUntil: ["domcontentloaded"], - }) - expect(result).toBe("# Test content") - }) - - it("should throw error when ERR_ABORTED retry also fails", async () => { - const abortedError = new Error("net::ERR_ABORTED at https://example.com") - const retryError = new Error("net::ERR_CONNECTION_REFUSED") - mockPage.goto.mockRejectedValueOnce(abortedError).mockRejectedValueOnce(retryError) - - await expect(urlContentFetcher.urlToMarkdown("https://example.com")).rejects.toThrow( - "net::ERR_CONNECTION_REFUSED", - ) - - expect(mockPage.goto).toHaveBeenCalledTimes(2) - }) - }) - - describe("closeBrowser", () => { - it("should close browser and reset state", async () => { - await urlContentFetcher.launchBrowser() - await urlContentFetcher.closeBrowser() - - expect(mockBrowser.close).toHaveBeenCalled() - }) - - it("should handle closing when browser not initialized", async () => { - await expect(urlContentFetcher.closeBrowser()).resolves.not.toThrow() - }) - }) -}) diff --git a/src/services/browser/browserDiscovery.ts b/src/services/browser/browserDiscovery.ts deleted file mode 100644 index ecfd1c868a..0000000000 --- a/src/services/browser/browserDiscovery.ts +++ /dev/null @@ -1,181 +0,0 @@ -import * as net from "net" -import axios from "axios" -import * as dns from "dns" - -/** - * Check if a port is open on a given host - */ -export async function isPortOpen(host: string, port: number, timeout = 1000): Promise { - return new Promise((resolve) => { - const socket = new net.Socket() - let status = false - - // Set timeout - socket.setTimeout(timeout) - - // Handle successful connection - socket.on("connect", () => { - status = true - socket.destroy() - }) - - // Handle any errors - socket.on("error", () => { - socket.destroy() - }) - - // Handle timeout - socket.on("timeout", () => { - socket.destroy() - }) - - // Handle close - socket.on("close", () => { - resolve(status) - }) - - // Attempt to connect - socket.connect(port, host) - }) -} - -/** - * Try to connect to Chrome at a specific IP address - */ -export async function tryChromeHostUrl(chromeHostUrl: string): Promise { - try { - console.log(`Trying to connect to Chrome at: ${chromeHostUrl}/json/version`) - await axios.get(`${chromeHostUrl}/json/version`, { timeout: 1000 }) - return true - } catch (error) { - return false - } -} - -/** - * Get Docker host IP - */ -export async function getDockerHostIP(): Promise { - try { - // Try to resolve host.docker.internal (works on Docker Desktop) - return new Promise((resolve) => { - dns.lookup("host.docker.internal", (err: any, address: string) => { - if (err) { - resolve(null) - } else { - resolve(address) - } - }) - }) - } catch (error) { - console.log("Could not determine Docker host IP:", error) - return null - } -} - -/** - * Scan a network range for Chrome debugging port - */ -export async function scanNetworkForChrome(baseIP: string, port: number): Promise { - if (!baseIP || !baseIP.match(/^\d+\.\d+\.\d+\./)) { - return null - } - - // Extract the network prefix (e.g., "192.168.65.") - const networkPrefix = baseIP.split(".").slice(0, 3).join(".") + "." - - // Common Docker host IPs to try first - const priorityIPs = [ - networkPrefix + "1", // Common gateway - networkPrefix + "2", // Common host - networkPrefix + "254", // Common host in some Docker setups - ] - - console.log(`Scanning priority IPs in network ${networkPrefix}*`) - - // Check priority IPs first - for (const ip of priorityIPs) { - const isOpen = await isPortOpen(ip, port) - if (isOpen) { - console.log(`Found Chrome debugging port open on ${ip}`) - return ip - } - } - - return null -} - -// Function to discover Chrome instances on the network -const discoverChromeHosts = async (port: number): Promise => { - // Get all network interfaces - const ipAddresses = [] - - // Try to get Docker host IP - const hostIP = await getDockerHostIP() - if (hostIP) { - console.log("Found Docker host IP:", hostIP) - ipAddresses.push(hostIP) - } - - // Remove duplicates - const uniqueIPs = [...new Set(ipAddresses)] - console.log("IP Addresses to try:", uniqueIPs) - - // Try connecting to each IP address - for (const ip of uniqueIPs) { - const hostEndpoint = `http://${ip}:${port}` - - const hostIsValid = await tryChromeHostUrl(hostEndpoint) - if (hostIsValid) { - // Store the successful IP for future use - console.log(`✅ Found Chrome at ${hostEndpoint}`) - - // Return the host URL and endpoint - return hostEndpoint - } - } - - return null -} - -/** - * Test connection to a remote browser debugging websocket. - * First tries specific hosts, then attempts auto-discovery if needed. - * @param browserHostUrl Optional specific host URL to check first - * @param port Browser debugging port (default: 9222) - * @returns WebSocket debugger URL if connection is successful, null otherwise - */ -export async function discoverChromeHostUrl(port: number = 9222): Promise { - // First try specific hosts - const hostsToTry = [`http://localhost:${port}`, `http://127.0.0.1:${port}`] - - // Try each host directly first - for (const hostUrl of hostsToTry) { - console.log(`Trying to connect to: ${hostUrl}`) - try { - const hostIsValid = await tryChromeHostUrl(hostUrl) - if (hostIsValid) return hostUrl - } catch (error) { - console.log(`Failed to connect to ${hostUrl}: ${error instanceof Error ? error.message : error}`) - } - } - - // If direct connections failed, attempt auto-discovery - console.log("Direct connections failed. Attempting auto-discovery...") - - const discoveredHostUrl = await discoverChromeHosts(port) - if (discoveredHostUrl) { - console.log(`Trying to connect to discovered host: ${discoveredHostUrl}`) - try { - const hostIsValid = await tryChromeHostUrl(discoveredHostUrl) - if (hostIsValid) return discoveredHostUrl - console.log(`Failed to connect to discovered host ${discoveredHostUrl}`) - } catch (error) { - console.log(`Error connecting to discovered host: ${error instanceof Error ? error.message : error}`) - } - } else { - console.log("No browser instances discovered on network") - } - - return null -} diff --git a/src/services/checkpoints/ShadowCheckpointService.ts b/src/services/checkpoints/ShadowCheckpointService.ts index fee08b2fa4..bd44afb358 100644 --- a/src/services/checkpoints/ShadowCheckpointService.ts +++ b/src/services/checkpoints/ShadowCheckpointService.ts @@ -9,6 +9,7 @@ import pWaitFor from "p-wait-for" import * as vscode from "vscode" import { fileExistsAtPath } from "../../utils/fs" +import { arePathsEqual } from "../../utils/path" import { executeRipgrep } from "../../services/search/file-search" import { t } from "../../i18n" @@ -155,9 +156,15 @@ export abstract class ShadowCheckpointService extends EventEmitter { this.log(`[${this.constructor.name}#initShadowGit] shadow git repo already exists at ${this.dotGitDir}`) const worktree = await this.getShadowGitConfigWorktree(git) - if (worktree !== this.workspaceDir) { + if (!worktree) { + throw new Error("Checkpoints require core.worktree to be set in the shadow git config") + } + + const worktreeTrimmed = worktree.trim() + + if (!arePathsEqual(worktreeTrimmed, this.workspaceDir)) { throw new Error( - `Checkpoints can only be used in the original workspace: ${worktree} !== ${this.workspaceDir}`, + `Checkpoints can only be used in the original workspace: ${worktreeTrimmed} !== ${this.workspaceDir}`, ) } diff --git a/src/services/checkpoints/__tests__/ShadowCheckpointService.spec.ts b/src/services/checkpoints/__tests__/ShadowCheckpointService.spec.ts index ee8f7bbdc9..92bf1f8e7d 100644 --- a/src/services/checkpoints/__tests__/ShadowCheckpointService.spec.ts +++ b/src/services/checkpoints/__tests__/ShadowCheckpointService.spec.ts @@ -915,3 +915,77 @@ describe.each([[RepoPerTaskCheckpointService, "RepoPerTaskCheckpointService"]])( }) }, ) + +describe("worktree path comparison", () => { + it("accepts core.worktree with trailing newline from git output", async () => { + const shadowDir = path.join(tmpDir, `worktree-trim-${Date.now()}`) + const workspaceDir = path.join(tmpDir, `workspace-trim-${Date.now()}`) + + try { + await fs.mkdir(workspaceDir, { recursive: true }) + const mainGit = simpleGit(workspaceDir) + await mainGit.init() + await mainGit.addConfig("user.name", "Roo Code") + await mainGit.addConfig("user.email", "support@roocode.com") + + await fs.writeFile(path.join(workspaceDir, "main.txt"), "main content") + await mainGit.add("main.txt") + await mainGit.commit("Initial commit") + + vitest.spyOn(fileSearch, "executeRipgrep").mockImplementation(() => { + return Promise.resolve([]) + }) + + // First init to create the shadow repo + const service1 = new RepoPerTaskCheckpointService("trim-test", shadowDir, workspaceDir, () => {}) + await service1.initShadowGit() + + // Second init with stubbed worktree returning a trailing newline + const service2 = new RepoPerTaskCheckpointService("trim-test-2", shadowDir, workspaceDir, () => {}) + vitest.spyOn(service2 as any, "getShadowGitConfigWorktree").mockResolvedValue(workspaceDir + "\n") + + await service2.initShadowGit() + } finally { + vitest.restoreAllMocks() + await fs.rm(shadowDir, { recursive: true, force: true }) + await fs.rm(workspaceDir, { recursive: true, force: true }) + } + }) + + it("throws when core.worktree is missing", async () => { + const shadowDir = path.join(tmpDir, `worktree-missing-${Date.now()}`) + const workspaceDir = path.join(tmpDir, `workspace-missing-${Date.now()}`) + + try { + await fs.mkdir(workspaceDir, { recursive: true }) + const mainGit = simpleGit(workspaceDir) + await mainGit.init() + await mainGit.addConfig("user.name", "Roo Code") + await mainGit.addConfig("user.email", "support@roocode.com") + + await fs.writeFile(path.join(workspaceDir, "main.txt"), "main content") + await mainGit.add("main.txt") + await mainGit.commit("Initial commit") + + vitest.spyOn(fileSearch, "executeRipgrep").mockImplementation(() => { + return Promise.resolve([]) + }) + + // First init to create the shadow repo + const service1 = new RepoPerTaskCheckpointService("missing-test", shadowDir, workspaceDir, () => {}) + await service1.initShadowGit() + + // Remove core.worktree from the shadow git config + const shadowGit = simpleGit(shadowDir) + await shadowGit.raw(["config", "--unset", "core.worktree"]) + + // Second init should throw because core.worktree is missing + const service2 = new RepoPerTaskCheckpointService("missing-test-2", shadowDir, workspaceDir, () => {}) + await expect(service2.initShadowGit()).rejects.toThrowError(/core\.worktree to be set/) + } finally { + vitest.restoreAllMocks() + await fs.rm(shadowDir, { recursive: true, force: true }) + await fs.rm(workspaceDir, { recursive: true, force: true }) + } + }) +}) diff --git a/src/services/code-index/__tests__/manager.spec.ts b/src/services/code-index/__tests__/manager.spec.ts index 929f6f93c8..49a6d91c76 100644 --- a/src/services/code-index/__tests__/manager.spec.ts +++ b/src/services/code-index/__tests__/manager.spec.ts @@ -3,18 +3,45 @@ import { CodeIndexServiceFactory } from "../service-factory" import type { MockedClass } from "vitest" import * as path from "path" +// Helper: create a mock vscode.Uri from an fsPath +function mockUri(fsPath: string, scheme = "file") { + return { + fsPath, + scheme, + authority: "", + path: fsPath, + toString: (skipEncoding?: boolean) => `${scheme}://${fsPath}`, + } +} + // Mock vscode module vi.mock("vscode", () => { const testPath = require("path") const testWorkspacePath = testPath.join(testPath.sep, "test", "workspace") return { + Uri: { + file: (p: string) => ({ + fsPath: p, + scheme: "file", + authority: "", + path: p, + toString: (_skipEncoding?: boolean) => `file://${p}`, + }), + joinPath: vi.fn((...args: any[]) => ({ fsPath: args.join("/") })), + }, window: { activeTextEditor: null, }, workspace: { workspaceFolders: [ { - uri: { fsPath: testWorkspacePath }, + uri: { + fsPath: testWorkspacePath, + scheme: "file", + authority: "", + path: testWorkspacePath, + toString: (_skipEncoding?: boolean) => `file://${testWorkspacePath}`, + }, name: "test", index: 0, }, @@ -25,8 +52,9 @@ vi.mock("vscode", () => { onDidDelete: vi.fn().mockReturnValue({ dispose: vi.fn() }), dispose: vi.fn(), }), + getWorkspaceFolder: vi.fn(), }, - RelativePattern: vi.fn().mockImplementation((base, pattern) => ({ base, pattern })), + RelativePattern: vi.fn().mockImplementation((base: any, pattern: any) => ({ base, pattern })), } }) @@ -95,10 +123,22 @@ describe("CodeIndexManager - handleSettingsChange regression", () => { // Clear all instances before each test CodeIndexManager.disposeAll() + const workspaceStateStore: Record = {} + const globalStateStore: Record = {} mockContext = { subscriptions: [], - workspaceState: {} as any, - globalState: {} as any, + workspaceState: { + get: vi.fn((key: string, defaultValue?: any) => workspaceStateStore[key] ?? defaultValue), + update: vi.fn(async (key: string, value: any) => { + workspaceStateStore[key] = value + }), + } as any, + globalState: { + get: vi.fn((key: string, defaultValue?: any) => globalStateStore[key] ?? defaultValue), + update: vi.fn(async (key: string, value: any) => { + globalStateStore[key] = value + }), + } as any, extensionUri: {} as any, extensionPath: testExtensionPath, asAbsolutePath: vi.fn(), @@ -222,7 +262,7 @@ describe("CodeIndexManager - handleSettingsChange regression", () => { ;(manager as any)._cacheManager = mockCacheManager // Simulate an initialized manager by setting the required properties - ;(manager as any)._orchestrator = { stopWatcher: vi.fn() } + ;(manager as any)._orchestrator = { stopWatcher: vi.fn(), stopIndexing: vi.fn() } ;(manager as any)._searchService = {} // Verify manager is considered initialized @@ -456,7 +496,7 @@ describe("CodeIndexManager - handleSettingsChange regression", () => { }) // Mock orchestrator and search service to simulate initialized state - ;(manager as any)._orchestrator = { stopWatcher: vi.fn(), state: "Error" } + ;(manager as any)._orchestrator = { stopWatcher: vi.fn(), stopIndexing: vi.fn(), state: "Error" } ;(manager as any)._searchService = {} ;(manager as any)._serviceFactory = {} }) @@ -540,6 +580,9 @@ describe("CodeIndexManager - handleSettingsChange regression", () => { }), } + // Enable workspace indexing before re-initialization + await manager.setWorkspaceEnabled(true) + // Re-initialize await manager.initialize(mockContextProxy as any) @@ -583,7 +626,7 @@ describe("CodeIndexManager - handleSettingsChange regression", () => { // Setup manager with service instances ;(manager as any)._configManager = mockConfigManager ;(manager as any)._serviceFactory = {} - ;(manager as any)._orchestrator = { stopWatcher: vi.fn() } + ;(manager as any)._orchestrator = { stopWatcher: vi.fn(), stopIndexing: vi.fn() } ;(manager as any)._searchService = {} // Spy on console.error @@ -608,4 +651,155 @@ describe("CodeIndexManager - handleSettingsChange regression", () => { consoleErrorSpy.mockRestore() }) }) + + describe("workspace-enabled gating", () => { + it("should not start indexing when workspace is not enabled", async () => { + await manager.setAutoEnableDefault(false) + + const mockStateManager = (manager as any)._stateManager + mockStateManager.setSystemState = vi.fn() + mockStateManager.getCurrentStatus = vi.fn().mockReturnValue({ + systemStatus: "Standby", + message: "", + processedItems: 0, + totalItems: 0, + currentItemUnit: "items", + }) + + expect(manager.isWorkspaceEnabled).toBe(false) + + await manager.startIndexing() + + expect(mockStateManager.setSystemState).not.toHaveBeenCalledWith("Indexing", expect.any(String)) + }) + + it("should include workspaceEnabled in getCurrentStatus", async () => { + await manager.setAutoEnableDefault(false) + + const mockStateManager = (manager as any)._stateManager + mockStateManager.getCurrentStatus = vi.fn().mockReturnValue({ + systemStatus: "Standby", + message: "", + processedItems: 0, + totalItems: 0, + currentItemUnit: "items", + }) + + const status = manager.getCurrentStatus() + expect(status.workspaceEnabled).toBe(false) + }) + + it("should persist workspace enabled state", async () => { + await manager.setAutoEnableDefault(false) + expect(manager.isWorkspaceEnabled).toBe(false) + + await manager.setWorkspaceEnabled(true) + expect(manager.isWorkspaceEnabled).toBe(true) + + await manager.setWorkspaceEnabled(false) + expect(manager.isWorkspaceEnabled).toBe(false) + }) + + it("should store enablement per folder URI, not per window", async () => { + CodeIndexManager.disposeAll() + + const vscode = await import("vscode") + + const folderAPath = path.join(path.sep, "test", "folderA") + const folderBPath = path.join(path.sep, "test", "folderB") + const folderAUri = mockUri(folderAPath) + const folderBUri = mockUri(folderBPath) + + // Both folders share the same workspaceState (same window) + const sharedStore: Record = {} + const sharedContext = { + ...mockContext, + workspaceState: { + get: vi.fn((key: string, defaultValue?: any) => sharedStore[key] ?? defaultValue), + update: vi.fn(async (key: string, value: any) => { + sharedStore[key] = value + }), + } as any, + globalState: { + get: vi.fn((_key: string, _defaultValue?: any) => false), + update: vi.fn(), + } as any, + } + + // Patch workspaceFolders to include both folders + ;(vscode.workspace as any).workspaceFolders = [ + { uri: folderAUri, name: "folderA", index: 0 }, + { uri: folderBUri, name: "folderB", index: 1 }, + ] + + const managerA = CodeIndexManager.getInstance(sharedContext as any, folderAPath)! + const managerB = CodeIndexManager.getInstance(sharedContext as any, folderBPath)! + + // Both start disabled (autoEnableDefault is false via globalState mock) + expect(managerA.isWorkspaceEnabled).toBe(false) + expect(managerB.isWorkspaceEnabled).toBe(false) + + // Enable A only + await managerA.setWorkspaceEnabled(true) + + expect(managerA.isWorkspaceEnabled).toBe(true) + expect(managerB.isWorkspaceEnabled).toBe(false) + + // Enable B, disable A + await managerB.setWorkspaceEnabled(true) + await managerA.setWorkspaceEnabled(false) + + expect(managerA.isWorkspaceEnabled).toBe(false) + expect(managerB.isWorkspaceEnabled).toBe(true) + + CodeIndexManager.disposeAll() + }) + }) + + describe("stopIndexing", () => { + it("should delegate to orchestrator.stopIndexing()", () => { + const mockOrchestrator = { + stopIndexing: vi.fn(), + stopWatcher: vi.fn(), + state: "Indexing", + } + ;(manager as any)._orchestrator = mockOrchestrator + + manager.stopIndexing() + + expect(mockOrchestrator.stopIndexing).toHaveBeenCalled() + }) + + it("should be safe to call when orchestrator is not set", () => { + ;(manager as any)._orchestrator = undefined + + expect(() => manager.stopIndexing()).not.toThrow() + }) + }) + + describe("handleSettingsChange - disable toggle bug fix", () => { + it("should abort active indexing when feature is disabled", async () => { + const mockOrchestrator = { + stopIndexing: vi.fn(), + stopWatcher: vi.fn(), + state: "Indexing", + } + ;(manager as any)._orchestrator = mockOrchestrator + + const mockConfigManager = { + loadConfiguration: vi.fn().mockResolvedValue({ requiresRestart: false }), + isFeatureConfigured: true, + isFeatureEnabled: false, + } + ;(manager as any)._configManager = mockConfigManager + + const mockStateManager = (manager as any)._stateManager + mockStateManager.setSystemState = vi.fn() + + await manager.handleSettingsChange() + + expect(mockOrchestrator.stopIndexing).toHaveBeenCalled() + expect(mockStateManager.setSystemState).toHaveBeenCalledWith("Standby", "Code indexing is disabled") + }) + }) }) diff --git a/src/services/code-index/__tests__/orchestrator.spec.ts b/src/services/code-index/__tests__/orchestrator.spec.ts index aab1ef888d..e940ea04c2 100644 --- a/src/services/code-index/__tests__/orchestrator.spec.ts +++ b/src/services/code-index/__tests__/orchestrator.spec.ts @@ -79,6 +79,7 @@ describe("CodeIndexOrchestrator - error path cleanup gating", () => { cacheManager = { clearCacheFile: vi.fn().mockResolvedValue(undefined), + flush: vi.fn().mockResolvedValue(undefined), } vectorStore = { @@ -158,3 +159,178 @@ describe("CodeIndexOrchestrator - error path cleanup gating", () => { expect(lastCall[0]).toBe("Error") }) }) + +describe("CodeIndexOrchestrator - stopIndexing", () => { + const workspacePath = "/test/workspace" + + let configManager: any + let stateManager: any + let cacheManager: any + let vectorStore: any + let scanner: any + let fileWatcher: any + + beforeEach(() => { + vi.clearAllMocks() + + configManager = { + isFeatureConfigured: true, + } + + let currentState = "Standby" + stateManager = { + get state() { + return currentState + }, + setSystemState: vi.fn().mockImplementation((state: string, _msg: string) => { + currentState = state + }), + reportFileQueueProgress: vi.fn(), + reportBlockIndexingProgress: vi.fn(), + } + + cacheManager = { + clearCacheFile: vi.fn().mockResolvedValue(undefined), + flush: vi.fn().mockResolvedValue(undefined), + } + + vectorStore = { + initialize: vi.fn().mockResolvedValue(false), + hasIndexedData: vi.fn().mockResolvedValue(false), + markIndexingIncomplete: vi.fn().mockResolvedValue(undefined), + markIndexingComplete: vi.fn().mockResolvedValue(undefined), + clearCollection: vi.fn().mockResolvedValue(undefined), + } + + scanner = { + scanDirectory: vi.fn(), + } + + fileWatcher = { + initialize: vi.fn().mockResolvedValue(undefined), + onDidStartBatchProcessing: vi.fn().mockReturnValue({ dispose: vi.fn() }), + onBatchProgressUpdate: vi.fn().mockReturnValue({ dispose: vi.fn() }), + onDidFinishBatchProcessing: vi.fn().mockReturnValue({ dispose: vi.fn() }), + dispose: vi.fn(), + } + }) + + it("should abort indexing when stopIndexing() is called", async () => { + // Make scanner hang until aborted + scanner.scanDirectory.mockImplementation( + async (_dir: string, _onError?: any, _onBlocksIndexed?: any, _onFileParsed?: any, signal?: AbortSignal) => { + // Wait for abort signal + await new Promise((resolve) => { + if (signal?.aborted) { + resolve() + return + } + signal?.addEventListener("abort", () => resolve()) + }) + return { stats: { processed: 0, skipped: 0 }, totalBlockCount: 0 } + }, + ) + + const orchestrator = new CodeIndexOrchestrator( + configManager, + stateManager, + workspacePath, + cacheManager, + vectorStore, + scanner, + fileWatcher, + ) + + // Start indexing (async, don't await) + const indexingPromise = orchestrator.startIndexing() + + // Give it a tick to begin + await new Promise((resolve) => setTimeout(resolve, 10)) + + // Stop indexing + orchestrator.stopIndexing() + + // Wait for indexing to complete + await indexingPromise + + // State should be Standby (not Error) + const setStateCalls = stateManager.setSystemState.mock.calls + const lastCall = setStateCalls[setStateCalls.length - 1] + expect(lastCall[0]).toBe("Standby") + }) + + it("should set state to Standby after abort, not Error", async () => { + // Make scanner throw AbortError when signal is aborted + scanner.scanDirectory.mockImplementation( + async (_dir: string, _onError?: any, _onBlocksIndexed?: any, _onFileParsed?: any, signal?: AbortSignal) => { + await new Promise((resolve) => { + if (signal?.aborted) { + resolve() + return + } + signal?.addEventListener("abort", () => resolve()) + }) + throw new DOMException("Indexing aborted", "AbortError") + }, + ) + + const orchestrator = new CodeIndexOrchestrator( + configManager, + stateManager, + workspacePath, + cacheManager, + vectorStore, + scanner, + fileWatcher, + ) + + const indexingPromise = orchestrator.startIndexing() + await new Promise((resolve) => setTimeout(resolve, 10)) + + orchestrator.stopIndexing() + await indexingPromise + + // Should NOT have set Error state — abort is handled gracefully + const errorCalls = stateManager.setSystemState.mock.calls.filter((call: any[]) => call[0] === "Error") + expect(errorCalls).toHaveLength(0) + + // Should NOT have cleared collection on abort + expect(vectorStore.clearCollection).not.toHaveBeenCalled() + }) + + it("should preserve partial index data after stop", async () => { + scanner.scanDirectory.mockImplementation( + async (_dir: string, _onError?: any, _onBlocksIndexed?: any, _onFileParsed?: any, signal?: AbortSignal) => { + await new Promise((resolve) => { + if (signal?.aborted) { + resolve() + return + } + signal?.addEventListener("abort", () => resolve()) + }) + return { stats: { processed: 5, skipped: 0 }, totalBlockCount: 5 } + }, + ) + + const orchestrator = new CodeIndexOrchestrator( + configManager, + stateManager, + workspacePath, + cacheManager, + vectorStore, + scanner, + fileWatcher, + ) + + const indexingPromise = orchestrator.startIndexing() + await new Promise((resolve) => setTimeout(resolve, 10)) + + orchestrator.stopIndexing() + await indexingPromise + + // Cache should NOT be cleared on user-initiated stop + expect(cacheManager.clearCacheFile).not.toHaveBeenCalled() + // Collection should NOT be cleared on user-initiated stop + expect(vectorStore.clearCollection).not.toHaveBeenCalled() + }) +}) diff --git a/src/services/code-index/__tests__/service-factory.spec.ts b/src/services/code-index/__tests__/service-factory.spec.ts index 1d8f7ba478..3e943ebd82 100644 --- a/src/services/code-index/__tests__/service-factory.spec.ts +++ b/src/services/code-index/__tests__/service-factory.spec.ts @@ -286,7 +286,7 @@ describe("CodeIndexServiceFactory", () => { // Arrange const testConfig = { embedderProvider: "gemini", - modelId: "text-embedding-004", + modelId: "gemini-embedding-001", geminiOptions: { apiKey: "test-gemini-api-key", }, @@ -297,6 +297,25 @@ describe("CodeIndexServiceFactory", () => { factory.createEmbedder() // Assert + expect(MockedGeminiEmbedder).toHaveBeenCalledWith("test-gemini-api-key", "gemini-embedding-001") + }) + + it("should pass deprecated text-embedding-004 modelId to GeminiEmbedder (migration happens inside GeminiEmbedder)", () => { + // Arrange - service-factory passes the config modelId directly; + // GeminiEmbedder handles the migration internally + const testConfig = { + embedderProvider: "gemini", + modelId: "text-embedding-004", + geminiOptions: { + apiKey: "test-gemini-api-key", + }, + } + mockConfigManager.getConfig.mockReturnValue(testConfig as any) + + // Act + factory.createEmbedder() + + // Assert - factory passes the original modelId; GeminiEmbedder migrates it internally expect(MockedGeminiEmbedder).toHaveBeenCalledWith("test-gemini-api-key", "text-embedding-004") }) diff --git a/src/services/code-index/cache-manager.ts b/src/services/code-index/cache-manager.ts index a9a4f0ac47..eadaa9e346 100644 --- a/src/services/code-index/cache-manager.ts +++ b/src/services/code-index/cache-manager.ts @@ -110,6 +110,13 @@ export class CacheManager implements ICacheManager { this._debouncedSaveCache() } + /** + * Flushes any pending debounced cache writes to disk immediately. + */ + async flush(): Promise { + await this._performSave() + } + /** * Gets a copy of all file hashes * @returns A copy of the file hashes record diff --git a/src/services/code-index/embedders/__tests__/gemini.spec.ts b/src/services/code-index/embedders/__tests__/gemini.spec.ts index d41a4dc1e9..d84dcd8abc 100644 --- a/src/services/code-index/embedders/__tests__/gemini.spec.ts +++ b/src/services/code-index/embedders/__tests__/gemini.spec.ts @@ -44,7 +44,7 @@ describe("GeminiEmbedder", () => { it("should create an instance with specified model", () => { // Arrange const apiKey = "test-gemini-api-key" - const modelId = "text-embedding-004" + const modelId = "gemini-embedding-001" // Act embedder = new GeminiEmbedder(apiKey, modelId) @@ -53,7 +53,24 @@ describe("GeminiEmbedder", () => { expect(MockedOpenAICompatibleEmbedder).toHaveBeenCalledWith( "https://generativelanguage.googleapis.com/v1beta/openai/", apiKey, - "text-embedding-004", + "gemini-embedding-001", + 2048, + ) + }) + + it("should migrate deprecated text-embedding-004 to gemini-embedding-001", () => { + // Arrange + const apiKey = "test-gemini-api-key" + const deprecatedModelId = "text-embedding-004" + + // Act + embedder = new GeminiEmbedder(apiKey, deprecatedModelId) + + // Assert - should be migrated to gemini-embedding-001 + expect(MockedOpenAICompatibleEmbedder).toHaveBeenCalledWith( + "https://generativelanguage.googleapis.com/v1beta/openai/", + apiKey, + "gemini-embedding-001", 2048, ) }) @@ -109,8 +126,8 @@ describe("GeminiEmbedder", () => { }) it("should use provided model parameter when specified", async () => { - // Arrange - embedder = new GeminiEmbedder("test-api-key", "text-embedding-004") + // Arrange - even with deprecated model in constructor, the runtime parameter takes precedence + embedder = new GeminiEmbedder("test-api-key", "gemini-embedding-001") const texts = ["test text 1", "test text 2"] const mockResponse = { embeddings: [ @@ -120,7 +137,7 @@ describe("GeminiEmbedder", () => { } mockCreateEmbeddings.mockResolvedValue(mockResponse) - // Act + // Act - specify a different model at runtime const result = await embedder.createEmbeddings(texts, "gemini-embedding-001") // Assert diff --git a/src/services/code-index/embedders/gemini.ts b/src/services/code-index/embedders/gemini.ts index 7e795875c9..03bfc35aae 100644 --- a/src/services/code-index/embedders/gemini.ts +++ b/src/services/code-index/embedders/gemini.ts @@ -10,15 +10,33 @@ import { TelemetryService } from "@roo-code/telemetry" * with configuration for Google's Gemini embedding API. * * Supported models: - * - text-embedding-004 (dimension: 768) - * - gemini-embedding-001 (dimension: 2048) + * - gemini-embedding-001 (dimension: 3072) + * + * Note: text-embedding-004 has been deprecated and is automatically + * migrated to gemini-embedding-001 for backward compatibility. */ export class GeminiEmbedder implements IEmbedder { private readonly openAICompatibleEmbedder: OpenAICompatibleEmbedder private static readonly GEMINI_BASE_URL = "https://generativelanguage.googleapis.com/v1beta/openai/" private static readonly DEFAULT_MODEL = "gemini-embedding-001" + /** + * Deprecated models that are automatically migrated to their replacements. + * Users with these models configured will be silently migrated without interruption. + */ + private static readonly DEPRECATED_MODEL_MIGRATIONS: Record = { + "text-embedding-004": "gemini-embedding-001", + } private readonly modelId: string + /** + * Migrates deprecated model IDs to their replacements. + * @param modelId The model ID to potentially migrate + * @returns The migrated model ID, or the original if no migration is needed + */ + private static migrateModelId(modelId: string): string { + return GeminiEmbedder.DEPRECATED_MODEL_MIGRATIONS[modelId] ?? modelId + } + /** * Creates a new Gemini embedder * @param apiKey The Gemini API key for authentication @@ -29,8 +47,11 @@ export class GeminiEmbedder implements IEmbedder { throw new Error(t("embeddings:validation.apiKeyRequired")) } - // Use provided model or default - this.modelId = modelId || GeminiEmbedder.DEFAULT_MODEL + // Migrate deprecated models to their replacements silently + const migratedModelId = modelId ? GeminiEmbedder.migrateModelId(modelId) : undefined + + // Use provided model (after migration) or default + this.modelId = migratedModelId || GeminiEmbedder.DEFAULT_MODEL // Create an OpenAI Compatible embedder with Gemini's configuration this.openAICompatibleEmbedder = new OpenAICompatibleEmbedder( diff --git a/src/services/code-index/interfaces/cache.ts b/src/services/code-index/interfaces/cache.ts index a2e62bcac1..01931a3a8b 100644 --- a/src/services/code-index/interfaces/cache.ts +++ b/src/services/code-index/interfaces/cache.ts @@ -2,5 +2,6 @@ export interface ICacheManager { getHash(filePath: string): string | undefined updateHash(filePath: string, hash: string): void deleteHash(filePath: string): void + flush(): Promise getAllHashes(): Record } diff --git a/src/services/code-index/interfaces/file-processor.ts b/src/services/code-index/interfaces/file-processor.ts index 88b19007c3..8ecdc518c8 100644 --- a/src/services/code-index/interfaces/file-processor.ts +++ b/src/services/code-index/interfaces/file-processor.ts @@ -37,6 +37,7 @@ export interface IDirectoryScanner { onError?: (error: Error) => void, onBlocksIndexed?: (indexedCount: number) => void, onFileParsed?: (fileBlockCount: number) => void, + signal?: AbortSignal, ): Promise<{ stats: { processed: number diff --git a/src/services/code-index/interfaces/manager.ts b/src/services/code-index/interfaces/manager.ts index 28ff552327..d657ad667c 100644 --- a/src/services/code-index/interfaces/manager.ts +++ b/src/services/code-index/interfaces/manager.ts @@ -39,6 +39,11 @@ export interface ICodeIndexManager { */ startIndexing(): Promise + /** + * Stops any in-progress indexing operation and the file watcher + */ + stopIndexing(): void + /** * Stops the file watcher */ @@ -69,7 +74,7 @@ export interface ICodeIndexManager { dispose(): void } -export type IndexingState = "Standby" | "Indexing" | "Indexed" | "Error" +export type IndexingState = "Standby" | "Indexing" | "Indexed" | "Error" | "Stopping" export type EmbedderProvider = | "openai" | "ollama" diff --git a/src/services/code-index/manager.ts b/src/services/code-index/manager.ts index dd79a3f161..91ea515e40 100644 --- a/src/services/code-index/manager.ts +++ b/src/services/code-index/manager.ts @@ -32,30 +32,47 @@ export class CodeIndexManager { private _isRecoveringFromError = false public static getInstance(context: vscode.ExtensionContext, workspacePath?: string): CodeIndexManager | undefined { - // If workspacePath is not provided, try to get it from the active editor or first workspace folder - if (!workspacePath) { + // Resolve the workspace folder to get both fsPath and the real URI + let folder: vscode.WorkspaceFolder | undefined + + if (workspacePath) { + folder = vscode.workspace.workspaceFolders?.find((f) => f.uri.fsPath === workspacePath) + } else { const activeEditor = vscode.window.activeTextEditor if (activeEditor) { - const workspaceFolder = vscode.workspace.getWorkspaceFolder(activeEditor.document.uri) - workspacePath = workspaceFolder?.uri.fsPath + folder = vscode.workspace.getWorkspaceFolder(activeEditor.document.uri) } - - if (!workspacePath) { + if (!folder) { const workspaceFolders = vscode.workspace.workspaceFolders if (!workspaceFolders || workspaceFolders.length === 0) { return undefined } - // Use the first workspace folder as fallback - workspacePath = workspaceFolders[0].uri.fsPath + folder = workspaceFolders[0] } + workspacePath = folder.uri.fsPath } if (!CodeIndexManager.instances.has(workspacePath)) { - CodeIndexManager.instances.set(workspacePath, new CodeIndexManager(workspacePath, context)) + // folder may be undefined when workspacePath was provided but doesn't match + // any workspace folder (e.g. cwd passed from a tool). Fall back to file:// URI. + const folderUri = + folder?.uri ?? + ({ + fsPath: workspacePath, + scheme: "file", + authority: "", + path: workspacePath, + toString: () => `file://${workspacePath}`, + } as unknown as vscode.Uri) + CodeIndexManager.instances.set(workspacePath, new CodeIndexManager(workspacePath, folderUri, context)) } return CodeIndexManager.instances.get(workspacePath)! } + public static getAllInstances(): CodeIndexManager[] { + return Array.from(CodeIndexManager.instances.values()) + } + public static disposeAll(): void { for (const instance of CodeIndexManager.instances.values()) { instance.dispose() @@ -64,17 +81,45 @@ export class CodeIndexManager { } private readonly workspacePath: string + private readonly _folderUri: vscode.Uri private readonly context: vscode.ExtensionContext // Private constructor for singleton pattern - private constructor(workspacePath: string, context: vscode.ExtensionContext) { + private constructor(workspacePath: string, folderUri: vscode.Uri, context: vscode.ExtensionContext) { this.workspacePath = workspacePath + this._folderUri = folderUri this.context = context this._stateManager = new CodeIndexStateManager() } // --- Public API --- + /** + * Returns the workspaceState key for per-folder indexing enablement, + * keyed by the real workspace folder URI so local/remote schemes cannot collide. + */ + private _workspaceEnabledKey(): string { + return "codeIndexWorkspaceEnabled:" + this._folderUri.toString(true) + } + + public get isWorkspaceEnabled(): boolean { + const explicit = this.context.workspaceState.get(this._workspaceEnabledKey(), undefined) + if (explicit !== undefined) return explicit + return this.autoEnableDefault + } + + public async setWorkspaceEnabled(enabled: boolean): Promise { + await this.context.workspaceState.update(this._workspaceEnabledKey(), enabled) + } + + public get autoEnableDefault(): boolean { + return this.context.globalState.get("codeIndexAutoEnableDefault", true) + } + + public async setAutoEnableDefault(enabled: boolean): Promise { + await this.context.globalState.update("codeIndexAutoEnableDefault", enabled) + } + public get onProgressUpdate() { return this._stateManager.onProgressUpdate } @@ -138,28 +183,32 @@ export class CodeIndexManager { return { requiresRestart } } - // 4. CacheManager Initialization + // 4. Check workspace-level enablement (before creating expensive services) + if (!this.isWorkspaceEnabled) { + this._stateManager.setSystemState("Standby", "Indexing not enabled for this workspace") + return { requiresRestart } + } + + // 5. CacheManager Initialization if (!this._cacheManager) { this._cacheManager = new CacheManager(this.context, this.workspacePath) await this._cacheManager.initialize() } - // 4. Determine if Core Services Need Recreation + // 6. Determine if Core Services Need Recreation const needsServiceRecreation = !this._serviceFactory || requiresRestart if (needsServiceRecreation) { await this._recreateServices() } - // 5. Handle Indexing Start/Restart - // The enhanced vectorStore.initialize() in startIndexing() now handles dimension changes automatically - // by detecting incompatible collections and recreating them, so we rely on that for dimension changes + // 7. Handle Indexing Start/Restart const shouldStartOrRestartIndexing = requiresRestart || (needsServiceRecreation && (!this._orchestrator || this._orchestrator.state !== "Indexing")) if (shouldStartOrRestartIndexing) { - this._orchestrator?.startIndexing() // This method is async, but we don't await it here + this._orchestrator?.startIndexing() } return { requiresRestart } @@ -173,7 +222,7 @@ export class CodeIndexManager { * The indexing will continue asynchronously and progress will be reported through events. */ public async startIndexing(): Promise { - if (!this.isFeatureEnabled) { + if (!this.isFeatureEnabled || !this.isWorkspaceEnabled) { return } @@ -191,6 +240,15 @@ export class CodeIndexManager { await this._orchestrator!.startIndexing() } + /** + * Stops any in-progress indexing operation and the file watcher. + */ + public stopIndexing(): void { + if (this._orchestrator) { + this._orchestrator.stopIndexing() + } + } + /** * Stops the file watcher and potentially cleans up resources. */ @@ -247,9 +305,7 @@ export class CodeIndexManager { * Cleans up the manager instance. */ public dispose(): void { - if (this._orchestrator) { - this.stopWatcher() - } + this.stopIndexing() this._stateManager.dispose() } @@ -273,6 +329,8 @@ export class CodeIndexManager { return { ...status, workspacePath: this.workspacePath, + workspaceEnabled: this.isWorkspaceEnabled, + autoEnableDefault: this.autoEnableDefault, } } @@ -384,13 +442,9 @@ export class CodeIndexManager { const isFeatureEnabled = this.isFeatureEnabled const isFeatureConfigured = this.isFeatureConfigured - // If feature is disabled, stop the service + // If feature is disabled, stop the service (including any active scan) if (!isFeatureEnabled) { - // Stop the orchestrator if it exists - if (this._orchestrator) { - this._orchestrator.stopWatcher() - } - // Set state to indicate service is disabled + this.stopIndexing() this._stateManager.setSystemState("Standby", "Code indexing is disabled") return } diff --git a/src/services/code-index/orchestrator.ts b/src/services/code-index/orchestrator.ts index 99f317882b..cd65fceb5e 100644 --- a/src/services/code-index/orchestrator.ts +++ b/src/services/code-index/orchestrator.ts @@ -15,6 +15,7 @@ import { t } from "../../i18n" export class CodeIndexOrchestrator { private _fileWatcherSubscriptions: vscode.Disposable[] = [] private _isProcessing: boolean = false + private _abortController: AbortController | null = null constructor( private readonly configManager: CodeIndexConfigManager, @@ -121,6 +122,8 @@ export class CodeIndexOrchestrator { } this._isProcessing = true + this._abortController = new AbortController() + const signal = this._abortController.signal this.stateManager.setSystemState("Indexing", "Initializing services...") // Track whether we successfully connected to Qdrant and started indexing @@ -178,8 +181,16 @@ export class CodeIndexOrchestrator { }, handleBlocksIndexed, handleFileParsed, + signal, ) + if (signal.aborted) { + await this.cacheManager.flush() + this.stopWatcher() + this.stateManager.setSystemState("Standby", t("embeddings:orchestrator.indexingStopped")) + return + } + if (!result) { throw new Error("Incremental scan failed, is scanner initialized?") } @@ -231,8 +242,16 @@ export class CodeIndexOrchestrator { }, handleBlocksIndexed, handleFileParsed, + signal, ) + if (signal.aborted) { + await this.cacheManager.flush() + this.stopWatcher() + this.stateManager.setSystemState("Standby", t("embeddings:orchestrator.indexingStopped")) + return + } + if (!result) { throw new Error("Scan failed, is scanner initialized?") } @@ -282,6 +301,15 @@ export class CodeIndexOrchestrator { this.stateManager.setSystemState("Indexed", t("embeddings:orchestrator.fileWatcherStarted")) } } catch (error: any) { + // Handle abort gracefully — not an error, just a user-initiated stop + if (error?.name === "AbortError" || signal.aborted) { + console.log("[CodeIndexOrchestrator] Indexing aborted by user.") + await this.cacheManager.flush() + this.stopWatcher() + this.stateManager.setSystemState("Standby", t("embeddings:orchestrator.indexingStopped")) + return + } + console.error("[CodeIndexOrchestrator] Error during indexing:", error) TelemetryService.instance.captureEvent(TelemetryEventName.CODE_INDEX_ERROR, { error: error instanceof Error ? error.message : String(error), @@ -325,9 +353,22 @@ export class CodeIndexOrchestrator { this.stopWatcher() } finally { this._isProcessing = false + this._abortController = null } } + /** + * Stops any in-progress indexing by aborting the scan and stopping the file watcher. + */ + public stopIndexing(): void { + if (this._abortController) { + this.stateManager.setSystemState("Stopping", t("embeddings:orchestrator.indexingStoppedPartial")) + this._abortController.abort() + this._abortController = null + } + this.stopWatcher() + } + /** * Stops the file watcher and cleans up resources. */ @@ -336,7 +377,7 @@ export class CodeIndexOrchestrator { this._fileWatcherSubscriptions.forEach((sub) => sub.dispose()) this._fileWatcherSubscriptions = [] - if (this.stateManager.state !== "Error") { + if (this.stateManager.state !== "Error" && this.stateManager.state !== "Stopping") { this.stateManager.setSystemState("Standby", t("embeddings:orchestrator.fileWatcherStopped")) } this._isProcessing = false diff --git a/src/services/code-index/processors/__tests__/scanner.spec.ts b/src/services/code-index/processors/__tests__/scanner.spec.ts index 4d4150b443..a6e68bc96b 100644 --- a/src/services/code-index/processors/__tests__/scanner.spec.ts +++ b/src/services/code-index/processors/__tests__/scanner.spec.ts @@ -394,5 +394,68 @@ describe("DirectoryScanner", () => { expect(points[1].payload.segmentHash).toBe("unique-segment-hash-2") expect(points[2].payload.segmentHash).toBe("unique-segment-hash-3") }) + + it("should stop processing files when signal is aborted", async () => { + const { listFiles } = await import("../../../glob/list-files") + vi.mocked(listFiles).mockResolvedValue([["test/file1.js", "test/file2.js", "test/file3.js"], false]) + + // Create an already-aborted signal + const controller = new AbortController() + controller.abort() + + const result = await scanner.scanDirectory("/test", undefined, undefined, undefined, controller.signal) + + // No files should have been processed since signal was already aborted + expect(mockCodeParser.parseFile).not.toHaveBeenCalled() + expect(result.stats.processed).toBe(0) + }) + + it("should stop processing batches when signal is aborted mid-scan", async () => { + const { listFiles } = await import("../../../glob/list-files") + vi.mocked(listFiles).mockResolvedValue([["test/file1.js", "test/file2.js"], false]) + + const controller = new AbortController() + + const mockBlocks: any[] = [ + { + file_path: "test/file1.js", + content: "function hello() {}", + start_line: 1, + end_line: 3, + identifier: "hello", + type: "function", + fileHash: "hash1", + segmentHash: "seg-hash-1", + }, + ] + + // Abort after first file is parsed + ;(mockCodeParser.parseFile as any).mockImplementation(async () => { + controller.abort() + return mockBlocks + }) + + // AbortError should propagate up (the orchestrator handles it in its catch block) + await expect( + scanner.scanDirectory("/test", undefined, undefined, undefined, controller.signal), + ).rejects.toThrow("Indexing aborted") + }) + + it("should not process deleted files when signal is aborted", async () => { + const { listFiles } = await import("../../../glob/list-files") + vi.mocked(listFiles).mockResolvedValue([[], false]) + + // Set up cached files that would normally be detected as deleted + ;(mockCacheManager.getAllHashes as any).mockReturnValue({ "old/file.js": "old-hash" }) + + // Create an already-aborted signal + const controller = new AbortController() + controller.abort() + + await scanner.scanDirectory("/test", undefined, undefined, undefined, controller.signal) + + // Deleted file cleanup should not have run + expect(mockVectorStore.deletePointsByFilePath).not.toHaveBeenCalled() + }) }) }) diff --git a/src/services/code-index/processors/scanner.ts b/src/services/code-index/processors/scanner.ts index 91689a56d7..5d9ff5e362 100644 --- a/src/services/code-index/processors/scanner.ts +++ b/src/services/code-index/processors/scanner.ts @@ -71,6 +71,7 @@ export class DirectoryScanner implements IDirectoryScanner { onError?: (error: Error) => void, onBlocksIndexed?: (indexedCount: number) => void, onFileParsed?: (fileBlockCount: number) => void, + signal?: AbortSignal, ): Promise<{ stats: { processed: number; skipped: number }; totalBlockCount: number }> { const directoryPath = directory // Capture workspace context at scan start @@ -127,6 +128,9 @@ export class DirectoryScanner implements IDirectoryScanner { // Process all files in parallel with concurrency control const parsePromises = supportedPaths.map((filePath) => parseLimiter(async () => { + // Check abort signal before processing each file + if (signal?.aborted) return + try { // Check file size const stats = await stat(filePath) @@ -173,10 +177,17 @@ export class DirectoryScanner implements IDirectoryScanner { addedBlocksFromFile = true // Check if batch threshold is met + // Check abort signal before dispatching batch + if (signal?.aborted) { + throw new DOMException("Indexing aborted", "AbortError") + } + if (currentBatchBlocks.length >= this.batchSegmentThreshold) { // Wait if we've reached the maximum pending batches while (pendingBatchCount >= MAX_PENDING_BATCHES) { - // Wait for at least one batch to complete + if (signal?.aborted) { + throw new DOMException("Indexing aborted", "AbortError") + } await Promise.race(activeBatchPromises) } @@ -235,6 +246,10 @@ export class DirectoryScanner implements IDirectoryScanner { await this.cacheManager.updateHash(filePath, currentFileHash) } } catch (error) { + // Re-throw AbortError — it's not a file processing error, just a user-initiated stop + if (error instanceof DOMException && error.name === "AbortError") { + throw error + } console.error(`Error processing file ${filePath} in workspace ${scanWorkspace}:`, error) TelemetryService.instance.captureEvent(TelemetryEventName.CODE_INDEX_ERROR, { error: sanitizeErrorMessage(error instanceof Error ? error.message : String(error)), @@ -258,6 +273,17 @@ export class DirectoryScanner implements IDirectoryScanner { // Wait for all parsing to complete await Promise.all(parsePromises) + // Check abort signal before processing remaining batch + if (signal?.aborted) { + return { + stats: { + processed: processedCount, + skipped: skippedCount, + }, + totalBlockCount, + } + } + // Process any remaining items in batch if (currentBatchBlocks.length > 0) { const release = await mutex.acquire() @@ -292,6 +318,17 @@ export class DirectoryScanner implements IDirectoryScanner { // Wait for all batch processing to complete await Promise.all(activeBatchPromises) + // Check abort signal before handling deleted files + if (signal?.aborted) { + return { + stats: { + processed: processedCount, + skipped: skippedCount, + }, + totalBlockCount, + } + } + // Handle deleted files const oldHashes = this.cacheManager.getAllHashes() for (const cachedFilePath of Object.keys(oldHashes)) { diff --git a/src/services/code-index/state-manager.ts b/src/services/code-index/state-manager.ts index 90257fdfb1..b678825147 100644 --- a/src/services/code-index/state-manager.ts +++ b/src/services/code-index/state-manager.ts @@ -1,6 +1,6 @@ import * as vscode from "vscode" -export type IndexingState = "Standby" | "Indexing" | "Indexed" | "Error" +export type IndexingState = "Standby" | "Indexing" | "Indexed" | "Error" | "Stopping" export class CodeIndexStateManager { private _systemStatus: IndexingState = "Standby" @@ -58,6 +58,8 @@ export class CodeIndexStateManager { public reportBlockIndexingProgress(processedItems: number, totalItems: number): void { const progressChanged = processedItems !== this._processedItems || totalItems !== this._totalItems + // Don't override Stopping state with progress updates + if (this._systemStatus === "Stopping") return // Update if progress changes OR if the system wasn't already in 'Indexing' state if (progressChanged || this._systemStatus !== "Indexing") { this._processedItems = processedItems @@ -81,6 +83,8 @@ export class CodeIndexStateManager { public reportFileQueueProgress(processedFiles: number, totalFiles: number, currentFileBasename?: string): void { const progressChanged = processedFiles !== this._processedItems || totalFiles !== this._totalItems + // Don't override Stopping state with progress updates + if (this._systemStatus === "Stopping") return if (progressChanged || this._systemStatus !== "Indexing") { this._processedItems = processedFiles this._totalItems = totalFiles diff --git a/src/services/mcp/McpHub.ts b/src/services/mcp/McpHub.ts index 3d02ce25f1..ea38ee02d6 100644 --- a/src/services/mcp/McpHub.ts +++ b/src/services/mcp/McpHub.ts @@ -161,14 +161,25 @@ export class McpHub { private isProgrammaticUpdate: boolean = false private flagResetTimer?: NodeJS.Timeout private sanitizedNameRegistry: Map = new Map() + private initializationPromise: Promise constructor(provider: ClineProvider) { this.providerRef = new WeakRef(provider) this.watchMcpSettingsFile() this.watchProjectMcpFile().catch(console.error) this.setupWorkspaceFoldersWatcher() - this.initializeGlobalMcpServers() - this.initializeProjectMcpServers() + this.initializationPromise = Promise.all([ + this.initializeGlobalMcpServers(), + this.initializeProjectMcpServers(), + ]).then(() => {}) + } + + /** + * Waits until all MCP servers have finished their initial connection attempts. + * Each server individually handles its own timeout, so this will not block indefinitely. + */ + async waitUntilReady(): Promise { + await this.initializationPromise } /** * Registers a client (e.g., ClineProvider) using this hub. diff --git a/src/services/mcp/McpServerManager.ts b/src/services/mcp/McpServerManager.ts index e15f9db0a7..3fd7146d9f 100644 --- a/src/services/mcp/McpServerManager.ts +++ b/src/services/mcp/McpServerManager.ts @@ -36,7 +36,10 @@ export class McpServerManager { try { // Double-check instance in case it was created while we were waiting if (!this.instance) { - this.instance = new McpHub(provider) + const hub = new McpHub(provider) + // Wait for all MCP servers to finish connecting (or timing out) + await hub.waitUntilReady() + this.instance = hub // Store a unique identifier in global state to track the primary instance await context.globalState.update(this.GLOBAL_STATE_KEY, Date.now().toString()) } diff --git a/src/services/roo-config/__tests__/index.spec.ts b/src/services/roo-config/__tests__/index.spec.ts index c060cdcb5a..1775d5502c 100644 --- a/src/services/roo-config/__tests__/index.spec.ts +++ b/src/services/roo-config/__tests__/index.spec.ts @@ -28,7 +28,9 @@ vi.mock("../../search/file-search", () => ({ import { getGlobalRooDirectory, + getGlobalAgentsDirectory, getProjectRooDirectoryForCwd, + getProjectAgentsDirectoryForCwd, directoryExists, fileExists, readFileIfExists, @@ -70,6 +72,27 @@ describe("RooConfigService", () => { }) }) + describe("getGlobalAgentsDirectory", () => { + it("should return correct path for global .agents directory", () => { + const result = getGlobalAgentsDirectory() + expect(result).toBe(path.join("/mock/home", ".agents")) + }) + + it("should handle different home directories", () => { + mockHomedir.mockReturnValue("/different/home") + const result = getGlobalAgentsDirectory() + expect(result).toBe(path.join("/different/home", ".agents")) + }) + }) + + describe("getProjectAgentsDirectoryForCwd", () => { + it("should return correct path for given cwd", () => { + const cwd = "/custom/project/path" + const result = getProjectAgentsDirectoryForCwd(cwd) + expect(result).toBe(path.join(cwd, ".agents")) + }) + }) + describe("directoryExists", () => { it("should return true for existing directory", async () => { mockStat.mockResolvedValue({ isDirectory: () => true } as any) diff --git a/src/services/roo-config/index.ts b/src/services/roo-config/index.ts index 166617834d..b97e01f5b5 100644 --- a/src/services/roo-config/index.ts +++ b/src/services/roo-config/index.ts @@ -28,6 +28,50 @@ export function getGlobalRooDirectory(): string { return path.join(homeDir, ".roo") } +/** + * Gets the global .agents directory path based on the current platform. + * This is a shared directory for agent skills across different AI coding tools. + * + * @returns The absolute path to the global .agents directory + * + * @example Platform-specific paths: + * ``` + * // macOS/Linux: ~/.agents/ + * // Example: /Users/john/.agents + * + * // Windows: %USERPROFILE%\.agents\ + * // Example: C:\Users\john\.agents + * ``` + * + * @example Usage: + * ```typescript + * const globalAgentsDir = getGlobalAgentsDirectory() + * // Returns: "/Users/john/.agents" (on macOS/Linux) + * // Returns: "C:\\Users\\john\\.agents" (on Windows) + * ``` + */ +export function getGlobalAgentsDirectory(): string { + const homeDir = os.homedir() + return path.join(homeDir, ".agents") +} + +/** + * Gets the project-local .agents directory path for a given cwd. + * This is a shared directory for agent skills across different AI coding tools. + * + * @param cwd - Current working directory (project path) + * @returns The absolute path to the project-local .agents directory + * + * @example + * ```typescript + * const projectAgentsDir = getProjectAgentsDirectoryForCwd('/Users/john/my-project') + * // Returns: "/Users/john/my-project/.agents" + * ``` + */ +export function getProjectAgentsDirectoryForCwd(cwd: string): string { + return path.join(cwd, ".agents") +} + /** * Gets the project-local .roo directory path for a given cwd * diff --git a/src/services/skills/SkillsManager.ts b/src/services/skills/SkillsManager.ts index 1c61b5b176..0959b977c9 100644 --- a/src/services/skills/SkillsManager.ts +++ b/src/services/skills/SkillsManager.ts @@ -4,11 +4,16 @@ import * as vscode from "vscode" import matter from "gray-matter" import type { ClineProvider } from "../../core/webview/ClineProvider" -import { getGlobalRooDirectory } from "../roo-config" +import { getGlobalRooDirectory, getGlobalAgentsDirectory, getProjectAgentsDirectoryForCwd } from "../roo-config" import { directoryExists, fileExists } from "../roo-config" import { SkillMetadata, SkillContent } from "../../shared/skills" import { modes, getAllModes } from "../../shared/modes" -import { getBuiltInSkills, getBuiltInSkillContent } from "./built-in-skills" +import { + validateSkillName as validateSkillNameShared, + SkillNameValidationError, + SKILL_NAME_MAX_LENGTH, +} from "@roo-code/types" +import { t } from "../../i18n" // Re-export for convenience export type { SkillMetadata, SkillContent } @@ -117,23 +122,11 @@ export class SkillsManager { return } - // Strict spec validation (https://agentskills.io/specification) - // Name constraints: - // - 1-64 chars - // - lowercase letters/numbers/hyphens only - // - must not start/end with hyphen - // - must not contain consecutive hyphens - if (effectiveSkillName.length < 1 || effectiveSkillName.length > 64) { - console.error( - `Skill name "${effectiveSkillName}" is invalid: name must be 1-64 characters (got ${effectiveSkillName.length})`, - ) - return - } - const nameFormat = /^[a-z0-9]+(?:-[a-z0-9]+)*$/ - if (!nameFormat.test(effectiveSkillName)) { - console.error( - `Skill name "${effectiveSkillName}" is invalid: must be lowercase letters/numbers/hyphens only (no leading/trailing hyphen, no consecutive hyphens)`, - ) + // Validate skill name per agentskills.io spec using shared validation + const nameValidation = validateSkillNameShared(effectiveSkillName) + if (!nameValidation.valid) { + const errorMessage = this.getSkillNameErrorMessage(effectiveSkillName, nameValidation.error!) + console.error(`Skill name "${effectiveSkillName}" is invalid: ${errorMessage}`) return } @@ -148,15 +141,34 @@ export class SkillsManager { return } - // Create unique key combining name, source, and mode for override resolution - const skillKey = this.getSkillKey(effectiveSkillName, source, mode) + // Parse modeSlugs from frontmatter (new format) or fall back to directory-based mode + // Priority: frontmatter.modeSlugs > frontmatter.mode > directory mode + let modeSlugs: string[] | undefined + if (Array.isArray(frontmatter.modeSlugs)) { + modeSlugs = frontmatter.modeSlugs.filter((s: unknown) => typeof s === "string" && s.length > 0) + if (modeSlugs.length === 0) { + modeSlugs = undefined // Empty array means "any mode" + } + } else if (typeof frontmatter.mode === "string" && frontmatter.mode.length > 0) { + // Legacy single mode in frontmatter + modeSlugs = [frontmatter.mode] + } else if (mode) { + // Fall back to directory-based mode (skills-{mode}/) + modeSlugs = [mode] + } + + // Create unique key combining name, source, and modeSlugs for override resolution + // For backward compatibility, use first mode slug or undefined for the key + const primaryMode = modeSlugs?.[0] + const skillKey = this.getSkillKey(effectiveSkillName, source, primaryMode) this.skills.set(skillKey, { name: effectiveSkillName, description, path: skillMdPath, source, - mode, // undefined for generic skills, string for mode-specific + mode: primaryMode, // Deprecated: kept for backward compatibility + modeSlugs, // New: array of mode slugs, undefined = any mode }) } catch (error) { console.error(`Failed to load skill at ${skillDir}:`, error) @@ -165,22 +177,19 @@ export class SkillsManager { /** * Get skills available for the current mode. - * Resolves overrides: project > global > built-in, mode-specific > generic. + * Resolves overrides: project > global, mode-specific > generic. * * @param currentMode - The current mode slug (e.g., 'code', 'architect') */ getSkillsForMode(currentMode: string): SkillMetadata[] { const resolvedSkills = new Map() - // First, add built-in skills (lowest priority) - for (const skill of getBuiltInSkills()) { - resolvedSkills.set(skill.name, skill) - } - - // Then, add discovered skills (will override built-in skills with same name) for (const skill of this.skills.values()) { - // Skip mode-specific skills that don't match current mode - if (skill.mode && skill.mode !== currentMode) continue + // Check if skill is available in current mode: + // - modeSlugs undefined or empty = available in all modes ("Any mode") + // - modeSlugs array with values = available only if currentMode is in the array + const isAvailableInMode = this.isSkillAvailableInMode(skill, currentMode) + if (!isAvailableInMode) continue const existingSkill = resolvedSkills.get(skill.name) @@ -199,16 +208,29 @@ export class SkillsManager { return Array.from(resolvedSkills.values()) } + /** + * Check if a skill is available in the given mode. + * - modeSlugs undefined or empty = available in all modes ("Any mode") + * - modeSlugs with values = available only if mode is in the array + */ + private isSkillAvailableInMode(skill: SkillMetadata, currentMode: string): boolean { + // No mode restrictions = available in all modes + if (!skill.modeSlugs || skill.modeSlugs.length === 0) { + return true + } + // Check if current mode is in the allowed modes + return skill.modeSlugs.includes(currentMode) + } + /** * Determine if newSkill should override existingSkill based on priority rules. - * Priority: project > global > built-in, mode-specific > generic + * Priority: project > global, mode-specific > generic */ private shouldOverrideSkill(existing: SkillMetadata, newSkill: SkillMetadata): boolean { - // Define source priority: project > global > built-in + // Define source priority: project > global const sourcePriority: Record = { - project: 3, - global: 2, - "built-in": 1, + project: 2, + global: 1, } const existingPriority = sourcePriority[existing.source] ?? 0 @@ -219,8 +241,11 @@ export class SkillsManager { if (newPriority < existingPriority) return false // Same source: mode-specific overrides generic - if (newSkill.mode && !existing.mode) return true - if (!newSkill.mode && existing.mode) return false + // A skill with modeSlugs (restricted) is more specific than one without (any mode) + const existingHasModes = existing.modeSlugs && existing.modeSlugs.length > 0 + const newHasModes = newSkill.modeSlugs && newSkill.modeSlugs.length > 0 + if (newHasModes && !existingHasModes) return true + if (!newHasModes && existingHasModes) return false // Same source and same mode-specificity: keep existing (first wins) return false @@ -241,21 +266,13 @@ export class SkillsManager { const modeSkills = this.getSkillsForMode(currentMode) skill = modeSkills.find((s) => s.name === name) } else { - // Fall back to any skill with this name (check discovered skills first, then built-in) + // Fall back to any skill with this name skill = Array.from(this.skills.values()).find((s) => s.name === name) - if (!skill) { - skill = getBuiltInSkills().find((s) => s.name === name) - } } if (!skill) return null - // For built-in skills, use the built-in content - if (skill.source === "built-in") { - return getBuiltInSkillContent(name) - } - - // For file-based skills, read from disk + // Read skill content from disk const fileContent = await fs.readFile(skill.path, "utf-8") const { content: body } = matter(fileContent) @@ -265,6 +282,285 @@ export class SkillsManager { } } + /** + * Get all skills metadata (for UI display) + * Returns skills from all sources without content + */ + getSkillsMetadata(): SkillMetadata[] { + return this.getAllSkills() + } + + /** + * Get a skill by name, source, and optionally mode + */ + getSkill(name: string, source: "global" | "project", mode?: string): SkillMetadata | undefined { + const skillKey = this.getSkillKey(name, source, mode) + return this.skills.get(skillKey) + } + + /** + * Find a skill by name and source (regardless of mode). + * Useful for opening/editing skills where the exact mode key may vary. + */ + findSkillByNameAndSource(name: string, source: "global" | "project"): SkillMetadata | undefined { + for (const skill of this.skills.values()) { + if (skill.name === name && skill.source === source) { + return skill + } + } + return undefined + } + + /** + * Validate skill name per agentskills.io spec using shared validation. + * Converts error codes to user-friendly error messages. + */ + private validateSkillName(name: string): { valid: boolean; error?: string } { + const result = validateSkillNameShared(name) + if (!result.valid) { + return { valid: false, error: this.getSkillNameErrorMessage(name, result.error!) } + } + return { valid: true } + } + + /** + * Convert skill name validation error code to a user-friendly error message. + */ + private getSkillNameErrorMessage(name: string, error: SkillNameValidationError): string { + switch (error) { + case SkillNameValidationError.Empty: + return t("skills:errors.name_length", { maxLength: SKILL_NAME_MAX_LENGTH, length: name.length }) + case SkillNameValidationError.TooLong: + return t("skills:errors.name_length", { maxLength: SKILL_NAME_MAX_LENGTH, length: name.length }) + case SkillNameValidationError.InvalidFormat: + return t("skills:errors.name_format") + } + } + + /** + * Create a new skill + * @param name - Skill name (must be valid per agentskills.io spec) + * @param source - "global" or "project" + * @param description - Skill description + * @param modeSlugs - Optional mode restrictions (undefined/empty = any mode) + * @returns Path to created SKILL.md file + */ + async createSkill( + name: string, + source: "global" | "project", + description: string, + modeSlugs?: string[], + ): Promise { + // Validate skill name + const validation = this.validateSkillName(name) + if (!validation.valid) { + throw new Error(validation.error) + } + + // Validate description + const trimmedDescription = description.trim() + if (trimmedDescription.length < 1 || trimmedDescription.length > 1024) { + throw new Error(t("skills:errors.description_length", { length: trimmedDescription.length })) + } + + // Determine base directory + let baseDir: string + if (source === "global") { + baseDir = getGlobalRooDirectory() + } else { + const provider = this.providerRef.deref() + if (!provider?.cwd) { + throw new Error(t("skills:errors.no_workspace")) + } + baseDir = path.join(provider.cwd, ".roo") + } + + // Always use the generic skills directory (mode info stored in frontmatter now) + const skillsDir = path.join(baseDir, "skills") + const skillDir = path.join(skillsDir, name) + const skillMdPath = path.join(skillDir, "SKILL.md") + + // Check if skill already exists + if (await fileExists(skillMdPath)) { + throw new Error(t("skills:errors.already_exists", { name, path: skillMdPath })) + } + + // Create the skill directory + await fs.mkdir(skillDir, { recursive: true }) + + // Generate SKILL.md content with frontmatter + const titleName = name + .split("-") + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(" ") + + // Build frontmatter with optional modeSlugs + const frontmatterLines = [`name: ${name}`, `description: ${trimmedDescription}`] + if (modeSlugs && modeSlugs.length > 0) { + frontmatterLines.push(`modeSlugs:`) + for (const slug of modeSlugs) { + frontmatterLines.push(` - ${slug}`) + } + } + + const skillContent = `--- +${frontmatterLines.join("\n")} +--- + +# ${titleName} + +## Instructions + +Add your skill instructions here. +` + + // Write the SKILL.md file + await fs.writeFile(skillMdPath, skillContent, "utf-8") + + // Refresh skills list + await this.discoverSkills() + + return skillMdPath + } + + /** + * Delete a skill + * @param name - Skill name to delete + * @param source - Where the skill is located + * @param mode - Optional mode (to locate in skills-{mode}/ directory) + */ + async deleteSkill(name: string, source: "global" | "project", mode?: string): Promise { + // Find the skill + const skill = this.getSkill(name, source, mode) + if (!skill) { + const modeInfo = mode ? ` (mode: ${mode})` : "" + throw new Error(t("skills:errors.not_found", { name, source, modeInfo })) + } + + // Get the skill directory (parent of SKILL.md) + const skillDir = path.dirname(skill.path) + + // Delete the entire skill directory + await fs.rm(skillDir, { recursive: true, force: true }) + + // Refresh skills list + await this.discoverSkills() + } + + /** + * Move a skill to a different mode + * @param name - Skill name to move + * @param source - Where the skill is located ("global" or "project") + * @param currentMode - Current mode (undefined for generic skills) + * @param newMode - Target mode (undefined for generic skills) + */ + async moveSkill( + name: string, + source: "global" | "project", + currentMode: string | undefined, + newMode: string | undefined, + ): Promise { + // Don't move if source and destination are the same + if (currentMode === newMode) { + return + } + + // Find the skill at its current location + const skill = this.getSkill(name, source, currentMode) + if (!skill) { + const modeInfo = currentMode ? ` (mode: ${currentMode})` : "" + throw new Error(t("skills:errors.not_found", { name, source, modeInfo })) + } + + // Determine base directory + let baseDir: string + if (source === "global") { + baseDir = getGlobalRooDirectory() + } else { + const provider = this.providerRef.deref() + if (!provider?.cwd) { + throw new Error(t("skills:errors.no_workspace")) + } + baseDir = path.join(provider.cwd, ".roo") + } + + // Determine source and destination directories + const sourceDirName = currentMode ? `skills-${currentMode}` : "skills" + const destDirName = newMode ? `skills-${newMode}` : "skills" + const sourceDir = path.join(baseDir, sourceDirName, name) + const destSkillsDir = path.join(baseDir, destDirName) + const destDir = path.join(destSkillsDir, name) + const destSkillMdPath = path.join(destDir, "SKILL.md") + + // Check if skill already exists at destination + if (await fileExists(destSkillMdPath)) { + throw new Error(t("skills:errors.already_exists", { name, path: destSkillMdPath })) + } + + // Ensure destination skills directory exists + await fs.mkdir(destSkillsDir, { recursive: true }) + + // Move the skill directory + await fs.rename(sourceDir, destDir) + + // Clean up empty source skills directory + const sourceSkillsDir = path.join(baseDir, sourceDirName) + try { + const entries = await fs.readdir(sourceSkillsDir) + if (entries.length === 0) { + await fs.rmdir(sourceSkillsDir) + } + } catch { + // Ignore errors - directory might not exist or have permission issues + } + + // Refresh skills list + await this.discoverSkills() + } + + /** + * Update the mode associations for a skill by modifying its SKILL.md frontmatter. + * @param name - Skill name + * @param source - Where the skill is located ("global" or "project") + * @param newModeSlugs - New mode slugs (undefined/empty = any mode) + */ + async updateSkillModes(name: string, source: "global" | "project", newModeSlugs?: string[]): Promise { + // Find any skill with this name and source (regardless of current mode) + let skill: SkillMetadata | undefined + for (const s of this.skills.values()) { + if (s.name === name && s.source === source) { + skill = s + break + } + } + + if (!skill) { + throw new Error(t("skills:errors.not_found", { name, source, modeInfo: "" })) + } + + // Read the current SKILL.md file + const fileContent = await fs.readFile(skill.path, "utf-8") + const { data: frontmatter, content: body } = matter(fileContent) + + // Update the frontmatter with new modeSlugs + if (newModeSlugs && newModeSlugs.length > 0) { + frontmatter.modeSlugs = newModeSlugs + // Remove legacy mode field if present + delete frontmatter.mode + } else { + // Empty/undefined = any mode, remove mode restrictions + delete frontmatter.modeSlugs + delete frontmatter.mode + } + + // Serialize back to SKILL.md format + const newContent = matter.stringify(body, frontmatter) + await fs.writeFile(skill.path, newContent, "utf-8") + + // Refresh skills list + await this.discoverSkills() + } + /** * Get all skills directories to scan, including mode-specific directories. */ @@ -277,19 +573,44 @@ export class SkillsManager { > { const dirs: Array<{ dir: string; source: "global" | "project"; mode?: string }> = [] const globalRooDir = getGlobalRooDirectory() + const globalAgentsDir = getGlobalAgentsDirectory() const provider = this.providerRef.deref() const projectRooDir = provider?.cwd ? path.join(provider.cwd, ".roo") : null + const projectAgentsDir = provider?.cwd ? getProjectAgentsDirectoryForCwd(provider.cwd) : null // Get list of modes to check for mode-specific skills const modesList = await this.getAvailableModes() - // Global directories + // Priority rules for skills with the same name: + // 1. Source level: project > global (handled by shouldOverrideSkill in getSkillsForMode) + // 2. Within the same source level: later-processed directories override earlier ones + // (via Map.set replacement during discovery - same source+mode+name key gets replaced) + // + // Processing order (later directories override earlier ones at the same source level): + // - Global: .agents/skills first, then .roo/skills (so .roo wins) + // - Project: .agents/skills first, then .roo/skills (so .roo wins) + + // Global .agents directories (lowest priority - shared across agents) + dirs.push({ dir: path.join(globalAgentsDir, "skills"), source: "global" }) + for (const mode of modesList) { + dirs.push({ dir: path.join(globalAgentsDir, `skills-${mode}`), source: "global", mode }) + } + + // Project .agents directories + if (projectAgentsDir) { + dirs.push({ dir: path.join(projectAgentsDir, "skills"), source: "project" }) + for (const mode of modesList) { + dirs.push({ dir: path.join(projectAgentsDir, `skills-${mode}`), source: "project", mode }) + } + } + + // Global .roo directories (Roo-specific, higher priority than .agents) dirs.push({ dir: path.join(globalRooDir, "skills"), source: "global" }) for (const mode of modesList) { dirs.push({ dir: path.join(globalRooDir, `skills-${mode}`), source: "global", mode }) } - // Project directories + // Project .roo directories (highest priority) if (projectRooDir) { dirs.push({ dir: path.join(projectRooDir, "skills"), source: "project" }) for (const mode of modesList) { @@ -334,20 +655,32 @@ export class SkillsManager { if (!provider?.cwd) return // Watch for changes in skills directories - const globalSkillsDir = path.join(getGlobalRooDirectory(), "skills") - const projectSkillsDir = path.join(provider.cwd, ".roo", "skills") + const globalRooDir = getGlobalRooDirectory() + const globalAgentsDir = getGlobalAgentsDirectory() + const projectRooDir = path.join(provider.cwd, ".roo") + const projectAgentsDir = getProjectAgentsDirectoryForCwd(provider.cwd) - // Watch global skills directory - this.watchDirectory(globalSkillsDir) + // Watch global .roo skills directory + this.watchDirectory(path.join(globalRooDir, "skills")) - // Watch project skills directory - this.watchDirectory(projectSkillsDir) + // Watch global .agents skills directory + this.watchDirectory(path.join(globalAgentsDir, "skills")) + + // Watch project .roo skills directory + this.watchDirectory(path.join(projectRooDir, "skills")) + + // Watch project .agents skills directory + this.watchDirectory(path.join(projectAgentsDir, "skills")) // Watch mode-specific directories for all available modes const modesList = await this.getAvailableModes() for (const mode of modesList) { - this.watchDirectory(path.join(getGlobalRooDirectory(), `skills-${mode}`)) - this.watchDirectory(path.join(provider.cwd, ".roo", `skills-${mode}`)) + // .roo mode-specific + this.watchDirectory(path.join(globalRooDir, `skills-${mode}`)) + this.watchDirectory(path.join(projectRooDir, `skills-${mode}`)) + // .agents mode-specific + this.watchDirectory(path.join(globalAgentsDir, `skills-${mode}`)) + this.watchDirectory(path.join(projectAgentsDir, `skills-${mode}`)) } } diff --git a/src/services/skills/__tests__/SkillsManager.spec.ts b/src/services/skills/__tests__/SkillsManager.spec.ts index aaf2792626..d36582d893 100644 --- a/src/services/skills/__tests__/SkillsManager.spec.ts +++ b/src/services/skills/__tests__/SkillsManager.spec.ts @@ -1,16 +1,33 @@ import * as path from "path" // Use vi.hoisted to ensure mocks are available during hoisting -const { mockStat, mockReadFile, mockReaddir, mockHomedir, mockDirectoryExists, mockFileExists, mockRealpath } = - vi.hoisted(() => ({ - mockStat: vi.fn(), - mockReadFile: vi.fn(), - mockReaddir: vi.fn(), - mockHomedir: vi.fn(), - mockDirectoryExists: vi.fn(), - mockFileExists: vi.fn(), - mockRealpath: vi.fn(), - })) +const { + mockStat, + mockReadFile, + mockReaddir, + mockHomedir, + mockDirectoryExists, + mockFileExists, + mockRealpath, + mockMkdir, + mockWriteFile, + mockRm, + mockRename, + mockRmdir, +} = vi.hoisted(() => ({ + mockStat: vi.fn(), + mockReadFile: vi.fn(), + mockReaddir: vi.fn(), + mockHomedir: vi.fn(), + mockDirectoryExists: vi.fn(), + mockFileExists: vi.fn(), + mockRealpath: vi.fn(), + mockMkdir: vi.fn(), + mockWriteFile: vi.fn(), + mockRm: vi.fn(), + mockRename: vi.fn(), + mockRmdir: vi.fn(), +})) // Platform-agnostic test paths // Use forward slashes for consistency, then normalize with path.normalize @@ -28,11 +45,21 @@ vi.mock("fs/promises", () => ({ readFile: mockReadFile, readdir: mockReaddir, realpath: mockRealpath, + mkdir: mockMkdir, + writeFile: mockWriteFile, + rm: mockRm, + rename: mockRename, + rmdir: mockRmdir, }, stat: mockStat, readFile: mockReadFile, readdir: mockReaddir, realpath: mockRealpath, + mkdir: mockMkdir, + writeFile: mockWriteFile, + rm: mockRm, + rename: mockRename, + rmdir: mockRmdir, })) // Mock os module @@ -55,20 +82,31 @@ vi.mock("vscode", () => ({ // Global roo directory - computed once const GLOBAL_ROO_DIR = p(HOME_DIR, ".roo") +const GLOBAL_AGENTS_DIR = p(HOME_DIR, ".agents") // Mock roo-config vi.mock("../../roo-config", () => ({ getGlobalRooDirectory: () => GLOBAL_ROO_DIR, + getGlobalAgentsDirectory: () => GLOBAL_AGENTS_DIR, + getProjectAgentsDirectoryForCwd: (cwd: string) => p(cwd, ".agents"), directoryExists: mockDirectoryExists, fileExists: mockFileExists, })) -// Mock built-in skills to isolate tests from actual built-in skills -vi.mock("../built-in-skills", () => ({ - getBuiltInSkills: () => [], - getBuiltInSkillContent: () => null, - isBuiltInSkill: () => false, - getBuiltInSkillNames: () => [], +// Mock i18n +vi.mock("../../../i18n", () => ({ + t: (key: string, params?: Record) => { + const translations: Record = { + "skills:errors.name_length": `Skill name must be 1-${params?.maxLength} characters (got ${params?.length})`, + "skills:errors.name_format": + "Skill name must be lowercase letters/numbers/hyphens only (no leading/trailing hyphen, no consecutive hyphens)", + "skills:errors.description_length": `Skill description must be 1-1024 characters (got ${params?.length})`, + "skills:errors.no_workspace": "Cannot create project skill: no workspace folder is open", + "skills:errors.already_exists": `Skill "${params?.name}" already exists at ${params?.path}`, + "skills:errors.not_found": `Skill "${params?.name}" not found in ${params?.source}${params?.modeInfo}`, + } + return translations[key] || key + }, })) import { SkillsManager } from "../SkillsManager" @@ -84,6 +122,11 @@ describe("SkillsManager", () => { const globalSkillsArchitectDir = p(GLOBAL_ROO_DIR, "skills-architect") const projectRooDir = p(PROJECT_DIR, ".roo") const projectSkillsDir = p(projectRooDir, "skills") + // .agents directory paths + const globalAgentsSkillsDir = p(GLOBAL_AGENTS_DIR, "skills") + const globalAgentsSkillsCodeDir = p(GLOBAL_AGENTS_DIR, "skills-code") + const projectAgentsDir = p(PROJECT_DIR, ".agents") + const projectAgentsSkillsDir = p(projectAgentsDir, "skills") beforeEach(() => { vi.clearAllMocks() @@ -572,6 +615,216 @@ Instructions here...` expect(skills[0].name).toBe("my-alias") expect(skills[0].source).toBe("global") }) + + it("should discover skills from global .agents directory", async () => { + const agentSkillDir = p(globalAgentsSkillsDir, "agent-skill") + const agentSkillMd = p(agentSkillDir, "SKILL.md") + + mockDirectoryExists.mockImplementation(async (dir: string) => { + return dir === globalAgentsSkillsDir + }) + + mockRealpath.mockImplementation(async (pathArg: string) => pathArg) + + mockReaddir.mockImplementation(async (dir: string) => { + if (dir === globalAgentsSkillsDir) { + return ["agent-skill"] + } + return [] + }) + + mockStat.mockImplementation(async (pathArg: string) => { + if (pathArg === agentSkillDir) { + return { isDirectory: () => true } + } + throw new Error("Not found") + }) + + mockFileExists.mockImplementation(async (file: string) => { + return file === agentSkillMd + }) + + mockReadFile.mockImplementation(async (file: string) => { + if (file === agentSkillMd) { + return `--- +name: agent-skill +description: A skill from .agents directory shared across AI coding tools +--- + +# Agent Skill + +Instructions here...` + } + throw new Error("File not found") + }) + + await skillsManager.discoverSkills() + + const skills = skillsManager.getAllSkills() + expect(skills).toHaveLength(1) + expect(skills[0].name).toBe("agent-skill") + expect(skills[0].description).toBe("A skill from .agents directory shared across AI coding tools") + expect(skills[0].source).toBe("global") + }) + + it("should discover skills from project .agents directory", async () => { + const projectAgentSkillDir = p(projectAgentsSkillsDir, "project-agent-skill") + const projectAgentSkillMd = p(projectAgentSkillDir, "SKILL.md") + + mockDirectoryExists.mockImplementation(async (dir: string) => { + return dir === projectAgentsSkillsDir + }) + + mockRealpath.mockImplementation(async (pathArg: string) => pathArg) + + mockReaddir.mockImplementation(async (dir: string) => { + if (dir === projectAgentsSkillsDir) { + return ["project-agent-skill"] + } + return [] + }) + + mockStat.mockImplementation(async (pathArg: string) => { + if (pathArg === projectAgentSkillDir) { + return { isDirectory: () => true } + } + throw new Error("Not found") + }) + + mockFileExists.mockImplementation(async (file: string) => { + return file === projectAgentSkillMd + }) + + mockReadFile.mockImplementation(async (file: string) => { + if (file === projectAgentSkillMd) { + return `--- +name: project-agent-skill +description: A project-level skill from .agents directory +--- + +# Project Agent Skill + +Instructions here...` + } + throw new Error("File not found") + }) + + await skillsManager.discoverSkills() + + const skills = skillsManager.getAllSkills() + expect(skills).toHaveLength(1) + expect(skills[0].name).toBe("project-agent-skill") + expect(skills[0].source).toBe("project") + }) + + it("should prioritize .roo skills over .agents skills with same name", async () => { + const agentSkillDir = p(globalAgentsSkillsDir, "common-skill") + const agentSkillMd = p(agentSkillDir, "SKILL.md") + const rooSkillDir = p(globalSkillsDir, "common-skill") + const rooSkillMd = p(rooSkillDir, "SKILL.md") + + mockDirectoryExists.mockImplementation(async (dir: string) => { + return dir === globalAgentsSkillsDir || dir === globalSkillsDir + }) + + mockRealpath.mockImplementation(async (pathArg: string) => pathArg) + + mockReaddir.mockImplementation(async (dir: string) => { + if (dir === globalAgentsSkillsDir || dir === globalSkillsDir) { + return ["common-skill"] + } + return [] + }) + + mockStat.mockImplementation(async (pathArg: string) => { + if (pathArg === agentSkillDir || pathArg === rooSkillDir) { + return { isDirectory: () => true } + } + throw new Error("Not found") + }) + + mockFileExists.mockImplementation(async (file: string) => { + return file === agentSkillMd || file === rooSkillMd + }) + + mockReadFile.mockImplementation(async (file: string) => { + if (file === agentSkillMd) { + return `--- +name: common-skill +description: Agent version (should be overridden) +--- + +# Agent Common Skill` + } + if (file === rooSkillMd) { + return `--- +name: common-skill +description: Roo version (should take priority) +--- + +# Roo Common Skill` + } + throw new Error("File not found") + }) + + await skillsManager.discoverSkills() + + const skills = skillsManager.getSkillsForMode("code") + const commonSkill = skills.find((s) => s.name === "common-skill") + expect(commonSkill).toBeDefined() + // .roo should override .agents + expect(commonSkill?.description).toBe("Roo version (should take priority)") + }) + + it("should discover mode-specific skills from .agents directory", async () => { + const agentCodeSkillDir = p(globalAgentsSkillsCodeDir, "agent-code-skill") + const agentCodeSkillMd = p(agentCodeSkillDir, "SKILL.md") + + mockDirectoryExists.mockImplementation(async (dir: string) => { + return dir === globalAgentsSkillsCodeDir + }) + + mockRealpath.mockImplementation(async (pathArg: string) => pathArg) + + mockReaddir.mockImplementation(async (dir: string) => { + if (dir === globalAgentsSkillsCodeDir) { + return ["agent-code-skill"] + } + return [] + }) + + mockStat.mockImplementation(async (pathArg: string) => { + if (pathArg === agentCodeSkillDir) { + return { isDirectory: () => true } + } + throw new Error("Not found") + }) + + mockFileExists.mockImplementation(async (file: string) => { + return file === agentCodeSkillMd + }) + + mockReadFile.mockImplementation(async (file: string) => { + if (file === agentCodeSkillMd) { + return `--- +name: agent-code-skill +description: A code mode skill from .agents directory +--- + +# Agent Code Skill + +Instructions here...` + } + throw new Error("File not found") + }) + + await skillsManager.discoverSkills() + + const skills = skillsManager.getAllSkills() + expect(skills).toHaveLength(1) + expect(skills[0].name).toBe("agent-code-skill") + expect(skills[0].mode).toBe("code") + }) }) describe("getSkillsForMode", () => { @@ -835,4 +1088,672 @@ description: A test skill expect(skills).toHaveLength(0) }) }) + + describe("getSkillsMetadata", () => { + it("should return all skills metadata", async () => { + const testSkillDir = p(globalSkillsDir, "test-skill") + const testSkillMd = p(testSkillDir, "SKILL.md") + + mockDirectoryExists.mockImplementation(async (dir: string) => { + return dir === globalSkillsDir + }) + + mockRealpath.mockImplementation(async (pathArg: string) => pathArg) + + mockReaddir.mockImplementation(async (dir: string) => { + if (dir === globalSkillsDir) { + return ["test-skill"] + } + return [] + }) + + mockStat.mockImplementation(async (pathArg: string) => { + if (pathArg === testSkillDir) { + return { isDirectory: () => true } + } + throw new Error("Not found") + }) + + mockFileExists.mockImplementation(async (file: string) => { + return file === testSkillMd + }) + + mockReadFile.mockResolvedValue(`--- +name: test-skill +description: A test skill +--- +Instructions`) + + await skillsManager.discoverSkills() + + const metadata = skillsManager.getSkillsMetadata() + + expect(metadata).toHaveLength(1) + expect(metadata[0].name).toBe("test-skill") + expect(metadata[0].description).toBe("A test skill") + }) + }) + + describe("getSkill", () => { + it("should return a skill by name, source, and mode", async () => { + const testSkillDir = p(globalSkillsDir, "test-skill") + const testSkillMd = p(testSkillDir, "SKILL.md") + + mockDirectoryExists.mockImplementation(async (dir: string) => { + return dir === globalSkillsDir + }) + + mockRealpath.mockImplementation(async (pathArg: string) => pathArg) + + mockReaddir.mockImplementation(async (dir: string) => { + if (dir === globalSkillsDir) { + return ["test-skill"] + } + return [] + }) + + mockStat.mockImplementation(async (pathArg: string) => { + if (pathArg === testSkillDir) { + return { isDirectory: () => true } + } + throw new Error("Not found") + }) + + mockFileExists.mockImplementation(async (file: string) => { + return file === testSkillMd + }) + + mockReadFile.mockResolvedValue(`--- +name: test-skill +description: A test skill +--- +Instructions`) + + await skillsManager.discoverSkills() + + const skill = skillsManager.getSkill("test-skill", "global") + + expect(skill).toBeDefined() + expect(skill?.name).toBe("test-skill") + expect(skill?.source).toBe("global") + }) + + it("should return undefined for non-existent skill", async () => { + mockDirectoryExists.mockResolvedValue(false) + mockRealpath.mockImplementation(async (p: string) => p) + mockReaddir.mockResolvedValue([]) + + await skillsManager.discoverSkills() + + const skill = skillsManager.getSkill("non-existent", "global") + + expect(skill).toBeUndefined() + }) + }) + + describe("createSkill", () => { + it("should create a new global skill", async () => { + // Setup: no existing skills + mockDirectoryExists.mockResolvedValue(false) + mockRealpath.mockImplementation(async (p: string) => p) + mockReaddir.mockResolvedValue([]) + mockFileExists.mockResolvedValue(false) + mockMkdir.mockResolvedValue(undefined) + mockWriteFile.mockResolvedValue(undefined) + + const createdPath = await skillsManager.createSkill("new-skill", "global", "A new skill description") + + expect(createdPath).toBe(p(GLOBAL_ROO_DIR, "skills", "new-skill", "SKILL.md")) + expect(mockMkdir).toHaveBeenCalledWith(p(GLOBAL_ROO_DIR, "skills", "new-skill"), { recursive: true }) + expect(mockWriteFile).toHaveBeenCalled() + + // Verify the content written + const writeCall = mockWriteFile.mock.calls[0] + expect(writeCall[0]).toBe(p(GLOBAL_ROO_DIR, "skills", "new-skill", "SKILL.md")) + expect(writeCall[1]).toContain("name: new-skill") + expect(writeCall[1]).toContain("description: A new skill description") + }) + + it("should create a mode-specific skill with modeSlugs array", async () => { + mockDirectoryExists.mockResolvedValue(false) + mockRealpath.mockImplementation(async (p: string) => p) + mockReaddir.mockResolvedValue([]) + mockFileExists.mockResolvedValue(false) + mockMkdir.mockResolvedValue(undefined) + mockWriteFile.mockResolvedValue(undefined) + + const createdPath = await skillsManager.createSkill("code-skill", "global", "A code skill", ["code"]) + + // Skills are always created in the generic skills directory now; mode info is in frontmatter + expect(createdPath).toBe(p(GLOBAL_ROO_DIR, "skills", "code-skill", "SKILL.md")) + + // Verify frontmatter contains modeSlugs + const writeCall = mockWriteFile.mock.calls[0] + expect(writeCall[1]).toContain("modeSlugs:") + expect(writeCall[1]).toContain("- code") + }) + + it("should create a project skill", async () => { + mockDirectoryExists.mockResolvedValue(false) + mockRealpath.mockImplementation(async (p: string) => p) + mockReaddir.mockResolvedValue([]) + mockFileExists.mockResolvedValue(false) + mockMkdir.mockResolvedValue(undefined) + mockWriteFile.mockResolvedValue(undefined) + + const createdPath = await skillsManager.createSkill("project-skill", "project", "A project skill") + + expect(createdPath).toBe(p(PROJECT_DIR, ".roo", "skills", "project-skill", "SKILL.md")) + }) + + it("should throw error for invalid skill name", async () => { + await expect(skillsManager.createSkill("Invalid-Name", "global", "Description")).rejects.toThrow( + "Skill name must be lowercase letters/numbers/hyphens only", + ) + }) + + it("should throw error for skill name that is too long", async () => { + const longName = "a".repeat(65) + await expect(skillsManager.createSkill(longName, "global", "Description")).rejects.toThrow( + "Skill name must be 1-64 characters", + ) + }) + + it("should throw error for skill name starting with hyphen", async () => { + await expect(skillsManager.createSkill("-invalid", "global", "Description")).rejects.toThrow( + "Skill name must be lowercase letters/numbers/hyphens only", + ) + }) + + it("should throw error for skill name ending with hyphen", async () => { + await expect(skillsManager.createSkill("invalid-", "global", "Description")).rejects.toThrow( + "Skill name must be lowercase letters/numbers/hyphens only", + ) + }) + + it("should throw error for skill name with consecutive hyphens", async () => { + await expect(skillsManager.createSkill("invalid--name", "global", "Description")).rejects.toThrow( + "Skill name must be lowercase letters/numbers/hyphens only", + ) + }) + + it("should throw error for empty description", async () => { + await expect(skillsManager.createSkill("valid-name", "global", " ")).rejects.toThrow( + "Skill description must be 1-1024 characters", + ) + }) + + it("should throw error for description that is too long", async () => { + const longDesc = "d".repeat(1025) + await expect(skillsManager.createSkill("valid-name", "global", longDesc)).rejects.toThrow( + "Skill description must be 1-1024 characters", + ) + }) + + it("should throw error if skill already exists", async () => { + mockFileExists.mockResolvedValue(true) + + await expect(skillsManager.createSkill("existing-skill", "global", "Description")).rejects.toThrow( + "already exists", + ) + }) + }) + + describe("deleteSkill", () => { + it("should delete an existing skill", async () => { + const testSkillDir = p(globalSkillsDir, "test-skill") + const testSkillMd = p(testSkillDir, "SKILL.md") + + // Setup: skill exists + mockDirectoryExists.mockImplementation(async (dir: string) => { + return dir === globalSkillsDir + }) + + mockRealpath.mockImplementation(async (pathArg: string) => pathArg) + + mockReaddir.mockImplementation(async (dir: string) => { + if (dir === globalSkillsDir) { + return ["test-skill"] + } + return [] + }) + + mockStat.mockImplementation(async (pathArg: string) => { + if (pathArg === testSkillDir) { + return { isDirectory: () => true } + } + throw new Error("Not found") + }) + + mockFileExists.mockImplementation(async (file: string) => { + return file === testSkillMd + }) + + mockReadFile.mockResolvedValue(`--- +name: test-skill +description: A test skill +--- +Instructions`) + + mockRm.mockResolvedValue(undefined) + + await skillsManager.discoverSkills() + + // Verify skill exists + expect(skillsManager.getSkill("test-skill", "global")).toBeDefined() + + // Delete the skill + await skillsManager.deleteSkill("test-skill", "global") + + expect(mockRm).toHaveBeenCalledWith(testSkillDir, { recursive: true, force: true }) + }) + + it("should throw error if skill does not exist", async () => { + mockDirectoryExists.mockResolvedValue(false) + mockRealpath.mockImplementation(async (p: string) => p) + mockReaddir.mockResolvedValue([]) + + await skillsManager.discoverSkills() + + await expect(skillsManager.deleteSkill("non-existent", "global")).rejects.toThrow("not found") + }) + }) + + describe("moveSkill", () => { + it("should move a skill from generic to mode-specific directory", async () => { + const sourceDir = p(globalSkillsDir, "test-skill") + const testSkillMd = p(sourceDir, "SKILL.md") + const destDir = p(GLOBAL_ROO_DIR, "skills-code", "test-skill") + const destSkillsDir = p(GLOBAL_ROO_DIR, "skills-code") + + // Setup: skill exists in generic skills directory + mockDirectoryExists.mockImplementation(async (dir: string) => { + return dir === globalSkillsDir + }) + + mockRealpath.mockImplementation(async (pathArg: string) => pathArg) + + mockReaddir.mockImplementation(async (dir: string) => { + if (dir === globalSkillsDir) { + return ["test-skill"] + } + return [] + }) + + mockStat.mockImplementation(async (pathArg: string) => { + if (pathArg === sourceDir) { + return { isDirectory: () => true } + } + throw new Error("Not found") + }) + + mockFileExists.mockImplementation(async (file: string) => { + // Skill exists in source + if (file === testSkillMd) return true + // Skill does not exist in destination + if (file === p(destDir, "SKILL.md")) return false + return false + }) + + mockReadFile.mockResolvedValue(`--- +name: test-skill +description: A test skill +--- +Instructions`) + + mockMkdir.mockResolvedValue(undefined) + mockRename.mockResolvedValue(undefined) + + await skillsManager.discoverSkills() + + // Verify skill exists + expect(skillsManager.getSkill("test-skill", "global")).toBeDefined() + + // Move the skill to code mode + await skillsManager.moveSkill("test-skill", "global", undefined, "code") + + expect(mockMkdir).toHaveBeenCalledWith(destSkillsDir, { recursive: true }) + expect(mockRename).toHaveBeenCalledWith(sourceDir, destDir) + }) + + it("should move a skill from one mode to another", async () => { + const sourceSkillsDir = p(GLOBAL_ROO_DIR, "skills-code") + const sourceDir = p(sourceSkillsDir, "test-skill") + const testSkillMd = p(sourceDir, "SKILL.md") + const destDir = p(GLOBAL_ROO_DIR, "skills-architect", "test-skill") + const destSkillsDir = p(GLOBAL_ROO_DIR, "skills-architect") + + // Setup: skill exists in code mode directory + mockDirectoryExists.mockImplementation(async (dir: string) => { + return dir === sourceSkillsDir + }) + + mockRealpath.mockImplementation(async (pathArg: string) => pathArg) + + mockReaddir.mockImplementation(async (dir: string) => { + if (dir === sourceSkillsDir) { + return ["test-skill"] + } + return [] + }) + + mockStat.mockImplementation(async (pathArg: string) => { + if (pathArg === sourceDir) { + return { isDirectory: () => true } + } + throw new Error("Not found") + }) + + mockFileExists.mockImplementation(async (file: string) => { + // Skill exists in source + if (file === testSkillMd) return true + // Skill does not exist in destination + if (file === p(destDir, "SKILL.md")) return false + return false + }) + + mockReadFile.mockResolvedValue(`--- +name: test-skill +description: A test skill +--- +Instructions`) + + mockMkdir.mockResolvedValue(undefined) + mockRename.mockResolvedValue(undefined) + + await skillsManager.discoverSkills() + + // Verify skill exists with mode + expect(skillsManager.getSkill("test-skill", "global", "code")).toBeDefined() + + // Move the skill to architect mode + await skillsManager.moveSkill("test-skill", "global", "code", "architect") + + expect(mockMkdir).toHaveBeenCalledWith(destSkillsDir, { recursive: true }) + expect(mockRename).toHaveBeenCalledWith(sourceDir, destDir) + }) + + it("should move a skill from mode-specific to generic directory", async () => { + const sourceSkillsDir = p(GLOBAL_ROO_DIR, "skills-code") + const sourceDir = p(sourceSkillsDir, "test-skill") + const testSkillMd = p(sourceDir, "SKILL.md") + const destDir = p(globalSkillsDir, "test-skill") + + // Setup: skill exists in code mode directory + mockDirectoryExists.mockImplementation(async (dir: string) => { + return dir === sourceSkillsDir + }) + + mockRealpath.mockImplementation(async (pathArg: string) => pathArg) + + mockReaddir.mockImplementation(async (dir: string) => { + if (dir === sourceSkillsDir) { + return ["test-skill"] + } + return [] + }) + + mockStat.mockImplementation(async (pathArg: string) => { + if (pathArg === sourceDir) { + return { isDirectory: () => true } + } + throw new Error("Not found") + }) + + mockFileExists.mockImplementation(async (file: string) => { + // Skill exists in source + if (file === testSkillMd) return true + // Skill does not exist in destination + if (file === p(destDir, "SKILL.md")) return false + return false + }) + + mockReadFile.mockResolvedValue(`--- +name: test-skill +description: A test skill +--- +Instructions`) + + mockMkdir.mockResolvedValue(undefined) + mockRename.mockResolvedValue(undefined) + + await skillsManager.discoverSkills() + + // Verify skill exists with mode + expect(skillsManager.getSkill("test-skill", "global", "code")).toBeDefined() + + // Move the skill to generic (no mode) + await skillsManager.moveSkill("test-skill", "global", "code", undefined) + + expect(mockMkdir).toHaveBeenCalledWith(globalSkillsDir, { recursive: true }) + expect(mockRename).toHaveBeenCalledWith(sourceDir, destDir) + }) + + it("should not do anything when source and destination modes are the same", async () => { + mockDirectoryExists.mockImplementation(async (dir: string) => { + return dir === globalSkillsDir + }) + + mockRealpath.mockImplementation(async (pathArg: string) => pathArg) + + mockReaddir.mockImplementation(async (dir: string) => { + if (dir === globalSkillsDir) { + return ["test-skill"] + } + return [] + }) + + const testSkillDir = p(globalSkillsDir, "test-skill") + mockStat.mockImplementation(async (pathArg: string) => { + if (pathArg === testSkillDir) { + return { isDirectory: () => true } + } + throw new Error("Not found") + }) + + mockFileExists.mockImplementation(async (file: string) => { + return file === p(testSkillDir, "SKILL.md") + }) + + mockReadFile.mockResolvedValue(`--- +name: test-skill +description: A test skill +--- +Instructions`) + + await skillsManager.discoverSkills() + + // Try to move skill to the same mode (undefined -> undefined) + await skillsManager.moveSkill("test-skill", "global", undefined, undefined) + + // Should not call rename + expect(mockRename).not.toHaveBeenCalled() + }) + + it("should throw error if skill does not exist", async () => { + mockDirectoryExists.mockResolvedValue(false) + mockRealpath.mockImplementation(async (p: string) => p) + mockReaddir.mockResolvedValue([]) + + await skillsManager.discoverSkills() + + await expect(skillsManager.moveSkill("non-existent", "global", undefined, "code")).rejects.toThrow( + "not found", + ) + }) + + it("should throw error if skill already exists at destination", async () => { + const sourceDir = p(globalSkillsDir, "test-skill") + const testSkillMd = p(sourceDir, "SKILL.md") + const destDir = p(GLOBAL_ROO_DIR, "skills-code", "test-skill") + const destSkillMd = p(destDir, "SKILL.md") + + // Setup: skill exists in both locations + mockDirectoryExists.mockImplementation(async (dir: string) => { + return dir === globalSkillsDir + }) + + mockRealpath.mockImplementation(async (pathArg: string) => pathArg) + + mockReaddir.mockImplementation(async (dir: string) => { + if (dir === globalSkillsDir) { + return ["test-skill"] + } + return [] + }) + + mockStat.mockImplementation(async (pathArg: string) => { + if (pathArg === sourceDir) { + return { isDirectory: () => true } + } + throw new Error("Not found") + }) + + mockFileExists.mockImplementation(async (file: string) => { + // Skill exists in both source and destination + if (file === testSkillMd) return true + if (file === destSkillMd) return true + return false + }) + + mockReadFile.mockResolvedValue(`--- +name: test-skill +description: A test skill +--- +Instructions`) + + await skillsManager.discoverSkills() + + await expect(skillsManager.moveSkill("test-skill", "global", undefined, "code")).rejects.toThrow( + "already exists", + ) + }) + + it("should clean up empty source skills directory after moving", async () => { + const sourceSkillsDir = p(GLOBAL_ROO_DIR, "skills-code") + const sourceDir = p(sourceSkillsDir, "test-skill") + const testSkillMd = p(sourceDir, "SKILL.md") + const destDir = p(GLOBAL_ROO_DIR, "skills-architect", "test-skill") + const destSkillsDir = p(GLOBAL_ROO_DIR, "skills-architect") + + // Setup: skill exists in code mode directory + mockDirectoryExists.mockImplementation(async (dir: string) => { + return dir === sourceSkillsDir + }) + + mockRealpath.mockImplementation(async (pathArg: string) => pathArg) + + // Track readdir calls - return skill for discovery, empty for cleanup check + let readdirCallCount = 0 + mockReaddir.mockImplementation(async (dir: string) => { + if (dir === sourceSkillsDir) { + readdirCallCount++ + // First call is for discovery, return the skill + // Second call is for cleanup check after move, return empty + if (readdirCallCount === 1) { + return ["test-skill"] + } + return [] + } + return [] + }) + + mockStat.mockImplementation(async (pathArg: string) => { + if (pathArg === sourceDir) { + return { isDirectory: () => true } + } + throw new Error("Not found") + }) + + mockFileExists.mockImplementation(async (file: string) => { + // Skill exists in source + if (file === testSkillMd) return true + // Skill does not exist in destination + if (file === p(destDir, "SKILL.md")) return false + return false + }) + + mockReadFile.mockResolvedValue(`--- +name: test-skill +description: A test skill +--- +Instructions`) + + mockMkdir.mockResolvedValue(undefined) + mockRename.mockResolvedValue(undefined) + mockRmdir.mockResolvedValue(undefined) + + await skillsManager.discoverSkills() + + // Move the skill to architect mode + await skillsManager.moveSkill("test-skill", "global", "code", "architect") + + // Verify empty directory was cleaned up + expect(mockRmdir).toHaveBeenCalledWith(sourceSkillsDir) + }) + + it("should not clean up source skills directory if it still has other skills", async () => { + const sourceSkillsDir = p(GLOBAL_ROO_DIR, "skills-code") + const sourceDir = p(sourceSkillsDir, "test-skill") + const testSkillMd = p(sourceDir, "SKILL.md") + const destDir = p(GLOBAL_ROO_DIR, "skills-architect", "test-skill") + const destSkillsDir = p(GLOBAL_ROO_DIR, "skills-architect") + + // Setup: skill exists in code mode directory along with another skill + mockDirectoryExists.mockImplementation(async (dir: string) => { + return dir === sourceSkillsDir + }) + + mockRealpath.mockImplementation(async (pathArg: string) => pathArg) + + // Track readdir calls - return skill for discovery, non-empty for cleanup check + let readdirCallCount = 0 + mockReaddir.mockImplementation(async (dir: string) => { + if (dir === sourceSkillsDir) { + readdirCallCount++ + // First call is for discovery + if (readdirCallCount === 1) { + return ["test-skill", "another-skill"] + } + // Second call for cleanup - still has another skill + return ["another-skill"] + } + return [] + }) + + mockStat.mockImplementation(async (pathArg: string) => { + if (pathArg === sourceDir || pathArg === p(sourceSkillsDir, "another-skill")) { + return { isDirectory: () => true } + } + throw new Error("Not found") + }) + + mockFileExists.mockImplementation(async (file: string) => { + // Skill exists in source + if (file === testSkillMd) return true + if (file === p(sourceSkillsDir, "another-skill", "SKILL.md")) return true + // Skill does not exist in destination + if (file === p(destDir, "SKILL.md")) return false + return false + }) + + mockReadFile.mockResolvedValue(`--- +name: test-skill +description: A test skill +--- +Instructions`) + + mockMkdir.mockResolvedValue(undefined) + mockRename.mockResolvedValue(undefined) + mockRmdir.mockResolvedValue(undefined) + + await skillsManager.discoverSkills() + + // Move the skill to architect mode + await skillsManager.moveSkill("test-skill", "global", "code", "architect") + + // Verify directory was NOT cleaned up (still has other skills) + expect(mockRmdir).not.toHaveBeenCalled() + }) + }) }) diff --git a/src/services/skills/__tests__/generate-built-in-skills.spec.ts b/src/services/skills/__tests__/generate-built-in-skills.spec.ts deleted file mode 100644 index 10b44b8716..0000000000 --- a/src/services/skills/__tests__/generate-built-in-skills.spec.ts +++ /dev/null @@ -1,175 +0,0 @@ -/** - * Tests for the built-in skills generation script validation logic. - * - * Note: These tests focus on the validation functions since the main script - * is designed to be run as a CLI tool. The actual generation is tested - * via the integration with the build process. - */ - -describe("generate-built-in-skills validation", () => { - describe("validateSkillName", () => { - // Validation function extracted from the generation script - function validateSkillName(name: string): string[] { - const errors: string[] = [] - - if (name.length < 1 || name.length > 64) { - errors.push(`Name must be 1-64 characters (got ${name.length})`) - } - - const nameFormat = /^[a-z0-9]+(?:-[a-z0-9]+)*$/ - if (!nameFormat.test(name)) { - errors.push( - "Name must be lowercase letters/numbers/hyphens only (no leading/trailing hyphen, no consecutive hyphens)", - ) - } - - return errors - } - - it("should accept valid skill names", () => { - expect(validateSkillName("mcp-builder")).toHaveLength(0) - expect(validateSkillName("create-mode")).toHaveLength(0) - expect(validateSkillName("pdf-processing")).toHaveLength(0) - expect(validateSkillName("a")).toHaveLength(0) - expect(validateSkillName("skill123")).toHaveLength(0) - expect(validateSkillName("my-skill-v2")).toHaveLength(0) - }) - - it("should reject names with uppercase letters", () => { - const errors = validateSkillName("Create-MCP-Server") - expect(errors).toHaveLength(1) - expect(errors[0]).toContain("lowercase") - }) - - it("should reject names with leading hyphen", () => { - const errors = validateSkillName("-my-skill") - expect(errors).toHaveLength(1) - expect(errors[0]).toContain("leading/trailing hyphen") - }) - - it("should reject names with trailing hyphen", () => { - const errors = validateSkillName("my-skill-") - expect(errors).toHaveLength(1) - expect(errors[0]).toContain("leading/trailing hyphen") - }) - - it("should reject names with consecutive hyphens", () => { - const errors = validateSkillName("my--skill") - expect(errors).toHaveLength(1) - expect(errors[0]).toContain("consecutive hyphens") - }) - - it("should reject empty names", () => { - const errors = validateSkillName("") - expect(errors.length).toBeGreaterThan(0) - }) - - it("should reject names longer than 64 characters", () => { - const longName = "a".repeat(65) - const errors = validateSkillName(longName) - expect(errors).toHaveLength(1) - expect(errors[0]).toContain("1-64 characters") - }) - - it("should reject names with special characters", () => { - expect(validateSkillName("my_skill").length).toBeGreaterThan(0) - expect(validateSkillName("my.skill").length).toBeGreaterThan(0) - expect(validateSkillName("my skill").length).toBeGreaterThan(0) - }) - }) - - describe("validateDescription", () => { - // Validation function extracted from the generation script - function validateDescription(description: string): string[] { - const errors: string[] = [] - const trimmed = description.trim() - - if (trimmed.length < 1 || trimmed.length > 1024) { - errors.push(`Description must be 1-1024 characters (got ${trimmed.length})`) - } - - return errors - } - - it("should accept valid descriptions", () => { - expect(validateDescription("A short description")).toHaveLength(0) - expect(validateDescription("x")).toHaveLength(0) - expect(validateDescription("x".repeat(1024))).toHaveLength(0) - }) - - it("should reject empty descriptions", () => { - const errors = validateDescription("") - expect(errors).toHaveLength(1) - expect(errors[0]).toContain("1-1024 characters") - }) - - it("should reject whitespace-only descriptions", () => { - const errors = validateDescription(" ") - expect(errors).toHaveLength(1) - expect(errors[0]).toContain("got 0") - }) - - it("should reject descriptions longer than 1024 characters", () => { - const longDesc = "x".repeat(1025) - const errors = validateDescription(longDesc) - expect(errors).toHaveLength(1) - expect(errors[0]).toContain("got 1025") - }) - }) - - describe("escapeForTemplateLiteral", () => { - // Escape function extracted from the generation script - function escapeForTemplateLiteral(str: string): string { - return str.replace(/\\/g, "\\\\").replace(/`/g, "\\`").replace(/\$\{/g, "\\${") - } - - it("should escape backticks", () => { - expect(escapeForTemplateLiteral("code `example`")).toBe("code \\`example\\`") - }) - - it("should escape template literal interpolation", () => { - expect(escapeForTemplateLiteral("value: ${foo}")).toBe("value: \\${foo}") - }) - - it("should escape backslashes", () => { - expect(escapeForTemplateLiteral("path\\to\\file")).toBe("path\\\\to\\\\file") - }) - - it("should handle combined escapes", () => { - const input = "const x = `${value}`" - const expected = "const x = \\`\\${value}\\`" - expect(escapeForTemplateLiteral(input)).toBe(expected) - }) - }) -}) - -describe("built-in skills integration", () => { - it("should have valid skill names matching directory names", async () => { - // Import the generated built-in skills - const { getBuiltInSkills, getBuiltInSkillContent } = await import("../built-in-skills") - - const skills = getBuiltInSkills() - - // Verify we have the expected skills - const skillNames = skills.map((s) => s.name) - expect(skillNames).toContain("create-mcp-server") - expect(skillNames).toContain("create-mode") - - // Verify each skill has valid content - for (const skill of skills) { - expect(skill.source).toBe("built-in") - expect(skill.path).toBe("built-in") - - const content = getBuiltInSkillContent(skill.name) - expect(content).not.toBeNull() - expect(content!.instructions.length).toBeGreaterThan(0) - } - }) - - it("should return null for non-existent skills", async () => { - const { getBuiltInSkillContent } = await import("../built-in-skills") - - const content = getBuiltInSkillContent("non-existent-skill") - expect(content).toBeNull() - }) -}) diff --git a/src/services/skills/built-in-skills.ts b/src/services/skills/built-in-skills.ts deleted file mode 100644 index a47092b38b..0000000000 --- a/src/services/skills/built-in-skills.ts +++ /dev/null @@ -1,428 +0,0 @@ -/** - * AUTO-GENERATED FILE - DO NOT EDIT DIRECTLY - * - * This file is generated by generate-built-in-skills.ts from the SKILL.md files - * in the built-in/ directory. To modify built-in skills, edit the corresponding - * SKILL.md file and run: pnpm generate:skills - * - * Generated at: 2026-01-28T23:09:14.137Z - */ - -import { SkillMetadata, SkillContent } from "../../shared/skills" - -interface BuiltInSkillDefinition { - name: string - description: string - instructions: string -} - -const BUILT_IN_SKILLS: Record = { - "create-mcp-server": { - name: "create-mcp-server", - description: - "Instructions for creating MCP (Model Context Protocol) servers that expose tools and resources for the agent to use. Use when the user asks to create a new MCP server or add MCP capabilities.", - instructions: `You have the ability to create an MCP server and add it to a configuration file that will then expose the tools and resources for you to use with \`use_mcp_tool\` and \`access_mcp_resource\`. - -When creating MCP servers, it's important to understand that they operate in a non-interactive environment. The server cannot initiate OAuth flows, open browser windows, or prompt for user input during runtime. All credentials and authentication tokens must be provided upfront through environment variables in the MCP settings configuration. For example, Spotify's API uses OAuth to get a refresh token for the user, but the MCP server cannot initiate this flow. While you can walk the user through obtaining an application client ID and secret, you may have to create a separate one-time setup script (like get-refresh-token.js) that captures and logs the final piece of the puzzle: the user's refresh token (i.e. you might run the script using execute_command which would open a browser for authentication, and then log the refresh token so that you can see it in the command output for you to use in the MCP settings configuration). - -Unless the user specifies otherwise, new local MCP servers should be created in your MCP servers directory. You can find the path to this directory by checking the MCP settings file, or ask the user where they'd like the server created. - -### MCP Server Types and Configuration - -MCP servers can be configured in two ways in the MCP settings file: - -1. Local (Stdio) Server Configuration: -\`\`\`json -{ - "mcpServers": { - "local-weather": { - "command": "node", - "args": ["/path/to/weather-server/build/index.js"], - "env": { - "OPENWEATHER_API_KEY": "your-api-key" - } - } - } -} -\`\`\` - -2. Remote (SSE) Server Configuration: -\`\`\`json -{ - "mcpServers": { - "remote-weather": { - "url": "https://api.example.com/mcp", - "headers": { - "Authorization": "Bearer your-api-key" - } - } - } -} -\`\`\` - -Common configuration options for both types: -- \`disabled\`: (optional) Set to true to temporarily disable the server -- \`timeout\`: (optional) Maximum time in seconds to wait for server responses (default: 60) -- \`alwaysAllow\`: (optional) Array of tool names that don't require user confirmation -- \`disabledTools\`: (optional) Array of tool names that are not included in the system prompt and won't be used - -### Example Local MCP Server - -For example, if the user wanted to give you the ability to retrieve weather information, you could create an MCP server that uses the OpenWeather API to get weather information, add it to the MCP settings configuration file, and then notice that you now have access to new tools and resources in the system prompt that you might use to show the user your new capabilities. - -The following example demonstrates how to build a local MCP server that provides weather data functionality using the Stdio transport. While this example shows how to implement resources, resource templates, and tools, in practice you should prefer using tools since they are more flexible and can handle dynamic parameters. The resource and resource template implementations are included here mainly for demonstration purposes of the different MCP capabilities, but a real weather server would likely just expose tools for fetching weather data. (The following steps are for macOS) - -1. Use the \`create-typescript-server\` tool to bootstrap a new project in your MCP servers directory: - -\`\`\`bash -cd /path/to/your/mcp-servers -npx @modelcontextprotocol/create-server weather-server -cd weather-server -# Install dependencies -npm install axios zod @modelcontextprotocol/sdk -\`\`\` - -This will create a new project with the following structure: - -\`\`\` -weather-server/ - ├── package.json - { - ... - "type": "module", // added by default, uses ES module syntax (import/export) rather than CommonJS (require/module.exports) (Important to know if you create additional scripts in this server repository like a get-refresh-token.js script) - "scripts": { - "build": "tsc && node -e \\"require('fs').chmodSync('build/index.js', '755')\\"", - ... - } - ... - } - ├── tsconfig.json - └── src/ - └── index.ts # Main server implementation -\`\`\` - -2. Replace \`src/index.ts\` with the following: - -\`\`\`typescript -#!/usr/bin/env node -import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; -import { z } from "zod"; -import axios from 'axios'; - -const API_KEY = process.env.OPENWEATHER_API_KEY; // provided by MCP config -if (!API_KEY) { - throw new Error('OPENWEATHER_API_KEY environment variable is required'); -} - -// Define types for OpenWeather API responses -interface WeatherData { - main: { - temp: number; - humidity: number; - }; - weather: Array<{ - description: string; - }>; - wind: { - speed: number; - }; -} - -interface ForecastData { - list: Array; -} - -// Create an MCP server -const server = new McpServer({ - name: "weather-server", - version: "0.1.0" -}); - -// Create axios instance for OpenWeather API -const weatherApi = axios.create({ - baseURL: 'http://api.openweathermap.org/data/2.5', - params: { - appid: API_KEY, - units: 'metric', - }, -}); - -// Add a tool for getting weather forecasts -server.tool( - "get_forecast", - { - city: z.string().describe("City name"), - days: z.number().min(1).max(5).optional().describe("Number of days (1-5)"), - }, - async ({ city, days = 3 }) => { - try { - const response = await weatherApi.get('forecast', { - params: { - q: city, - cnt: Math.min(days, 5) * 8, - }, - }); - - return { - content: [ - { - type: "text", - text: JSON.stringify(response.data.list, null, 2), - }, - ], - }; - } catch (error) { - if (axios.isAxiosError(error)) { - return { - content: [ - { - type: "text", - text: \`Weather API error: \${ - error.response?.data.message ?? error.message - }\`, - }, - ], - isError: true, - }; - } - throw error; - } - } -); - -// Add a resource for current weather in San Francisco -server.resource( - "sf_weather", - { uri: "weather://San Francisco/current", list: true }, - async (uri) => { - try { - const response = weatherApi.get('weather', { - params: { q: "San Francisco" }, - }); - - return { - contents: [ - { - uri: uri.href, - mimeType: "application/json", - text: JSON.stringify( - { - temperature: response.data.main.temp, - conditions: response.data.weather[0].description, - humidity: response.data.main.humidity, - wind_speed: response.data.wind.speed, - timestamp: new Date().toISOString(), - }, - null, - 2 - ), - }, - ], - }; - } catch (error) { - if (axios.isAxiosError(error)) { - throw new Error(\`Weather API error: \${ - error.response?.data.message ?? error.message - }\`); - } - throw error; - } - } -); - -// Add a dynamic resource template for current weather by city -server.resource( - "current_weather", - new ResourceTemplate("weather://{city}/current", { list: true }), - async (uri, { city }) => { - try { - const response = await weatherApi.get('weather', { - params: { q: city }, - }); - - return { - contents: [ - { - uri: uri.href, - mimeType: "application/json", - text: JSON.stringify( - { - temperature: response.data.main.temp, - conditions: response.data.weather[0].description, - humidity: response.data.main.humidity, - wind_speed: response.data.wind.speed, - timestamp: new Date().toISOString(), - }, - null, - 2 - ), - }, - ], - }; - } catch (error) { - if (axios.isAxiosError(error)) { - throw new Error(\`Weather API error: \${ - error.response?.data.message ?? error.message - }\`); - } - throw error; - } - } -); - -// Start receiving messages on stdin and sending messages on stdout -const transport = new StdioServerTransport(); -await server.connect(transport); -console.error('Weather MCP server running on stdio'); -\`\`\` - -(Remember: This is just an example–you may use different dependencies, break the implementation up into multiple files, etc.) - -3. Build and compile the executable JavaScript file - -\`\`\`bash -npm run build -\`\`\` - -4. Whenever you need an environment variable such as an API key to configure the MCP server, walk the user through the process of getting the key. For example, they may need to create an account and go to a developer dashboard to generate the key. Provide step-by-step instructions and URLs to make it easy for the user to retrieve the necessary information. Then use the ask_followup_question tool to ask the user for the key, in this case the OpenWeather API key. - -5. Install the MCP Server by adding the MCP server configuration to the MCP settings file. On macOS/Linux this is typically at \`~/.roo-code/settings/mcp_settings.json\`, on Windows at \`%APPDATA%\\roo-code\\settings\\mcp_settings.json\`. The settings file may have other MCP servers already configured, so you would read it first and then add your new server to the existing \`mcpServers\` object. - -IMPORTANT: Regardless of what else you see in the MCP settings file, you must default any new MCP servers you create to disabled=false, alwaysAllow=[] and disabledTools=[]. - -\`\`\`json -{ - "mcpServers": { - ..., - "weather": { - "command": "node", - "args": ["/path/to/weather-server/build/index.js"], - "env": { - "OPENWEATHER_API_KEY": "user-provided-api-key" - } - }, - } -} -\`\`\` - -(Note: the user may also ask you to install the MCP server to the Claude desktop app, in which case you would read then modify \`~/Library/Application\\ Support/Claude/claude_desktop_config.json\` on macOS for example. It follows the same format of a top level \`mcpServers\` object.) - -6. After you have edited the MCP settings configuration file, the system will automatically run all the servers and expose the available tools and resources in the 'Connected MCP Servers' section. - -7. Now that you have access to these new tools and resources, you may suggest ways the user can command you to invoke them - for example, with this new weather tool now available, you can invite the user to ask "what's the weather in San Francisco?" - -## Editing MCP Servers - -The user may ask to add tools or resources that may make sense to add to an existing MCP server (listed under 'Connected MCP Servers' in the system prompt), e.g. if it would use the same API. This would be possible if you can locate the MCP server repository on the user's system by looking at the server arguments for a filepath. You might then use list_files and read_file to explore the files in the repository, and use write_to_file or apply_diff to make changes to the files. - -However some MCP servers may be running from installed packages rather than a local repository, in which case it may make more sense to create a new MCP server. - -# MCP Servers Are Not Always Necessary - -The user may not always request the use or creation of MCP servers. Instead, they might provide tasks that can be completed with existing tools. While using the MCP SDK to extend your capabilities can be useful, it's important to understand that this is just one specialized type of task you can accomplish. You should only implement MCP servers when the user explicitly requests it (e.g., "add a tool that..."). - -Remember: The MCP documentation and example provided above are to help you understand and work with existing MCP servers or create new ones when requested by the user. You already have access to tools and capabilities that can be used to accomplish a wide range of tasks.`, - }, - "create-mode": { - name: "create-mode", - description: - "Instructions for creating custom modes in Roo Code. Use when the user asks to create a new mode, edit an existing mode, or configure mode settings.", - instructions: `Custom modes can be configured in two ways: - -1. Globally via the custom modes file in your Roo Code settings directory (typically ~/.roo-code/settings/custom_modes.yaml on macOS/Linux or %APPDATA%\\roo-code\\settings\\custom_modes.yaml on Windows) - created automatically on startup -2. Per-workspace via '.roomodes' in the workspace root directory - -When modes with the same slug exist in both files, the workspace-specific .roomodes version takes precedence. This allows projects to override global modes or define project-specific modes. - -If asked to create a project mode, create it in .roomodes in the workspace root. If asked to create a global mode, use the global custom modes file. - -- The following fields are required and must not be empty: - - - slug: A valid slug (lowercase letters, numbers, and hyphens). Must be unique, and shorter is better. - - name: The display name for the mode - - roleDefinition: A detailed description of the mode's role and capabilities - - groups: Array of allowed tool groups (can be empty). Each group can be specified either as a string (e.g., "edit" to allow editing any file) or with file restrictions (e.g., ["edit", { fileRegex: "\\.md$", description: "Markdown files only" }] to only allow editing markdown files) - -- The following fields are optional but highly recommended: - - - description: A short, human-readable description of what this mode does (5 words) - - whenToUse: A clear description of when this mode should be selected and what types of tasks it's best suited for. This helps the Orchestrator mode make better decisions. - - customInstructions: Additional instructions for how the mode should operate - -- For multi-line text, include newline characters in the string like "This is the first line.\\nThis is the next line.\\n\\nThis is a double line break." - -Both files should follow this structure (in YAML format): - -customModes: - -- slug: designer # Required: unique slug with lowercase letters, numbers, and hyphens - name: Designer # Required: mode display name - description: UI/UX design systems expert # Optional but recommended: short description (5 words) - roleDefinition: >- - You are Roo, a UI/UX expert specializing in design systems and frontend development. Your expertise includes: - - Creating and maintaining design systems - - Implementing responsive and accessible web interfaces - - Working with CSS, HTML, and modern frontend frameworks - - Ensuring consistent user experiences across platforms # Required: non-empty - whenToUse: >- - Use this mode when creating or modifying UI components, implementing design systems, - or ensuring responsive web interfaces. This mode is especially effective with CSS, - HTML, and modern frontend frameworks. # Optional but recommended - groups: # Required: array of tool groups (can be empty) - - read # Read files group (read_file, search_files, list_files, codebase_search) - - edit # Edit files group (apply_diff, write_to_file) - allows editing any file - # Or with file restrictions: - # - - edit - # - fileRegex: \\.md$ - # description: Markdown files only # Edit group that only allows editing markdown files - - browser # Browser group (browser_action) - - command # Command group (execute_command) - - mcp # MCP group (use_mcp_tool, access_mcp_resource) - customInstructions: Additional instructions for the Designer mode # Optional`, - }, -} - -/** - * Get all built-in skills as SkillMetadata objects - */ -export function getBuiltInSkills(): SkillMetadata[] { - return Object.values(BUILT_IN_SKILLS).map((skill) => ({ - name: skill.name, - description: skill.description, - path: "built-in", - source: "built-in" as const, - })) -} - -/** - * Get a specific built-in skill's full content by name - */ -export function getBuiltInSkillContent(name: string): SkillContent | null { - const skill = BUILT_IN_SKILLS[name] - if (!skill) return null - - return { - name: skill.name, - description: skill.description, - path: "built-in", - source: "built-in" as const, - instructions: skill.instructions, - } -} - -/** - * Check if a skill name is a built-in skill - */ -export function isBuiltInSkill(name: string): boolean { - return name in BUILT_IN_SKILLS -} - -/** - * Get names of all built-in skills - */ -export function getBuiltInSkillNames(): string[] { - return Object.keys(BUILT_IN_SKILLS) -} diff --git a/src/services/skills/built-in/create-mcp-server/SKILL.md b/src/services/skills/built-in/create-mcp-server/SKILL.md deleted file mode 100644 index be52e91c89..0000000000 --- a/src/services/skills/built-in/create-mcp-server/SKILL.md +++ /dev/null @@ -1,304 +0,0 @@ ---- -name: create-mcp-server -description: Instructions for creating MCP (Model Context Protocol) servers that expose tools and resources for the agent to use. Use when the user asks to create a new MCP server or add MCP capabilities. ---- - -You have the ability to create an MCP server and add it to a configuration file that will then expose the tools and resources for you to use with `use_mcp_tool` and `access_mcp_resource`. - -When creating MCP servers, it's important to understand that they operate in a non-interactive environment. The server cannot initiate OAuth flows, open browser windows, or prompt for user input during runtime. All credentials and authentication tokens must be provided upfront through environment variables in the MCP settings configuration. For example, Spotify's API uses OAuth to get a refresh token for the user, but the MCP server cannot initiate this flow. While you can walk the user through obtaining an application client ID and secret, you may have to create a separate one-time setup script (like get-refresh-token.js) that captures and logs the final piece of the puzzle: the user's refresh token (i.e. you might run the script using execute_command which would open a browser for authentication, and then log the refresh token so that you can see it in the command output for you to use in the MCP settings configuration). - -Unless the user specifies otherwise, new local MCP servers should be created in your MCP servers directory. You can find the path to this directory by checking the MCP settings file, or ask the user where they'd like the server created. - -### MCP Server Types and Configuration - -MCP servers can be configured in two ways in the MCP settings file: - -1. Local (Stdio) Server Configuration: - -```json -{ - "mcpServers": { - "local-weather": { - "command": "node", - "args": ["/path/to/weather-server/build/index.js"], - "env": { - "OPENWEATHER_API_KEY": "your-api-key" - } - } - } -} -``` - -2. Remote (SSE) Server Configuration: - -```json -{ - "mcpServers": { - "remote-weather": { - "url": "https://api.example.com/mcp", - "headers": { - "Authorization": "Bearer your-api-key" - } - } - } -} -``` - -Common configuration options for both types: - -- `disabled`: (optional) Set to true to temporarily disable the server -- `timeout`: (optional) Maximum time in seconds to wait for server responses (default: 60) -- `alwaysAllow`: (optional) Array of tool names that don't require user confirmation -- `disabledTools`: (optional) Array of tool names that are not included in the system prompt and won't be used - -### Example Local MCP Server - -For example, if the user wanted to give you the ability to retrieve weather information, you could create an MCP server that uses the OpenWeather API to get weather information, add it to the MCP settings configuration file, and then notice that you now have access to new tools and resources in the system prompt that you might use to show the user your new capabilities. - -The following example demonstrates how to build a local MCP server that provides weather data functionality using the Stdio transport. While this example shows how to implement resources, resource templates, and tools, in practice you should prefer using tools since they are more flexible and can handle dynamic parameters. The resource and resource template implementations are included here mainly for demonstration purposes of the different MCP capabilities, but a real weather server would likely just expose tools for fetching weather data. (The following steps are for macOS) - -1. Use the `create-typescript-server` tool to bootstrap a new project in your MCP servers directory: - -```bash -cd /path/to/your/mcp-servers -npx @modelcontextprotocol/create-server weather-server -cd weather-server -# Install dependencies -npm install axios zod @modelcontextprotocol/sdk -``` - -This will create a new project with the following structure: - -``` -weather-server/ - ├── package.json - { - ... - "type": "module", // added by default, uses ES module syntax (import/export) rather than CommonJS (require/module.exports) (Important to know if you create additional scripts in this server repository like a get-refresh-token.js script) - "scripts": { - "build": "tsc && node -e \"require('fs').chmodSync('build/index.js', '755')\"", - ... - } - ... - } - ├── tsconfig.json - └── src/ - └── index.ts # Main server implementation -``` - -2. Replace `src/index.ts` with the following: - -```typescript -#!/usr/bin/env node -import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js" -import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js" -import { z } from "zod" -import axios from "axios" - -const API_KEY = process.env.OPENWEATHER_API_KEY // provided by MCP config -if (!API_KEY) { - throw new Error("OPENWEATHER_API_KEY environment variable is required") -} - -// Define types for OpenWeather API responses -interface WeatherData { - main: { - temp: number - humidity: number - } - weather: Array<{ - description: string - }> - wind: { - speed: number - } -} - -interface ForecastData { - list: Array< - WeatherData & { - dt_txt: string - } - > -} - -// Create an MCP server -const server = new McpServer({ - name: "weather-server", - version: "0.1.0", -}) - -// Create axios instance for OpenWeather API -const weatherApi = axios.create({ - baseURL: "http://api.openweathermap.org/data/2.5", - params: { - appid: API_KEY, - units: "metric", - }, -}) - -// Add a tool for getting weather forecasts -server.tool( - "get_forecast", - { - city: z.string().describe("City name"), - days: z.number().min(1).max(5).optional().describe("Number of days (1-5)"), - }, - async ({ city, days = 3 }) => { - try { - const response = await weatherApi.get("forecast", { - params: { - q: city, - cnt: Math.min(days, 5) * 8, - }, - }) - - return { - content: [ - { - type: "text", - text: JSON.stringify(response.data.list, null, 2), - }, - ], - } - } catch (error) { - if (axios.isAxiosError(error)) { - return { - content: [ - { - type: "text", - text: `Weather API error: ${error.response?.data.message ?? error.message}`, - }, - ], - isError: true, - } - } - throw error - } - }, -) - -// Add a resource for current weather in San Francisco -server.resource("sf_weather", { uri: "weather://San Francisco/current", list: true }, async (uri) => { - try { - const response = weatherApi.get("weather", { - params: { q: "San Francisco" }, - }) - - return { - contents: [ - { - uri: uri.href, - mimeType: "application/json", - text: JSON.stringify( - { - temperature: response.data.main.temp, - conditions: response.data.weather[0].description, - humidity: response.data.main.humidity, - wind_speed: response.data.wind.speed, - timestamp: new Date().toISOString(), - }, - null, - 2, - ), - }, - ], - } - } catch (error) { - if (axios.isAxiosError(error)) { - throw new Error(`Weather API error: ${error.response?.data.message ?? error.message}`) - } - throw error - } -}) - -// Add a dynamic resource template for current weather by city -server.resource( - "current_weather", - new ResourceTemplate("weather://{city}/current", { list: true }), - async (uri, { city }) => { - try { - const response = await weatherApi.get("weather", { - params: { q: city }, - }) - - return { - contents: [ - { - uri: uri.href, - mimeType: "application/json", - text: JSON.stringify( - { - temperature: response.data.main.temp, - conditions: response.data.weather[0].description, - humidity: response.data.main.humidity, - wind_speed: response.data.wind.speed, - timestamp: new Date().toISOString(), - }, - null, - 2, - ), - }, - ], - } - } catch (error) { - if (axios.isAxiosError(error)) { - throw new Error(`Weather API error: ${error.response?.data.message ?? error.message}`) - } - throw error - } - }, -) - -// Start receiving messages on stdin and sending messages on stdout -const transport = new StdioServerTransport() -await server.connect(transport) -console.error("Weather MCP server running on stdio") -``` - -(Remember: This is just an example–you may use different dependencies, break the implementation up into multiple files, etc.) - -3. Build and compile the executable JavaScript file - -```bash -npm run build -``` - -4. Whenever you need an environment variable such as an API key to configure the MCP server, walk the user through the process of getting the key. For example, they may need to create an account and go to a developer dashboard to generate the key. Provide step-by-step instructions and URLs to make it easy for the user to retrieve the necessary information. Then use the ask_followup_question tool to ask the user for the key, in this case the OpenWeather API key. - -5. Install the MCP Server by adding the MCP server configuration to the MCP settings file. On macOS/Linux this is typically at `~/.roo-code/settings/mcp_settings.json`, on Windows at `%APPDATA%\roo-code\settings\mcp_settings.json`. The settings file may have other MCP servers already configured, so you would read it first and then add your new server to the existing `mcpServers` object. - -IMPORTANT: Regardless of what else you see in the MCP settings file, you must default any new MCP servers you create to disabled=false, alwaysAllow=[] and disabledTools=[]. - -```json -{ - "mcpServers": { - ..., - "weather": { - "command": "node", - "args": ["/path/to/weather-server/build/index.js"], - "env": { - "OPENWEATHER_API_KEY": "user-provided-api-key" - } - }, - } -} -``` - -(Note: the user may also ask you to install the MCP server to the Claude desktop app, in which case you would read then modify `~/Library/Application\ Support/Claude/claude_desktop_config.json` on macOS for example. It follows the same format of a top level `mcpServers` object.) - -6. After you have edited the MCP settings configuration file, the system will automatically run all the servers and expose the available tools and resources in the 'Connected MCP Servers' section. - -7. Now that you have access to these new tools and resources, you may suggest ways the user can command you to invoke them - for example, with this new weather tool now available, you can invite the user to ask "what's the weather in San Francisco?" - -## Editing MCP Servers - -The user may ask to add tools or resources that may make sense to add to an existing MCP server (listed under 'Connected MCP Servers' in the system prompt), e.g. if it would use the same API. This would be possible if you can locate the MCP server repository on the user's system by looking at the server arguments for a filepath. You might then use list_files and read_file to explore the files in the repository, and use write_to_file or apply_diff to make changes to the files. - -However some MCP servers may be running from installed packages rather than a local repository, in which case it may make more sense to create a new MCP server. - -# MCP Servers Are Not Always Necessary - -The user may not always request the use or creation of MCP servers. Instead, they might provide tasks that can be completed with existing tools. While using the MCP SDK to extend your capabilities can be useful, it's important to understand that this is just one specialized type of task you can accomplish. You should only implement MCP servers when the user explicitly requests it (e.g., "add a tool that..."). - -Remember: The MCP documentation and example provided above are to help you understand and work with existing MCP servers or create new ones when requested by the user. You already have access to tools and capabilities that can be used to accomplish a wide range of tasks. diff --git a/src/services/skills/built-in/create-mode/SKILL.md b/src/services/skills/built-in/create-mode/SKILL.md deleted file mode 100644 index ec43ac9bea..0000000000 --- a/src/services/skills/built-in/create-mode/SKILL.md +++ /dev/null @@ -1,57 +0,0 @@ ---- -name: create-mode -description: Instructions for creating custom modes in Roo Code. Use when the user asks to create a new mode, edit an existing mode, or configure mode settings. ---- - -Custom modes can be configured in two ways: - -1. Globally via the custom modes file in your Roo Code settings directory (typically ~/.roo-code/settings/custom_modes.yaml on macOS/Linux or %APPDATA%\roo-code\settings\custom_modes.yaml on Windows) - created automatically on startup -2. Per-workspace via '.roomodes' in the workspace root directory - -When modes with the same slug exist in both files, the workspace-specific .roomodes version takes precedence. This allows projects to override global modes or define project-specific modes. - -If asked to create a project mode, create it in .roomodes in the workspace root. If asked to create a global mode, use the global custom modes file. - -- The following fields are required and must not be empty: - - - slug: A valid slug (lowercase letters, numbers, and hyphens). Must be unique, and shorter is better. - - name: The display name for the mode - - roleDefinition: A detailed description of the mode's role and capabilities - - groups: Array of allowed tool groups (can be empty). Each group can be specified either as a string (e.g., "edit" to allow editing any file) or with file restrictions (e.g., ["edit", { fileRegex: "\.md$", description: "Markdown files only" }] to only allow editing markdown files) - -- The following fields are optional but highly recommended: - - - description: A short, human-readable description of what this mode does (5 words) - - whenToUse: A clear description of when this mode should be selected and what types of tasks it's best suited for. This helps the Orchestrator mode make better decisions. - - customInstructions: Additional instructions for how the mode should operate - -- For multi-line text, include newline characters in the string like "This is the first line.\nThis is the next line.\n\nThis is a double line break." - -Both files should follow this structure (in YAML format): - -customModes: - -- slug: designer # Required: unique slug with lowercase letters, numbers, and hyphens - name: Designer # Required: mode display name - description: UI/UX design systems expert # Optional but recommended: short description (5 words) - roleDefinition: >- - You are Roo, a UI/UX expert specializing in design systems and frontend development. Your expertise includes: - - Creating and maintaining design systems - - Implementing responsive and accessible web interfaces - - Working with CSS, HTML, and modern frontend frameworks - - Ensuring consistent user experiences across platforms # Required: non-empty - whenToUse: >- - Use this mode when creating or modifying UI components, implementing design systems, - or ensuring responsive web interfaces. This mode is especially effective with CSS, - HTML, and modern frontend frameworks. # Optional but recommended - groups: # Required: array of tool groups (can be empty) - - read # Read files group (read_file, search_files, list_files, codebase_search) - - edit # Edit files group (apply_diff, write_to_file) - allows editing any file - # Or with file restrictions: - # - - edit - # - fileRegex: \.md$ - # description: Markdown files only # Edit group that only allows editing markdown files - - browser # Browser group (browser_action) - - command # Command group (execute_command) - - mcp # MCP group (use_mcp_tool, access_mcp_resource) - customInstructions: Additional instructions for the Designer mode # Optional diff --git a/src/services/skills/generate-built-in-skills.ts b/src/services/skills/generate-built-in-skills.ts deleted file mode 100644 index a1fb0fcb10..0000000000 --- a/src/services/skills/generate-built-in-skills.ts +++ /dev/null @@ -1,302 +0,0 @@ -#!/usr/bin/env tsx -/** - * Build script to generate built-in-skills.ts from SKILL.md files. - * - * This script scans the built-in/ directory for skill folders, parses each - * SKILL.md file using gray-matter, validates the frontmatter, and generates - * the built-in-skills.ts file. - * - * Run with: npx tsx src/services/skills/generate-built-in-skills.ts - */ - -import * as fs from "fs/promises" -import * as path from "path" -import { execSync } from "child_process" -import matter from "gray-matter" - -const BUILT_IN_DIR = path.join(__dirname, "built-in") -const OUTPUT_FILE = path.join(__dirname, "built-in-skills.ts") - -interface SkillData { - name: string - description: string - instructions: string -} - -interface ValidationError { - skillDir: string - errors: string[] -} - -/** - * Validate a skill name according to Agent Skills spec: - * - 1-64 characters - * - lowercase letters, numbers, and hyphens only - * - must not start/end with hyphen - * - must not contain consecutive hyphens - */ -function validateSkillName(name: string): string[] { - const errors: string[] = [] - - if (name.length < 1 || name.length > 64) { - errors.push(`Name must be 1-64 characters (got ${name.length})`) - } - - const nameFormat = /^[a-z0-9]+(?:-[a-z0-9]+)*$/ - if (!nameFormat.test(name)) { - errors.push( - "Name must be lowercase letters/numbers/hyphens only (no leading/trailing hyphen, no consecutive hyphens)", - ) - } - - return errors -} - -/** - * Validate a skill description: - * - 1-1024 characters (after trimming) - */ -function validateDescription(description: string): string[] { - const errors: string[] = [] - const trimmed = description.trim() - - if (trimmed.length < 1 || trimmed.length > 1024) { - errors.push(`Description must be 1-1024 characters (got ${trimmed.length})`) - } - - return errors -} - -/** - * Parse and validate a single SKILL.md file - */ -async function parseSkillFile( - skillDir: string, - dirName: string, -): Promise<{ skill?: SkillData; errors?: ValidationError }> { - const skillMdPath = path.join(skillDir, "SKILL.md") - - try { - const fileContent = await fs.readFile(skillMdPath, "utf-8") - const { data: frontmatter, content: body } = matter(fileContent) - - const errors: string[] = [] - - // Validate required fields - if (!frontmatter.name || typeof frontmatter.name !== "string") { - errors.push("Missing required 'name' field in frontmatter") - } - if (!frontmatter.description || typeof frontmatter.description !== "string") { - errors.push("Missing required 'description' field in frontmatter") - } - - if (errors.length > 0) { - return { errors: { skillDir, errors } } - } - - // Validate name matches directory name - if (frontmatter.name !== dirName) { - errors.push(`Frontmatter name "${frontmatter.name}" doesn't match directory name "${dirName}"`) - } - - // Validate name format - errors.push(...validateSkillName(dirName)) - - // Validate description - errors.push(...validateDescription(frontmatter.description)) - - if (errors.length > 0) { - return { errors: { skillDir, errors } } - } - - return { - skill: { - name: frontmatter.name, - description: frontmatter.description.trim(), - instructions: body.trim(), - }, - } - } catch (error) { - return { - errors: { - skillDir, - errors: [`Failed to read or parse SKILL.md: ${error instanceof Error ? error.message : String(error)}`], - }, - } - } -} - -/** - * Escape a string for use in TypeScript template literal - */ -function escapeForTemplateLiteral(str: string): string { - return str.replace(/\\/g, "\\\\").replace(/`/g, "\\`").replace(/\$\{/g, "\\${") -} - -/** - * Generate the TypeScript code for built-in-skills.ts - */ -function generateTypeScript(skills: Record): string { - const skillEntries = Object.entries(skills) - .map(([key, skill]) => { - const escapedInstructions = escapeForTemplateLiteral(skill.instructions) - return `\t"${key}": { - name: "${skill.name}", - description: "${skill.description.replace(/"/g, '\\"')}", - instructions: \`${escapedInstructions}\`, - }` - }) - .join(",\n") - - return `/** - * AUTO-GENERATED FILE - DO NOT EDIT DIRECTLY - * - * This file is generated by generate-built-in-skills.ts from the SKILL.md files - * in the built-in/ directory. To modify built-in skills, edit the corresponding - * SKILL.md file and run: pnpm generate:skills - * - * Generated at: ${new Date().toISOString()} - */ - -import { SkillMetadata, SkillContent } from "../../shared/skills" - -interface BuiltInSkillDefinition { - name: string - description: string - instructions: string -} - -const BUILT_IN_SKILLS: Record = { -${skillEntries} -} - -/** - * Get all built-in skills as SkillMetadata objects - */ -export function getBuiltInSkills(): SkillMetadata[] { - return Object.values(BUILT_IN_SKILLS).map((skill) => ({ - name: skill.name, - description: skill.description, - path: "built-in", - source: "built-in" as const, - })) -} - -/** - * Get a specific built-in skill's full content by name - */ -export function getBuiltInSkillContent(name: string): SkillContent | null { - const skill = BUILT_IN_SKILLS[name] - if (!skill) return null - - return { - name: skill.name, - description: skill.description, - path: "built-in", - source: "built-in" as const, - instructions: skill.instructions, - } -} - -/** - * Check if a skill name is a built-in skill - */ -export function isBuiltInSkill(name: string): boolean { - return name in BUILT_IN_SKILLS -} - -/** - * Get names of all built-in skills - */ -export function getBuiltInSkillNames(): string[] { - return Object.keys(BUILT_IN_SKILLS) -} -` -} - -async function main() { - console.log("Generating built-in skills from SKILL.md files...") - - // Check if built-in directory exists - try { - await fs.access(BUILT_IN_DIR) - } catch { - console.error(`Error: Built-in skills directory not found: ${BUILT_IN_DIR}`) - process.exit(1) - } - - // Scan for skill directories - const entries = await fs.readdir(BUILT_IN_DIR) - const skills: Record = {} - const validationErrors: ValidationError[] = [] - - for (const entry of entries) { - const skillDir = path.join(BUILT_IN_DIR, entry) - const stats = await fs.stat(skillDir) - - if (!stats.isDirectory()) { - continue - } - - // Check if SKILL.md exists - const skillMdPath = path.join(skillDir, "SKILL.md") - try { - await fs.access(skillMdPath) - } catch { - console.warn(`Warning: No SKILL.md found in ${entry}, skipping`) - continue - } - - const result = await parseSkillFile(skillDir, entry) - - if (result.errors) { - validationErrors.push(result.errors) - } else if (result.skill) { - skills[entry] = result.skill - console.log(` ✓ Parsed ${entry}`) - } - } - - // Report validation errors - if (validationErrors.length > 0) { - console.error("\nValidation errors:") - for (const { skillDir, errors } of validationErrors) { - console.error(`\n ${path.basename(skillDir)}:`) - for (const error of errors) { - console.error(` - ${error}`) - } - } - process.exit(1) - } - - // Check if any skills were found - if (Object.keys(skills).length === 0) { - console.error("Error: No valid skills found in built-in directory") - process.exit(1) - } - - // Generate TypeScript - const output = generateTypeScript(skills) - - // Write output file - await fs.writeFile(OUTPUT_FILE, output, "utf-8") - - // Format with prettier to ensure stable output - // Run from workspace root (3 levels up from src/services/skills/) to find .prettierrc.json - const workspaceRoot = path.resolve(__dirname, "..", "..", "..") - try { - execSync(`npx prettier --write "${OUTPUT_FILE}"`, { - cwd: workspaceRoot, - stdio: "pipe", - }) - console.log(`\n✓ Generated and formatted ${OUTPUT_FILE}`) - } catch { - console.log(`\n✓ Generated ${OUTPUT_FILE} (prettier not available)`) - } - console.log(` Skills: ${Object.keys(skills).join(", ")}`) -} - -main().catch((error) => { - console.error("Fatal error:", error) - process.exit(1) -}) diff --git a/src/shared/ProfileValidator.ts b/src/shared/ProfileValidator.ts index 3ca5b5616d..ae58763d6a 100644 --- a/src/shared/ProfileValidator.ts +++ b/src/shared/ProfileValidator.ts @@ -61,16 +61,11 @@ export class ProfileValidator { case "mistral": case "deepseek": case "xai": - case "groq": case "sambanova": - case "chutes": case "fireworks": - case "featherless": return profile.apiModelId case "litellm": return profile.litellmModelId - case "unbound": - return profile.unboundModelId case "lmstudio": return profile.lmStudioModelId case "vscode-lm": @@ -82,10 +77,6 @@ export class ProfileValidator { return profile.ollamaModelId case "requesty": return profile.requestyModelId - case "io-intelligence": - return profile.ioIntelligenceModelId - case "deepinfra": - return profile.deepInfraModelId case "fake-ai": default: return undefined diff --git a/src/shared/__tests__/ProfileValidator.spec.ts b/src/shared/__tests__/ProfileValidator.spec.ts index 04bd171696..9bf913cdc2 100644 --- a/src/shared/__tests__/ProfileValidator.spec.ts +++ b/src/shared/__tests__/ProfileValidator.spec.ts @@ -176,11 +176,8 @@ describe("ProfileValidator", () => { "mistral", "deepseek", "xai", - "groq", - "chutes", "sambanova", "fireworks", - "featherless", ] apiModelProviders.forEach((provider) => { @@ -216,22 +213,6 @@ describe("ProfileValidator", () => { expect(ProfileValidator.isProfileAllowed(profile, allowList)).toBe(true) }) - // Test for io-intelligence provider which uses ioIntelligenceModelId - it(`should extract ioIntelligenceModelId for io-intelligence provider`, () => { - const allowList: OrganizationAllowList = { - allowAll: false, - providers: { - "io-intelligence": { allowAll: false, models: ["test-model"] }, - }, - } - const profile: ProviderSettings = { - apiProvider: "io-intelligence" as any, - ioIntelligenceModelId: "test-model", - } - - expect(ProfileValidator.isProfileAllowed(profile, allowList)).toBe(true) - }) - it("should extract vsCodeLmModelSelector.id for vscode-lm provider", () => { const allowList: OrganizationAllowList = { allowAll: false, @@ -247,21 +228,6 @@ describe("ProfileValidator", () => { expect(ProfileValidator.isProfileAllowed(profile, allowList)).toBe(true) }) - it("should extract unboundModelId for unbound provider", () => { - const allowList: OrganizationAllowList = { - allowAll: false, - providers: { - unbound: { allowAll: false, models: ["unbound-model"] }, - }, - } - const profile: ProviderSettings = { - apiProvider: "unbound", - unboundModelId: "unbound-model", - } - - expect(ProfileValidator.isProfileAllowed(profile, allowList)).toBe(true) - }) - it("should extract lmStudioModelId for lmstudio provider", () => { const allowList: OrganizationAllowList = { allowAll: false, diff --git a/src/shared/__tests__/checkExistApiConfig.spec.ts b/src/shared/__tests__/checkExistApiConfig.spec.ts index 55dae005f2..d6dd1db24f 100644 --- a/src/shared/__tests__/checkExistApiConfig.spec.ts +++ b/src/shared/__tests__/checkExistApiConfig.spec.ts @@ -55,7 +55,6 @@ describe("checkExistKey", () => { mistralApiKey: undefined, vsCodeLmModelSelector: undefined, requestyApiKey: undefined, - unboundApiKey: undefined, } expect(checkExistKey(config)).toBe(false) }) diff --git a/src/shared/__tests__/embeddingModels.spec.ts b/src/shared/__tests__/embeddingModels.spec.ts new file mode 100644 index 0000000000..16aa019c7f --- /dev/null +++ b/src/shared/__tests__/embeddingModels.spec.ts @@ -0,0 +1,95 @@ +import { describe, it, expect } from "vitest" +import { + getModelDimension, + getModelScoreThreshold, + getDefaultModelId, + EMBEDDING_MODEL_PROFILES, +} from "../embeddingModels" + +describe("embeddingModels", () => { + describe("EMBEDDING_MODEL_PROFILES", () => { + it("should have gemini provider with gemini-embedding-001 model", () => { + const geminiProfiles = EMBEDDING_MODEL_PROFILES.gemini + expect(geminiProfiles).toBeDefined() + expect(geminiProfiles!["gemini-embedding-001"]).toBeDefined() + expect(geminiProfiles!["gemini-embedding-001"].dimension).toBe(3072) + }) + + it("should have deprecated text-embedding-004 in gemini profiles for backward compatibility", () => { + // This is critical for backward compatibility: + // Users with text-embedding-004 configured need dimension lookup to work + // even though the model is migrated to gemini-embedding-001 in GeminiEmbedder + const geminiProfiles = EMBEDDING_MODEL_PROFILES.gemini + expect(geminiProfiles).toBeDefined() + expect(geminiProfiles!["text-embedding-004"]).toBeDefined() + expect(geminiProfiles!["text-embedding-004"].dimension).toBe(3072) + }) + }) + + describe("getModelDimension", () => { + it("should return dimension for gemini-embedding-001", () => { + const dimension = getModelDimension("gemini", "gemini-embedding-001") + expect(dimension).toBe(3072) + }) + + it("should return dimension for deprecated text-embedding-004", () => { + // This ensures createVectorStore() works for users with text-embedding-004 configured + // The dimension should be 3072 (matching gemini-embedding-001) because: + // 1. GeminiEmbedder migrates text-embedding-004 to gemini-embedding-001 + // 2. gemini-embedding-001 produces 3072-dimensional embeddings + // 3. Vector store dimension must match the actual embedding dimension + const dimension = getModelDimension("gemini", "text-embedding-004") + expect(dimension).toBe(3072) + }) + + it("should return undefined for unknown model", () => { + const dimension = getModelDimension("gemini", "unknown-model") + expect(dimension).toBeUndefined() + }) + + it("should return undefined for unknown provider", () => { + const dimension = getModelDimension("unknown-provider" as any, "some-model") + expect(dimension).toBeUndefined() + }) + + it("should return correct dimensions for openai models", () => { + expect(getModelDimension("openai", "text-embedding-3-small")).toBe(1536) + expect(getModelDimension("openai", "text-embedding-3-large")).toBe(3072) + expect(getModelDimension("openai", "text-embedding-ada-002")).toBe(1536) + }) + }) + + describe("getModelScoreThreshold", () => { + it("should return score threshold for gemini-embedding-001", () => { + const threshold = getModelScoreThreshold("gemini", "gemini-embedding-001") + expect(threshold).toBe(0.4) + }) + + it("should return score threshold for deprecated text-embedding-004", () => { + const threshold = getModelScoreThreshold("gemini", "text-embedding-004") + expect(threshold).toBe(0.4) + }) + + it("should return undefined for unknown model", () => { + const threshold = getModelScoreThreshold("gemini", "unknown-model") + expect(threshold).toBeUndefined() + }) + }) + + describe("getDefaultModelId", () => { + it("should return gemini-embedding-001 for gemini provider", () => { + const defaultModel = getDefaultModelId("gemini") + expect(defaultModel).toBe("gemini-embedding-001") + }) + + it("should return text-embedding-3-small for openai provider", () => { + const defaultModel = getDefaultModelId("openai") + expect(defaultModel).toBe("text-embedding-3-small") + }) + + it("should return codestral-embed-2505 for mistral provider", () => { + const defaultModel = getDefaultModelId("mistral") + expect(defaultModel).toBe("codestral-embed-2505") + }) + }) +}) diff --git a/src/shared/__tests__/modes.spec.ts b/src/shared/__tests__/modes.spec.ts index e1d6612a14..ceb3cacb4d 100644 --- a/src/shared/__tests__/modes.spec.ts +++ b/src/shared/__tests__/modes.spec.ts @@ -19,19 +19,19 @@ describe("isToolAllowedForMode", () => { slug: "markdown-editor", name: "Markdown Editor", roleDefinition: "You are a markdown editor", - groups: ["read", ["edit", { fileRegex: "\\.md$" }], "browser"], + groups: ["read", ["edit", { fileRegex: "\\.md$" }]], }, { slug: "css-editor", name: "CSS Editor", roleDefinition: "You are a CSS editor", - groups: ["read", ["edit", { fileRegex: "\\.css$" }], "browser"], + groups: ["read", ["edit", { fileRegex: "\\.css$" }]], }, { slug: "test-exp-mode", name: "Test Exp Mode", roleDefinition: "You are an experimental tester", - groups: ["read", "edit", "browser"], + groups: ["read", "edit"], }, ] @@ -42,7 +42,6 @@ describe("isToolAllowedForMode", () => { it("allows unrestricted tools", () => { expect(isToolAllowedForMode("read_file", "markdown-editor", customModes)).toBe(true) - expect(isToolAllowedForMode("browser_action", "markdown-editor", customModes)).toBe(true) }) describe("file restrictions", () => { @@ -151,11 +150,7 @@ describe("isToolAllowedForMode", () => { slug: "docs-editor", name: "Documentation Editor", roleDefinition: "You are a documentation editor", - groups: [ - "read", - ["edit", { fileRegex: "\\.(md|txt)$", description: "Documentation files only" }], - "browser", - ], + groups: ["read", ["edit", { fileRegex: "\\.(md|txt)$", description: "Documentation files only" }]], }, ] @@ -243,7 +238,6 @@ describe("isToolAllowedForMode", () => { // Should maintain read capabilities expect(isToolAllowedForMode("read_file", "architect", [])).toBe(true) - expect(isToolAllowedForMode("browser_action", "architect", [])).toBe(true) expect(isToolAllowedForMode("use_mcp_tool", "architect", [])).toBe(true) }) @@ -535,7 +529,7 @@ describe("isToolAllowedForMode", () => { slug: "test-custom-tools", name: "Test Custom Tools Mode", roleDefinition: "You are a test mode", - groups: ["read", "edit", "browser"], + groups: ["read", "edit"], }, ] @@ -567,7 +561,7 @@ describe("isToolAllowedForMode", () => { slug: "no-edit-mode", name: "No Edit Mode", roleDefinition: "You have no edit powers", - groups: ["read", "browser"], // No edit group + groups: ["read"], // No edit group }, ] @@ -619,7 +613,7 @@ describe("FileRestrictionError", () => { name: "🪲 Debug", roleDefinition: "You are Roo, an expert software debugger specializing in systematic problem diagnosis and resolution.", - groups: ["read", "edit", "browser", "command", "mcp"], + groups: ["read", "edit", "command", "mcp"], }) expect(debugMode?.customInstructions).toContain( "Reflect on 5-7 different possible sources of the problem, distill those down to 1-2 most likely sources, and then add logs to validate your assumptions. Explicitly ask the user to confirm the diagnosis before fixing the problem.", diff --git a/src/shared/api.ts b/src/shared/api.ts index b2ba1e3542..7e999e1289 100644 --- a/src/shared/api.ts +++ b/src/shared/api.ts @@ -171,16 +171,11 @@ type CommonFetchParams = { const dynamicProviderExtras = { openrouter: {} as {}, // eslint-disable-line @typescript-eslint/no-empty-object-type "vercel-ai-gateway": {} as {}, // eslint-disable-line @typescript-eslint/no-empty-object-type - huggingface: {} as {}, // eslint-disable-line @typescript-eslint/no-empty-object-type litellm: {} as { apiKey: string; baseUrl: string }, - deepinfra: {} as { apiKey?: string; baseUrl?: string }, - "io-intelligence": {} as { apiKey: string }, requesty: {} as { apiKey?: string; baseUrl?: string }, - unbound: {} as { apiKey?: string }, ollama: {} as {}, // eslint-disable-line @typescript-eslint/no-empty-object-type lmstudio: {} as {}, // eslint-disable-line @typescript-eslint/no-empty-object-type roo: {} as { apiKey?: string; baseUrl?: string }, - chutes: {} as { apiKey?: string }, } as const satisfies Record // Build the dynamic options union from the map, intersected with CommonFetchParams diff --git a/src/shared/browserUtils.ts b/src/shared/browserUtils.ts deleted file mode 100644 index 4e071121c1..0000000000 --- a/src/shared/browserUtils.ts +++ /dev/null @@ -1,95 +0,0 @@ -/** - * Parses coordinate string and scales from image dimensions to viewport dimensions - * The LLM examines the screenshot it receives (which may be downscaled by the API) - * and reports coordinates in format: "x,y@widthxheight" where widthxheight is what the LLM observed - * - * Format: "x,y@widthxheight" (required) - * Returns: scaled coordinate string "x,y" in viewport coordinates - * Throws: Error if format is invalid or missing image dimensions - */ -export function scaleCoordinate(coordinate: string, viewportWidth: number, viewportHeight: number): string { - // Parse coordinate with required image dimensions (accepts both 'x' and ',' as dimension separators) - const match = coordinate.match(/^\s*(\d+)\s*,\s*(\d+)\s*@\s*(\d+)\s*[x,]\s*(\d+)\s*$/) - - if (!match) { - throw new Error( - `Invalid coordinate format: "${coordinate}". ` + - `Expected format: "x,y@widthxheight" (e.g., "450,300@1024x768")`, - ) - } - - const [, xStr, yStr, imgWidthStr, imgHeightStr] = match - const x = parseInt(xStr, 10) - const y = parseInt(yStr, 10) - const imgWidth = parseInt(imgWidthStr, 10) - const imgHeight = parseInt(imgHeightStr, 10) - - // Scale coordinates from image dimensions to viewport dimensions - const scaledX = Math.round((x / imgWidth) * viewportWidth) - const scaledY = Math.round((y / imgHeight) * viewportHeight) - - return `${scaledX},${scaledY}` -} - -/** - * Formats a key string into a more readable format (e.g., "Control+c" -> "Ctrl + C") - */ -export function prettyKey(k?: string): string { - if (!k) return "" - return k - .split("+") - .map((part) => { - const p = part.trim() - const lower = p.toLowerCase() - const map: Record = { - enter: "Enter", - tab: "Tab", - escape: "Esc", - esc: "Esc", - backspace: "Backspace", - space: "Space", - shift: "Shift", - control: "Ctrl", - ctrl: "Ctrl", - alt: "Alt", - meta: "Meta", - command: "Cmd", - cmd: "Cmd", - arrowup: "Arrow Up", - arrowdown: "Arrow Down", - arrowleft: "Arrow Left", - arrowright: "Arrow Right", - pageup: "Page Up", - pagedown: "Page Down", - home: "Home", - end: "End", - } - if (map[lower]) return map[lower] - const keyMatch = /^Key([A-Z])$/.exec(p) - if (keyMatch) return keyMatch[1].toUpperCase() - const digitMatch = /^Digit([0-9])$/.exec(p) - if (digitMatch) return digitMatch[1] - const spaced = p.replace(/([a-z])([A-Z])/g, "$1 $2") - return spaced.charAt(0).toUpperCase() + spaced.slice(1) - }) - .join(" + ") -} - -/** - * Wrapper around scaleCoordinate that handles failures gracefully by checking for simple coordinates - */ -export function getViewportCoordinate( - coord: string | undefined, - viewportWidth: number, - viewportHeight: number, -): string { - if (!coord) return "" - - try { - return scaleCoordinate(coord, viewportWidth, viewportHeight) - } catch (e) { - // Fallback to simple x,y parsing or return as is - const simpleMatch = /^\s*(\d+)\s*,\s*(\d+)/.exec(coord) - return simpleMatch ? `${simpleMatch[1]},${simpleMatch[2]}` : coord - } -} diff --git a/src/shared/embeddingModels.ts b/src/shared/embeddingModels.ts index a4c5217a9d..0b59c5b4b2 100644 --- a/src/shared/embeddingModels.ts +++ b/src/shared/embeddingModels.ts @@ -34,8 +34,10 @@ export const EMBEDDING_MODEL_PROFILES: EmbeddingModelProfiles = { }, }, gemini: { - "text-embedding-004": { dimension: 768 }, "gemini-embedding-001": { dimension: 3072, scoreThreshold: 0.4 }, + // Deprecated: text-embedding-004 is migrated to gemini-embedding-001 in GeminiEmbedder + // Kept here for backward-compatible dimension lookup in createVectorStore() + "text-embedding-004": { dimension: 3072, scoreThreshold: 0.4 }, }, mistral: { "codestral-embed-2505": { dimension: 1536, scoreThreshold: 0.4 }, diff --git a/src/shared/globalFileNames.ts b/src/shared/globalFileNames.ts index 98b48485f0..0b54ff6809 100644 --- a/src/shared/globalFileNames.ts +++ b/src/shared/globalFileNames.ts @@ -4,4 +4,6 @@ export const GlobalFileNames = { mcpSettings: "mcp_settings.json", customModes: "custom_modes.yaml", taskMetadata: "task_metadata.json", + historyItem: "history_item.json", + historyIndex: "_index.json", } diff --git a/src/shared/skills.ts b/src/shared/skills.ts index ae35b8c387..f5151181f6 100644 --- a/src/shared/skills.ts +++ b/src/shared/skills.ts @@ -5,9 +5,19 @@ export interface SkillMetadata { name: string // Required: skill identifier description: string // Required: when to use this skill - path: string // Absolute path to SKILL.md (or "" for built-in skills) - source: "global" | "project" | "built-in" // Where the skill was discovered - mode?: string // If set, skill is only available in this mode + path: string // Absolute path to SKILL.md + source: "global" | "project" // Where the skill was discovered + /** + * @deprecated Use modeSlugs instead. Kept for backward compatibility. + * If set, skill is only available in this mode. + */ + mode?: string + /** + * Mode slugs where this skill is available. + * - undefined or empty array means the skill is available in all modes ("Any mode"). + * - An array with one or more mode slugs restricts the skill to those modes. + */ + modeSlugs?: string[] } /** diff --git a/src/shared/tools.ts b/src/shared/tools.ts index 5d7435573c..491ba69361 100644 --- a/src/shared/tools.ts +++ b/src/shared/tools.ts @@ -1,14 +1,6 @@ import { Anthropic } from "@anthropic-ai/sdk" -import type { - ClineAsk, - ToolProgressStatus, - ToolGroup, - ToolName, - FileEntry, - BrowserActionParams, - GenerateImageParams, -} from "@roo-code/types" +import type { ClineAsk, ToolProgressStatus, ToolGroup, ToolName, GenerateImageParams } from "@roo-code/types" export type ToolResponse = string | Array @@ -66,17 +58,28 @@ export const toolParamNames = [ "todos", "prompt", "image", - "files", // Native protocol parameter for read_file + // read_file parameters (native protocol) "operations", // search_and_replace parameter for multiple operations "patch", // apply_patch parameter "file_path", // search_replace and edit_file parameter "old_string", // search_replace and edit_file parameter "new_string", // search_replace and edit_file parameter + "replace_all", // edit tool parameter for replacing all occurrences "expected_replacements", // edit_file parameter for multiple occurrences "artifact_id", // read_command_output parameter "search", // read_command_output parameter for grep-like search - "offset", // read_command_output parameter for pagination - "limit", // read_command_output parameter for max bytes to return + "offset", // read_command_output and read_file parameter + "limit", // read_command_output and read_file parameter + // read_file indentation mode parameters + "indentation", + "anchor_line", + "max_levels", + "include_siblings", + "include_header", + "max_lines", + // read_file legacy format parameter (backward compatibility) + "files", + "line_ranges", ] as const export type ToolParamName = (typeof toolParamNames)[number] @@ -87,12 +90,13 @@ export type ToolParamName = (typeof toolParamNames)[number] */ export type NativeToolArgs = { access_mcp_resource: { server_name: string; uri: string } - read_file: { files: FileEntry[] } + read_file: import("@roo-code/types").ReadFileToolParams read_command_output: { artifact_id: string; search?: string; offset?: number; limit?: number } attempt_completion: { result: string } execute_command: { command: string; cwd?: string } apply_diff: { path: string; diff: string } - search_and_replace: { path: string; operations: Array<{ search: string; replace: string }> } + edit: { file_path: string; old_string: string; new_string: string; replace_all?: boolean } + search_and_replace: { file_path: string; old_string: string; new_string: string; replace_all?: boolean } search_replace: { file_path: string; old_string: string; new_string: string } edit_file: { file_path: string; old_string: string; new_string: string; expected_replacements?: number } apply_patch: { patch: string } @@ -102,7 +106,6 @@ export type NativeToolArgs = { question: string follow_up: Array<{ text: string; mode?: string }> } - browser_action: BrowserActionParams codebase_search: { query: string; path?: string } generate_image: GenerateImageParams run_slash_command: { command: string; args?: string } @@ -135,6 +138,11 @@ export interface ToolUse { partial: boolean // nativeArgs is properly typed based on TName if it's in NativeToolArgs, otherwise never nativeArgs?: TName extends keyof NativeToolArgs ? NativeToolArgs[TName] : never + /** + * Flag indicating whether the tool call used a legacy/deprecated format. + * Used for telemetry tracking to monitor migration from old formats. + */ + usedLegacyFormat?: boolean } /** @@ -165,7 +173,23 @@ export interface ExecuteCommandToolUse extends ToolUse<"execute_command"> { export interface ReadFileToolUse extends ToolUse<"read_file"> { name: "read_file" - params: Partial, "args" | "path" | "start_line" | "end_line" | "files">> + params: Partial< + Pick< + Record, + | "args" + | "path" + | "start_line" + | "end_line" + | "mode" + | "offset" + | "limit" + | "indentation" + | "anchor_line" + | "max_levels" + | "include_siblings" + | "include_header" + > + > } export interface WriteToFileToolUse extends ToolUse<"write_to_file"> { @@ -188,11 +212,6 @@ export interface ListFilesToolUse extends ToolUse<"list_files"> { params: Partial, "path" | "recursive">> } -export interface BrowserActionToolUse extends ToolUse<"browser_action"> { - name: "browser_action" - params: Partial, "action" | "url" | "coordinate" | "text" | "size" | "path">> -} - export interface UseMcpToolToolUse extends ToolUse<"use_mcp_tool"> { name: "use_mcp_tool" params: Partial, "server_name" | "tool_name" | "arguments">> @@ -251,13 +270,13 @@ export const TOOL_DISPLAY_NAMES: Record = { read_command_output: "read command output", write_to_file: "write files", apply_diff: "apply changes", + edit: "edit files", search_and_replace: "apply changes using search and replace", search_replace: "apply single search and replace", edit_file: "edit files using search and replace", apply_patch: "apply patches using codex format", search_files: "search files", list_files: "list files", - browser_action: "use a browser", use_mcp_tool: "use mcp tools", access_mcp_resource: "access mcp resources", ask_followup_question: "ask questions", @@ -279,10 +298,7 @@ export const TOOL_GROUPS: Record = { }, edit: { tools: ["apply_diff", "write_to_file", "generate_image"], - customTools: ["search_and_replace", "search_replace", "edit_file", "apply_patch"], - }, - browser: { - tools: ["browser_action"], + customTools: ["edit", "search_replace", "edit_file", "apply_patch"], }, command: { tools: ["execute_command", "read_command_output"], @@ -319,6 +335,7 @@ export const ALWAYS_AVAILABLE_TOOLS: ToolName[] = [ */ export const TOOL_ALIASES: Record = { write_file: "write_to_file", + search_and_replace: "edit", } as const export type DiffResult = diff --git a/src/utils/__tests__/json-schema.spec.ts b/src/utils/__tests__/json-schema.spec.ts index c939095340..6f2096e626 100644 --- a/src/utils/__tests__/json-schema.spec.ts +++ b/src/utils/__tests__/json-schema.spec.ts @@ -86,9 +86,9 @@ describe("normalizeToolSchema", () => { type: "object", properties: { path: { type: "string" }, - line_ranges: { + tags: { type: ["array", "null"], - items: { type: "integer" }, + items: { type: "string" }, }, }, }, @@ -104,8 +104,8 @@ describe("normalizeToolSchema", () => { type: "object", properties: { path: { type: "string" }, - line_ranges: { - anyOf: [{ type: "array", items: { type: "integer" } }, { type: "null" }], + tags: { + anyOf: [{ type: "array", items: { type: "string" } }, { type: "null" }], }, }, additionalProperties: false, @@ -123,7 +123,7 @@ describe("normalizeToolSchema", () => { type: "object", properties: { path: { type: "string" }, - line_ranges: { + ranges: { type: ["array", "null"], items: { type: "array", @@ -131,7 +131,7 @@ describe("normalizeToolSchema", () => { }, }, }, - required: ["path", "line_ranges"], + required: ["path", "ranges"], }, }, }, @@ -144,7 +144,7 @@ describe("normalizeToolSchema", () => { const filesItems = properties.files.items as Record const filesItemsProps = filesItems.properties as Record> // Array-specific properties (items) should be moved inside the array variant - expect(filesItemsProps.line_ranges.anyOf).toEqual([ + expect(filesItemsProps.ranges.anyOf).toEqual([ { type: "array", items: { type: "array", items: { type: "integer" } } }, { type: "null" }, ]) @@ -224,60 +224,32 @@ describe("normalizeToolSchema", () => { const input = { type: "object", properties: { - files: { - type: "array", - description: "List of files to read", - items: { - type: "object", - properties: { - path: { - type: "string", - description: "Path to the file", - }, - line_ranges: { - type: ["array", "null"], - description: "Optional line ranges", - items: { - type: "array", - items: { type: "integer" }, - minItems: 2, - maxItems: 2, - }, - }, + path: { + type: "string", + description: "Path to the file", + }, + indentation: { + type: ["object", "null"], + properties: { + anchor_line: { + type: ["integer", "null"], }, - required: ["path", "line_ranges"], - additionalProperties: false, }, - minItems: 1, }, }, - required: ["files"], + required: ["path"], additionalProperties: false, } const result = normalizeToolSchema(input) - // Verify the line_ranges was transformed with items inside the array variant - const files = (result.properties as Record).files as Record - const items = files.items as Record - const props = items.properties as Record> - // Array-specific properties (items, minItems, maxItems) should be moved inside the array variant - expect(props.line_ranges.anyOf).toEqual([ - { - type: "array", - items: { - type: "array", - items: { type: "integer" }, - minItems: 2, - maxItems: 2, - }, - }, - { type: "null" }, - ]) - // items should NOT be at root level anymore - expect(props.line_ranges.items).toBeUndefined() - // Other properties are preserved at root level - expect(props.line_ranges.description).toBe("Optional line ranges") + // Verify nested nullable objects are transformed correctly + const props = result.properties as Record> + expect(props.indentation.anyOf).toEqual([{ type: "object" }, { type: "null" }]) + expect(props.indentation.additionalProperties).toBe(false) + expect((props.indentation.properties as Record).anchor_line).toEqual({ + anyOf: [{ type: "integer" }, { type: "null" }], + }) }) describe("format field handling", () => { diff --git a/src/utils/__tests__/tool-id.spec.ts b/src/utils/__tests__/tool-id.spec.ts index c047184417..2459786cea 100644 --- a/src/utils/__tests__/tool-id.spec.ts +++ b/src/utils/__tests__/tool-id.spec.ts @@ -47,6 +47,14 @@ describe("sanitizeToolUseId", () => { it("should replace multiple invalid characters", () => { expect(sanitizeToolUseId("mcp.server:tool/name")).toBe("mcp_server_tool_name") }) + + it("should sanitize Gemini/OpenRouter function call IDs with dots and colons", () => { + // This is the exact pattern seen in PostHog errors where tool_result IDs + // didn't match tool_use IDs due to missing sanitization + expect(sanitizeToolUseId("functions.read_file:0")).toBe("functions_read_file_0") + expect(sanitizeToolUseId("functions.write_to_file:1")).toBe("functions_write_to_file_1") + expect(sanitizeToolUseId("read_file:0")).toBe("read_file_0") + }) }) describe("real-world MCP tool use ID patterns", () => { diff --git a/webview-ui/browser-panel.html b/webview-ui/browser-panel.html deleted file mode 100644 index 92943abfe3..0000000000 --- a/webview-ui/browser-panel.html +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - Browser Session - - -
- - - \ No newline at end of file diff --git a/webview-ui/package.json b/webview-ui/package.json index d72c6a1a2c..7722f4119f 100644 --- a/webview-ui/package.json +++ b/webview-ui/package.json @@ -33,7 +33,6 @@ "@roo-code/types": "workspace:^", "@tailwindcss/vite": "^4.0.0", "@tanstack/react-query": "^5.68.0", - "@types/qrcode": "^1.5.5", "@vscode/codicons": "^0.0.36", "@vscode/webview-ui-toolkit": "^1.4.0", "axios": "^1.12.0", @@ -55,7 +54,6 @@ "mermaid": "^11.4.1", "posthog-js": "^1.227.2", "pretty-bytes": "^7.0.0", - "qrcode": "^1.5.4", "react": "^18.3.1", "react-dom": "^18.3.1", "react-compiler-runtime": "^1.0.0", diff --git a/webview-ui/src/__tests__/App.spec.tsx b/webview-ui/src/__tests__/App.spec.tsx index e8e08782da..e04bc14200 100644 --- a/webview-ui/src/__tests__/App.spec.tsx +++ b/webview-ui/src/__tests__/App.spec.tsx @@ -193,7 +193,7 @@ describe("App", () => { const chatView = screen.getByTestId("chat-view") expect(chatView).toBeInTheDocument() expect(chatView.getAttribute("data-hidden")).toBe("false") - }) + }, 10000) it("switches to settings view when receiving settingsButtonClicked action", async () => { render() diff --git a/webview-ui/src/__tests__/FileChangesPanel.spec.tsx b/webview-ui/src/__tests__/FileChangesPanel.spec.tsx new file mode 100644 index 0000000000..b28102b1fe --- /dev/null +++ b/webview-ui/src/__tests__/FileChangesPanel.spec.tsx @@ -0,0 +1,175 @@ +import React from "react" +import { fireEvent, render, screen } from "@/utils/test-utils" +import type { ClineMessage } from "@roo-code/types" +import { TranslationProvider } from "@/i18n/__mocks__/TranslationContext" +import FileChangesPanel from "../components/chat/FileChangesPanel" + +const mockPostMessage = vi.fn() + +vi.mock("@src/utils/vscode", () => ({ + vscode: { + postMessage: (...args: unknown[]) => mockPostMessage(...args), + }, +})) + +// Mock i18n to return readable header with count +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string, opts?: { count?: number }) => { + if (key === "chat:fileChangesInConversation.header" && opts?.count != null) { + return `${opts.count} file(s) changed in this conversation` + } + return key + }, + }), +})) + +// Lightweight mock so we don't pull in CodeBlock/DiffView +vi.mock("@src/components/common/CodeAccordian", () => ({ + default: ({ + path, + isExpanded, + onToggleExpand, + }: { + path?: string + isExpanded: boolean + onToggleExpand: () => void + }) => ( +
+ {path} + +
+ ), +})) + +function createFileEditMessage(path: string, diff: string): ClineMessage { + return { + type: "ask", + ask: "tool", + ts: Date.now(), + partial: false, + isAnswered: true, + text: JSON.stringify({ + tool: "appliedDiff", + path, + diff, + }), + } +} + +function renderPanel(messages: ClineMessage[] | undefined) { + return render( + + + , + ) +} + +describe("FileChangesPanel", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it("renders nothing when clineMessages is undefined", () => { + const { container } = renderPanel(undefined) + expect(container.firstChild).toBeNull() + }) + + it("renders nothing when clineMessages is empty", () => { + const { container } = renderPanel([]) + expect(container.firstChild).toBeNull() + }) + + it("renders nothing when there are no file-edit messages", () => { + const messages: ClineMessage[] = [ + { + type: "say", + say: "text", + ts: Date.now(), + partial: false, + text: "hello", + }, + { + type: "ask", + ask: "tool", + ts: Date.now(), + partial: false, + text: JSON.stringify({ tool: "read_file", path: "x.ts" }), + }, + ] + const { container } = renderPanel(messages) + expect(container.firstChild).toBeNull() + }) + + it("renders nothing when file-edit ask tool is not approved (isAnswered false or missing)", () => { + const messages: ClineMessage[] = [ + { + type: "ask", + ask: "tool", + ts: Date.now(), + partial: false, + text: JSON.stringify({ + tool: "appliedDiff", + path: "src/foo.ts", + diff: "+line", + }), + }, + ] + const { container } = renderPanel(messages) + expect(container.firstChild).toBeNull() + }) + + it("renders panel with header when there is one file edit", () => { + const messages = [createFileEditMessage("src/foo.ts", "@@ -1 +1 @@\n+line")] + renderPanel(messages) + + expect(screen.getByText("1 file(s) changed in this conversation")).toBeInTheDocument() + // Expand panel so file row is in DOM (CollapsibleContent may not render when closed in some setups) + fireEvent.click(screen.getByText("1 file(s) changed in this conversation").closest("button")!) + expect(screen.getByTestId("accordian-path")).toHaveTextContent("src/foo.ts") + }) + + it("renders one row per unique path when multiple files edited", () => { + const messages = [createFileEditMessage("src/a.ts", "diff a"), createFileEditMessage("src/b.ts", "diff b")] + renderPanel(messages) + + expect(screen.getByText("2 file(s) changed in this conversation")).toBeInTheDocument() + // Expand panel so file rows are rendered + fireEvent.click(screen.getByText("2 file(s) changed in this conversation").closest("button")!) + const paths = screen.getAllByTestId("accordian-path") + expect(paths).toHaveLength(2) + expect(paths.map((el) => el.textContent)).toEqual(expect.arrayContaining(["src/a.ts", "src/b.ts"])) + }) + + it("collapsed by default: panel trigger shows chevron and expanding reveals file rows", () => { + const messages = [createFileEditMessage("src/foo.ts", "diff")] + renderPanel(messages) + + // Header visible + const headerText = screen.getByText("1 file(s) changed in this conversation") + expect(headerText).toBeInTheDocument() + // Trigger is the button that contains the header text + const trigger = headerText.closest("button") + expect(trigger).toBeInTheDocument() + + // Expand panel + fireEvent.click(trigger!) + expect(screen.getByTestId("accordian-path")).toHaveTextContent("src/foo.ts") + }) + + it("toggling a file row expand calls onToggleExpand", () => { + const messages = [createFileEditMessage("src/foo.ts", "diff")] + renderPanel(messages) + + // Expand panel first so the file row is rendered + const headerText = screen.getByText("1 file(s) changed in this conversation") + fireEvent.click(headerText.closest("button")!) + + const accordianToggle = screen.getByTestId("accordian-toggle") + expect(accordianToggle).toHaveTextContent("collapsed") + fireEvent.click(accordianToggle) + expect(accordianToggle).toHaveTextContent("expanded") + }) +}) diff --git a/webview-ui/src/__tests__/fileChangesFromMessages.spec.ts b/webview-ui/src/__tests__/fileChangesFromMessages.spec.ts new file mode 100644 index 0000000000..8fab8b14d5 --- /dev/null +++ b/webview-ui/src/__tests__/fileChangesFromMessages.spec.ts @@ -0,0 +1,280 @@ +import type { ClineMessage } from "@roo-code/types" +import { fileChangesFromMessages } from "../components/chat/utils/fileChangesFromMessages" + +function msg(overrides: Partial & { text: string }): ClineMessage { + return { + type: "say", + say: "tool", + ts: Date.now(), + partial: false, + ...overrides, + } +} + +describe("fileChangesFromMessages", () => { + it("returns empty array for undefined messages", () => { + expect(fileChangesFromMessages(undefined)).toEqual([]) + }) + + it("returns empty array for empty messages", () => { + expect(fileChangesFromMessages([])).toEqual([]) + }) + + it("ignores non-tool messages", () => { + const messages: ClineMessage[] = [ + msg({ type: "say", say: "text", text: "hello" }), + msg({ type: "ask", ask: "followup", text: "world" }), + ] + expect(fileChangesFromMessages(messages)).toEqual([]) + }) + + it("ignores tool messages with non-file-edit tool type", () => { + const messages: ClineMessage[] = [ + msg({ + type: "ask", + ask: "tool", + text: JSON.stringify({ tool: "read_file", path: "a.ts" }), + }), + ] + expect(fileChangesFromMessages(messages)).toEqual([]) + }) + + it("skips partial messages", () => { + const messages: ClineMessage[] = [ + msg({ + type: "ask", + ask: "tool", + partial: true, + text: JSON.stringify({ + tool: "appliedDiff", + path: "src/file.ts", + diff: "+x", + }), + }), + ] + expect(fileChangesFromMessages(messages)).toEqual([]) + }) + + it("excludes ask tool file-edit when isAnswered is false or undefined", () => { + const payload = JSON.stringify({ + tool: "appliedDiff", + path: "src/foo.ts", + diff: "+line", + }) + expect(fileChangesFromMessages([msg({ type: "ask", ask: "tool", text: payload, isAnswered: false })])).toEqual( + [], + ) + expect(fileChangesFromMessages([msg({ type: "ask", ask: "tool", text: payload })])).toEqual([]) + }) + + it("includes ask tool file-edit when isAnswered is true", () => { + const messages: ClineMessage[] = [ + msg({ + type: "ask", + ask: "tool", + isAnswered: true, + text: JSON.stringify({ + tool: "appliedDiff", + path: "src/foo.ts", + diff: "+line", + }), + }), + ] + const result = fileChangesFromMessages(messages) + expect(result).toHaveLength(1) + expect(result[0].path).toBe("src/foo.ts") + }) + + it("extracts single-file edit from ask tool message", () => { + const messages: ClineMessage[] = [ + msg({ + type: "ask", + ask: "tool", + isAnswered: true, + text: JSON.stringify({ + tool: "appliedDiff", + path: "src/foo.ts", + diff: "@@ -1 +1 @@\n+line", + diffStats: { added: 1, removed: 0 }, + }), + }), + ] + const result = fileChangesFromMessages(messages) + expect(result).toHaveLength(1) + expect(result[0]).toEqual({ + path: "src/foo.ts", + diff: "@@ -1 +1 @@\n+line", + diffStats: { added: 1, removed: 0 }, + }) + }) + + it("extracts single-file edit from say tool message", () => { + const messages: ClineMessage[] = [ + msg({ + type: "say", + say: "tool", + text: JSON.stringify({ + tool: "editedExistingFile", + path: "lib/bar.ts", + diff: "-old\n+new", + }), + }), + ] + const result = fileChangesFromMessages(messages) + expect(result).toHaveLength(1) + expect(result[0].path).toBe("lib/bar.ts") + expect(result[0].diff).toBe("-old\n+new") + }) + + it("uses content when diff is missing for single-file", () => { + const messages: ClineMessage[] = [ + msg({ + type: "ask", + ask: "tool", + isAnswered: true, + text: JSON.stringify({ + tool: "newFileCreated", + path: "new.ts", + content: "full file content", + }), + }), + ] + const result = fileChangesFromMessages(messages) + expect(result).toHaveLength(1) + expect(result[0].diff).toBe("full file content") + }) + + it("ignores single-file tool when path is missing", () => { + const messages: ClineMessage[] = [ + msg({ + type: "ask", + ask: "tool", + text: JSON.stringify({ + tool: "appliedDiff", + diff: "something", + }), + }), + ] + expect(fileChangesFromMessages(messages)).toEqual([]) + }) + + it("ignores single-file tool when diff and content are empty", () => { + const messages: ClineMessage[] = [ + msg({ + type: "ask", + ask: "tool", + text: JSON.stringify({ + tool: "appliedDiff", + path: "x.ts", + }), + }), + ] + expect(fileChangesFromMessages(messages)).toEqual([]) + }) + + it("extracts from batchDiffs", () => { + const messages: ClineMessage[] = [ + msg({ + type: "ask", + ask: "tool", + isAnswered: true, + text: JSON.stringify({ + tool: "appliedDiff", + batchDiffs: [ + { path: "a.ts", content: "content a" }, + { path: "b.ts", diffs: [{ content: "content b" }] }, + { path: "c.ts" }, // no content + ], + }), + }), + ] + const result = fileChangesFromMessages(messages) + expect(result).toHaveLength(2) + expect(result[0]).toEqual({ path: "a.ts", diff: "content a" }) + expect(result[1].path).toBe("b.ts") + expect(result[1].diff).toBe("content b") + }) + + it("includes diffStats from batchDiffs when present", () => { + const messages: ClineMessage[] = [ + msg({ + type: "ask", + ask: "tool", + isAnswered: true, + text: JSON.stringify({ + tool: "appliedDiff", + batchDiffs: [ + { + path: "f.ts", + content: "x", + diffStats: { added: 2, removed: 1 }, + }, + ], + }), + }), + ] + const result = fileChangesFromMessages(messages) + expect(result[0].diffStats).toEqual({ added: 2, removed: 1 }) + }) + + it("recognizes all ClineSayTool file-edit tool names (editedExistingFile, appliedDiff, newFileCreated)", () => { + const tools = ["editedExistingFile", "appliedDiff", "newFileCreated"] + for (const tool of tools) { + const messages: ClineMessage[] = [ + msg({ + type: "ask", + ask: "tool", + isAnswered: true, + text: JSON.stringify({ + tool, + path: "f.ts", + diff: "d", + }), + }), + ] + const result = fileChangesFromMessages(messages) + expect(result).toHaveLength(1) + expect(result[0].path).toBe("f.ts") + } + }) + + it("returns multiple entries for multiple file-edit messages", () => { + const messages: ClineMessage[] = [ + msg({ + type: "ask", + ask: "tool", + isAnswered: true, + text: JSON.stringify({ + tool: "appliedDiff", + path: "first.ts", + diff: "a", + }), + }), + msg({ + type: "ask", + ask: "tool", + isAnswered: true, + text: JSON.stringify({ + tool: "editedExistingFile", + path: "second.ts", + diff: "b", + }), + }), + ] + const result = fileChangesFromMessages(messages) + expect(result).toHaveLength(2) + expect(result[0].path).toBe("first.ts") + expect(result[1].path).toBe("second.ts") + }) + + it("skips invalid JSON in message text", () => { + const messages: ClineMessage[] = [ + msg({ + type: "ask", + ask: "tool", + text: "not json", + }), + ] + expect(fileChangesFromMessages(messages)).toEqual([]) + }) +}) diff --git a/webview-ui/src/browser-panel.tsx b/webview-ui/src/browser-panel.tsx deleted file mode 100644 index a7f5af891e..0000000000 --- a/webview-ui/src/browser-panel.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import { StrictMode } from "react" -import { createRoot } from "react-dom/client" - -import "./index.css" -import BrowserSessionPanel from "./components/browser-session/BrowserSessionPanel" -import "../node_modules/@vscode/codicons/dist/codicon.css" - -createRoot(document.getElementById("root")!).render( - - - , -) diff --git a/webview-ui/src/components/__tests__/ErrorBoundary.spec.tsx b/webview-ui/src/components/__tests__/ErrorBoundary.spec.tsx deleted file mode 100644 index 1fbb6774f2..0000000000 --- a/webview-ui/src/components/__tests__/ErrorBoundary.spec.tsx +++ /dev/null @@ -1,97 +0,0 @@ -import React from "react" -import { render, screen } from "@testing-library/react" - -import ErrorBoundary from "../ErrorBoundary" - -// Mock telemetryClient -vi.mock("@src/utils/TelemetryClient", () => ({ - telemetryClient: { - capture: vi.fn(), - }, -})) - -// Mock translation -vi.mock("react-i18next", () => ({ - withTranslation: () => (Component: any) => { - Component.defaultProps = { - ...Component.defaultProps, - t: (key: string) => { - // Mock translations for tests - const translations: Record = { - "errorBoundary.title": "Something went wrong", - "errorBoundary.reportText": "Please help us improve by reporting this error on", - "errorBoundary.githubText": "GitHub", - "errorBoundary.copyInstructions": "Please copy and paste the following error message:", - } - return translations[key] || key - }, - } - return Component - }, -})) - -// Test component that throws an error -const ErrorThrowingComponent = ({ shouldThrow = false }) => { - if (shouldThrow) { - throw new Error("Test error") - } - return
Content rendered normally
-} - -describe("ErrorBoundary", () => { - // Suppress console errors during tests - const originalConsoleError = console.error - beforeAll(() => { - console.error = vi.fn() - }) - afterAll(() => { - console.error = originalConsoleError - }) - - test("renders children when no error occurs", () => { - render( - - - , - ) - - expect(screen.getByTestId("normal-render")).toBeInTheDocument() - }) - - test("renders error UI when an error occurs", () => { - // React will log the error to the console - we're just testing the UI behavior - render( - - - , - ) - - // Verify error message is displayed using a more flexible approach - const errorTitle = screen.getByRole("heading", { level: 2 }) - expect(errorTitle.textContent).toContain("Something went wrong") - expect(screen.getByText(/please copy and paste the following error message/i)).toBeInTheDocument() - }) - - test("error boundary renders error UI when component changes but still in error state", () => { - const { rerender } = render( - - - , - ) - - // Verify error message is displayed using a more flexible approach - const errorTitle = screen.getByRole("heading", { level: 2 }) - expect(errorTitle.textContent).toContain("Something went wrong") - - // Update the component to not throw - rerender( - - - , - ) - - // The error boundary should still show the error since it doesn't automatically reset - const errorTitleAfterRerender = screen.getByRole("heading", { level: 2 }) - expect(errorTitleAfterRerender.textContent).toContain("Something went wrong") - }) -}) diff --git a/webview-ui/src/components/browser-session/BrowserPanelStateProvider.tsx b/webview-ui/src/components/browser-session/BrowserPanelStateProvider.tsx deleted file mode 100644 index 8430c772aa..0000000000 --- a/webview-ui/src/components/browser-session/BrowserPanelStateProvider.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import React, { createContext, useContext, useState, useEffect, useCallback } from "react" - -import { type ExtensionMessage } from "@roo-code/types" - -interface BrowserPanelState { - browserViewportSize: string - isBrowserSessionActive: boolean - language: string -} - -const BrowserPanelStateContext = createContext(undefined) - -export const BrowserPanelStateProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { - const [state, setState] = useState({ - browserViewportSize: "900x600", - isBrowserSessionActive: false, - language: "en", - }) - - const handleMessage = useCallback((event: MessageEvent) => { - const message: ExtensionMessage = event.data - - switch (message.type) { - case "state": - if (message.state) { - setState((prev) => ({ - ...prev, - browserViewportSize: message.state?.browserViewportSize || "900x600", - isBrowserSessionActive: message.state?.isBrowserSessionActive || false, - language: message.state?.language || "en", - })) - } - break - case "browserSessionUpdate": - if (message.isBrowserSessionActive !== undefined) { - setState((prev) => ({ - ...prev, - isBrowserSessionActive: message.isBrowserSessionActive || false, - })) - } - break - } - }, []) - - useEffect(() => { - window.addEventListener("message", handleMessage) - return () => { - window.removeEventListener("message", handleMessage) - } - }, [handleMessage]) - - return {children} -} - -export const useBrowserPanelState = () => { - const context = useContext(BrowserPanelStateContext) - if (context === undefined) { - throw new Error("useBrowserPanelState must be used within a BrowserPanelStateProvider") - } - return context -} diff --git a/webview-ui/src/components/browser-session/BrowserSessionPanel.tsx b/webview-ui/src/components/browser-session/BrowserSessionPanel.tsx deleted file mode 100644 index d9667c56f1..0000000000 --- a/webview-ui/src/components/browser-session/BrowserSessionPanel.tsx +++ /dev/null @@ -1,106 +0,0 @@ -import React, { useEffect, useState } from "react" - -import { type ClineMessage, type ExtensionMessage } from "@roo-code/types" - -import { TooltipProvider } from "@src/components/ui/tooltip" -import TranslationProvider from "@src/i18n/TranslationContext" -import { vscode } from "@src/utils/vscode" - -import { ExtensionStateContextProvider } from "@/context/ExtensionStateContext" - -import BrowserSessionRow from "../chat/BrowserSessionRow" -import ErrorBoundary from "../ErrorBoundary" - -import { BrowserPanelStateProvider, useBrowserPanelState } from "./BrowserPanelStateProvider" - -interface BrowserSessionPanelState { - messages: ClineMessage[] -} - -const BrowserSessionPanelContent: React.FC = () => { - const { browserViewportSize, isBrowserSessionActive } = useBrowserPanelState() - const [state, setState] = useState({ - messages: [], - }) - // Target page index to navigate BrowserSessionRow to - const [navigateToStepIndex, setNavigateToStepIndex] = useState(undefined) - - const [expandedRows, setExpandedRows] = useState>({}) - - useEffect(() => { - const handleMessage = (event: MessageEvent) => { - const message: ExtensionMessage = event.data - - switch (message.type) { - case "browserSessionUpdate": - if (message.browserSessionMessages) { - setState((prev) => ({ - ...prev, - messages: message.browserSessionMessages || [], - })) - } - break - case "browserSessionNavigate": - if (typeof message.stepIndex === "number" && message.stepIndex >= 0) { - setNavigateToStepIndex(message.stepIndex) - } - break - } - } - - window.addEventListener("message", handleMessage) - - return () => { - window.removeEventListener("message", handleMessage) - } - }, []) - - return ( -
- expandedRows[messageTs] ?? false} - onToggleExpand={(messageTs: number) => { - setExpandedRows((prev: Record) => ({ - ...prev, - [messageTs]: !prev[messageTs], - })) - }} - fullScreen={true} - browserViewportSizeProp={browserViewportSize} - isBrowserSessionActiveProp={isBrowserSessionActive} - navigateToPageIndex={navigateToStepIndex} - /> -
- ) -} - -const BrowserSessionPanel: React.FC = () => { - // Ensure the panel receives initial state and becomes "ready" without needing a second click - useEffect(() => { - try { - vscode.postMessage({ type: "webviewDidLaunch" }) - } catch { - // Ignore errors during initial launch - } - }, []) - - return ( - - - - - - - - - - - - ) -} - -export default BrowserSessionPanel diff --git a/webview-ui/src/components/chat/Announcement.tsx b/webview-ui/src/components/chat/Announcement.tsx index 7e13c34de6..4f49f8230f 100644 --- a/webview-ui/src/components/chat/Announcement.tsx +++ b/webview-ui/src/components/chat/Announcement.tsx @@ -44,7 +44,9 @@ const Announcement = ({ hideAnnouncement }: AnnouncementProps) => {

{t("chat:announcement.release.heading")}

    -
  • {t("chat:announcement.release.smartCodeFolding")}
  • +
  • {t("chat:announcement.release.geminiPro")}
  • +
  • {t("chat:announcement.release.cliNdjson")}
  • +
  • {t("chat:announcement.release.cliRelease")}
diff --git a/webview-ui/src/components/chat/ApiConfigSelector.tsx b/webview-ui/src/components/chat/ApiConfigSelector.tsx index 4396019a2d..e370296ec3 100644 --- a/webview-ui/src/components/chat/ApiConfigSelector.tsx +++ b/webview-ui/src/components/chat/ApiConfigSelector.tsx @@ -20,6 +20,8 @@ interface ApiConfigSelectorProps { listApiConfigMeta: Array<{ id: string; name: string; modelId?: string }> pinnedApiConfigs?: Record togglePinnedApiConfig: (id: string) => void + lockApiConfigAcrossModes: boolean + onToggleLockApiConfig: () => void } export const ApiConfigSelector = ({ @@ -32,6 +34,8 @@ export const ApiConfigSelector = ({ listApiConfigMeta, pinnedApiConfigs, togglePinnedApiConfig, + lockApiConfigAcrossModes, + onToggleLockApiConfig, }: ApiConfigSelectorProps) => { const { t } = useAppTranslation() const [open, setOpen] = useState(false) @@ -228,6 +232,16 @@ export const ApiConfigSelector = ({ onClick={handleEditClick} tooltip={false} /> + {/* Info icon and title on the right with matching spacing */} diff --git a/webview-ui/src/components/chat/AutoApproveDropdown.tsx b/webview-ui/src/components/chat/AutoApproveDropdown.tsx index 857eb5cfb1..8a5b8adfd6 100644 --- a/webview-ui/src/components/chat/AutoApproveDropdown.tsx +++ b/webview-ui/src/components/chat/AutoApproveDropdown.tsx @@ -34,7 +34,6 @@ export const AutoApproveDropdown = ({ disabled = false, triggerClassName = "" }: setAlwaysAllowReadOnly, setAlwaysAllowWrite, setAlwaysAllowExecute, - setAlwaysAllowBrowser, setAlwaysAllowMcp, setAlwaysAllowModeSwitch, setAlwaysAllowSubtasks, @@ -57,9 +56,6 @@ export const AutoApproveDropdown = ({ disabled = false, triggerClassName = "" }: case "alwaysAllowExecute": setAlwaysAllowExecute(value) break - case "alwaysAllowBrowser": - setAlwaysAllowBrowser(value) - break case "alwaysAllowMcp": setAlwaysAllowMcp(value) break @@ -85,7 +81,6 @@ export const AutoApproveDropdown = ({ disabled = false, triggerClassName = "" }: setAlwaysAllowReadOnly, setAlwaysAllowWrite, setAlwaysAllowExecute, - setAlwaysAllowBrowser, setAlwaysAllowMcp, setAlwaysAllowModeSwitch, setAlwaysAllowSubtasks, diff --git a/webview-ui/src/components/chat/BatchDiffApproval.tsx b/webview-ui/src/components/chat/BatchDiffApproval.tsx index a88914cd88..f128e4310d 100644 --- a/webview-ui/src/components/chat/BatchDiffApproval.tsx +++ b/webview-ui/src/components/chat/BatchDiffApproval.tsx @@ -35,12 +35,12 @@ export const BatchDiffApproval = memo(({ files = [], ts }: BatchDiffApprovalProp return (
- {files.map((file) => { + {files.map((file, index) => { // Use backend-provided unified diff only. Stats also provided by backend. const unified = file.content || "" return ( -
+
{/* Individual files */}
- {files.map((file) => { + {files.map((file, index) => { return ( -
+
vscode.postMessage({ type: "openFile", text: file.content })}> diff --git a/webview-ui/src/components/chat/BatchListFilesPermission.tsx b/webview-ui/src/components/chat/BatchListFilesPermission.tsx new file mode 100644 index 0000000000..a5d08c244b --- /dev/null +++ b/webview-ui/src/components/chat/BatchListFilesPermission.tsx @@ -0,0 +1,45 @@ +import { memo } from "react" + +import { ToolUseBlock, ToolUseBlockHeader } from "../common/ToolUseBlock" +import { PathTooltip } from "../ui/PathTooltip" + +interface DirPermissionItem { + path: string + key: string +} + +interface BatchListFilesPermissionProps { + dirs: DirPermissionItem[] + ts: number +} + +export const BatchListFilesPermission = memo(({ dirs = [], ts }: BatchListFilesPermissionProps) => { + if (!dirs?.length) { + return null + } + + return ( +
+
+ {dirs.map((dir, index) => { + return ( +
+ + + + + {dir.path} + + +
+
+
+
+ ) + })} +
+
+ ) +}) + +BatchListFilesPermission.displayName = "BatchListFilesPermission" diff --git a/webview-ui/src/components/chat/BrowserActionRow.tsx b/webview-ui/src/components/chat/BrowserActionRow.tsx deleted file mode 100644 index abc0983280..0000000000 --- a/webview-ui/src/components/chat/BrowserActionRow.tsx +++ /dev/null @@ -1,195 +0,0 @@ -import { memo, useMemo, useEffect, useRef } from "react" -import { useTranslation } from "react-i18next" -import { - MousePointer as MousePointerIcon, - Keyboard, - ArrowDown, - ArrowUp, - Pointer, - Play, - Check, - Maximize2, - Camera, -} from "lucide-react" - -import type { ClineMessage, ClineSayBrowserAction } from "@roo-code/types" - -import { getViewportCoordinate as getViewportCoordinateShared, prettyKey } from "@roo/browserUtils" - -import { vscode } from "@src/utils/vscode" -import { useExtensionState } from "@src/context/ExtensionStateContext" - -interface BrowserActionRowProps { - message: ClineMessage - nextMessage?: ClineMessage - actionIndex?: number - totalActions?: number -} - -// Get icon for each action type -const getActionIcon = (action: string) => { - switch (action) { - case "click": - return - case "type": - case "press": - return - case "scroll_down": - return - case "scroll_up": - return - case "launch": - return - case "close": - return - case "resize": - return - case "screenshot": - return - case "hover": - default: - return - } -} - -const BrowserActionRow = memo(({ message, nextMessage, actionIndex, totalActions }: BrowserActionRowProps) => { - const { t } = useTranslation() - const { isBrowserSessionActive } = useExtensionState() - const hasHandledAutoOpenRef = useRef(false) - - // Parse this specific browser action - const browserAction = useMemo(() => { - try { - return JSON.parse(message.text || "{}") as ClineSayBrowserAction - } catch { - return null - } - }, [message.text]) - - // Get viewport dimensions from the result message if available - const viewportDimensions = useMemo(() => { - if (!nextMessage || nextMessage.say !== "browser_action_result") return null - try { - const result = JSON.parse(nextMessage.text || "{}") - return { - width: result.viewportWidth, - height: result.viewportHeight, - } - } catch { - return null - } - }, [nextMessage]) - - // Format action display text - const actionText = useMemo(() => { - if (!browserAction) return t("chat:browser.actions.title") - - // Helper to scale coordinates from screenshot dimensions to viewport dimensions - // Matches the backend's scaleCoordinate function logic - const getViewportCoordinate = (coord?: string): string => - getViewportCoordinateShared(coord, viewportDimensions?.width ?? 0, viewportDimensions?.height ?? 0) - - switch (browserAction.action) { - case "launch": - return t("chat:browser.actions.launched") - case "click": - return t("chat:browser.actions.clicked", { - coordinate: browserAction.executedCoordinate || getViewportCoordinate(browserAction.coordinate), - }) - case "type": - return t("chat:browser.actions.typed", { text: browserAction.text }) - case "press": - return t("chat:browser.actions.pressed", { key: prettyKey(browserAction.text) }) - case "hover": - return t("chat:browser.actions.hovered", { - coordinate: browserAction.executedCoordinate || getViewportCoordinate(browserAction.coordinate), - }) - case "scroll_down": - return t("chat:browser.actions.scrolledDown") - case "scroll_up": - return t("chat:browser.actions.scrolledUp") - case "resize": - return t("chat:browser.actions.resized", { size: browserAction.size?.split(/[x,]/).join(" x ") }) - case "screenshot": - return t("chat:browser.actions.screenshotSaved") - case "close": - return t("chat:browser.actions.closed") - default: - return browserAction.action - } - }, [browserAction, viewportDimensions, t]) - - // Auto-open Browser Session panel when: - // 1. This is a "launch" action (new browser session) - always opens and navigates to launch - // 2. Regular actions - only open panel if user hasn't manually closed it, let internal auto-advance logic handle step - // Only run this once per action to avoid re-sending messages when scrolling - useEffect(() => { - if (!isBrowserSessionActive || hasHandledAutoOpenRef.current) { - return - } - - const isLaunchAction = browserAction?.action === "launch" - - if (isLaunchAction) { - // Launch action: navigate to step 0 (the launch) - vscode.postMessage({ - type: "showBrowserSessionPanelAtStep", - stepIndex: 0, - isLaunchAction: true, - }) - hasHandledAutoOpenRef.current = true - } else { - // Regular actions: just show panel, don't navigate - // BrowserSessionRow's internal auto-advance logic will handle jumping to new steps - // only if user is currently on the most recent step - vscode.postMessage({ - type: "showBrowserSessionPanelAtStep", - isLaunchAction: false, - }) - hasHandledAutoOpenRef.current = true - } - }, [isBrowserSessionActive, browserAction]) - - const headerStyle: React.CSSProperties = { - display: "flex", - alignItems: "center", - gap: "10px", - marginBottom: "10px", - wordBreak: "break-word", - } - - return ( -
- {/* Header with action description - clicking opens Browser Session panel at this step */} -
{ - const idx = typeof actionIndex === "number" ? Math.max(0, actionIndex - 1) : 0 - vscode.postMessage({ type: "showBrowserSessionPanelAtStep", stepIndex: idx, forceShow: true }) - }}> - - {t("chat:browser.actions.title")} - {actionIndex !== undefined && totalActions !== undefined && ( - - {" "} - - {actionIndex}/{totalActions} -{" "} - - )} - {browserAction && ( - <> - {getActionIcon(browserAction.action)} - {actionText} - - )} -
-
- ) -}) - -BrowserActionRow.displayName = "BrowserActionRow" - -export default BrowserActionRow diff --git a/webview-ui/src/components/chat/BrowserSessionRow.tsx b/webview-ui/src/components/chat/BrowserSessionRow.tsx deleted file mode 100644 index cf67abdc58..0000000000 --- a/webview-ui/src/components/chat/BrowserSessionRow.tsx +++ /dev/null @@ -1,1137 +0,0 @@ -import React, { memo, useEffect, useMemo, useRef, useState } from "react" -import deepEqual from "fast-deep-equal" -import { useTranslation } from "react-i18next" -import type { TFunction } from "i18next" - -import type { ClineMessage, BrowserAction, BrowserActionResult, ClineSayBrowserAction } from "@roo-code/types" - -import { vscode } from "@src/utils/vscode" -import { useExtensionState } from "@src/context/ExtensionStateContext" - -import CodeBlock from "../common/CodeBlock" -import { ProgressIndicator } from "./ProgressIndicator" -import { Button, StandardTooltip } from "@src/components/ui" -import { getViewportCoordinate as getViewportCoordinateShared, prettyKey } from "@roo/browserUtils" -import { - Globe, - Pointer, - SquareTerminal, - MousePointer as MousePointerIcon, - Keyboard, - ArrowDown, - ArrowUp, - Play, - Check, - Maximize2, - OctagonX, - ArrowLeft, - ArrowRight, - ChevronsLeft, - ChevronsRight, - ExternalLink, - Copy, - Camera, -} from "lucide-react" - -const getBrowserActionText = ( - t: TFunction, - action: BrowserAction, - executedCoordinate?: string, - coordinate?: string, - text?: string, - size?: string, - viewportWidth?: number, - viewportHeight?: number, -) => { - // Helper to scale coordinates from screenshot dimensions to viewport dimensions - // Matches the backend's scaleCoordinate function logic - const getViewportCoordinate = (coord?: string): string => - getViewportCoordinateShared(coord, viewportWidth ?? 0, viewportHeight ?? 0) - - switch (action) { - case "launch": - return t("chat:browser.actions.launched") - case "click": - return t("chat:browser.actions.clicked", { - coordinate: executedCoordinate || getViewportCoordinate(coordinate), - }) - case "type": - return t("chat:browser.actions.typed", { text }) - case "press": - return t("chat:browser.actions.pressed", { key: prettyKey(text) }) - case "scroll_down": - return t("chat:browser.actions.scrolledDown") - case "scroll_up": - return t("chat:browser.actions.scrolledUp") - case "hover": - return t("chat:browser.actions.hovered", { - coordinate: executedCoordinate || getViewportCoordinate(coordinate), - }) - case "resize": - return t("chat:browser.actions.resized", { size: size?.split(/[x,]/).join(" x ") }) - case "screenshot": - return t("chat:browser.actions.screenshotSaved") - case "close": - return t("chat:browser.actions.closed") - default: - return action - } -} - -const getActionIcon = (action: BrowserAction) => { - switch (action) { - case "click": - return - case "type": - case "press": - return - case "scroll_down": - return - case "scroll_up": - return - case "launch": - return - case "close": - return - case "resize": - return - case "screenshot": - return - case "hover": - default: - return - } -} - -interface BrowserSessionRowProps { - messages: ClineMessage[] - isExpanded: (messageTs: number) => boolean - onToggleExpand: (messageTs: number) => void - lastModifiedMessage?: ClineMessage - isLast: boolean - onHeightChange?: (isTaller: boolean) => void - isStreaming: boolean - onExpandChange?: (expanded: boolean) => void - fullScreen?: boolean - // Optional props for standalone panel (when not using ExtensionStateContext) - browserViewportSizeProp?: string - isBrowserSessionActiveProp?: boolean - // Optional: navigate to a specific page index (used by Browser Session panel) - navigateToPageIndex?: number -} - -const BrowserSessionRow = memo((props: BrowserSessionRowProps) => { - const { messages, isLast, onHeightChange, lastModifiedMessage, onExpandChange, fullScreen } = props - const { t } = useTranslation() - const prevHeightRef = useRef(0) - const [consoleLogsExpanded, setConsoleLogsExpanded] = useState(false) - const [nextActionsExpanded, setNextActionsExpanded] = useState(false) - const [logFilter, setLogFilter] = useState<"all" | "debug" | "info" | "warn" | "error" | "log">("all") - // Track screenshot container size for precise cursor positioning with object-fit: contain - const screenshotRef = useRef(null) - const [sW, setSW] = useState(0) - const [sH, setSH] = useState(0) - - // Auto-expand drawer when in fullScreen takeover mode so content is visible immediately - useEffect(() => { - if (fullScreen) { - setNextActionsExpanded(true) - } - }, [fullScreen]) - - // Observe screenshot container size to align cursor correctly with letterboxing - useEffect(() => { - const el = screenshotRef.current - if (!el) return - const update = () => { - const r = el.getBoundingClientRect() - setSW(r.width) - setSH(r.height) - } - update() - const ro = - typeof window !== "undefined" && "ResizeObserver" in window ? new ResizeObserver(() => update()) : null - if (ro) ro.observe(el) - return () => { - if (ro) ro.disconnect() - } - }, []) - - // Try to use ExtensionStateContext if available, otherwise use props - let browserViewportSize = props.browserViewportSizeProp || "900x600" - let isBrowserSessionActive = props.isBrowserSessionActiveProp || false - - try { - const extensionState = useExtensionState() - browserViewportSize = extensionState.browserViewportSize || "900x600" - isBrowserSessionActive = extensionState.isBrowserSessionActive || false - } catch (_e) { - // Not in ExtensionStateContext, use props - } - - const [viewportWidth, viewportHeight] = browserViewportSize.split("x").map(Number) - const defaultMousePosition = `${Math.round(viewportWidth / 2)},${Math.round(viewportHeight / 2)}` - - const isLastApiReqInterrupted = useMemo(() => { - // Check if last api_req_started is cancelled - const lastApiReqStarted = [...messages].reverse().find((m) => m.say === "api_req_started") - if (lastApiReqStarted?.text) { - const info = JSON.parse(lastApiReqStarted.text) as { cancelReason: string | null } - if (info && info.cancelReason !== null) { - return true - } - } - const lastApiReqFailed = isLast && lastModifiedMessage?.ask === "api_req_failed" - if (lastApiReqFailed) { - return true - } - return false - }, [messages, lastModifiedMessage, isLast]) - - const isBrowsing = useMemo(() => { - return isLast && messages.some((m) => m.say === "browser_action_result") && !isLastApiReqInterrupted // after user approves, browser_action_result with "" is sent to indicate that the session has started - }, [isLast, messages, isLastApiReqInterrupted]) - - // Organize messages into pages based on ALL browser actions (including those without screenshots) - const pages = useMemo(() => { - const result: { - url?: string - screenshot?: string - mousePosition?: string - consoleLogs?: string - action?: ClineSayBrowserAction - size?: string - viewportWidth?: number - viewportHeight?: number - }[] = [] - - // Build pages from browser_action messages and pair with results - messages.forEach((message) => { - if (message.say === "browser_action") { - try { - const action = JSON.parse(message.text || "{}") as ClineSayBrowserAction - // Find the corresponding result message - const resultMessage = messages.find( - (m) => m.say === "browser_action_result" && m.ts > message.ts && m.text !== "", - ) - - if (resultMessage) { - const resultData = JSON.parse(resultMessage.text || "{}") as BrowserActionResult - result.push({ - url: resultData.currentUrl, - screenshot: resultData.screenshot, - mousePosition: resultData.currentMousePosition, - consoleLogs: resultData.logs, - action, - size: action.size, - viewportWidth: resultData.viewportWidth, - viewportHeight: resultData.viewportHeight, - }) - } else { - // For actions without results (like close), add a page without screenshot - result.push({ action, size: action.size }) - } - } catch { - // ignore parse errors - } - } - }) - - // Add placeholder page if no actions yet - if (result.length === 0) { - result.push({}) - } - - return result - }, [messages]) - - // Page index + user navigation guard (don't auto-jump while exploring history) - const [currentPageIndex, setCurrentPageIndex] = useState(0) - const hasUserNavigatedRef = useRef(false) - const didInitIndexRef = useRef(false) - const prevPagesLengthRef = useRef(0) - - useEffect(() => { - // Initialize to last page on mount - if (!didInitIndexRef.current && pages.length > 0) { - didInitIndexRef.current = true - setCurrentPageIndex(pages.length - 1) - prevPagesLengthRef.current = pages.length - return - } - - // Auto-advance if user is on the most recent step and a new step arrives - if (pages.length > prevPagesLengthRef.current) { - const wasOnLastPage = currentPageIndex === prevPagesLengthRef.current - 1 - if (wasOnLastPage && !hasUserNavigatedRef.current) { - // User was on the most recent step, auto-advance to the new step - setCurrentPageIndex(pages.length - 1) - } - prevPagesLengthRef.current = pages.length - } - }, [pages.length, currentPageIndex]) - - // External navigation request (from panel host) - // Only navigate when navigateToPageIndex actually changes, not when pages.length changes - const prevNavigateToPageIndexRef = useRef() - useEffect(() => { - if ( - typeof props.navigateToPageIndex === "number" && - props.navigateToPageIndex !== prevNavigateToPageIndexRef.current && - pages.length > 0 - ) { - const idx = Math.max(0, Math.min(pages.length - 1, props.navigateToPageIndex)) - setCurrentPageIndex(idx) - // Only reset manual navigation guard if navigating to the last page - // This allows auto-advance to work when clicking to the most recent step - // but prevents unwanted auto-advance when viewing historical steps - if (idx === pages.length - 1) { - hasUserNavigatedRef.current = false - } - prevNavigateToPageIndexRef.current = props.navigateToPageIndex - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [props.navigateToPageIndex]) - - // Get initial URL from launch message - const initialUrl = useMemo(() => { - const launchMessage = messages.find((m) => m.ask === "browser_action_launch") - return launchMessage?.text || "" - }, [messages]) - - const currentPage = pages[currentPageIndex] - - // Use actual viewport dimensions from result if available, otherwise fall back to settings - - // Find the last available screenshot and its associated data to use as placeholders - const lastPageWithScreenshot = useMemo(() => { - for (let i = pages.length - 1; i >= 0; i--) { - if (pages[i].screenshot) { - return pages[i] - } - } - return undefined - }, [pages]) - - // Find last mouse position up to current page (not from future pages) - const lastPageWithMousePositionUpToCurrent = useMemo(() => { - for (let i = currentPageIndex; i >= 0; i--) { - if (pages[i].mousePosition) { - return pages[i] - } - } - return undefined - }, [pages, currentPageIndex]) - - // Display state from current page, with smart fallbacks - const displayState = { - url: currentPage?.url || initialUrl, - mousePosition: - currentPage?.mousePosition || lastPageWithMousePositionUpToCurrent?.mousePosition || defaultMousePosition, - consoleLogs: currentPage?.consoleLogs, - screenshot: currentPage?.screenshot || lastPageWithScreenshot?.screenshot, - } - - // Parse logs for counts and filtering - const parsedLogs = useMemo(() => { - const counts = { debug: 0, info: 0, warn: 0, error: 0, log: 0 } - const byType: Record<"debug" | "info" | "warn" | "error" | "log", string[]> = { - debug: [], - info: [], - warn: [], - error: [], - log: [], - } - const raw = displayState.consoleLogs || "" - raw.split(/\r?\n/).forEach((line) => { - const trimmed = line.trim() - if (!trimmed) return - const m = /^\[([^\]]+)\]\s*/i.exec(trimmed) - let type = (m?.[1] || "").toLowerCase() - if (type === "warning") type = "warn" - if (!["debug", "info", "warn", "error", "log"].includes(type)) type = "log" - counts[type as keyof typeof counts]++ - byType[type as keyof typeof byType].push(line) - }) - return { counts, byType } - }, [displayState.consoleLogs]) - - const logsToShow = useMemo(() => { - if (!displayState.consoleLogs) return t("chat:browser.noNewLogs") as string - if (logFilter === "all") return displayState.consoleLogs - const arr = parsedLogs.byType[logFilter] - return arr.length ? arr.join("\n") : (t("chat:browser.noNewLogs") as string) - }, [displayState.consoleLogs, logFilter, parsedLogs, t]) - - // Meta for log badges (include "All" first) - const logTypeMeta = [ - { key: "all", label: "All" }, - { key: "debug", label: "Debug" }, - { key: "info", label: "Info" }, - { key: "warn", label: "Warn" }, - { key: "error", label: "Error" }, - { key: "log", label: "Log" }, - ] as const - - // Use a fixed standard aspect ratio and dimensions for the drawer to prevent flickering - // Even if viewport changes, the drawer maintains consistent size - const fixedDrawerWidth = 900 - const fixedDrawerHeight = 600 - const drawerAspectRatio = (fixedDrawerHeight / fixedDrawerWidth) * 100 - - // For cursor positioning, use the viewport dimensions from the same page as the data we're displaying - // This ensures cursor position matches the screenshot/mouse position being shown - let cursorViewportWidth: number - let cursorViewportHeight: number - - if (currentPage?.screenshot) { - // Current page has screenshot - use its dimensions - cursorViewportWidth = currentPage.viewportWidth ?? viewportWidth - cursorViewportHeight = currentPage.viewportHeight ?? viewportHeight - } else if (lastPageWithScreenshot) { - // Using placeholder screenshot - use dimensions from that page - cursorViewportWidth = lastPageWithScreenshot.viewportWidth ?? viewportWidth - cursorViewportHeight = lastPageWithScreenshot.viewportHeight ?? viewportHeight - } else { - // No screenshot available - use default settings - cursorViewportWidth = viewportWidth - cursorViewportHeight = viewportHeight - } - - // Get browser action for current page (now stored in pages array) - const currentPageAction = useMemo(() => { - return pages[currentPageIndex]?.action - }, [pages, currentPageIndex]) - - // Latest non-close browser_action for header summary (fallback) - - const lastBrowserActionOverall = useMemo(() => { - const all = messages.filter((m) => m.say === "browser_action") - return all.at(-1) - }, [messages]) - - // Use actual Playwright session state from extension (not message parsing) - const isBrowserSessionOpen = isBrowserSessionActive - - // Check if a browser action is currently in flight (for spinner) - const isActionRunning = useMemo(() => { - if (!lastBrowserActionOverall || isLastApiReqInterrupted) { - return false - } - - // Find the last browser_action_result (including empty text) to detect completion - const lastBrowserActionResult = [...messages].reverse().find((m) => m.say === "browser_action_result") - - if (!lastBrowserActionResult) { - // We have at least one action, but haven't seen any result yet - return true - } - - // If the last action happened after the last result, it's still running - return lastBrowserActionOverall.ts > lastBrowserActionResult.ts - }, [messages, lastBrowserActionOverall, isLastApiReqInterrupted]) - - // Browser session drawer never auto-expands - user must manually toggle it - - // Calculate total API cost for the browser session - const totalApiCost = useMemo(() => { - let total = 0 - messages.forEach((message) => { - if (message.say === "api_req_started" && message.text) { - try { - const data = JSON.parse(message.text) - if (data.cost && typeof data.cost === "number") { - total += data.cost - } - } catch { - // Ignore parsing errors - } - } - }) - return total - }, [messages]) - - // Local size tracking without react-use to avoid timers after unmount in tests - const containerRef = useRef(null) - const [rowHeight, setRowHeight] = useState(0) - useEffect(() => { - const el = containerRef.current - if (!el) return - let mounted = true - const setH = (h: number) => { - if (mounted) setRowHeight(h) - } - const ro = - typeof window !== "undefined" && "ResizeObserver" in window - ? new ResizeObserver((entries) => { - const entry = entries[0] - setH(entry?.contentRect?.height ?? el.getBoundingClientRect().height) - }) - : null - // initial - setH(el.getBoundingClientRect().height) - if (ro) ro.observe(el) - return () => { - mounted = false - if (ro) ro.disconnect() - } - }, []) - - const BrowserSessionHeader: React.FC = () => ( -
- {/* Globe icon - green when browser session is open */} - - setNextActionsExpanded((v) => { - const nv = !v - onExpandChange?.(nv) - return nv - }), - })} - /> - - {/* Simple text: "Browser Session" with step counter */} - - setNextActionsExpanded((v) => { - const nv = !v - onExpandChange?.(nv) - return nv - }), - })} - style={{ - flex: 1, - fontSize: 13, - fontWeight: 500, - lineHeight: "22px", - color: "var(--vscode-editor-foreground)", - cursor: fullScreen ? "default" : "pointer", - display: "flex", - alignItems: "center", - gap: 8, - }}> - {t("chat:browser.session")} - {isActionRunning && ( - - )} - {pages.length > 0 && ( - - {currentPageIndex + 1}/{pages.length} - - )} - {/* Inline action summary to the right, similar to ChatView */} - - {(() => { - const action = currentPageAction - const pageSize = pages[currentPageIndex]?.size - const pageViewportWidth = pages[currentPageIndex]?.viewportWidth - const pageViewportHeight = pages[currentPageIndex]?.viewportHeight - if (action) { - return ( - <> - {getActionIcon(action.action)} - - {getBrowserActionText( - t, - action.action, - action.executedCoordinate, - action.coordinate, - action.text, - pageSize, - pageViewportWidth, - pageViewportHeight, - )} - - - ) - } else if (initialUrl) { - return ( - <> - {getActionIcon("launch" as any)} - {getBrowserActionText(t, "launch", undefined, initialUrl, undefined)} - - ) - } - return null - })()} - - - - {/* Right side: cost badge and chevron */} - {totalApiCost > 0 && ( -
- ${totalApiCost.toFixed(4)} -
- )} - - {/* Chevron toggle hidden in fullScreen */} - {!fullScreen && ( - - setNextActionsExpanded((v) => { - const nv = !v - onExpandChange?.(nv) - return nv - }) - } - className={`codicon ${nextActionsExpanded ? "codicon-chevron-up" : "codicon-chevron-down"}`} - style={{ - fontSize: 13, - fontWeight: 500, - lineHeight: "22px", - color: "var(--vscode-editor-foreground)", - cursor: "pointer", - display: "inline-block", - transition: "transform 150ms ease", - }} - /> - )} - - {/* Kill browser button hidden from header in fullScreen; kept in toolbar */} - {isBrowserSessionOpen && !fullScreen && ( - - - - )} -
- ) - - const BrowserSessionDrawer: React.FC = () => { - if (!nextActionsExpanded) return null - - return ( -
- {/* Browser-like Toolbar */} -
- {/* Go to beginning */} - - - - - {/* Back */} - - - - - {/* Forward */} - - - - - {/* Go to end */} - - - - - {/* Address Bar */} -
- - - {displayState.url || "about:blank"} - - {/* Step counter removed */} -
- - {/* Kill (Disconnect) replaces Reload */} - - - - - {/* Open External */} - - - - - {/* Copy URL */} - - - -
- {/* Screenshot Area */} -
- {displayState.screenshot ? ( - {t("chat:browser.screenshot")} - vscode.postMessage({ - type: "openImage", - text: displayState.screenshot, - }) - } - /> - ) : ( -
- -
- )} - {displayState.mousePosition && - (() => { - // Use measured size if available; otherwise fall back to current client size so cursor remains visible - const containerW = sW || (screenshotRef.current?.clientWidth ?? 0) - const containerH = sH || (screenshotRef.current?.clientHeight ?? 0) - if (containerW <= 0 || containerH <= 0) { - // Minimal fallback to keep cursor visible before first measurement - return ( - - ) - } - - // Compute displayed image box within the container for object-fit: contain; objectPosition: top center - const imgAspect = cursorViewportWidth / cursorViewportHeight - const containerAspect = containerW / containerH - let displayW = containerW - let displayH = containerH - let offsetX = 0 - let offsetY = 0 - if (containerAspect > imgAspect) { - // Full height, letterboxed left/right; top aligned - displayH = containerH - displayW = containerH * imgAspect - offsetX = (containerW - displayW) / 2 - offsetY = 0 - } else { - // Full width, potential space below; top aligned - displayW = containerW - displayH = containerW / imgAspect - offsetX = 0 - offsetY = 0 - } - - // Parse "x,y" or "x,y@widthxheight" for original basis - const m = /^\s*(\d+)\s*,\s*(\d+)(?:\s*@\s*(\d+)\s*[x,]\s*(\d+))?\s*$/.exec( - displayState.mousePosition || "", - ) - const mx = parseInt(m?.[1] || "0", 10) - const my = parseInt(m?.[2] || "0", 10) - const baseW = m?.[3] ? parseInt(m[3], 10) : cursorViewportWidth - const baseH = m?.[4] ? parseInt(m[4], 10) : cursorViewportHeight - - const leftPx = offsetX + (baseW > 0 ? (mx / baseW) * displayW : 0) - const topPx = offsetY + (baseH > 0 ? (my / baseH) * displayH : 0) - - return ( - - ) - })()} -
- - {/* Browser Action summary moved inline to header; row removed */} - - {/* Console Logs Section (collapsible, default collapsed) */} -
-
{ - e.stopPropagation() - setConsoleLogsExpanded((v) => !v) - }} - className="text-vscode-editor-foreground/70 hover:text-vscode-editor-foreground transition-colors" - style={{ - display: "flex", - alignItems: "center", - gap: "8px", - marginBottom: consoleLogsExpanded ? "6px" : 0, - cursor: "pointer", - }}> - - - {t("chat:browser.consoleLogs")} - - - {/* Log type indicators */} -
e.stopPropagation()} - style={{ display: "flex", alignItems: "center", gap: 6, marginLeft: "auto" }}> - {logTypeMeta.map(({ key, label }) => { - const isAll = key === "all" - const count = isAll - ? (Object.values(parsedLogs.counts) as number[]).reduce((a, b) => a + b, 0) - : parsedLogs.counts[key as "debug" | "info" | "warn" | "error" | "log"] - const isActive = logFilter === (key as any) - const disabled = count === 0 - return ( - - ) - })} - setConsoleLogsExpanded((v) => !v)} - className={`codicon codicon-chevron-${consoleLogsExpanded ? "down" : "right"}`} - style={{ marginLeft: 6 }} - /> -
-
- {consoleLogsExpanded && ( -
- -
- )} -
-
- ) - } - - const browserSessionRow = ( -
- - - {/* Expanded drawer content - inline/fullscreen */} - -
- ) - - // Height change effect - useEffect(() => { - const isInitialRender = prevHeightRef.current === 0 - if (isLast && rowHeight !== 0 && rowHeight !== Infinity && rowHeight !== prevHeightRef.current) { - if (!isInitialRender) { - onHeightChange?.(rowHeight > prevHeightRef.current) - } - prevHeightRef.current = rowHeight - } - }, [rowHeight, isLast, onHeightChange]) - - return browserSessionRow -}, deepEqual) - -const BrowserCursor: React.FC<{ style?: React.CSSProperties }> = ({ style }) => { - const { t } = useTranslation() - // (can't use svgs in vsc extensions) - const cursorBase64 = - "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABUAAAAYCAYAAAAVibZIAAAAAXNSR0IArs4c6QAAAERlWElmTU0AKgAAAAgAAYdpAAQAAAABAAAAGgAAAAAAA6ABAAMAAAABAAEAAKACAAQAAAABAAAAFaADAAQAAAABAAAAGAAAAADwi9a/AAADGElEQVQ4EZ2VbUiTURTH772be/PxZdsz3cZwC4RVaB8SAjMpxQwSWZbQG/TFkN7oW1Df+h6IRV9C+hCpKUSIZUXOfGM5tAKViijFFEyfZ7Ol29S1Pbdzl8Uw9+aBu91zzv3/nt17zt2DEZjBYOAkKrtFMXIghAWM8U2vMN/FctsxGRMpM7NbEEYNMM2CYUSInlJx3OpawO9i+XSNQYkmk2uFb9njzkcfVSr1p/GJiQKMULVaw2WuBv296UKRxWJR6wxGCmM1EAhSNppv33GBH9qI32cPTAtss9lUm6EM3N7R+RbigT+5/CeosFCZKpjEW+iorS1pb30wDUXzQfHqtD/9L3ieZ2ee1OJCmbL8QHnRs+4uj0wmW4QzrpCwvJ8zGg3JqAmhTLynuLiwv8/5KyND8Q3cEkUEDWu15oJE4KRQJt5hs1rcriGNRqP+DK4dyyWXXm/aFQ+cEpSJ8/LyDGPuEZNOmzsOroUSOqzXG/dtBU4ZysTZYKNut91sNo2Cq6cE9enz86s2g9OCMrFSqVC5hgb32u072W3jKMU90Hb1seC0oUwsB+t92bO/rKx0EFGkgFCnjjc1/gVvC8rE0L+4o63t4InjxwbAJQjTe3qD8QrLkXA4DC24fWtuajp06cLFYSBIFKGmXKPRRmAnME9sPt+yLwIWb9WN69fKoTneQz4Dh2mpPNkvfeV0jjecb9wNAkwIEVQq5VJOds4Kb+DXoAsiVquVwI1Dougpij6UyGYx+5cKroeDEFibm5lWRRMbH1+npmYrq6qhwlQHIbajZEf1fElcqGGFpGg9HMuKzpfBjhytCTMgkJ56RX09zy/ysENTBElmjIgJnmNChJqohDVQqpEfwkILE8v/o0GAnV9F1eEvofVQCbiTBEXOIPQh5PGgefDZeAcjrpGZjULBr/m3tZOnz7oEQWRAQZLjWlEU/XEJWySiILgRc5Cz1DkcAyuBFcnpfF0JiXWKpcolQXizhS5hKAqFpr0MVbgbuxJ6+5xX+P4wNpbqPPrugZfbmIbLmgQR3Aw8QSi66hUXulOFbF73GxqjE5BNXWNeAAAAAElFTkSuQmCC" - - return ( - {t("chat:browser.cursor")} - ) -} - -export default BrowserSessionRow diff --git a/webview-ui/src/components/chat/BrowserSessionStatusRow.tsx b/webview-ui/src/components/chat/BrowserSessionStatusRow.tsx deleted file mode 100644 index 862dc80a62..0000000000 --- a/webview-ui/src/components/chat/BrowserSessionStatusRow.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import { memo } from "react" -import { Globe } from "lucide-react" -import { ClineMessage } from "@roo-code/types" - -interface BrowserSessionStatusRowProps { - message: ClineMessage -} - -const BrowserSessionStatusRow = memo(({ message }: BrowserSessionStatusRowProps) => { - const isOpened = message.text?.includes("opened") - - return ( -
- - - {message.text} - -
- ) -}) - -BrowserSessionStatusRow.displayName = "BrowserSessionStatusRow" - -export default BrowserSessionStatusRow diff --git a/webview-ui/src/components/chat/ChatRow.tsx b/webview-ui/src/components/chat/ChatRow.tsx index 25bcd61ee3..96bfb280a9 100644 --- a/webview-ui/src/components/chat/ChatRow.tsx +++ b/webview-ui/src/components/chat/ChatRow.tsx @@ -143,11 +143,12 @@ const ChatRow = memo( ) useEffect(() => { + const isHeightValid = height !== 0 && height !== Infinity // used for partials, command output, etc. // NOTE: it's important we don't distinguish between partial or complete here since our scroll effects in chatview need to handle height change during partial -> complete const isInitialRender = prevHeightRef.current === 0 // prevents scrolling when new element is added since we already scroll for that // height starts off at Infinity - if (isLast && height !== 0 && height !== Infinity && height !== prevHeightRef.current) { + if (isLast && isHeightValid && height !== prevHeightRef.current) { if (!isInitialRender) { onHeightChange(height > prevHeightRef.current) } @@ -405,6 +406,14 @@ export const ChatRowContent = ({ return (tool.content ?? tool.diff) as string | undefined }, [tool]) + const onJumpToCreatedFile = useMemo(() => { + if (!tool || tool.tool !== "newFileCreated" || !tool.path) { + return undefined + } + + return () => vscode.postMessage({ type: "openFile", text: "./" + tool.path }) + }, [tool]) + const followUpData = useMemo(() => { if (message.type === "ask" && message.ask === "followup" && !message.partial) { return safeJsonParse(message.text) @@ -422,6 +431,14 @@ export const ChatRowContent = ({ switch (tool.tool as string) { case "editedExistingFile": case "appliedDiff": + case "newFileCreated": + case "searchAndReplace": + case "search_and_replace": + case "search_replace": + case "edit": + case "edit_file": + case "apply_patch": + case "apply_diff": // Check if this is a batch diff request if (message.type === "ask" && tool.batchDiffs && Array.isArray(tool.batchDiffs)) { return ( @@ -447,7 +464,7 @@ export const ChatRowContent = ({ style={{ color: "var(--vscode-editorWarning-foreground)", marginBottom: "-1.5px" }} /> ) : ( - toolIcon(tool.tool === "appliedDiff" ? "diff" : "edit") + toolIcon("diff") )} {tool.isProtected @@ -460,12 +477,13 @@ export const ChatRowContent = ({
@@ -509,40 +527,6 @@ export const ChatRowContent = ({
) - case "searchAndReplace": - return ( - <> -
- {tool.isProtected ? ( - - ) : ( - toolIcon("replace") - )} - - {tool.isProtected && message.type === "ask" - ? t("chat:fileOperations.wantsToEditProtected") - : message.type === "ask" - ? t("chat:fileOperations.wantsToSearchReplace") - : t("chat:fileOperations.didSearchReplace")} - -
-
- -
- - ) case "codebaseSearch": { return (
@@ -572,38 +556,6 @@ export const ChatRowContent = ({ return } - case "newFileCreated": - return ( - <> -
- {tool.isProtected ? ( - - ) : ( - toolIcon("new-file") - )} - - {tool.isProtected - ? t("chat:fileOperations.wantsToEditProtected") - : t("chat:fileOperations.wantsToCreate")} - -
-
- vscode.postMessage({ type: "openFile", text: "./" + tool.path })} - diffStats={tool.diffStats} - /> -
- - ) case "readFile": // Check if this is a batch file permission request const isBatchRequest = message.type === "ask" && tool.batchFiles && Array.isArray(tool.batchFiles) @@ -649,7 +601,13 @@ export const ChatRowContent = ({ vscode.postMessage({ type: "openFile", text: tool.content })}> + onClick={() => + vscode.postMessage({ + type: "openFile", + text: tool.content, + values: tool.startLine ? { line: tool.startLine } : undefined, + }) + }> {tool.path?.startsWith(".") && .} @@ -1574,10 +1532,6 @@ export const ChatRowContent = ({
) - case "browser_action": - case "browser_action_result": - // Handled by BrowserSessionRow; prevent raw JSON (action/result) from rendering here - return null case "too_many_tools_warning": { const warningData = safeJsonParse<{ toolCount: number diff --git a/webview-ui/src/components/chat/ChatTextArea.tsx b/webview-ui/src/components/chat/ChatTextArea.tsx index 654f2e1011..c521388206 100644 --- a/webview-ui/src/components/chat/ChatTextArea.tsx +++ b/webview-ui/src/components/chat/ChatTextArea.tsx @@ -52,9 +52,6 @@ interface ChatTextAreaProps { // Edit mode props isEditMode?: boolean onCancel?: () => void - // Browser session status - isBrowserSessionActive?: boolean - showBrowserDockToggle?: boolean // Stop/Queue functionality isStreaming?: boolean onStop?: () => void @@ -79,8 +76,6 @@ export const ChatTextArea = forwardRef( modeShortcutText, isEditMode = false, onCancel, - isBrowserSessionActive = false, - showBrowserDockToggle = false, isStreaming = false, onStop, onEnqueueMessage, @@ -103,6 +98,7 @@ export const ChatTextArea = forwardRef( commands, cloudUserInfo, enterBehavior, + lockApiConfigAcrossModes, } = useExtensionState() // Find the ID and display text for the currently selected API configuration. @@ -945,6 +941,11 @@ export const ChatTextArea = forwardRef( vscode.postMessage({ type: "loadApiConfigurationById", text: value }) }, []) + const handleToggleLockApiConfig = useCallback(() => { + const newValue = !lockApiConfigAcrossModes + vscode.postMessage({ type: "lockApiConfigAcrossModes", bool: newValue }) + }, [lockApiConfigAcrossModes]) + return (
( listApiConfigMeta={listApiConfigMeta || []} pinnedApiConfigs={pinnedApiConfigs} togglePinnedApiConfig={togglePinnedApiConfig} + lockApiConfigAcrossModes={!!lockApiConfigAcrossModes} + onToggleLockApiConfig={handleToggleLockApiConfig} />
@@ -1346,12 +1349,6 @@ export const ChatTextArea = forwardRef( )} {!isEditMode ? : null} {!isEditMode && cloudUserInfo && } - {/* keep props referenced after moving browser button */} -
diff --git a/webview-ui/src/components/chat/ChatView.tsx b/webview-ui/src/components/chat/ChatView.tsx index 5dcdf1998e..fd0aca66cb 100644 --- a/webview-ui/src/components/chat/ChatView.tsx +++ b/webview-ui/src/components/chat/ChatView.tsx @@ -1,6 +1,5 @@ import React, { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from "react" import { useDeepCompareEffect, useEvent } from "react-use" -import debounce from "debounce" import { Virtuoso, type VirtuosoHandle } from "react-virtuoso" import removeMd from "remove-markdown" import { VSCodeLink } from "@vscode/webview-ui-toolkit/react" @@ -11,8 +10,10 @@ import { Trans } from "react-i18next" import { useDebounceEffect } from "@src/utils/useDebounceEffect" import { appendImages } from "@src/utils/imageUtils" import { getCostBreakdownIfNeeded } from "@src/utils/costFormatting" +import { batchConsecutive } from "@src/utils/batchConsecutive" import type { ClineAsk, ClineSayTool, ClineMessage, ExtensionMessage, AudioType } from "@roo-code/types" +import { isRetiredProvider } from "@roo-code/types" import { findLast } from "@roo/array" import { SuggestionItem } from "@roo-code/types" @@ -36,18 +37,18 @@ import TelemetryBanner from "../common/TelemetryBanner" import VersionIndicator from "../common/VersionIndicator" import HistoryPreview from "../history/HistoryPreview" import Announcement from "./Announcement" -import BrowserActionRow from "./BrowserActionRow" -import BrowserSessionStatusRow from "./BrowserSessionStatusRow" import ChatRow from "./ChatRow" +import WarningRow from "./WarningRow" import { ChatTextArea } from "./ChatTextArea" import TaskHeader from "./TaskHeader" -import SystemPromptWarning from "./SystemPromptWarning" import ProfileViolationWarning from "./ProfileViolationWarning" import { CheckpointWarning } from "./CheckpointWarning" import { QueuedMessages } from "./QueuedMessages" import { WorktreeSelector } from "./WorktreeSelector" +import FileChangesPanel from "./FileChangesPanel" import DismissibleUpsell from "../common/DismissibleUpsell" import { useCloudUpsell } from "@src/hooks/useCloudUpsell" +import { useScrollLifecycle } from "@src/hooks/useScrollLifecycle" import { Cloud } from "lucide-react" export interface ChatViewProps { @@ -68,11 +69,8 @@ const ChatViewComponent: React.ForwardRefRenderFunction { - const isMountedRef = useRef(true) - const [audioBaseUri] = useState(() => { - const w = window as any - return w.AUDIO_BASE_URI || "" + return (window as unknown as { AUDIO_BASE_URI?: string }).AUDIO_BASE_URI || "" }) const { t } = useAppTranslation() @@ -90,15 +88,22 @@ const ChatViewComponent: React.ForwardRefRenderFunction { + setShowRetiredProviderWarning(false) + }, [providerName]) + const messagesRef = useRef(messages) useEffect(() => { @@ -150,9 +155,6 @@ const ChatViewComponent: React.ForwardRefRenderFunction>({}) const prevExpandedRowsRef = useRef>() const scrollContainerRef = useRef(null) - const stickyFollowRef = useRef(false) - const [showScrollToBottom, setShowScrollToBottom] = useState(false) - const [isAtBottom, setIsAtBottom] = useState(false) const lastTtsRef = useRef("") const [wasStreaming, setWasStreaming] = useState(false) const [checkpointWarning, setCheckpointWarning] = useState< @@ -213,13 +215,6 @@ const ChatViewComponent: React.ForwardRefRenderFunction { - isMountedRef.current = true - return () => { - isMountedRef.current = false - } - }, []) - const isProfileDisabled = useMemo( () => !!apiConfiguration && !ProfileValidator.isProfileAllowed(apiConfiguration, organizationAllowList), [apiConfiguration, organizationAllowList], @@ -233,9 +228,11 @@ const ChatViewComponent: React.ForwardRefRenderFunction messages.at(-2), [messages]) const volume = typeof soundVolume === "number" ? soundVolume : 0.5 - const [playNotification] = useSound(`${audioBaseUri}/notification.wav`, { volume, soundEnabled }) - const [playCelebration] = useSound(`${audioBaseUri}/celebration.wav`, { volume, soundEnabled }) - const [playProgressLoop] = useSound(`${audioBaseUri}/progress_loop.wav`, { volume, soundEnabled }) + const [playNotification] = useSound(`${audioBaseUri}/notification.wav`, { volume, soundEnabled, interrupt: true }) + const [playCelebration] = useSound(`${audioBaseUri}/celebration.wav`, { volume, soundEnabled, interrupt: true }) + const [playProgressLoop] = useSound(`${audioBaseUri}/progress_loop.wav`, { volume, soundEnabled, interrupt: true }) + + const lastPlayedRef = useRef>({}) const playSound = useCallback( (audioType: AudioType) => { @@ -243,6 +240,13 @@ const ChatViewComponent: React.ForwardRefRenderFunction { - // Reset UI states only when task changes setExpandedRows({}) - everVisibleMessagesTsRef.current.clear() // Clear for new task - setCurrentFollowUpTs(null) // Clear follow-up answered state for new task - setIsCondensing(false) // Reset condensing state when switching tasks - // Note: sendingDisabled is not reset here as it's managed by message effects + everVisibleMessagesTsRef.current.clear() + setCurrentFollowUpTs(null) + setIsCondensing(false) - // Clear any pending auto-approval timeout from previous task if (autoApproveTimeoutRef.current) { clearTimeout(autoApproveTimeoutRef.current) autoApproveTimeoutRef.current = null } - // Reset user response flag for new task userRespondedRef.current = false }, [task?.ts]) @@ -505,28 +520,6 @@ const ChatViewComponent: React.ForwardRefRenderFunction { - const prev = prevExpandedRowsRef.current - let wasAnyRowExpandedByUser = false - if (prev) { - // Check if any row transitioned from false/undefined to true - for (const [tsKey, isExpanded] of Object.entries(expandedRows)) { - const ts = Number(tsKey) - if (isExpanded && !(prev[ts] ?? false)) { - wasAnyRowExpandedByUser = true - break - } - } - } - - // Expanding a row indicates the user is browsing; disable sticky follow - if (wasAnyRowExpandedByUser) { - stickyFollowRef.current = false - } - - prevExpandedRowsRef.current = expandedRows // Store current state for next comparison - }, [expandedRows]) - const isStreaming = useMemo(() => { // Checking clineAsk isn't enough since messages effect may be called // again for a tool for example, set clineAsk to its value, and if the @@ -608,11 +601,24 @@ const ChatViewComponent: React.ForwardRefRenderFunction 0) { + // Intercept when the active provider is retired — show a + // WarningRow instead of sending anything to the backend. + if (apiConfiguration?.apiProvider && isRetiredProvider(apiConfiguration.apiProvider)) { + setShowRetiredProviderWarning(true) + return + } + // Queue message if: // - Task is busy (sendingDisabled) // - API request in progress (isStreaming) // - Queue has items (preserve message order during drain) - if (sendingDisabled || isStreaming || messageQueue.length > 0) { + // - Command is running (command_output) - user's message should be queued for AI, not sent to terminal + if ( + sendingDisabled || + isStreaming || + messageQueue.length > 0 || + clineAskRef.current === "command_output" + ) { try { console.log("queueMessage", text, images) vscode.postMessage({ type: "queueMessage", text, images }) @@ -643,9 +649,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction vscode.postMessage({ type: "clearTask" }), []) + const startNewTask = useCallback(() => { + setShowRetiredProviderWarning(false) + vscode.postMessage({ type: "clearTask" }) + }, []) // Handle stop button click from textarea const handleStopTask = useCallback(() => { @@ -722,7 +736,6 @@ const ChatViewComponent: React.ForwardRefRenderFunction 0)) { @@ -952,10 +964,10 @@ const ChatViewComponent: React.ForwardRefRenderFunction { - for (let i = 0; i < messages.length; i++) { - if (messages[i].ask === "browser_action_launch") { - return i + const groupedMessages = useMemo(() => { + const filtered: ClineMessage[] = visibleMessages + + // Helper to check if a message is a read_file ask that should be batched + const isReadFileAsk = (msg: ClineMessage): boolean => { + if (msg.type !== "ask" || msg.ask !== "tool") return false + try { + const tool = JSON.parse(msg.text || "{}") + return tool.tool === "readFile" && !tool.batchFiles // Don't re-batch already batched + } catch { + return false } } - return -1 - }, [messages]) - const _browserSessionMessages = useMemo(() => { - if (browserSessionStartIndex === -1) return [] - return messages.slice(browserSessionStartIndex) - }, [browserSessionStartIndex, messages]) - - // Show globe toggle only when in a task that has a browser session (active or inactive) - const showBrowserDockToggle = useMemo( - () => Boolean(task && (browserSessionStartIndex !== -1 || isBrowserSessionActive)), - [task, browserSessionStartIndex, isBrowserSessionActive], - ) - - const isBrowserSessionMessage = useCallback((message: ClineMessage): boolean => { - // Only the launch ask should be hidden from chat (it's shown in the drawer header) - if (message.type === "ask" && message.ask === "browser_action_launch") { - return true + // Helper to check if a message is a list_files ask that should be batched + const isListFilesAsk = (msg: ClineMessage): boolean => { + if (msg.type !== "ask" || msg.ask !== "tool") return false + try { + const tool = JSON.parse(msg.text || "{}") + return ( + (tool.tool === "listFilesTopLevel" || tool.tool === "listFilesRecursive") && !tool.batchDirs // Don't re-batch already batched + ) + } catch { + return false + } } - // browser_action_result messages are paired with browser_action and should not appear independently - if (message.type === "say" && message.say === "browser_action_result") { - return true - } - return false - }, []) - const groupedMessages = useMemo(() => { - // Only filter out the launch ask and result messages - browser actions appear in chat - const result: ClineMessage[] = visibleMessages.filter((msg) => !isBrowserSessionMessage(msg)) + // Set of tool names that represent file-editing operations + const editFileTools = new Set([ + "editedExistingFile", + "appliedDiff", + "newFileCreated", + "insertContent", + "searchAndReplace", + ]) + + // Helper to check if a message is a file-edit ask that should be batched + const isEditFileAsk = (msg: ClineMessage): boolean => { + if (msg.type !== "ask" || msg.ask !== "tool") return false + try { + const tool = JSON.parse(msg.text || "{}") + return editFileTools.has(tool.tool) && !tool.batchDiffs // Don't re-batch already batched + } catch { + return false + } + } + + // Synthesize a batch of consecutive read_file asks into a single message + const synthesizeReadFileBatch = (batch: ClineMessage[]): ClineMessage => { + const batchFiles = batch.map((batchMsg) => { + try { + const tool = JSON.parse(batchMsg.text || "{}") + return { + path: tool.path || "", + lineSnippet: tool.reason || "", + isOutsideWorkspace: tool.isOutsideWorkspace || false, + key: `${tool.path}${tool.reason ? ` (${tool.reason})` : ""}`, + content: tool.content || "", + } + } catch { + return { path: "", lineSnippet: "", key: "", content: "" } + } + }) + + let firstTool + try { + firstTool = JSON.parse(batch[0].text || "{}") + } catch { + return batch[0] + } + return { + ...batch[0], + text: JSON.stringify({ ...firstTool, batchFiles }), + } + } + + // Synthesize a batch of consecutive list_files asks into a single message + const synthesizeListFilesBatch = (batch: ClineMessage[]): ClineMessage => { + const batchDirs = batch.map((batchMsg) => { + try { + const tool = JSON.parse(batchMsg.text || "{}") + return { + path: tool.path || "", + recursive: tool.tool === "listFilesRecursive", + isOutsideWorkspace: tool.isOutsideWorkspace || false, + key: tool.path || "", + } + } catch { + return { path: "", recursive: false, key: "" } + } + }) + + let firstTool + try { + firstTool = JSON.parse(batch[0].text || "{}") + } catch { + return batch[0] + } + return { + ...batch[0], + text: JSON.stringify({ ...firstTool, batchDirs }), + } + } + + // Synthesize a batch of consecutive file-edit asks into a single message + const synthesizeEditFileBatch = (batch: ClineMessage[]): ClineMessage => { + const batchDiffs = batch.map((batchMsg) => { + try { + const tool = JSON.parse(batchMsg.text || "{}") + return { + path: tool.path || "", + changeCount: 1, + key: tool.path || "", + content: tool.content || tool.diff || "", + diffStats: tool.diffStats, + } + } catch { + return { path: "", changeCount: 0, key: "", content: "" } + } + }) + + let firstTool + try { + firstTool = JSON.parse(batch[0].text || "{}") + } catch { + return batch[0] + } + return { + ...batch[0], + text: JSON.stringify({ ...firstTool, batchDiffs }), + } + } + + // Consolidate consecutive ask messages into batches + const readFileBatched = batchConsecutive(filtered, isReadFileAsk, synthesizeReadFileBatch) + const listFilesBatched = batchConsecutive(readFileBatched, isListFilesAsk, synthesizeListFilesBatch) + const result = batchConsecutive(listFilesBatched, isEditFileAsk, synthesizeEditFileBatch) if (isCondensing) { result.push({ @@ -1144,35 +1256,53 @@ const ChatViewComponent: React.ForwardRefRenderFunction - debounce(() => virtuosoRef.current?.scrollTo({ top: Number.MAX_SAFE_INTEGER, behavior: "smooth" }), 10, { - immediate: true, - }), - [], - ) + // Scroll lifecycle is managed by a dedicated hook to keep ChatView focused + // on message handling and UI orchestration. + const { + showScrollToBottom, + handleRowHeightChange, + handleScrollToBottomClick, + enterUserBrowsingHistory, + followOutputCallback, + atBottomStateChangeCallback, + scrollToBottomAuto, + isAtBottomRef, + scrollPhaseRef, + } = useScrollLifecycle({ + virtuosoRef, + scrollContainerRef, + taskTs: task?.ts, + isStreaming, + isHidden, + hasTask: !!task, + }) + // Expanding a row indicates the user is browsing; disable sticky follow. + // Placed after the hook call so enterUserBrowsingHistory is defined. useEffect(() => { - return () => { - if (scrollToBottomSmooth && typeof (scrollToBottomSmooth as any).cancel === "function") { - ;(scrollToBottomSmooth as any).cancel() + const prev = prevExpandedRowsRef.current + let wasAnyRowExpandedByUser = false + if (prev) { + for (const [tsKey, isExpanded] of Object.entries(expandedRows)) { + const ts = Number(tsKey) + if (isExpanded && !(prev[ts] ?? false)) { + wasAnyRowExpandedByUser = true + break + } } } - }, [scrollToBottomSmooth]) - const scrollToBottomAuto = useCallback(() => { - virtuosoRef.current?.scrollTo({ - top: Number.MAX_SAFE_INTEGER, - behavior: "auto", // Instant causes crash. - }) - }, []) + if (wasAnyRowExpandedByUser) { + enterUserBrowsingHistory("row-expansion") + } + + prevExpandedRowsRef.current = expandedRows + }, [enterUserBrowsingHistory, expandedRows]) const handleSetExpandedRow = useCallback( (ts: number, expand?: boolean) => { @@ -1194,45 +1324,6 @@ const ChatViewComponent: React.ForwardRefRenderFunction { - if (isAtBottom) { - if (isTaller) { - scrollToBottomSmooth() - } else { - setTimeout(() => scrollToBottomAuto(), 0) - } - } - }, - [scrollToBottomSmooth, scrollToBottomAuto, isAtBottom], - ) - - // Disable sticky follow when user scrolls up inside the chat container - const handleWheel = useCallback((event: Event) => { - const wheelEvent = event as WheelEvent - if (wheelEvent.deltaY < 0 && scrollContainerRef.current?.contains(wheelEvent.target as Node)) { - stickyFollowRef.current = false - } - }, []) - useEvent("wheel", handleWheel, window, { passive: true }) - - // Also disable sticky follow when the chat container is scrolled away from bottom - useEffect(() => { - const el = scrollContainerRef.current - if (!el) return - const onScroll = () => { - // Consider near-bottom within a small threshold consistent with Virtuoso settings - const nearBottom = Math.abs(el.scrollHeight - el.scrollTop - el.clientHeight) < 10 - if (!nearBottom) { - stickyFollowRef.current = false - } - // Keep UI button state in sync with scroll position - setShowScrollToBottom(!nearBottom) - } - el.addEventListener("scroll", onScroll, { passive: true }) - return () => el.removeEventListener("scroll", onScroll) - }, []) - // Effect to clear checkpoint warning when messages appear or task changes useEffect(() => { if (isHidden || !task) { @@ -1297,38 +1388,16 @@ const ChatViewComponent: React.ForwardRefRenderFunction { + vscode.postMessage({ type: "cancelAutoApproval" }) + }, []) + const itemContent = useCallback( (index: number, messageOrGroup: ClineMessage) => { const hasCheckpoint = modifiedMessages.some((message) => message.say === "checkpoint_saved") - // Check if this is a browser action message - if (messageOrGroup.type === "say" && messageOrGroup.say === "browser_action") { - // Find the corresponding result message by looking for the next browser_action_result after this action's timestamp - const nextMessage = modifiedMessages.find( - (m) => m.ts > messageOrGroup.ts && m.say === "browser_action_result", - ) - - // Calculate action index and total count - const browserActions = modifiedMessages.filter((m) => m.say === "browser_action") - const actionIndex = browserActions.findIndex((m) => m.ts === messageOrGroup.ts) + 1 - const totalActions = browserActions.length - - return ( - - ) - } - - // Check if this is a browser session status message - if (messageOrGroup.type === "say" && messageOrGroup.say === "browser_session_status") { - return - } - // regular message return ( { - // Check for Command/Ctrl + Period (with or without Shift) - // Using event.key to respect keyboard layouts (e.g., Dvorak) if ((event.metaKey || event.ctrlKey) && event.key === ".") { - event.preventDefault() // Prevent default browser behavior - + event.preventDefault() if (event.shiftKey) { - // Shift + Period = Previous mode switchToPreviousMode() } else { - // Just Period = Next mode switchToNextMode() } } @@ -1427,9 +1494,20 @@ const ChatViewComponent: React.ForwardRefRenderFunction ({ acceptInput: () => { + const hasInput = inputValue.trim() || selectedImages.length > 0 + + // Special case: during command_output, queue the message instead of + // triggering the primary button action (which would lose the message) + if (clineAskRef.current === "command_output" && hasInput) { + vscode.postMessage({ type: "queueMessage", text: inputValue.trim(), images: selectedImages }) + setInputValue("") + setSelectedImages([]) + return + } + if (enableButtons && primaryButtonText) { handlePrimaryButtonClick(inputValue, selectedImages) - } else if (!sendingDisabled && !isProfileDisabled && (inputValue.trim() || selectedImages.length > 0)) { + } else if (!sendingDisabled && !isProfileDisabled && hasInput) { handleSendMessage(inputValue, selectedImages) } }, @@ -1499,12 +1577,6 @@ const ChatViewComponent: React.ForwardRefRenderFunction - {hasSystemPromptOverride && ( -
- -
- )} - {checkpointWarning && (
@@ -1557,16 +1629,12 @@ const ChatViewComponent: React.ForwardRefRenderFunction isAtBottom || stickyFollowRef.current} - atBottomStateChange={(isAtBottom: boolean) => { - setIsAtBottom(isAtBottom) - // Only show the scroll-to-bottom button if not at bottom - setShowScrollToBottom(!isAtBottom) - }} + followOutput={followOutputCallback} + atBottomStateChange={atBottomStateChangeCallback} atBottomThreshold={10} - initialTopMostItemIndex={groupedMessages.length - 1} />
+ {areButtonsVisible && (
{ - // Engage sticky follow until user scrolls up - stickyFollowRef.current = true - // Pin immediately to avoid lag during fast streaming - scrollToBottomAuto() - // Hide button immediately to prevent flash - setShowScrollToBottom(false) - }}> + onClick={handleScrollToBottomClick}> @@ -1667,6 +1728,16 @@ const ChatViewComponent: React.ForwardRefRenderFunction + {showRetiredProviderWarning && ( +
+ vscode.postMessage({ type: "switchTab", tab: "settings" })} + /> +
+ )} { - if (isAtBottom) { + if (isAtBottomRef.current && scrollPhaseRef.current !== "USER_BROWSING_HISTORY") { scrollToBottomAuto() } }} mode={mode} setMode={setMode} modeShortcutText={modeShortcutText} - isBrowserSessionActive={!!isBrowserSessionActive} - showBrowserDockToggle={showBrowserDockToggle} isStreaming={isStreaming} onStop={handleStopTask} onEnqueueMessage={handleEnqueueCurrentMessage} diff --git a/webview-ui/src/components/chat/CloudTaskButton.tsx b/webview-ui/src/components/chat/CloudTaskButton.tsx deleted file mode 100644 index 672bf020bb..0000000000 --- a/webview-ui/src/components/chat/CloudTaskButton.tsx +++ /dev/null @@ -1,126 +0,0 @@ -import { useState, useEffect, useCallback } from "react" -import { useTranslation } from "react-i18next" -import { Copy, Check, CloudUploadIcon } from "lucide-react" -import QRCode from "qrcode" - -import type { HistoryItem } from "@roo-code/types" - -import { useExtensionState } from "@/context/ExtensionStateContext" -import { useCopyToClipboard } from "@/utils/clipboard" -import { Button, Dialog, DialogContent, DialogHeader, DialogTitle, Input } from "@/components/ui" -import { vscode } from "@/utils/vscode" -import { LucideIconButton } from "./LucideIconButton" - -interface CloudTaskButtonProps { - item?: HistoryItem - disabled?: boolean -} - -export const CloudTaskButton = ({ item, disabled = false }: CloudTaskButtonProps) => { - const [dialogOpen, setDialogOpen] = useState(false) - const { t } = useTranslation() - const { cloudUserInfo, cloudApiUrl } = useExtensionState() - const { copyWithFeedback, showCopyFeedback } = useCopyToClipboard() - const [canvasElement, setCanvasElement] = useState(null) - - // Generate the cloud URL for the task - const cloudTaskUrl = item?.id ? `${cloudApiUrl}/task/${item.id}` : "" - - const generateQRCode = useCallback( - (canvas: HTMLCanvasElement, context: string) => { - if (!cloudTaskUrl) { - // This will run again later when ready - return - } - - QRCode.toCanvas( - canvas, - cloudTaskUrl, - { - width: 140, - margin: 0, - color: { - dark: "#000000", - light: "#FFFFFF", - }, - }, - (error: Error | null | undefined) => { - if (error) { - console.error(`Error generating QR code (${context}):`, error) - } - }, - ) - }, - [cloudTaskUrl], - ) - - // Callback ref to capture canvas element when it mounts - const canvasRef = useCallback( - (node: HTMLCanvasElement | null) => { - if (node) { - setCanvasElement(node) - - // Try to generate QR code immediately when canvas is available - if (dialogOpen) { - generateQRCode(node, "on mount") - } - } else { - setCanvasElement(null) - } - }, - [dialogOpen, generateQRCode], - ) - - // Also generate QR code when dialog opens after canvas is available - useEffect(() => { - if (dialogOpen && canvasElement) { - generateQRCode(canvasElement, "in useEffect") - } - }, [dialogOpen, canvasElement, generateQRCode]) - - if (!cloudUserInfo?.extensionBridgeEnabled || !item?.id) { - return null - } - - return ( - <> - setDialogOpen(true)}> - - - - - {t("chat:task.openInCloud")} - - -
-

{t("chat:task.openInCloudIntro")}

-
-
vscode.postMessage({ type: "openExternal", url: cloudTaskUrl })} - title={t("chat:task.openInCloud")}> - -
-
- -
- - -
-
-
-
- - ) -} diff --git a/webview-ui/src/components/chat/CodeIndexPopover.tsx b/webview-ui/src/components/chat/CodeIndexPopover.tsx index 4fcf6406e3..763c243ec1 100644 --- a/webview-ui/src/components/chat/CodeIndexPopover.tsx +++ b/webview-ui/src/components/chat/CodeIndexPopover.tsx @@ -1590,6 +1590,58 @@ export const CodeIndexPopover: React.FC = ({ )}
+ {/* Auto-enable default */} + {currentSettings.codebaseIndexEnabled && ( +
+ + vscode.postMessage({ + type: "setAutoEnableDefault", + bool: e.target.checked, + }) + } + className="accent-vscode-focusBorder" + /> + +
+ )} + + {/* Workspace Toggle */} + {currentSettings.codebaseIndexEnabled && ( +
+ + vscode.postMessage({ + type: "toggleWorkspaceIndexing", + bool: e.target.checked, + }) + } + className="accent-vscode-focusBorder" + /> + +
+ )} + + {currentSettings.codebaseIndexEnabled && !indexingStatus.workspaceEnabled && ( +

+ {t("settings:codeIndex.workspaceDisabledMessage")} +

+ )} + {/* Action Buttons */}
@@ -1603,6 +1655,20 @@ export const CodeIndexPopover: React.FC = ({ )} + {currentSettings.codebaseIndexEnabled && indexingStatus.systemStatus === "Indexing" && ( + + )} + + {currentSettings.codebaseIndexEnabled && indexingStatus.systemStatus === "Stopping" && ( + + )} + {currentSettings.codebaseIndexEnabled && (indexingStatus.systemStatus === "Indexed" || indexingStatus.systemStatus === "Error") && ( diff --git a/webview-ui/src/components/chat/FileChangesPanel.tsx b/webview-ui/src/components/chat/FileChangesPanel.tsx new file mode 100644 index 0000000000..8a4eb016cc --- /dev/null +++ b/webview-ui/src/components/chat/FileChangesPanel.tsx @@ -0,0 +1,118 @@ +import { memo, useEffect, useMemo, useState, useCallback } from "react" +import { useTranslation } from "react-i18next" +import { ChevronDown, ChevronRight, FileDiff } from "lucide-react" + +import type { ClineMessage } from "@roo-code/types" + +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui" +import { cn } from "@/lib/utils" +import { vscode } from "@src/utils/vscode" + +import { fileChangesFromMessages, type FileChangeEntry } from "./utils/fileChangesFromMessages" +import CodeAccordian from "../common/CodeAccordian" + +interface FileChangesPanelProps { + clineMessages: ClineMessage[] | undefined + className?: string +} + +const FileChangesPanel = memo(({ clineMessages, className }: FileChangesPanelProps) => { + const { t } = useTranslation() + const [panelExpanded, setPanelExpanded] = useState(false) + const [expandedPaths, setExpandedPaths] = useState>(new Set()) + + // Reset expanded file rows when switching to a different task (clineMessages identity change) + useEffect(() => { + setExpandedPaths(new Set()) + }, [clineMessages]) + + const fileChanges = useMemo(() => fileChangesFromMessages(clineMessages), [clineMessages]) + + // Group by path so we show one row per file (multiple edits to same file combined for display) + const byPath = useMemo(() => { + const map = new Map() + for (const entry of fileChanges) { + const key = entry.path + const list = map.get(key) ?? [] + list.push(entry) + map.set(key, list) + } + return map + }, [fileChanges]) + + const togglePath = useCallback((path: string) => { + setExpandedPaths((prev) => { + const next = new Set(prev) + if (next.has(path)) next.delete(path) + else next.add(path) + return next + }) + }, []) + + if (fileChanges.length === 0) return null + + const fileCount = byPath.size + + return ( + + + {panelExpanded ? ( + + ) : ( + + )} + + + {t("chat:fileChangesInConversation.header", { count: fileCount })} + + + +
+ {Array.from(byPath.entries()).map(([path, entries]) => { + // If multiple edits to same file, concatenate diffs with a separator + const combinedDiff = entries.map((e) => e.diff).join("\n\n") + const combinedStats = entries.reduce( + (acc, e) => ({ + added: acc.added + (e.diffStats?.added ?? 0), + removed: acc.removed + (e.diffStats?.removed ?? 0), + }), + { added: 0, removed: 0 }, + ) + const isExpanded = expandedPaths.has(path) + return ( +
+ togglePath(path)} + diffStats={ + combinedStats.added > 0 || combinedStats.removed > 0 ? combinedStats : undefined + } + onJumpToFile={ + path + ? () => + vscode.postMessage({ + type: "openFile", + text: path.startsWith("./") ? path : "./" + path, + }) + : undefined + } + /> +
+ ) + })} +
+
+
+ ) +}) + +FileChangesPanel.displayName = "FileChangesPanel" + +export default FileChangesPanel diff --git a/webview-ui/src/components/chat/IndexingStatusBadge.tsx b/webview-ui/src/components/chat/IndexingStatusBadge.tsx index 82f654a82f..227df3e645 100644 --- a/webview-ui/src/components/chat/IndexingStatusBadge.tsx +++ b/webview-ui/src/components/chat/IndexingStatusBadge.tsx @@ -64,6 +64,8 @@ export const IndexingStatusBadge: React.FC = ({ classN return t("chat:indexingStatus.indexing", { percentage: progressPercentage }) case "Indexed": return t("chat:indexingStatus.indexed") + case "Stopping": + return t("chat:indexingStatus.stopping") case "Error": return t("chat:indexingStatus.error") default: @@ -76,6 +78,7 @@ export const IndexingStatusBadge: React.FC = ({ classN Standby: "bg-vscode-descriptionForeground/60", Indexing: "bg-yellow-500 animate-pulse", Indexed: "bg-green-500", + Stopping: "bg-amber-500 animate-pulse", Error: "bg-red-500", } diff --git a/webview-ui/src/components/chat/SlashCommandItem.tsx b/webview-ui/src/components/chat/SlashCommandItem.tsx deleted file mode 100644 index 04ade08bbd..0000000000 --- a/webview-ui/src/components/chat/SlashCommandItem.tsx +++ /dev/null @@ -1,84 +0,0 @@ -import React from "react" -import { Edit, Trash2 } from "lucide-react" - -import type { Command } from "@roo-code/types" - -import { useAppTranslation } from "@/i18n/TranslationContext" -import { Button, StandardTooltip } from "@/components/ui" -import { vscode } from "@/utils/vscode" - -interface SlashCommandItemProps { - command: Command - onDelete: (command: Command) => void - onClick?: (command: Command) => void -} - -export const SlashCommandItem: React.FC = ({ command, onDelete, onClick }) => { - const { t } = useAppTranslation() - - // Built-in commands cannot be edited or deleted - const isBuiltIn = command.source === "built-in" - - const handleEdit = () => { - if (command.filePath) { - vscode.postMessage({ - type: "openFile", - text: command.filePath, - }) - } else { - // Fallback: request to open command file by name and source - vscode.postMessage({ - type: "openCommandFile", - text: command.name, - values: { source: command.source }, - }) - } - } - - const handleDelete = () => { - onDelete(command) - } - - return ( -
- {/* Command name - clickable */} -
onClick?.(command)}> -
- {command.name} - {command.description && ( -
- {command.description} -
- )} -
-
- - {/* Action buttons - only show for non-built-in commands */} - {!isBuiltIn && ( -
- - - - - - - -
- )} -
- ) -} diff --git a/webview-ui/src/components/chat/SystemPromptWarning.tsx b/webview-ui/src/components/chat/SystemPromptWarning.tsx deleted file mode 100644 index 0ed7a72733..0000000000 --- a/webview-ui/src/components/chat/SystemPromptWarning.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import React from "react" -import { useAppTranslation } from "@/i18n/TranslationContext" - -export const SystemPromptWarning: React.FC = () => { - const { t } = useAppTranslation() - - return ( -
-
- -
- {t("chat:systemPromptWarning")} -
- ) -} - -export default SystemPromptWarning diff --git a/webview-ui/src/components/chat/TaskActions.tsx b/webview-ui/src/components/chat/TaskActions.tsx index 74575ddc28..c7401425f6 100644 --- a/webview-ui/src/components/chat/TaskActions.tsx +++ b/webview-ui/src/components/chat/TaskActions.tsx @@ -9,7 +9,6 @@ import { useExtensionState } from "@/context/ExtensionStateContext" import { DeleteTaskDialog } from "../history/DeleteTaskDialog" import { ShareButton } from "./ShareButton" -import { CloudTaskButton } from "./CloudTaskButton" import { CopyIcon, DownloadIcon, Trash2Icon, FileJsonIcon, MessageSquareCodeIcon } from "lucide-react" import { LucideIconButton } from "./LucideIconButton" @@ -64,7 +63,6 @@ export const TaskActions = ({ item, buttonsDisabled }: TaskActionsProps) => { )} - {debug && item?.id && ( <> { const { t } = useTranslation() - const { apiConfiguration, currentTaskItem, clineMessages, isBrowserSessionActive } = useExtensionState() + const { apiConfiguration, currentTaskItem, clineMessages } = useExtensionState() const { id: modelId, info: model } = useSelectedModel(apiConfiguration) const [isTaskExpanded, setIsTaskExpanded] = useState(false) const [showLongRunningTaskMessage, setShowLongRunningTaskMessage] = useState(false) @@ -118,18 +110,6 @@ const TaskHeader = ({ ) const reservedForOutput = maxTokens || 0 - // Detect if this task had any browser session activity so we can show a grey globe when inactive - const browserSessionStartIndex = useMemo(() => { - const msgs = clineMessages || [] - for (let i = 0; i < msgs.length; i++) { - const m = msgs[i] as any - if (m?.ask === "browser_action_launch") return i - } - return -1 - }, [clineMessages]) - - const showBrowserGlobe = browserSessionStartIndex !== -1 || !!isBrowserSessionActive - const condenseButton = ( )}
- {showBrowserGlobe && ( -
e.stopPropagation()}> - - - - {isBrowserSessionActive && ( - - {t("chat:browser.active")} - - )} -
- )}
)} {/* Expanded state: Show task text and images */} diff --git a/webview-ui/src/components/chat/__tests__/ApiConfigSelector.spec.tsx b/webview-ui/src/components/chat/__tests__/ApiConfigSelector.spec.tsx index ff1b95f949..a71216d96f 100644 --- a/webview-ui/src/components/chat/__tests__/ApiConfigSelector.spec.tsx +++ b/webview-ui/src/components/chat/__tests__/ApiConfigSelector.spec.tsx @@ -72,6 +72,8 @@ describe("ApiConfigSelector", () => { ], pinnedApiConfigs: { config1: true }, togglePinnedApiConfig: mockTogglePinnedApiConfig, + lockApiConfigAcrossModes: false, + onToggleLockApiConfig: vi.fn(), } beforeEach(() => { diff --git a/webview-ui/src/components/chat/__tests__/BatchListFilesPermission.spec.tsx b/webview-ui/src/components/chat/__tests__/BatchListFilesPermission.spec.tsx new file mode 100644 index 0000000000..21ea05192f --- /dev/null +++ b/webview-ui/src/components/chat/__tests__/BatchListFilesPermission.spec.tsx @@ -0,0 +1,103 @@ +import { render, screen } from "@/utils/test-utils" + +import { TranslationProvider } from "@/i18n/__mocks__/TranslationContext" + +import { BatchListFilesPermission } from "../BatchListFilesPermission" + +describe("BatchListFilesPermission", () => { + const mockDirs = [ + { + key: "apps/cli", + path: "apps/cli", + }, + { + key: "apps/web-roo-code", + path: "apps/web-roo-code", + }, + { + key: "packages/core", + path: "packages/core", + }, + ] + + beforeEach(() => { + vi.clearAllMocks() + }) + + it("renders directory list correctly", () => { + render( + + + , + ) + + expect(screen.getByText("apps/cli")).toBeInTheDocument() + expect(screen.getByText("apps/web-roo-code")).toBeInTheDocument() + expect(screen.getByText("packages/core")).toBeInTheDocument() + }) + + it("renders nothing when dirs array is empty", () => { + const { container } = render( + + + , + ) + + expect(container.firstChild).toBeNull() + }) + + it("re-renders when timestamp changes", () => { + const { rerender } = render( + + + , + ) + + expect(screen.getByText("apps/cli")).toBeInTheDocument() + + rerender( + + + , + ) + + expect(screen.getByText("apps/cli")).toBeInTheDocument() + }) + + it("renders all directories in a single container", () => { + render( + + + , + ) + + // All directories should be within a single bordered container + const container = screen.getByText("apps/cli").closest(".border.border-border.rounded-md") + expect(container).toBeInTheDocument() + + // All 3 dirs should be inside this container + expect(container?.querySelectorAll(".flex.items-center.gap-2")).toHaveLength(mockDirs.length) + }) + + it("renders a single directory", () => { + const singleDir = [ + { + key: "apps/cli", + path: "apps/cli", + }, + ] + + render( + + + , + ) + + expect(screen.getByText("apps/cli")).toBeInTheDocument() + + // Single directory should still be rendered inside the container + const bordered = screen.getByText("apps/cli").closest(".border.border-border.rounded-md") + expect(bordered).toBeInTheDocument() + expect(bordered?.querySelectorAll(".flex.items-center.gap-2")).toHaveLength(1) + }) +}) diff --git a/webview-ui/src/components/chat/__tests__/BrowserSessionRow.aspect-ratio.spec.tsx b/webview-ui/src/components/chat/__tests__/BrowserSessionRow.aspect-ratio.spec.tsx deleted file mode 100644 index 8746586203..0000000000 --- a/webview-ui/src/components/chat/__tests__/BrowserSessionRow.aspect-ratio.spec.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import { render, screen, fireEvent } from "@testing-library/react" -import React from "react" -import BrowserSessionRow from "../BrowserSessionRow" -import { ExtensionStateContext } from "@src/context/ExtensionStateContext" -import { TooltipProvider } from "@src/components/ui/tooltip" - -describe("BrowserSessionRow - screenshot area", () => { - const renderRow = (messages: any[]) => { - const mockExtState: any = { - // Ensure known viewport so expected aspect ratio is deterministic (600/900 = 66.67%) - browserViewportSize: "900x600", - isBrowserSessionActive: false, - } - - return render( - - - true} - onToggleExpand={() => {}} - lastModifiedMessage={undefined as any} - isLast={true} - onHeightChange={() => {}} - isStreaming={false} - /> - - , - ) - } - - it("reserves height while screenshot is loading (no layout collapse)", () => { - // Only a launch action, no corresponding browser_action_result yet (no screenshot) - const messages = [ - { - ts: 1, - say: "browser_action", - text: JSON.stringify({ action: "launch", url: "http://localhost:3000" }), - }, - ] - - renderRow(messages) - - // Open the browser session drawer - const globe = screen.getByLabelText("Browser interaction") - fireEvent.click(globe) - - const container = screen.getByTestId("screenshot-container") as HTMLDivElement - // padding-bottom should reflect aspect ratio (600/900 * 100) even without an image - const pb = parseFloat(container.style.paddingBottom || "0") - expect(pb).toBeGreaterThan(0) - // Be tolerant of rounding - expect(Math.round(pb)).toBe(67) - }) -}) diff --git a/webview-ui/src/components/chat/__tests__/BrowserSessionRow.disconnect-button.spec.tsx b/webview-ui/src/components/chat/__tests__/BrowserSessionRow.disconnect-button.spec.tsx deleted file mode 100644 index 0c2b4762c4..0000000000 --- a/webview-ui/src/components/chat/__tests__/BrowserSessionRow.disconnect-button.spec.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import React from "react" -import { render, screen } from "@testing-library/react" -import BrowserSessionRow from "../BrowserSessionRow" -import { ExtensionStateContext } from "@src/context/ExtensionStateContext" -import { TooltipProvider } from "@radix-ui/react-tooltip" - -describe("BrowserSessionRow - Disconnect session button", () => { - const renderRow = (isActive: boolean) => { - const mockExtState: any = { - browserViewportSize: "900x600", - isBrowserSessionActive: isActive, - } - - return render( - - - false} - onToggleExpand={() => {}} - lastModifiedMessage={undefined as any} - isLast={true} - onHeightChange={() => {}} - isStreaming={false} - /> - - , - ) - } - - it("shows the Disconnect session button when a session is active", () => { - renderRow(true) - const btn = screen.getByLabelText("Disconnect session") - expect(btn).toBeInTheDocument() - }) - - it("does not render the button when no session is active", () => { - renderRow(false) - const btn = screen.queryByLabelText("Disconnect session") - expect(btn).toBeNull() - }) -}) diff --git a/webview-ui/src/components/chat/__tests__/BrowserSessionRow.spec.tsx b/webview-ui/src/components/chat/__tests__/BrowserSessionRow.spec.tsx deleted file mode 100644 index 684145f255..0000000000 --- a/webview-ui/src/components/chat/__tests__/BrowserSessionRow.spec.tsx +++ /dev/null @@ -1,126 +0,0 @@ -import React from "react" -import { describe, it, expect, vi } from "vitest" -import { render, screen } from "@testing-library/react" - -import BrowserSessionRow from "../BrowserSessionRow" - -// Mock ExtensionStateContext so BrowserSessionRow falls back to props -vi.mock("@src/context/ExtensionStateContext", () => ({ - useExtensionState: () => { - throw new Error("No ExtensionStateContext in test environment") - }, -})) - -// Simplify i18n usage and provide initReactI18next for i18n setup -vi.mock("react-i18next", () => ({ - useTranslation: () => ({ - t: (key: string) => key, - }), - initReactI18next: { - type: "3rdParty", - init: () => {}, - }, -})) - -// Replace ProgressIndicator with a simple test marker -vi.mock("../ProgressIndicator", () => ({ - ProgressIndicator: () =>
, -})) - -const baseProps = { - isExpanded: () => false, - onToggleExpand: () => {}, - lastModifiedMessage: undefined, - isLast: true, - onHeightChange: () => {}, - isStreaming: false, -} - -describe("BrowserSessionRow - action spinner", () => { - it("does not show spinner when there are no browser actions", () => { - const messages = [ - { - type: "say", - say: "task", - ts: 1, - text: "Task started", - } as any, - ] - - render() - - expect(screen.queryByTestId("browser-session-spinner")).toBeNull() - }) - - it("shows spinner while the latest browser action is still running", () => { - const messages = [ - { - type: "say", - say: "task", - ts: 1, - text: "Task started", - } as any, - { - type: "say", - say: "browser_action", - ts: 2, - text: JSON.stringify({ action: "click" }), - } as any, - { - type: "say", - say: "browser_action_result", - ts: 3, - text: JSON.stringify({ currentUrl: "https://example.com" }), - } as any, - { - type: "say", - say: "browser_action", - ts: 4, - text: JSON.stringify({ action: "scroll_down" }), - } as any, - ] - - render() - - expect(screen.getByTestId("browser-session-spinner")).toBeInTheDocument() - }) - - it("hides spinner once the latest browser action has a result", () => { - const messages = [ - { - type: "say", - say: "task", - ts: 1, - text: "Task started", - } as any, - { - type: "say", - say: "browser_action", - ts: 2, - text: JSON.stringify({ action: "click" }), - } as any, - { - type: "say", - say: "browser_action_result", - ts: 3, - text: JSON.stringify({ currentUrl: "https://example.com" }), - } as any, - { - type: "say", - say: "browser_action", - ts: 4, - text: JSON.stringify({ action: "scroll_down" }), - } as any, - { - type: "say", - say: "browser_action_result", - ts: 5, - text: JSON.stringify({ currentUrl: "https://example.com/page2" }), - } as any, - ] - - render() - - expect(screen.queryByTestId("browser-session-spinner")).toBeNull() - }) -}) diff --git a/webview-ui/src/components/chat/__tests__/ChatRow.diff-actions.spec.tsx b/webview-ui/src/components/chat/__tests__/ChatRow.diff-actions.spec.tsx index 61a6633f86..7876420959 100644 --- a/webview-ui/src/components/chat/__tests__/ChatRow.diff-actions.spec.tsx +++ b/webview-ui/src/components/chat/__tests__/ChatRow.diff-actions.spec.tsx @@ -1,15 +1,27 @@ import React from "react" -import { render, screen } from "@/utils/test-utils" +import { fireEvent, render, screen } from "@/utils/test-utils" import { QueryClient, QueryClientProvider } from "@tanstack/react-query" +import type { ClineMessage } from "@roo-code/types" import { ExtensionStateContextProvider } from "@src/context/ExtensionStateContext" import { ChatRowContent } from "../ChatRow" +const mockPostMessage = vi.fn() + +vi.mock("@src/utils/vscode", () => ({ + vscode: { + postMessage: (...args: unknown[]) => mockPostMessage(...args), + }, +})) + // Mock i18n vi.mock("react-i18next", () => ({ useTranslation: () => ({ t: (key: string) => { const map: Record = { "chat:fileOperations.wantsToEdit": "Roo wants to edit this file", + "chat:fileOperations.wantsToEditProtected": "Roo wants to edit a protected file", + "chat:fileOperations.wantsToEditOutsideWorkspace": "Roo wants to edit outside workspace", + "chat:fileOperations.wantsToApplyBatchChanges": "Roo wants to apply batch changes", } return map[key] || key }, @@ -25,7 +37,17 @@ vi.mock("@src/components/common/CodeBlock", () => ({ const queryClient = new QueryClient() -function renderChatRow(message: any, isExpanded = false) { +function createToolAskMessage(toolPayload: Record): ClineMessage { + return { + type: "ask", + ask: "tool", + ts: Date.now(), + partial: false, + text: JSON.stringify(toolPayload), + } +} + +function renderChatRow(message: ClineMessage, isExpanded = false) { return render( @@ -48,92 +70,141 @@ function renderChatRow(message: any, isExpanded = false) { describe("ChatRow - inline diff stats and actions", () => { beforeEach(() => { vi.clearAllMocks() + mockPostMessage.mockClear() }) - it("shows + and - counts for editedExistingFile ask", () => { + it("uses appliedDiff edit treatment (header/icon/diff stats)", () => { const diff = "@@ -1,1 +1,1 @@\n-old\n+new\n" - const message: any = { - type: "ask", - ask: "tool", - ts: Date.now(), - partial: false, - text: JSON.stringify({ - tool: "editedExistingFile", - path: "src/file.ts", - diff, - diffStats: { added: 1, removed: 1 }, - }), - } + const message = createToolAskMessage({ + tool: "appliedDiff", + path: "src/file.ts", + diff, + diffStats: { added: 1, removed: 1 }, + }) - renderChatRow(message, false) + const { container } = renderChatRow(message, false) - // Plus/minus counts + expect(screen.getByText("Roo wants to edit this file")).toBeInTheDocument() + expect(container.querySelector(".codicon-diff")).toBeInTheDocument() expect(screen.getByText("+1")).toBeInTheDocument() expect(screen.getByText("-1")).toBeInTheDocument() }) - it("derives counts from searchAndReplace diff", () => { + it("uses same edit treatment for editedExistingFile", () => { + const diff = "@@ -1,1 +1,1 @@\n-old\n+new\n" + const message = createToolAskMessage({ + tool: "editedExistingFile", + path: "src/file.ts", + diff, + diffStats: { added: 1, removed: 1 }, + }) + + const { container } = renderChatRow(message) + + expect(screen.getByText("Roo wants to edit this file")).toBeInTheDocument() + expect(container.querySelector(".codicon-diff")).toBeInTheDocument() + expect(screen.getByText("+1")).toBeInTheDocument() + expect(screen.getByText("-1")).toBeInTheDocument() + }) + + it("uses same edit treatment for searchAndReplace", () => { const diff = "-a\n-b\n+c\n" - const message: any = { - type: "ask", - ask: "tool", - ts: Date.now(), - partial: false, - text: JSON.stringify({ - tool: "searchAndReplace", - path: "src/file.ts", - diff, - diffStats: { added: 1, removed: 2 }, - }), - } + const message = createToolAskMessage({ + tool: "searchAndReplace", + path: "src/file.ts", + diff, + diffStats: { added: 1, removed: 2 }, + }) - renderChatRow(message) + const { container } = renderChatRow(message) + expect(screen.getByText("Roo wants to edit this file")).toBeInTheDocument() + expect(container.querySelector(".codicon-diff")).toBeInTheDocument() expect(screen.getByText("+1")).toBeInTheDocument() expect(screen.getByText("-2")).toBeInTheDocument() }) - it("counts only added lines for newFileCreated (ignores diff headers)", () => { + it("uses same edit treatment for newFileCreated", () => { const content = "a\nb\nc" - const message: any = { - type: "ask", - ask: "tool", - ts: Date.now(), - partial: false, - text: JSON.stringify({ - tool: "newFileCreated", - path: "src/new-file.ts", - content, - diffStats: { added: 3, removed: 0 }, - }), - } + const message = createToolAskMessage({ + tool: "newFileCreated", + path: "src/new-file.ts", + content, + diffStats: { added: 3, removed: 0 }, + }) - renderChatRow(message) + const { container } = renderChatRow(message) - // Should only count the three content lines as additions + expect(screen.getByText("Roo wants to edit this file")).toBeInTheDocument() + expect(container.querySelector(".codicon-diff")).toBeInTheDocument() expect(screen.getByText("+3")).toBeInTheDocument() expect(screen.getByText("-0")).toBeInTheDocument() }) - it("counts only added lines for newFileCreated with trailing newline", () => { - const content = "a\nb\nc\n" - const message: any = { - type: "ask", - ask: "tool", - ts: Date.now(), - partial: false, - text: JSON.stringify({ - tool: "newFileCreated", - path: "src/new-file.ts", - content, - diffStats: { added: 3, removed: 0 }, - }), + it("preserves jump-to-file affordance for newFileCreated", () => { + const message = createToolAskMessage({ + tool: "newFileCreated", + path: "src/new-file.ts", + content: "+new file", + diffStats: { added: 1, removed: 0 }, + }) + + const { container } = renderChatRow(message) + const openFileIcon = container.querySelector(".codicon-link-external") as HTMLElement | null + + expect(openFileIcon).toBeInTheDocument() + if (!openFileIcon) { + throw new Error("Expected external link icon for newFileCreated") } + fireEvent.click(openFileIcon) + + expect(mockPostMessage).toHaveBeenCalledWith({ + type: "openFile", + text: "./src/new-file.ts", + }) + }) + + it("preserves protected and outside-workspace messaging in unified branch", () => { + const outsideWorkspaceMessage = createToolAskMessage({ + tool: "searchAndReplace", + path: "../outside/file.ts", + diff: "-a\n+b\n", + isOutsideWorkspace: true, + diffStats: { added: 1, removed: 1 }, + }) + renderChatRow(outsideWorkspaceMessage) + expect(screen.getByText("Roo wants to edit outside workspace")).toBeInTheDocument() + + const protectedMessage = createToolAskMessage({ + tool: "appliedDiff", + path: "src/protected.ts", + diff: "-a\n+b\n", + isProtected: true, + diffStats: { added: 1, removed: 1 }, + }) + const { container } = renderChatRow(protectedMessage) + expect(screen.getByText("Roo wants to edit a protected file")).toBeInTheDocument() + expect(container.querySelector(".codicon-lock")).toBeInTheDocument() + }) + + it("keeps batch diff handling for unified edit tools", () => { + const message = createToolAskMessage({ + tool: "searchAndReplace", + batchDiffs: [ + { + path: "src/a.ts", + changeCount: 1, + key: "a", + content: "@@ -1,1 +1,1 @@\n-a\n+b\n", + diffStats: { added: 1, removed: 1 }, + }, + ], + }) + renderChatRow(message) - // Trailing newline should not increase the added count - expect(screen.getByText("+3")).toBeInTheDocument() - expect(screen.getByText("-0")).toBeInTheDocument() + expect(screen.getByText("Roo wants to apply batch changes")).toBeInTheDocument() + expect(screen.getByText((text) => text.includes("src/a.ts"))).toBeInTheDocument() }) }) diff --git a/webview-ui/src/components/chat/__tests__/ChatTextArea.lockApiConfig.spec.tsx b/webview-ui/src/components/chat/__tests__/ChatTextArea.lockApiConfig.spec.tsx new file mode 100644 index 0000000000..d3fb2b6890 --- /dev/null +++ b/webview-ui/src/components/chat/__tests__/ChatTextArea.lockApiConfig.spec.tsx @@ -0,0 +1,156 @@ +import { defaultModeSlug } from "@roo/modes" + +import { render, fireEvent, screen } from "@src/utils/test-utils" +import { useExtensionState } from "@src/context/ExtensionStateContext" +import { vscode } from "@src/utils/vscode" + +import { ChatTextArea } from "../ChatTextArea" + +vi.mock("@src/utils/vscode", () => ({ + vscode: { + postMessage: vi.fn(), + }, +})) + +vi.mock("@src/components/common/CodeBlock") +vi.mock("@src/components/common/MarkdownBlock") +vi.mock("@src/utils/path-mentions", () => ({ + convertToMentionPath: vi.fn((path: string) => path), +})) + +// Mock ExtensionStateContext +vi.mock("@src/context/ExtensionStateContext") + +const mockPostMessage = vscode.postMessage as ReturnType + +describe("ChatTextArea - lockApiConfigAcrossModes toggle", () => { + const defaultProps = { + inputValue: "", + setInputValue: vi.fn(), + onSend: vi.fn(), + sendingDisabled: false, + selectApiConfigDisabled: false, + onSelectImages: vi.fn(), + shouldDisableImages: false, + placeholderText: "Type a message...", + selectedImages: [] as string[], + setSelectedImages: vi.fn(), + onHeightChange: vi.fn(), + mode: defaultModeSlug, + setMode: vi.fn(), + modeShortcutText: "(⌘. for next mode)", + } + + const defaultState = { + filePaths: [], + openedTabs: [], + apiConfiguration: { apiProvider: "anthropic" }, + taskHistory: [], + cwd: "/test/workspace", + listApiConfigMeta: [{ id: "default", name: "Default", modelId: "claude-3" }], + currentApiConfigName: "Default", + pinnedApiConfigs: {}, + togglePinnedApiConfig: vi.fn(), + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + /** + * Helper: Opens the ApiConfigSelector popover by clicking the trigger, + * then returns the lock toggle button by its aria-label. + */ + const openPopoverAndGetLockToggle = (ariaLabel: string) => { + const trigger = screen.getByTestId("dropdown-trigger") + fireEvent.click(trigger) + return screen.getByRole("button", { name: ariaLabel }) + } + + describe("rendering", () => { + it("renders with muted opacity when lockApiConfigAcrossModes is false", () => { + ;(useExtensionState as ReturnType).mockReturnValue({ + ...defaultState, + lockApiConfigAcrossModes: false, + }) + + render() + + const button = openPopoverAndGetLockToggle("chat:lockApiConfigAcrossModes") + expect(button).toBeInTheDocument() + // Unlocked state has muted opacity + expect(button.className).toContain("opacity-60") + expect(button.className).not.toContain("text-vscode-focusBorder") + }) + + it("renders with highlight color when lockApiConfigAcrossModes is true", () => { + ;(useExtensionState as ReturnType).mockReturnValue({ + ...defaultState, + lockApiConfigAcrossModes: true, + }) + + render() + + const button = openPopoverAndGetLockToggle("chat:unlockApiConfigAcrossModes") + expect(button).toBeInTheDocument() + // Locked state has the focus border highlight color + expect(button.className).toContain("text-vscode-focusBorder") + expect(button.className).not.toContain("opacity-60") + }) + + it("renders in unlocked state when lockApiConfigAcrossModes is undefined (default)", () => { + ;(useExtensionState as ReturnType).mockReturnValue({ + ...defaultState, + }) + + render() + + const button = openPopoverAndGetLockToggle("chat:lockApiConfigAcrossModes") + expect(button).toBeInTheDocument() + // Default (undefined/falsy) renders in unlocked style + expect(button.className).toContain("opacity-60") + }) + }) + + describe("interaction", () => { + it("posts lockApiConfigAcrossModes=true message when locking", () => { + ;(useExtensionState as ReturnType).mockReturnValue({ + ...defaultState, + lockApiConfigAcrossModes: false, + }) + + render() + + // Clear any initialization messages + mockPostMessage.mockClear() + + const button = openPopoverAndGetLockToggle("chat:lockApiConfigAcrossModes") + fireEvent.click(button) + + expect(mockPostMessage).toHaveBeenCalledWith({ + type: "lockApiConfigAcrossModes", + bool: true, + }) + }) + + it("posts lockApiConfigAcrossModes=false message when unlocking", () => { + ;(useExtensionState as ReturnType).mockReturnValue({ + ...defaultState, + lockApiConfigAcrossModes: true, + }) + + render() + + // Clear any initialization messages + mockPostMessage.mockClear() + + const button = openPopoverAndGetLockToggle("chat:unlockApiConfigAcrossModes") + fireEvent.click(button) + + expect(mockPostMessage).toHaveBeenCalledWith({ + type: "lockApiConfigAcrossModes", + bool: false, + }) + }) + }) +}) diff --git a/webview-ui/src/components/chat/__tests__/ChatView.keyboard-fix.spec.tsx b/webview-ui/src/components/chat/__tests__/ChatView.keyboard-fix.spec.tsx index 96efb00673..78dcce08ae 100644 --- a/webview-ui/src/components/chat/__tests__/ChatView.keyboard-fix.spec.tsx +++ b/webview-ui/src/components/chat/__tests__/ChatView.keyboard-fix.spec.tsx @@ -24,10 +24,6 @@ vi.mock("use-sound", () => ({ })) // Mock components -vi.mock("../BrowserSessionRow", () => ({ - default: () => null, -})) - vi.mock("../ChatRow", () => ({ default: () => null, })) diff --git a/webview-ui/src/components/chat/__tests__/ChatView.notification-sound.spec.tsx b/webview-ui/src/components/chat/__tests__/ChatView.notification-sound.spec.tsx index 4115356449..4c4d70f716 100644 --- a/webview-ui/src/components/chat/__tests__/ChatView.notification-sound.spec.tsx +++ b/webview-ui/src/components/chat/__tests__/ChatView.notification-sound.spec.tsx @@ -49,12 +49,6 @@ vi.mock("use-sound", () => ({ })) // Mock components that use ESM dependencies -vi.mock("../BrowserSessionRow", () => ({ - default: function MockBrowserSessionRow({ messages }: { messages: ClineMessage[] }) { - return
{JSON.stringify(messages)}
- }, -})) - vi.mock("../ChatRow", () => ({ default: function MockChatRow({ message }: { message: ClineMessage }) { return
{JSON.stringify(message)}
@@ -520,3 +514,110 @@ describe("ChatView - Notification Sound with Queued Messages", () => { ) }) }) + +describe("ChatView - Sound Debounce", () => { + beforeEach(() => vi.clearAllMocks()) + + it("should not play the same sound type twice within 100ms", async () => { + const now = 1_000_000 + const dateNowSpy = vi.spyOn(Date, "now").mockReturnValue(now) + + renderChatView() + + // Hydrate with initial task + mockPostMessage({ + soundEnabled: true, + messageQueue: [], + clineMessages: [{ type: "say", say: "task", ts: now - 2000, text: "Initial task" }], + }) + + // Clear any setup calls + mockPlayFunction.mockClear() + + // First completion_result — should trigger celebration sound + mockPostMessage({ + soundEnabled: true, + messageQueue: [], + clineMessages: [ + { type: "say", say: "task", ts: now - 2000, text: "Initial task" }, + { type: "ask", ask: "completion_result", ts: now, text: "Task completed", partial: false }, + ], + }) + + await waitFor(() => { + expect(mockPlayFunction).toHaveBeenCalledTimes(1) + }) + + // Simulate only 50ms passing — still inside the 100ms debounce window + dateNowSpy.mockReturnValue(now + 50) + + // Second completion_result with slightly different content to force useDeepCompareEffect re-fire + mockPostMessage({ + soundEnabled: true, + messageQueue: [], + clineMessages: [ + { type: "say", say: "task", ts: now - 2000, text: "Initial task" }, + { type: "ask", ask: "completion_result", ts: now + 50, text: "Task completed again", partial: false }, + ], + }) + + // Allow time for the second state update to propagate through React effects + await new Promise((resolve) => setTimeout(resolve, 300)) + + // Debounce should have prevented the second play + expect(mockPlayFunction).toHaveBeenCalledTimes(1) + + dateNowSpy.mockRestore() + }) + + it("should allow playing the same sound type again after 100ms", async () => { + const now = 1_000_000 + const dateNowSpy = vi.spyOn(Date, "now").mockReturnValue(now) + + renderChatView() + + // Hydrate with initial task + mockPostMessage({ + soundEnabled: true, + messageQueue: [], + clineMessages: [{ type: "say", say: "task", ts: now - 2000, text: "Initial task" }], + }) + + // Clear any setup calls + mockPlayFunction.mockClear() + + // First completion_result — triggers sound + mockPostMessage({ + soundEnabled: true, + messageQueue: [], + clineMessages: [ + { type: "say", say: "task", ts: now - 2000, text: "Initial task" }, + { type: "ask", ask: "completion_result", ts: now, text: "Task completed", partial: false }, + ], + }) + + await waitFor(() => { + expect(mockPlayFunction).toHaveBeenCalledTimes(1) + }) + + // Advance past the 100ms debounce window + dateNowSpy.mockReturnValue(now + 101) + + // Second completion_result with different content to trigger a fresh effect cycle + mockPostMessage({ + soundEnabled: true, + messageQueue: [], + clineMessages: [ + { type: "say", say: "task", ts: now - 2000, text: "Initial task" }, + { type: "ask", ask: "completion_result", ts: now + 101, text: "Second task completed", partial: false }, + ], + }) + + // This time the debounce window has elapsed — sound should play again + await waitFor(() => { + expect(mockPlayFunction).toHaveBeenCalledTimes(2) + }) + + dateNowSpy.mockRestore() + }) +}) diff --git a/webview-ui/src/components/chat/__tests__/ChatView.preserve-images.spec.tsx b/webview-ui/src/components/chat/__tests__/ChatView.preserve-images.spec.tsx new file mode 100644 index 0000000000..a167c09c05 --- /dev/null +++ b/webview-ui/src/components/chat/__tests__/ChatView.preserve-images.spec.tsx @@ -0,0 +1,479 @@ +// npx vitest run src/components/chat/__tests__/ChatView.preserve-images.spec.tsx + +import React from "react" +import { render, waitFor, act } from "@/utils/test-utils" +import { QueryClient, QueryClientProvider } from "@tanstack/react-query" + +import { ExtensionStateContextProvider } from "@src/context/ExtensionStateContext" + +import ChatView, { ChatViewProps } from "../ChatView" + +// Define minimal types needed for testing +interface ClineMessage { + type: "say" | "ask" + say?: string + ask?: string + ts: number + text?: string + partial?: boolean +} + +interface ExtensionState { + version: string + clineMessages: ClineMessage[] + taskHistory: any[] + shouldShowAnnouncement: boolean + allowedCommands: string[] + alwaysAllowExecute: boolean + [key: string]: any +} + +// Mock vscode API +vi.mock("@src/utils/vscode", () => ({ + vscode: { + postMessage: vi.fn(), + }, +})) + +// Mock use-sound hook +const mockPlayFunction = vi.fn() +vi.mock("use-sound", () => ({ + default: vi.fn().mockImplementation(() => { + return [mockPlayFunction] + }), +})) + +// Mock components that use ESM dependencies +vi.mock("../ChatRow", () => ({ + default: function MockChatRow({ message }: { message: ClineMessage }) { + return
{JSON.stringify(message)}
+ }, +})) + +vi.mock("../AutoApproveMenu", () => ({ + default: () => null, +})) + +// Mock VersionIndicator +vi.mock("../../common/VersionIndicator", () => ({ + default: vi.fn(() => null), +})) + +vi.mock("../Announcement", () => ({ + default: function MockAnnouncement({ hideAnnouncement }: { hideAnnouncement: () => void }) { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const React = require("react") + return React.createElement( + "div", + { "data-testid": "announcement-modal" }, + React.createElement("div", null, "What's New"), + React.createElement("button", { onClick: hideAnnouncement }, "Close"), + ) + }, +})) + +// Mock DismissibleUpsell component +vi.mock("@/components/common/DismissibleUpsell", () => ({ + default: function MockDismissibleUpsell({ children }: { children: React.ReactNode }) { + return
{children}
+ }, +})) + +// Mock QueuedMessages component +vi.mock("../QueuedMessages", () => ({ + QueuedMessages: function MockQueuedMessages({ + queue = [], + onRemove, + }: { + queue?: Array<{ id: string; text: string; images?: string[] }> + onRemove?: (index: number) => void + onUpdate?: (index: number, newText: string) => void + }) { + if (!queue || queue.length === 0) { + return null + } + return ( +
+ {queue.map((msg, index) => ( +
+ {msg.text} + +
+ ))} +
+ ) + }, +})) + +// Mock RooTips component +vi.mock("@src/components/welcome/RooTips", () => ({ + default: function MockRooTips() { + return
Tips content
+ }, +})) + +// Mock RooHero component +vi.mock("@src/components/welcome/RooHero", () => ({ + default: function MockRooHero() { + return
Hero content
+ }, +})) + +// Mock TelemetryBanner component +vi.mock("../common/TelemetryBanner", () => ({ + default: function MockTelemetryBanner() { + return null + }, +})) + +// Mock i18n +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string, options?: any) => { + if (key === "chat:versionIndicator.ariaLabel" && options?.version) { + return `Version ${options.version}` + } + return key + }, + }), + initReactI18next: { + type: "3rdParty", + init: () => {}, + }, + Trans: ({ i18nKey, children }: { i18nKey: string; children?: React.ReactNode }) => { + return <>{children || i18nKey} + }, +})) + +interface ChatTextAreaProps { + onSend: () => void + inputValue?: string + setInputValue?: (value: string) => void + sendingDisabled?: boolean + placeholderText?: string + selectedImages?: string[] + setSelectedImages?: React.Dispatch> + shouldDisableImages?: boolean +} + +const mockInputRef = React.createRef() +const mockFocus = vi.fn() + +// Mock ChatTextArea to expose selectedImages via a data attribute +vi.mock("../ChatTextArea", () => { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const mockReact = require("react") + + const ChatTextAreaComponent = mockReact.forwardRef(function MockChatTextArea( + props: ChatTextAreaProps, + ref: React.ForwardedRef<{ focus: () => void }>, + ) { + mockReact.useImperativeHandle(ref, () => ({ + focus: mockFocus, + })) + + return ( +
+ { + if (props.setInputValue) { + props.setInputValue(e.target.value) + } + }} + onKeyDown={(e) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault() + props.onSend() + } + }} + data-sending-disabled={props.sendingDisabled} + /> +
+ ) + }) + + return { + default: ChatTextAreaComponent, + ChatTextArea: ChatTextAreaComponent, + } +}) + +// Mock react-virtuoso +vi.mock("react-virtuoso", () => ({ + Virtuoso: function MockVirtuoso({ + data, + itemContent, + }: { + data: ClineMessage[] + itemContent: (index: number, item: ClineMessage) => React.ReactNode + }) { + return ( +
+ {data.map((item, index) => ( +
+ {itemContent(index, item)} +
+ ))} +
+ ) + }, +})) + +// Mock window.postMessage to trigger state hydration +const mockPostMessage = (state: Partial) => { + window.postMessage( + { + type: "state", + state: { + version: "1.0.0", + clineMessages: [], + taskHistory: [], + shouldShowAnnouncement: false, + allowedCommands: [], + alwaysAllowExecute: false, + cloudIsAuthenticated: false, + telemetrySetting: "enabled", + ...state, + }, + }, + "*", + ) +} + +const defaultProps: ChatViewProps = { + isHidden: false, + showAnnouncement: false, + hideAnnouncement: () => {}, +} + +const queryClient = new QueryClient() + +const renderChatView = (props: Partial = {}) => { + return render( + + + + + , + ) +} + +describe("ChatView - Preserve Images During Chat Activity", () => { + beforeEach(() => vi.clearAllMocks()) + + it("should not clear selectedImages when api_req_started message arrives", async () => { + const { getByTestId } = renderChatView() + + // Hydrate state with an active task + await act(async () => { + mockPostMessage({ + clineMessages: [ + { + type: "say", + say: "task", + ts: Date.now() - 5000, + text: "Initial task", + }, + ], + }) + }) + + // Wait for the component to render + await waitFor(() => { + expect(getByTestId("chat-textarea")).toBeInTheDocument() + }) + + // Simulate user pasting an image via the selectedImages message + await act(async () => { + window.postMessage( + { + type: "selectedImages", + images: [ + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==", + ], + }, + "*", + ) + }) + + // Verify images are set + await waitFor(() => { + const textArea = getByTestId("chat-textarea") + const images = JSON.parse(textArea.getAttribute("data-selected-images") || "[]") + expect(images).toHaveLength(1) + }) + + // Now simulate an api_req_started message (which happens during chat activity) + await act(async () => { + mockPostMessage({ + clineMessages: [ + { + type: "say", + say: "task", + ts: Date.now() - 5000, + text: "Initial task", + }, + { + type: "say", + say: "api_req_started", + ts: Date.now(), + text: JSON.stringify({ request: "test" }), + }, + ], + }) + }) + + // Images should still be present after api_req_started + await waitFor(() => { + const textArea = getByTestId("chat-textarea") + const images = JSON.parse(textArea.getAttribute("data-selected-images") || "[]") + expect(images).toHaveLength(1) + expect(images[0]).toContain("data:image/png;base64,") + }) + }) + + it("should preserve images through multiple api_req_started messages", async () => { + const { getByTestId } = renderChatView() + + // Hydrate state with an active task + await act(async () => { + mockPostMessage({ + clineMessages: [ + { + type: "say", + say: "task", + ts: Date.now() - 5000, + text: "Initial task", + }, + ], + }) + }) + + await waitFor(() => { + expect(getByTestId("chat-textarea")).toBeInTheDocument() + }) + + // Simulate user pasting two images + await act(async () => { + window.postMessage( + { + type: "selectedImages", + images: ["data:image/png;base64,image1", "data:image/png;base64,image2"], + }, + "*", + ) + }) + + // Verify both images are set + await waitFor(() => { + const textArea = getByTestId("chat-textarea") + const images = JSON.parse(textArea.getAttribute("data-selected-images") || "[]") + expect(images).toHaveLength(2) + }) + + // Simulate multiple api_req_started messages (multiple API calls during task processing) + const baseTs = Date.now() + for (let i = 0; i < 3; i++) { + await act(async () => { + mockPostMessage({ + clineMessages: [ + { + type: "say", + say: "task", + ts: baseTs - 5000, + text: "Initial task", + }, + { + type: "say", + say: "api_req_started", + ts: baseTs + i * 1000, + text: JSON.stringify({ request: `test-${i}` }), + }, + ], + }) + }) + } + + // Images should still be preserved after multiple api_req_started messages + await waitFor(() => { + const textArea = getByTestId("chat-textarea") + const images = JSON.parse(textArea.getAttribute("data-selected-images") || "[]") + expect(images).toHaveLength(2) + expect(images[0]).toBe("data:image/png;base64,image1") + expect(images[1]).toBe("data:image/png;base64,image2") + }) + }) + + it("should still clear images when user sends a message", async () => { + const { getByTestId } = renderChatView() + + // Hydrate with an active task that has a followup ask (so sending is enabled) + await act(async () => { + mockPostMessage({ + clineMessages: [ + { + type: "say", + say: "task", + ts: Date.now() - 5000, + text: "Initial task", + }, + { + type: "ask", + ask: "followup", + ts: Date.now(), + text: "What do you want to do?", + }, + ], + }) + }) + + await waitFor(() => { + expect(getByTestId("chat-textarea")).toBeInTheDocument() + }) + + // Add an image + await act(async () => { + window.postMessage( + { + type: "selectedImages", + images: ["data:image/png;base64,testimage"], + }, + "*", + ) + }) + + // Verify image is set + await waitFor(() => { + const textArea = getByTestId("chat-textarea") + const images = JSON.parse(textArea.getAttribute("data-selected-images") || "[]") + expect(images).toHaveLength(1) + }) + + // Type something and send (Enter key triggers onSend -> handleSendMessage) + const input = mockInputRef.current! + await act(async () => { + // Set input value first + input.focus() + // Fire change event to set the input value + input.value = "Here is my image" + input.dispatchEvent(new Event("change", { bubbles: true })) + }) + + await act(async () => { + // Press Enter to send + input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true })) + }) + + // After sending, images should be cleared + await waitFor(() => { + const textArea = getByTestId("chat-textarea") + const images = JSON.parse(textArea.getAttribute("data-selected-images") || "[]") + expect(images).toHaveLength(0) + }) + }) +}) diff --git a/webview-ui/src/components/chat/__tests__/ChatView.scroll-debug-repro.spec.tsx b/webview-ui/src/components/chat/__tests__/ChatView.scroll-debug-repro.spec.tsx new file mode 100644 index 0000000000..c71df99f70 --- /dev/null +++ b/webview-ui/src/components/chat/__tests__/ChatView.scroll-debug-repro.spec.tsx @@ -0,0 +1,506 @@ +import React, { useEffect, useImperativeHandle, useRef } from "react" +import { act, fireEvent, render, waitFor } from "@/utils/test-utils" +import { QueryClient, QueryClientProvider } from "@tanstack/react-query" + +import type { ClineMessage } from "@roo-code/types" + +import { ExtensionStateContextProvider } from "@src/context/ExtensionStateContext" + +import ChatView, { type ChatViewProps } from "../ChatView" + +type FollowOutput = ((isAtBottom: boolean) => "auto" | false) | "auto" | false + +interface ExtensionStateMessage { + type: "state" + state: { + version: string + clineMessages: ClineMessage[] + taskHistory: unknown[] + shouldShowAnnouncement: boolean + allowedCommands: string[] + alwaysAllowExecute: boolean + cloudIsAuthenticated: boolean + telemetrySetting: "enabled" | "disabled" | "unset" + } +} + +interface MockVirtuosoHandle { + scrollToIndex: (options: { + index: number | "LAST" + align?: "end" | "start" | "center" + behavior?: "auto" | "smooth" + }) => void +} + +interface MockVirtuosoProps { + data: ClineMessage[] + itemContent: (index: number, item: ClineMessage) => React.ReactNode + atBottomStateChange?: (isAtBottom: boolean) => void + followOutput?: FollowOutput + className?: string + initialTopMostItemIndex?: number +} + +interface VirtuosoHarnessState { + scrollCalls: number + atBottomAfterCalls: number + signalDelayMs: number + emitFalseOnDataChange: boolean + delayedGrowthMs: number | null + initialTopMostItemIndex: number | undefined + followOutput: FollowOutput | undefined + emitAtBottom: (isAtBottom: boolean) => void +} + +const harness = vi.hoisted(() => ({ + scrollCalls: 0, + atBottomAfterCalls: Number.POSITIVE_INFINITY, + signalDelayMs: 20, + emitFalseOnDataChange: true, + delayedGrowthMs: null, + initialTopMostItemIndex: undefined, + followOutput: undefined, + emitAtBottom: () => {}, +})) + +function nullDefaultModule() { + return { default: () => null } +} + +vi.mock("@src/utils/vscode", () => ({ vscode: { postMessage: vi.fn() } })) +vi.mock("use-sound", () => ({ default: vi.fn().mockImplementation(() => [vi.fn()]) })) +vi.mock("@src/components/cloud/CloudUpsellDialog", () => ({ CloudUpsellDialog: () => null })) +vi.mock("@src/hooks/useCloudUpsell", () => ({ + useCloudUpsell: () => ({ + isOpen: false, + openUpsell: vi.fn(), + closeUpsell: vi.fn(), + handleConnect: vi.fn(), + }), +})) + +vi.mock("../common/TelemetryBanner", nullDefaultModule) +vi.mock("../common/VersionIndicator", nullDefaultModule) +vi.mock("../history/HistoryPreview", nullDefaultModule) +vi.mock("@src/components/welcome/RooHero", nullDefaultModule) +vi.mock("@src/components/welcome/RooTips", nullDefaultModule) +vi.mock("../Announcement", nullDefaultModule) +vi.mock("./TaskHeader", () => ({ default: () =>
})) +vi.mock("./ProfileViolationWarning", nullDefaultModule) +vi.mock("../common/DismissibleUpsell", nullDefaultModule) + +vi.mock("./CheckpointWarning", () => ({ CheckpointWarning: () => null })) +vi.mock("./QueuedMessages", () => ({ QueuedMessages: () => null })) +vi.mock("./WorktreeSelector", () => ({ WorktreeSelector: () => null })) + +vi.mock("@vscode/webview-ui-toolkit/react", () => ({ + VSCodeLink: ({ children }: { children: React.ReactNode }) => <>{children}, +})) + +vi.mock("@/components/ui", async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + StandardTooltip: ({ children }: { children: React.ReactNode }) => <>{children}, + } +}) + +vi.mock("../ChatTextArea", () => { + const MockTextArea = React.forwardRef(function MockTextArea( + props: { + inputValue?: string + setInputValue?: (value: string) => void + onSend: () => void + sendingDisabled?: boolean + }, + ref: React.ForwardedRef<{ focus: () => void }>, + ) { + useImperativeHandle(ref, () => ({ focus: () => {} })) + + return ( + props.setInputValue?.(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter" && !props.sendingDisabled) { + props.onSend() + } + }} + /> + ) + }) + + return { default: MockTextArea, ChatTextArea: MockTextArea } +}) + +vi.mock("../ChatRow", () => ({ + default: ({ message }: { message: ClineMessage }) =>
{message.ts}
, +})) + +vi.mock("react-virtuoso", () => { + const MockVirtuoso = React.forwardRef(function MockVirtuoso( + { data, itemContent, atBottomStateChange, followOutput, className, initialTopMostItemIndex }, + ref, + ) { + const atBottomRef = useRef(atBottomStateChange) + const timeoutIdsRef = useRef([]) + + harness.followOutput = followOutput + harness.initialTopMostItemIndex = initialTopMostItemIndex + harness.emitAtBottom = (isAtBottom: boolean) => { + atBottomRef.current?.(isAtBottom) + } + + useImperativeHandle(ref, () => ({ + scrollToIndex: () => { + harness.scrollCalls += 1 + const reachedBottom = harness.scrollCalls >= harness.atBottomAfterCalls + const timeoutId = window.setTimeout(() => { + atBottomRef.current?.(reachedBottom) + }, harness.signalDelayMs) + timeoutIdsRef.current.push(timeoutId) + }, + })) + + useEffect(() => { + atBottomRef.current = atBottomStateChange + }, [atBottomStateChange]) + + useEffect(() => { + if (harness.emitFalseOnDataChange) { + atBottomStateChange?.(false) + } + + if (harness.delayedGrowthMs !== null) { + const timeoutId = window.setTimeout(() => { + atBottomRef.current?.(false) + }, harness.delayedGrowthMs) + timeoutIdsRef.current.push(timeoutId) + } + }, [data.length, atBottomStateChange]) + + useEffect( + () => () => { + timeoutIdsRef.current.forEach((id) => window.clearTimeout(id)) + timeoutIdsRef.current = [] + }, + [], + ) + + return ( +
+ {data.map((item, index) => ( +
+ {itemContent(index, item)} +
+ ))} +
+ ) + }) + + return { Virtuoso: MockVirtuoso } +}) + +const props: ChatViewProps = { + isHidden: false, + showAnnouncement: false, + hideAnnouncement: () => {}, +} + +const sleep = (ms: number) => new Promise((resolve) => window.setTimeout(resolve, ms)) + +const buildMessages = (baseTs: number): ClineMessage[] => [ + { type: "say", say: "text", ts: baseTs, text: "task" }, + { type: "say", say: "text", ts: baseTs + 1, text: "row-1" }, + { type: "say", say: "text", ts: baseTs + 2, text: "row-2" }, +] + +const resolveFollowOutput = (isAtBottom: boolean): "auto" | false => { + const followOutput = harness.followOutput + if (typeof followOutput === "function") { + return followOutput(isAtBottom) + } + return followOutput === "auto" ? "auto" : false +} + +const postState = (clineMessages: ClineMessage[]) => { + const message: ExtensionStateMessage = { + type: "state", + state: { + version: "1.0.0", + clineMessages, + taskHistory: [], + shouldShowAnnouncement: false, + allowedCommands: [], + alwaysAllowExecute: false, + cloudIsAuthenticated: false, + telemetrySetting: "enabled", + }, + } + + window.dispatchEvent( + new MessageEvent("message", { + data: message, + }), + ) +} + +const renderView = () => + render( + + + + + , + ) + +const hydrate = async (atBottomAfterCalls: number) => { + harness.atBottomAfterCalls = atBottomAfterCalls + renderView() + await act(async () => { + await Promise.resolve() + }) + await act(async () => { + postState(buildMessages(Date.now() - 3_000)) + }) + await waitFor(() => { + const list = document.querySelector("[data-testid='virtuoso-item-list']") + expect(list).toBeTruthy() + expect(list?.getAttribute("data-count")).toBe("2") + }) +} + +const waitForCalls = async (min: number, timeout = 1_500) => { + await waitFor(() => expect(harness.scrollCalls).toBeGreaterThanOrEqual(min), { timeout }) +} + +const waitForCallsSettled = async (idleMs = 80, timeoutMs = 2_000) => { + const deadline = Date.now() + timeoutMs + let lastSeen = harness.scrollCalls + + while (Date.now() < deadline) { + await sleep(idleMs) + const current = harness.scrollCalls + + if (current === lastSeen) { + await sleep(idleMs) + if (harness.scrollCalls === current) { + return + } + } + + lastSeen = current + } + + throw new Error(`Expected scroll calls to settle within ${timeoutMs}ms, last count: ${harness.scrollCalls}`) +} + +const getScrollable = (): HTMLElement => { + const scrollable = document.querySelector(".scrollable") + if (!(scrollable instanceof HTMLElement)) { + throw new Error("Expected ChatView scrollable container") + } + return scrollable +} + +const getScrollToBottomButton = (): HTMLButtonElement => { + const icon = document.querySelector(".codicon-chevron-down") + if (!(icon instanceof HTMLElement)) { + throw new Error("Expected scroll-to-bottom icon") + } + + const button = icon.closest("button") + if (!(button instanceof HTMLButtonElement)) { + throw new Error("Expected scroll-to-bottom button") + } + + return button +} + +describe("ChatView scroll behavior regression coverage", () => { + beforeEach(() => { + harness.scrollCalls = 0 + harness.atBottomAfterCalls = Number.POSITIVE_INFINITY + harness.signalDelayMs = 20 + harness.emitFalseOnDataChange = true + harness.delayedGrowthMs = null + harness.initialTopMostItemIndex = undefined + harness.followOutput = undefined + harness.emitAtBottom = () => {} + }) + + it("existing-task entry does not set a top-most initial anchor", async () => { + await hydrate(2) + expect(harness.initialTopMostItemIndex).toBeUndefined() + }) + + it("rehydration uses bounded bottom pinning", async () => { + await hydrate(2) + await waitForCalls(2, 1_200) + await waitForCallsSettled() + expect(harness.scrollCalls).toBe(2) + expect(resolveFollowOutput(false)).toBe("auto") + expect(document.querySelector(".codicon-chevron-down")).toBeNull() + }) + + it("transient hydration-time not-at-bottom signals do not disable sticky follow", async () => { + await hydrate(2) + await waitForCalls(1, 1_200) + expect(resolveFollowOutput(false)).toBe("auto") + expect(document.querySelector(".codicon-chevron-down")).toBeNull() + + await act(async () => { + harness.emitAtBottom(false) + }) + + expect(resolveFollowOutput(false)).toBe("auto") + expect(document.querySelector(".codicon-chevron-down")).toBeNull() + + await waitForCalls(2, 1_200) + await waitForCallsSettled() + expect(harness.scrollCalls).toBe(2) + expect(resolveFollowOutput(false)).toBe("auto") + }) + + it("delayed last-row growth during hydration keeps anchored follow with one bounded repin", async () => { + harness.delayedGrowthMs = 320 + await hydrate(3) + await waitForCalls(1, 1_200) + + await sleep(950) + + expect(harness.scrollCalls).toBe(2) + expect(resolveFollowOutput(false)).toBe("auto") + expect(document.querySelector(".codicon-chevron-down")).toBeNull() + }) + + it("user escape hatch during hydration prevents repinning", async () => { + await hydrate(Number.POSITIVE_INFINITY) + await waitForCalls(1, 1_200) + + await act(async () => { + fireEvent.keyDown(window, { key: "PageUp" }) + }) + + expect(resolveFollowOutput(false)).toBe(false) + + await act(async () => { + harness.emitAtBottom(true) + }) + + expect(resolveFollowOutput(false)).toBe(false) + + await waitFor(() => expect(document.querySelector(".codicon-chevron-down")).toBeTruthy(), { + timeout: 1_200, + }) + }) + + it("non-wheel upward intent disengages sticky follow", async () => { + await hydrate(2) + await waitForCalls(2) + await waitForCallsSettled() + expect(resolveFollowOutput(false)).toBe("auto") + + const scrollable = getScrollable() + scrollable.scrollTop = 240 + + await act(async () => { + fireEvent.pointerDown(scrollable) + scrollable.scrollTop = 120 + fireEvent.scroll(scrollable) + fireEvent.pointerUp(window) + }) + + expect(resolveFollowOutput(false)).toBe(false) + }) + + it("nested scroller scroll events do not falsely disengage sticky follow", async () => { + await hydrate(2) + await waitForCalls(2) + await waitForCallsSettled() + expect(resolveFollowOutput(false)).toBe("auto") + + const scrollable = getScrollable() + const nestedScrollable = document.createElement("div") + nestedScrollable.style.overflowY = "auto" + nestedScrollable.scrollTop = 0 + scrollable.appendChild(nestedScrollable) + + scrollable.scrollTop = 240 + + await act(async () => { + fireEvent.pointerDown(nestedScrollable) + nestedScrollable.scrollTop = 120 + fireEvent.scroll(nestedScrollable) + fireEvent.pointerUp(window) + }) + + expect(resolveFollowOutput(false)).toBe("auto") + expect(document.querySelector(".codicon-chevron-down")).toBeNull() + }) + + it("wheel-up intent disengages sticky follow", async () => { + await hydrate(2) + await waitForCalls(2) + await waitForCallsSettled() + expect(resolveFollowOutput(false)).toBe("auto") + + const scrollable = getScrollable() + + await act(async () => { + fireEvent.wheel(scrollable, { deltaY: -120 }) + }) + + expect(resolveFollowOutput(false)).toBe(false) + await waitFor(() => expect(document.querySelector(".codicon-chevron-down")).toBeTruthy(), { + timeout: 1_200, + }) + }) + + it("hydration completion cannot override user escape hatch", async () => { + await hydrate(Number.POSITIVE_INFINITY) + await waitForCalls(1, 1_200) + + await act(async () => { + fireEvent.keyDown(window, { key: "PageUp" }) + }) + + expect(resolveFollowOutput(false)).toBe(false) + + await sleep(700) + + expect(resolveFollowOutput(false)).toBe(false) + await waitFor(() => expect(document.querySelector(".codicon-chevron-down")).toBeTruthy(), { + timeout: 1_200, + }) + }) + + it("scroll-to-bottom CTA re-anchors with one interaction", async () => { + await hydrate(2) + await waitForCalls(2) + await waitForCallsSettled() + expect(resolveFollowOutput(false)).toBe("auto") + + await act(async () => { + fireEvent.keyDown(window, { key: "PageUp" }) + }) + + expect(resolveFollowOutput(false)).toBe(false) + await waitFor(() => expect(document.querySelector(".codicon-chevron-down")).toBeTruthy(), { + timeout: 1_200, + }) + + const callsBeforeClick = harness.scrollCalls + harness.atBottomAfterCalls = callsBeforeClick + 2 + + await act(async () => { + getScrollToBottomButton().click() + }) + + expect(resolveFollowOutput(false)).toBe("auto") + await waitFor(() => expect(harness.scrollCalls).toBe(callsBeforeClick + 2), { + timeout: 1_200, + }) + await waitFor(() => expect(document.querySelector(".codicon-chevron-down")).toBeNull(), { timeout: 1_200 }) + }) +}) diff --git a/webview-ui/src/components/chat/__tests__/ChatView.spec.tsx b/webview-ui/src/components/chat/__tests__/ChatView.spec.tsx index bb12700c4f..63e71c9bd1 100644 --- a/webview-ui/src/components/chat/__tests__/ChatView.spec.tsx +++ b/webview-ui/src/components/chat/__tests__/ChatView.spec.tsx @@ -45,12 +45,6 @@ vi.mock("use-sound", () => ({ })) // Mock components that use ESM dependencies -vi.mock("../BrowserSessionRow", () => ({ - default: function MockBrowserSessionRow({ messages }: { messages: ClineMessage[] }) { - return
{JSON.stringify(messages)}
- }, -})) - vi.mock("../ChatRow", () => ({ default: function MockChatRow({ message }: { message: ClineMessage }) { return
{JSON.stringify(message)}
@@ -1081,6 +1075,68 @@ describe("ChatView - Message Queueing Tests", () => { }), ) }) + + it("queues messages during command_output state instead of losing them", async () => { + const { getByTestId } = renderChatView() + + // Hydrate state with command_output ask (Proceed While Running state) + mockPostMessage({ + clineMessages: [ + { + type: "say", + say: "task", + ts: Date.now() - 2000, + text: "Initial task", + }, + { + type: "ask", + ask: "command_output", + ts: Date.now(), + text: "", + partial: false, // Non-partial so buttons are enabled + }, + ], + }) + + // Wait for state to be updated - need to allow time for React effects to propagate + // (clineAsk state update -> clineAskRef.current update) + await waitFor(() => { + expect(getByTestId("chat-textarea")).toBeInTheDocument() + }) + + // Allow React effects to complete (clineAsk -> clineAskRef sync) + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 50)) + }) + + // Clear message calls before simulating user input + vi.mocked(vscode.postMessage).mockClear() + + // Simulate user typing and sending a message during command execution + const chatTextArea = getByTestId("chat-textarea") + const input = chatTextArea.querySelector("input")! as HTMLInputElement + + await act(async () => { + fireEvent.change(input, { target: { value: "message during command execution" } }) + fireEvent.keyDown(input, { key: "Enter", code: "Enter" }) + }) + + // Verify that the message was queued (not lost via terminalOperation) + await waitFor(() => { + expect(vscode.postMessage).toHaveBeenCalledWith({ + type: "queueMessage", + text: "message during command execution", + images: [], + }) + }) + + // Verify it was NOT sent as terminalOperation (which would lose the message) + expect(vscode.postMessage).not.toHaveBeenCalledWith( + expect.objectContaining({ + type: "terminalOperation", + }), + ) + }) }) describe("ChatView - Context Condensing Indicator Tests", () => { diff --git a/webview-ui/src/components/chat/__tests__/CloudTaskButton.spec.tsx b/webview-ui/src/components/chat/__tests__/CloudTaskButton.spec.tsx deleted file mode 100644 index fc2b9f025e..0000000000 --- a/webview-ui/src/components/chat/__tests__/CloudTaskButton.spec.tsx +++ /dev/null @@ -1,234 +0,0 @@ -import { useTranslation } from "react-i18next" - -import { render, screen, fireEvent, waitFor } from "@/utils/test-utils" - -import { CloudTaskButton } from "../CloudTaskButton" - -// Mock the qrcode library -vi.mock("qrcode", () => ({ - default: { - toCanvas: vi.fn((_canvas, _text, _options, callback) => { - // Simulate successful QR code generation - if (callback) { - callback(null) - } - }), - }, -})) - -// Mock react-i18next -vi.mock("react-i18next") - -// Mock the cloud config -vi.mock("@roo-code/cloud/src/config", () => ({ - getRooCodeApiUrl: vi.fn(() => "https://app.roocode.com"), -})) - -// Mock the extension state context -vi.mock("@/context/ExtensionStateContext", () => ({ - ExtensionStateContextProvider: ({ children }: { children: React.ReactNode }) => children, - useExtensionState: vi.fn(), -})) - -// Mock clipboard utility -vi.mock("@/utils/clipboard", () => ({ - useCopyToClipboard: () => ({ - copyWithFeedback: vi.fn(), - showCopyFeedback: false, - }), -})) - -const mockUseTranslation = vi.mocked(useTranslation) -const { useExtensionState } = await import("@/context/ExtensionStateContext") -const mockUseExtensionState = vi.mocked(useExtensionState) - -describe("CloudTaskButton", () => { - const mockT = vi.fn((key: string) => key) - const mockItem = { - id: "test-task-id", - number: 1, - ts: Date.now(), - task: "Test Task", - tokensIn: 100, - tokensOut: 50, - totalCost: 0.01, - } - - beforeEach(() => { - vi.clearAllMocks() - - mockUseTranslation.mockReturnValue({ - t: mockT, - i18n: {} as any, - ready: true, - } as any) - - // Default extension state with bridge enabled - mockUseExtensionState.mockReturnValue({ - cloudUserInfo: { - id: "test-user", - email: "test@example.com", - extensionBridgeEnabled: true, - }, - cloudApiUrl: "https://app.roocode.com", - } as any) - }) - - test("renders cloud task button when extension bridge is enabled", () => { - render() - - const button = screen.getByTestId("cloud-task-button") - expect(button).toBeInTheDocument() - expect(button).toHaveAttribute("aria-label", "chat:task.openInCloud") - }) - - test("does not render when extension bridge is disabled", () => { - mockUseExtensionState.mockReturnValue({ - cloudUserInfo: { - id: "test-user", - email: "test@example.com", - extensionBridgeEnabled: false, - }, - cloudApiUrl: "https://app.roocode.com", - } as any) - - render() - - expect(screen.queryByTestId("cloud-task-button")).not.toBeInTheDocument() - }) - - test("does not render when cloudUserInfo is null", () => { - mockUseExtensionState.mockReturnValue({ - cloudUserInfo: null, - cloudApiUrl: "https://app.roocode.com", - } as any) - - render() - - expect(screen.queryByTestId("cloud-task-button")).not.toBeInTheDocument() - }) - - test("does not render when item has no id", () => { - const itemWithoutId = { ...mockItem, id: undefined } - render() - - expect(screen.queryByTestId("cloud-task-button")).not.toBeInTheDocument() - }) - - test("opens dialog when button is clicked", async () => { - render() - - const button = screen.getByTestId("cloud-task-button") - fireEvent.click(button) - - await waitFor(() => { - expect(screen.getByText("chat:task.openInCloud")).toBeInTheDocument() - }) - }) - - test("displays correct cloud URL in dialog", async () => { - render() - - const button = screen.getByTestId("cloud-task-button") - fireEvent.click(button) - - await waitFor(() => { - const input = screen.getByDisplayValue("https://app.roocode.com/task/test-task-id") - expect(input).toBeInTheDocument() - expect(input).toBeDisabled() - }) - }) - - test("displays intro text in dialog", async () => { - render() - - const button = screen.getByTestId("cloud-task-button") - fireEvent.click(button) - - await waitFor(() => { - expect(screen.getByText("chat:task.openInCloudIntro")).toBeInTheDocument() - }) - }) - - // Note: QR code generation is tested implicitly through the canvas rendering test below - - test("QR code canvas is rendered", async () => { - render() - - const button = screen.getByTestId("cloud-task-button") - fireEvent.click(button) - - await waitFor(() => { - // Canvas element doesn't have a specific aria label, find it directly - const canvas = document.querySelector("canvas") - expect(canvas).toBeInTheDocument() - expect(canvas?.tagName).toBe("CANVAS") - }) - }) - - // Note: Error handling for QR code generation is non-critical as per PR feedback - - test("button is disabled when disabled prop is true", () => { - render() - - const button = screen.getByTestId("cloud-task-button") - expect(button).toBeDisabled() - }) - - test("button is enabled when disabled prop is false", () => { - render() - - const button = screen.getByTestId("cloud-task-button") - expect(button).not.toBeDisabled() - }) - - test("dialog can be closed", async () => { - render() - - // Open dialog - const button = screen.getByTestId("cloud-task-button") - fireEvent.click(button) - - await waitFor(() => { - expect(screen.getByText("chat:task.openInCloud")).toBeInTheDocument() - }) - - // Close dialog by clicking the X button (assuming it exists in Dialog component) - const closeButton = screen.getByRole("button", { name: /close/i }) - fireEvent.click(closeButton) - - await waitFor(() => { - expect(screen.queryByText("chat:task.openInCloud")).not.toBeInTheDocument() - }) - }) - - test("copy button exists in dialog", async () => { - render() - - const button = screen.getByTestId("cloud-task-button") - fireEvent.click(button) - - await waitFor(() => { - // Look for the copy button (it should have a Copy icon) - const copyButtons = screen.getAllByRole("button") - const copyButton = copyButtons.find( - (btn) => btn.querySelector('[class*="lucide"]') || btn.textContent?.includes("Copy"), - ) - expect(copyButton).toBeInTheDocument() - }) - }) - - test("uses correct URL from getRooCodeApiUrl", async () => { - // Mock getRooCodeApiUrl to return a custom URL - vi.doMock("@roo-code/cloud/src/config", () => ({ - getRooCodeApiUrl: vi.fn(() => "https://custom.roocode.com"), - })) - - // Clear module cache and re-import to get the mocked version - vi.resetModules() - - // Since we can't easily test the dynamic import, let's skip this specific test - // The functionality is already covered by the main component using getRooCodeApiUrl - expect(true).toBe(true) - }) -}) diff --git a/webview-ui/src/components/chat/__tests__/FollowUpSuggest.spec.tsx b/webview-ui/src/components/chat/__tests__/FollowUpSuggest.spec.tsx index c2f2d56f34..e489911268 100644 --- a/webview-ui/src/components/chat/__tests__/FollowUpSuggest.spec.tsx +++ b/webview-ui/src/components/chat/__tests__/FollowUpSuggest.spec.tsx @@ -592,4 +592,102 @@ describe("FollowUpSuggest", () => { expect(screen.getByText(/3s/)).toBeInTheDocument() }) }) + + describe("auto-approve toggle off mid-countdown", () => { + it("should call onCancelAutoApproval when autoApprovalEnabled changes to false during countdown", async () => { + const { rerender } = renderWithTestProviders( + , + defaultTestState, + ) + + // Should show countdown initially + expect(screen.getByText(/3s/)).toBeInTheDocument() + + // Advance timer partially + await act(async () => { + vi.advanceTimersByTime(1000) + }) + + // Countdown should be at 2s + expect(screen.getByText(/2s/)).toBeInTheDocument() + + // Clear mock to track calls from the toggle-off + mockOnCancelAutoApproval.mockClear() + + // User toggles auto-approve off + rerender( + + + + + , + ) + + // Countdown should disappear + expect(screen.queryByText(/\d+s/)).not.toBeInTheDocument() + + // onCancelAutoApproval should have been called to cancel the backend timeout + expect(mockOnCancelAutoApproval).toHaveBeenCalled() + + // Advance timer past original timeout - nothing should happen + await act(async () => { + vi.advanceTimersByTime(5000) + }) + + // onSuggestionClick should NOT have been called + expect(mockOnSuggestionClick).not.toHaveBeenCalled() + }) + + it("should call onCancelAutoApproval when alwaysAllowFollowupQuestions changes to false during countdown", async () => { + const { rerender } = renderWithTestProviders( + , + defaultTestState, + ) + + // Should show countdown initially + expect(screen.getByText(/3s/)).toBeInTheDocument() + + // Clear mock to track calls from the toggle-off + mockOnCancelAutoApproval.mockClear() + + // User disables follow-up question auto-approval + rerender( + + + + + , + ) + + // Countdown should disappear + expect(screen.queryByText(/\d+s/)).not.toBeInTheDocument() + + // onCancelAutoApproval should have been called to cancel the backend timeout + expect(mockOnCancelAutoApproval).toHaveBeenCalled() + }) + }) }) diff --git a/webview-ui/src/components/chat/utils/fileChangesFromMessages.ts b/webview-ui/src/components/chat/utils/fileChangesFromMessages.ts new file mode 100644 index 0000000000..6b77833e9d --- /dev/null +++ b/webview-ui/src/components/chat/utils/fileChangesFromMessages.ts @@ -0,0 +1,64 @@ +import type { ClineMessage, ClineSayTool } from "@roo-code/types" +import { safeJsonParse } from "@roo/core" + +/** File-edit tool names from ClineSayTool["tool"] (packages/types). */ +const FILE_EDIT_TOOLS = new Set(["editedExistingFile", "appliedDiff", "newFileCreated"]) + +export interface FileChangeEntry { + path: string + diff: string + diffStats?: { added: number; removed: number } +} + +/** + * Derives a list of file changes from clineMessages for the current conversation. + * Includes: + * - type "say" + say "tool" (applied tool results, if any are ever pushed that way) + * - type "ask" + ask "tool" (tool approval messages; after approval the message stays as ask, so this is where file edits appear in the UI) + */ +export function fileChangesFromMessages(messages: ClineMessage[] | undefined): FileChangeEntry[] { + if (!messages?.length) return [] + + const entries: FileChangeEntry[] = [] + + for (const msg of messages) { + // Tool payload can be in say "tool" (rare) or ask "tool" (how file edits are stored after approval) + const isSayTool = msg.type === "say" && msg.say === "tool" + const isAskTool = msg.type === "ask" && msg.ask === "tool" + if ((!isSayTool && !isAskTool) || !msg.text || msg.partial) continue + // Only include ask "tool" file edits that the user (or auto-approval) has approved + if (isAskTool && !msg.isAnswered) continue + + const tool = safeJsonParse(msg.text) + if (!tool || !FILE_EDIT_TOOLS.has(tool.tool as string)) continue + + // Batch diffs + if (tool.batchDiffs && Array.isArray(tool.batchDiffs)) { + for (const file of tool.batchDiffs) { + if (!file.path) continue + const content = file.content ?? file.diffs?.map((d) => d.content).join("\n") ?? "" + if (content) { + entries.push({ + path: file.path, + diff: content, + diffStats: file.diffStats, + }) + } + } + continue + } + + // Single file + if (!tool.path) continue + const diff = tool.diff ?? tool.content ?? "" + if (diff) { + entries.push({ + path: tool.path, + diff, + diffStats: tool.diffStats, + }) + } + } + + return entries +} diff --git a/webview-ui/src/components/cloud/CloudView.tsx b/webview-ui/src/components/cloud/CloudView.tsx index e8ed9e163c..997997ccd0 100644 --- a/webview-ui/src/components/cloud/CloudView.tsx +++ b/webview-ui/src/components/cloud/CloudView.tsx @@ -9,7 +9,7 @@ import { vscode } from "@src/utils/vscode" import { telemetryClient } from "@src/utils/TelemetryClient" import { ToggleSwitch } from "@/components/ui/toggle-switch" import { renderCloudBenefitsContent } from "./CloudUpsellDialog" -import { ArrowRight, CircleAlert, Info, Lock, TriangleAlert } from "lucide-react" +import { ArrowRight, Info, Lock, TriangleAlert } from "lucide-react" import { cn } from "@/lib/utils" import { Tab, TabContent } from "../common/Tab" import { Button } from "@/components/ui/button" @@ -28,13 +28,7 @@ type CloudViewProps = { export const CloudView = ({ userInfo, isAuthenticated, cloudApiUrl, organizations = [] }: CloudViewProps) => { const { t } = useAppTranslation() - const { - remoteControlEnabled, - setRemoteControlEnabled, - taskSyncEnabled, - setTaskSyncEnabled, - featureRoomoteControlEnabled, - } = useExtensionState() + const { taskSyncEnabled, setTaskSyncEnabled } = useExtensionState() const wasAuthenticatedRef = useRef(false) const timeoutRef = useRef(null) const manualUrlInputRef = useRef(null) @@ -144,12 +138,6 @@ export const CloudView = ({ userInfo, isAuthenticated, cloudApiUrl, organization } } - const handleRemoteControlToggle = () => { - const newValue = !remoteControlEnabled - setRemoteControlEnabled(newValue) - vscode.postMessage({ type: "remoteControlEnabled", bool: newValue }) - } - const handleTaskSyncToggle = () => { const newValue = !taskSyncEnabled setTaskSyncEnabled(newValue) @@ -219,34 +207,6 @@ export const CloudView = ({ userInfo, isAuthenticated, cloudApiUrl, organization
{t("cloud:taskSyncDescription")}
- - {/* Remote Control Toggle - Only shown when both extensionBridgeEnabled and featureRoomoteControlEnabled are true */} - {userInfo?.extensionBridgeEnabled && featureRoomoteControlEnabled && ( - <> -
- - - {t("cloud:remoteControl")} - -
-
- {t("cloud:remoteControlDescription")} - {!taskSyncEnabled && ( -
- - {t("cloud:remoteControlRequiresTaskSync")} -
- )} -
- - )}
diff --git a/webview-ui/src/components/cloud/__tests__/CloudView.spec.tsx b/webview-ui/src/components/cloud/__tests__/CloudView.spec.tsx index 87f5da9c65..120579e732 100644 --- a/webview-ui/src/components/cloud/__tests__/CloudView.spec.tsx +++ b/webview-ui/src/components/cloud/__tests__/CloudView.spec.tsx @@ -23,10 +23,6 @@ vi.mock("@src/i18n/TranslationContext", () => ({ "cloud:taskSync": "Task sync", "cloud:taskSyncDescription": "Sync your tasks for viewing and sharing on Roo Code Cloud", "cloud:taskSyncManagedByOrganization": "Task sync is managed by your organization", - "cloud:remoteControl": "Roomote Control", - "cloud:remoteControlDescription": - "Enable following and interacting with tasks in this workspace with Roo Code Cloud", - "cloud:remoteControlRequiresTaskSync": "Task sync must be enabled to use Roomote Control", "cloud:usageMetricsAlwaysReported": "Model usage info is always reported when logged in", "cloud:profilePicture": "Profile picture", "cloud:cloudUrlPillLabel": "Roo Code Cloud URL: ", @@ -52,12 +48,8 @@ vi.mock("@src/utils/TelemetryClient", () => ({ // Mock the extension state context const mockExtensionState = { - remoteControlEnabled: false, - setRemoteControlEnabled: vi.fn(), taskSyncEnabled: true, setTaskSyncEnabled: vi.fn(), - featureRoomoteControlEnabled: true, // Default to true for tests - setFeatureRoomoteControlEnabled: vi.fn(), } vi.mock("@src/context/ExtensionStateContext", () => ({ @@ -116,82 +108,6 @@ describe("CloudView", () => { expect(screen.getByText("test@example.com")).toBeInTheDocument() }) - it("should display remote control toggle when user has extension bridge enabled and roomote control enabled", () => { - const mockUserInfo = { - name: "Test User", - email: "test@example.com", - extensionBridgeEnabled: true, - } - - render() - - // Check that the remote control toggle is displayed - expect(screen.getByTestId("remote-control-toggle")).toBeInTheDocument() - expect(screen.getByText("Roomote Control")).toBeInTheDocument() - expect( - screen.getByText("Enable following and interacting with tasks in this workspace with Roo Code Cloud"), - ).toBeInTheDocument() - }) - - it("should not display remote control toggle when user does not have extension bridge enabled", () => { - const mockUserInfo = { - name: "Test User", - email: "test@example.com", - extensionBridgeEnabled: false, - } - - render() - - // Check that the remote control toggle is NOT displayed - expect(screen.queryByTestId("remote-control-toggle")).not.toBeInTheDocument() - expect(screen.queryByText("Roomote Control")).not.toBeInTheDocument() - }) - - it("should not display remote control toggle when roomote control is disabled", () => { - // Temporarily override the mock for this specific test - const originalFeatureRoomoteControlEnabled = mockExtensionState.featureRoomoteControlEnabled - mockExtensionState.featureRoomoteControlEnabled = false - - const mockUserInfo = { - name: "Test User", - email: "test@example.com", - extensionBridgeEnabled: true, // Bridge enabled but roomote control disabled - } - - render() - - // Check that the remote control toggle is NOT displayed - expect(screen.queryByTestId("remote-control-toggle")).not.toBeInTheDocument() - expect(screen.queryByText("Roomote Control")).not.toBeInTheDocument() - - // Restore the original value - mockExtensionState.featureRoomoteControlEnabled = originalFeatureRoomoteControlEnabled - }) - - it("should display remote control toggle for organization users (simulating backend logic)", () => { - // This test simulates what the ClineProvider would do: - // Organization users are treated as having featureRoomoteControlEnabled true - const originalFeatureRoomoteControlEnabled = mockExtensionState.featureRoomoteControlEnabled - mockExtensionState.featureRoomoteControlEnabled = true // Simulating ClineProvider logic for org users - - const mockUserInfo = { - name: "Test User", - email: "test@example.com", - organizationId: "org-123", // User is in an organization - extensionBridgeEnabled: true, - } - - render() - - // Check that the remote control toggle IS displayed for organization users - // (The ClineProvider would set featureRoomoteControlEnabled to true for org users) - expect(screen.getByTestId("remote-control-toggle")).toBeInTheDocument() - expect(screen.getByText("Roomote Control")).toBeInTheDocument() - - // Restore the original value - mockExtensionState.featureRoomoteControlEnabled = originalFeatureRoomoteControlEnabled - }) - it("should not display cloud URL pill when pointing to production", () => { const mockUserInfo = { name: "Test User", diff --git a/webview-ui/src/components/common/CodeBlock.tsx b/webview-ui/src/components/common/CodeBlock.tsx index b13a6ec24d..042b764a9a 100644 --- a/webview-ui/src/components/common/CodeBlock.tsx +++ b/webview-ui/src/components/common/CodeBlock.tsx @@ -299,9 +299,6 @@ const CodeBlock = memo( // potentially changes scrollHeight const wasScrolledUpRef = useRef(false) - // Ref to track if outer container was near bottom - const outerContainerNearBottomRef = useRef(false) - // Effect to listen to scroll events and update the ref useEffect(() => { const preElement = preRef.current @@ -323,28 +320,6 @@ const CodeBlock = memo( } }, []) // Empty dependency array: runs once on mount - // Effect to track outer container scroll position - useEffect(() => { - const scrollContainer = document.querySelector('[data-virtuoso-scroller="true"]') - if (!scrollContainer) return - - const handleOuterScroll = () => { - const isAtBottom = - Math.abs(scrollContainer.scrollHeight - scrollContainer.scrollTop - scrollContainer.clientHeight) < - SCROLL_SNAP_TOLERANCE - outerContainerNearBottomRef.current = isAtBottom - } - - scrollContainer.addEventListener("scroll", handleOuterScroll, { passive: true }) - - // Initial check - handleOuterScroll() - - return () => { - scrollContainer.removeEventListener("scroll", handleOuterScroll) - } - }, []) - // Store whether we should scroll after highlighting completes const shouldScrollAfterHighlightRef = useRef(false) @@ -471,14 +446,8 @@ const CodeBlock = memo( wasScrolledUpRef.current = false } - // Also scroll outer container if it was near bottom - if (outerContainerNearBottomRef.current) { - const scrollContainer = document.querySelector('[data-virtuoso-scroller="true"]') - if (scrollContainer) { - scrollContainer.scrollTop = scrollContainer.scrollHeight - outerContainerNearBottomRef.current = true - } - } + // Outer container scrolling is handled by Virtuoso's followOutput + // and ChatView's handleRowHeightChange — no direct DOM manipulation needed. // Reset the flag shouldScrollAfterHighlightRef.current = false diff --git a/webview-ui/src/components/common/__tests__/MarkdownBlock.spec.tsx b/webview-ui/src/components/common/__tests__/MarkdownBlock.spec.tsx index a0b6857a37..8e41eefa14 100644 --- a/webview-ui/src/components/common/__tests__/MarkdownBlock.spec.tsx +++ b/webview-ui/src/components/common/__tests__/MarkdownBlock.spec.tsx @@ -34,7 +34,7 @@ describe("MarkdownBlock", () => { // Check that the period is outside the link const paragraph = container.querySelector("p") expect(paragraph?.textContent).toBe("Check out this link: https://example.com.") - }) + }, 10000) it("should render unordered lists with proper styling", async () => { const markdown = `Here are some items: diff --git a/webview-ui/src/components/history/HistoryPreview.tsx b/webview-ui/src/components/history/HistoryPreview.tsx index 02464e69c0..70467c44fb 100644 --- a/webview-ui/src/components/history/HistoryPreview.tsx +++ b/webview-ui/src/components/history/HistoryPreview.tsx @@ -38,6 +38,7 @@ const HistoryPreview = () => { group={group} variant="compact" onToggleExpand={() => toggleExpand(group.parent.id)} + onToggleSubtaskExpand={toggleExpand} /> ))} diff --git a/webview-ui/src/components/history/HistoryView.tsx b/webview-ui/src/components/history/HistoryView.tsx index 88b6551881..1d6de93e64 100644 --- a/webview-ui/src/components/history/HistoryView.tsx +++ b/webview-ui/src/components/history/HistoryView.tsx @@ -21,6 +21,7 @@ import { useAppTranslation } from "@/i18n/TranslationContext" import { Tab, TabContent, TabHeader } from "../common/Tab" import { useTaskSearch } from "./useTaskSearch" import { useGroupedTasks } from "./useGroupedTasks" +import { countAllSubtasks } from "./types" import TaskItem from "./TaskItem" import TaskGroupItem from "./TaskGroupItem" @@ -52,11 +53,11 @@ const HistoryView = ({ onDone }: HistoryViewProps) => { const [selectedTaskIds, setSelectedTaskIds] = useState([]) const [showBatchDeleteDialog, setShowBatchDeleteDialog] = useState(false) - // Get subtask count for a task + // Get subtask count for a task (recursive total) const getSubtaskCount = useMemo(() => { const countMap = new Map() for (const group of groups) { - countMap.set(group.parent.id, group.subtasks.length) + countMap.set(group.parent.id, countAllSubtasks(group.subtasks)) } return (taskId: string) => countMap.get(taskId) || 0 }, [groups]) @@ -300,6 +301,7 @@ const HistoryView = ({ onDone }: HistoryViewProps) => { onToggleSelection={toggleTaskSelection} onDelete={handleDelete} onToggleExpand={() => toggleExpand(group.parent.id)} + onToggleSubtaskExpand={toggleExpand} className="m-2" /> )} diff --git a/webview-ui/src/components/history/SubtaskRow.tsx b/webview-ui/src/components/history/SubtaskRow.tsx index dec227ebc8..0089e1f81d 100644 --- a/webview-ui/src/components/history/SubtaskRow.tsx +++ b/webview-ui/src/components/history/SubtaskRow.tsx @@ -2,46 +2,87 @@ import { memo } from "react" import { ArrowRight } from "lucide-react" import { vscode } from "@/utils/vscode" import { cn } from "@/lib/utils" -import type { DisplayHistoryItem } from "./types" +import type { SubtaskTreeNode } from "./types" +import { countAllSubtasks } from "./types" import { StandardTooltip } from "../ui" +import SubtaskCollapsibleRow from "./SubtaskCollapsibleRow" interface SubtaskRowProps { - /** The subtask to display */ - item: DisplayHistoryItem + /** The subtask tree node to display */ + node: SubtaskTreeNode + /** Nesting depth (1 = direct child of parent group) */ + depth: number + /** Callback when expand/collapse is toggled for a node */ + onToggleExpand: (taskId: string) => void /** Optional className for styling */ className?: string } /** - * Displays an individual subtask row when the parent's subtask list is expanded. - * Shows the task name and token/cost info in an indented format. + * Displays a subtask row with recursive nesting support. + * Leaf nodes render just the task row. Nodes with children show + * a collapsible section that can be expanded to reveal nested subtasks. */ -const SubtaskRow = ({ item, className }: SubtaskRowProps) => { +const SubtaskRow = ({ node, depth, onToggleExpand, className }: SubtaskRowProps) => { + const { item, children, isExpanded } = node + const hasChildren = children.length > 0 + const handleClick = () => { vscode.postMessage({ type: "showTaskWithId", text: item.id }) } return ( -
+ {/* Task row with depth indentation */} +
{ + if (e.key === "Enter" || e.key === " ") { + e.preventDefault() + handleClick() + } + }}> + + {item.task} + + +
+ + {/* Nested subtask collapsible section */} + {hasChildren && ( +
+ onToggleExpand(item.id)} + /> +
+ )} + + {/* Expanded nested subtasks */} + {hasChildren && ( +
+ {children.map((child) => ( + + ))} +
)} - onClick={handleClick} - role="button" - tabIndex={0} - onKeyDown={(e) => { - if (e.key === "Enter" || e.key === " ") { - e.preventDefault() - handleClick() - } - }}> - - {item.task} - -
) } diff --git a/webview-ui/src/components/history/TaskGroupItem.tsx b/webview-ui/src/components/history/TaskGroupItem.tsx index 6bf2e1a957..45b8293f01 100644 --- a/webview-ui/src/components/history/TaskGroupItem.tsx +++ b/webview-ui/src/components/history/TaskGroupItem.tsx @@ -1,6 +1,7 @@ import { memo } from "react" import { cn } from "@/lib/utils" import type { TaskGroup } from "./types" +import { countAllSubtasks } from "./types" import TaskItem from "./TaskItem" import SubtaskCollapsibleRow from "./SubtaskCollapsibleRow" import SubtaskRow from "./SubtaskRow" @@ -20,15 +21,17 @@ interface TaskGroupItemProps { onToggleSelection?: (taskId: string, isSelected: boolean) => void /** Callback when delete is requested */ onDelete?: (taskId: string) => void - /** Callback when expand/collapse is toggled */ + /** Callback when the parent group expand/collapse is toggled */ onToggleExpand: () => void + /** Callback when a nested subtask node expand/collapse is toggled */ + onToggleSubtaskExpand: (taskId: string) => void /** Optional className for styling */ className?: string } /** - * Renders a task group consisting of a parent task and its collapsible subtask list. - * When expanded, shows individual subtask rows. + * Renders a task group consisting of a parent task and its collapsible subtask tree. + * When expanded, shows recursively nested subtask rows. */ const TaskGroupItem = ({ group, @@ -39,10 +42,12 @@ const TaskGroupItem = ({ onToggleSelection, onDelete, onToggleExpand, + onToggleSubtaskExpand, className, }: TaskGroupItemProps) => { const { parent, subtasks, isExpanded } = group const hasSubtasks = subtasks.length > 0 + const totalSubtaskCount = hasSubtasks ? countAllSubtasks(subtasks) : 0 return (
- {/* Subtask collapsible row */} + {/* Subtask collapsible row — shows total recursive count */} {hasSubtasks && ( - + )} - {/* Expanded subtasks */} + {/* Expanded subtask tree */} {hasSubtasks && (
- {subtasks.map((subtask) => ( - + {subtasks.map((node) => ( + ))}
)} diff --git a/webview-ui/src/components/history/__tests__/HistoryPreview.spec.tsx b/webview-ui/src/components/history/__tests__/HistoryPreview.spec.tsx index da344970a8..652200d3a8 100644 --- a/webview-ui/src/components/history/__tests__/HistoryPreview.spec.tsx +++ b/webview-ui/src/components/history/__tests__/HistoryPreview.spec.tsx @@ -3,31 +3,35 @@ import { render, screen } from "@/utils/test-utils" import type { HistoryItem } from "@roo-code/types" import HistoryPreview from "../HistoryPreview" +import type { TaskGroup } from "../types" vi.mock("../useTaskSearch") +vi.mock("../useGroupedTasks") -vi.mock("../TaskItem", () => { +vi.mock("../TaskGroupItem", () => { return { - default: vi.fn(({ item, variant }) => ( -
- {item.task} + default: vi.fn(({ group, variant }) => ( +
+ {group.parent.task}
)), } }) import { useTaskSearch } from "../useTaskSearch" -import TaskItem from "../TaskItem" +import { useGroupedTasks } from "../useGroupedTasks" +import TaskGroupItem from "../TaskGroupItem" const mockUseTaskSearch = useTaskSearch as any -const mockTaskItem = TaskItem as any +const mockUseGroupedTasks = useGroupedTasks as any +const mockTaskGroupItem = TaskGroupItem as any const mockTasks: HistoryItem[] = [ { id: "task-1", number: 1, task: "First task", - ts: Date.now(), + ts: 600, tokensIn: 100, tokensOut: 50, totalCost: 0.01, @@ -36,7 +40,7 @@ const mockTasks: HistoryItem[] = [ id: "task-2", number: 2, task: "Second task", - ts: Date.now(), + ts: 500, tokensIn: 200, tokensOut: 100, totalCost: 0.02, @@ -45,7 +49,7 @@ const mockTasks: HistoryItem[] = [ id: "task-3", number: 3, task: "Third task", - ts: Date.now(), + ts: 400, tokensIn: 150, tokensOut: 75, totalCost: 0.015, @@ -54,7 +58,7 @@ const mockTasks: HistoryItem[] = [ id: "task-4", number: 4, task: "Fourth task", - ts: Date.now(), + ts: 300, tokensIn: 300, tokensOut: 150, totalCost: 0.03, @@ -63,7 +67,7 @@ const mockTasks: HistoryItem[] = [ id: "task-5", number: 5, task: "Fifth task", - ts: Date.now(), + ts: 200, tokensIn: 250, tokensOut: 125, totalCost: 0.025, @@ -72,13 +76,22 @@ const mockTasks: HistoryItem[] = [ id: "task-6", number: 6, task: "Sixth task", - ts: Date.now(), + ts: 100, tokensIn: 400, tokensOut: 200, totalCost: 0.04, }, ] +// Helper to create mock groups from tasks +function createMockGroups(tasks: HistoryItem[]): TaskGroup[] { + return tasks.map((task) => ({ + parent: { ...task, isSubtask: false }, + subtasks: [], + isExpanded: false, + })) +} + describe("HistoryPreview", () => { beforeEach(() => { vi.clearAllMocks() @@ -97,14 +110,21 @@ describe("HistoryPreview", () => { setShowAllWorkspaces: vi.fn(), }) + mockUseGroupedTasks.mockReturnValue({ + groups: [], + flatTasks: null, + toggleExpand: vi.fn(), + isSearchMode: false, + }) + const { container } = render() - // Should render the container but no task items + // Should render the container but no task groups expect(container.firstChild).toHaveClass("flex", "flex-col", "gap-1") - expect(screen.queryByTestId(/task-item-/)).not.toBeInTheDocument() + expect(screen.queryByTestId(/task-group-/)).not.toBeInTheDocument() }) - it("renders up to 4 tasks when tasks are available", () => { + it("renders up to 4 groups when tasks are available", () => { mockUseTaskSearch.mockReturnValue({ tasks: mockTasks, searchQuery: "", @@ -117,18 +137,26 @@ describe("HistoryPreview", () => { setShowAllWorkspaces: vi.fn(), }) + const mockGroups = createMockGroups(mockTasks) + mockUseGroupedTasks.mockReturnValue({ + groups: mockGroups, + flatTasks: null, + toggleExpand: vi.fn(), + isSearchMode: false, + }) + render() - // Should render only the first 3 tasks - expect(screen.getByTestId("task-item-task-1")).toBeInTheDocument() - expect(screen.getByTestId("task-item-task-2")).toBeInTheDocument() - expect(screen.getByTestId("task-item-task-3")).toBeInTheDocument() - expect(screen.getByTestId("task-item-task-4")).toBeInTheDocument() - expect(screen.queryByTestId("task-item-task-5")).not.toBeInTheDocument() - expect(screen.queryByTestId("task-item-task-6")).not.toBeInTheDocument() + // Should render only the first 4 groups + expect(screen.getByTestId("task-group-task-1")).toBeInTheDocument() + expect(screen.getByTestId("task-group-task-2")).toBeInTheDocument() + expect(screen.getByTestId("task-group-task-3")).toBeInTheDocument() + expect(screen.getByTestId("task-group-task-4")).toBeInTheDocument() + expect(screen.queryByTestId("task-group-task-5")).not.toBeInTheDocument() + expect(screen.queryByTestId("task-group-task-6")).not.toBeInTheDocument() }) - it("renders all tasks when there are 3 or fewer", () => { + it("renders all groups when there are 4 or fewer", () => { const threeTasks = mockTasks.slice(0, 3) mockUseTaskSearch.mockReturnValue({ tasks: threeTasks, @@ -142,17 +170,25 @@ describe("HistoryPreview", () => { setShowAllWorkspaces: vi.fn(), }) + const mockGroups = createMockGroups(threeTasks) + mockUseGroupedTasks.mockReturnValue({ + groups: mockGroups, + flatTasks: null, + toggleExpand: vi.fn(), + isSearchMode: false, + }) + render() - expect(screen.getByTestId("task-item-task-1")).toBeInTheDocument() - expect(screen.getByTestId("task-item-task-2")).toBeInTheDocument() - expect(screen.getByTestId("task-item-task-3")).toBeInTheDocument() - expect(screen.queryByTestId("task-item-task-4")).not.toBeInTheDocument() - expect(screen.queryByTestId("task-item-task-5")).not.toBeInTheDocument() - expect(screen.queryByTestId("task-item-task-6")).not.toBeInTheDocument() + expect(screen.getByTestId("task-group-task-1")).toBeInTheDocument() + expect(screen.getByTestId("task-group-task-2")).toBeInTheDocument() + expect(screen.getByTestId("task-group-task-3")).toBeInTheDocument() + expect(screen.queryByTestId("task-group-task-4")).not.toBeInTheDocument() + expect(screen.queryByTestId("task-group-task-5")).not.toBeInTheDocument() + expect(screen.queryByTestId("task-group-task-6")).not.toBeInTheDocument() }) - it("renders only 1 task when there is only 1 task", () => { + it("renders only 1 group when there is only 1 task", () => { const oneTask = mockTasks.slice(0, 1) mockUseTaskSearch.mockReturnValue({ tasks: oneTask, @@ -166,15 +202,24 @@ describe("HistoryPreview", () => { setShowAllWorkspaces: vi.fn(), }) + const mockGroups = createMockGroups(oneTask) + mockUseGroupedTasks.mockReturnValue({ + groups: mockGroups, + flatTasks: null, + toggleExpand: vi.fn(), + isSearchMode: false, + }) + render() - expect(screen.getByTestId("task-item-task-1")).toBeInTheDocument() - expect(screen.queryByTestId("task-item-task-2")).not.toBeInTheDocument() + expect(screen.getByTestId("task-group-task-1")).toBeInTheDocument() + expect(screen.queryByTestId("task-group-task-2")).not.toBeInTheDocument() }) - it("passes correct props to TaskItem components", () => { + it("passes correct props to TaskGroupItem components", () => { + const threeTasks = mockTasks.slice(0, 3) mockUseTaskSearch.mockReturnValue({ - tasks: mockTasks.slice(0, 3), + tasks: threeTasks, searchQuery: "", setSearchQuery: vi.fn(), sortOption: "newest", @@ -185,35 +230,43 @@ describe("HistoryPreview", () => { setShowAllWorkspaces: vi.fn(), }) + const mockGroups = createMockGroups(threeTasks) + mockUseGroupedTasks.mockReturnValue({ + groups: mockGroups, + flatTasks: null, + toggleExpand: vi.fn(), + isSearchMode: false, + }) + render() - // Verify TaskItem was called with correct props for first 3 tasks - expect(mockTaskItem).toHaveBeenCalledWith( + // Verify TaskGroupItem was called with correct props for first 3 groups + expect(mockTaskGroupItem).toHaveBeenCalledWith( expect.objectContaining({ - item: mockTasks[0], + group: mockGroups[0], variant: "compact", }), expect.anything(), ) - expect(mockTaskItem).toHaveBeenCalledWith( + expect(mockTaskGroupItem).toHaveBeenCalledWith( expect.objectContaining({ - item: mockTasks[1], + group: mockGroups[1], variant: "compact", }), expect.anything(), ) - expect(mockTaskItem).toHaveBeenCalledWith( + expect(mockTaskGroupItem).toHaveBeenCalledWith( expect.objectContaining({ - item: mockTasks[2], + group: mockGroups[2], variant: "compact", }), expect.anything(), ) }) - it("renders with correct container classes", () => { + it("displays the header and view all button", () => { mockUseTaskSearch.mockReturnValue({ - tasks: mockTasks.slice(0, 1), + tasks: mockTasks, searchQuery: "", setSearchQuery: vi.fn(), sortOption: "newest", @@ -224,8 +277,59 @@ describe("HistoryPreview", () => { setShowAllWorkspaces: vi.fn(), }) - const { container } = render() + const mockGroups = createMockGroups(mockTasks) + mockUseGroupedTasks.mockReturnValue({ + groups: mockGroups, + flatTasks: null, + toggleExpand: vi.fn(), + isSearchMode: false, + }) - expect(container.firstChild).toHaveClass("flex", "flex-col", "gap-1") + render() + + // Should show header and view all button + expect(screen.getByText("history:recentTasks")).toBeInTheDocument() + expect(screen.getByText("history:viewAllHistory")).toBeInTheDocument() + }) + + it("calls toggleExpand when onToggleExpand is called", () => { + const oneTask = mockTasks.slice(0, 1) + mockUseTaskSearch.mockReturnValue({ + tasks: oneTask, + searchQuery: "", + setSearchQuery: vi.fn(), + sortOption: "newest", + setSortOption: vi.fn(), + lastNonRelevantSort: null, + setLastNonRelevantSort: vi.fn(), + showAllWorkspaces: false, + setShowAllWorkspaces: vi.fn(), + }) + + const mockToggleExpand = vi.fn() + const mockGroups = createMockGroups(oneTask) + mockUseGroupedTasks.mockReturnValue({ + groups: mockGroups, + flatTasks: null, + toggleExpand: mockToggleExpand, + isSearchMode: false, + }) + + render() + + // Verify TaskGroupItem received onToggleExpand prop + expect(mockTaskGroupItem).toHaveBeenCalledWith( + expect.objectContaining({ + onToggleExpand: expect.any(Function), + }), + expect.anything(), + ) + + // Call the onToggleExpand function passed to TaskGroupItem + const callArgs = mockTaskGroupItem.mock.calls[0][0] + callArgs.onToggleExpand() + + // Verify toggleExpand was called with the parent id + expect(mockToggleExpand).toHaveBeenCalledWith("task-1") }) }) diff --git a/webview-ui/src/components/history/__tests__/SubtaskRow.spec.tsx b/webview-ui/src/components/history/__tests__/SubtaskRow.spec.tsx new file mode 100644 index 0000000000..6337b9f1fa --- /dev/null +++ b/webview-ui/src/components/history/__tests__/SubtaskRow.spec.tsx @@ -0,0 +1,213 @@ +import { render, screen, fireEvent } from "@/utils/test-utils" + +import { vscode } from "@src/utils/vscode" + +import SubtaskRow from "../SubtaskRow" +import type { SubtaskTreeNode, DisplayHistoryItem } from "../types" + +vi.mock("@src/utils/vscode") +vi.mock("@src/i18n/TranslationContext", () => ({ + useAppTranslation: () => ({ + t: (key: string, options?: Record) => { + if (key === "history:subtasks" && options?.count !== undefined) { + return `${options.count} Subtask${options.count === 1 ? "" : "s"}` + } + if (key === "history:collapseSubtasks") return "Collapse subtasks" + if (key === "history:expandSubtasks") return "Expand subtasks" + return key + }, + }), +})) + +const createMockDisplayItem = (overrides: Partial = {}): DisplayHistoryItem => ({ + id: "task-1", + number: 1, + task: "Test task", + ts: Date.now(), + tokensIn: 100, + tokensOut: 50, + totalCost: 0.01, + workspace: "/workspace/project", + ...overrides, +}) + +const createMockNode = ( + itemOverrides: Partial = {}, + children: SubtaskTreeNode[] = [], + isExpanded = false, +): SubtaskTreeNode => ({ + item: createMockDisplayItem(itemOverrides), + children, + isExpanded, +}) + +describe("SubtaskRow", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe("leaf node rendering", () => { + it("renders leaf node with correct text", () => { + const node = createMockNode({ id: "leaf-1", task: "Leaf task content" }) + + render() + + expect(screen.getByText("Leaf task content")).toBeInTheDocument() + }) + + it("renders with correct depth indentation", () => { + const node = createMockNode({ id: "leaf-1", task: "Indented task" }) + + render() + + const row = screen.getByTestId("subtask-row-leaf-1") + // The clickable row inside should have paddingLeft = depth * 16 = 32px + const clickableRow = row.querySelector("[role='button']") + expect(clickableRow).toHaveStyle({ paddingLeft: "32px" }) + }) + + it("does not render collapsible row for leaf node", () => { + const node = createMockNode({ id: "leaf-1", task: "Leaf only" }) + + render() + + expect(screen.queryByTestId("subtask-collapsible-row")).not.toBeInTheDocument() + }) + }) + + describe("node with children", () => { + it("renders collapsible row with correct child count", () => { + const node = createMockNode( + { id: "parent-1", task: "Parent task" }, + [ + createMockNode({ id: "child-1", task: "Child 1" }), + createMockNode({ id: "child-2", task: "Child 2" }), + ], + false, + ) + + render() + + expect(screen.getByText("2 Subtasks")).toBeInTheDocument() + expect(screen.getByTestId("subtask-collapsible-row")).toBeInTheDocument() + }) + + it("renders nested children count including grandchildren", () => { + const node = createMockNode( + { id: "parent-1", task: "Parent task" }, + [ + createMockNode({ id: "child-1", task: "Child 1" }, [ + createMockNode({ id: "grandchild-1", task: "Grandchild 1" }), + ]), + ], + false, + ) + + render() + + // countAllSubtasks counts child-1 (1) + grandchild-1 (1) = 2 + expect(screen.getByText("2 Subtasks")).toBeInTheDocument() + }) + }) + + describe("click behavior", () => { + it("sends showTaskWithId message when task row is clicked", () => { + const node = createMockNode({ id: "task-42", task: "Clickable task" }) + + render() + + const row = screen.getByRole("button") + fireEvent.click(row) + + expect(vscode.postMessage).toHaveBeenCalledWith({ + type: "showTaskWithId", + text: "task-42", + }) + }) + + it("calls onToggleExpand with correct task ID when collapsible row is clicked", () => { + const onToggleExpand = vi.fn() + const node = createMockNode( + { id: "expandable-1", task: "Expandable task" }, + [createMockNode({ id: "child-1", task: "Child" })], + false, + ) + + render() + + const collapsibleRow = screen.getByTestId("subtask-collapsible-row") + fireEvent.click(collapsibleRow) + + expect(onToggleExpand).toHaveBeenCalledWith("expandable-1") + }) + }) + + describe("expand/collapse behavior", () => { + it("renders child SubtaskRow components when expanded", () => { + const node = createMockNode( + { id: "parent-1", task: "Parent" }, + [ + createMockNode({ id: "child-1", task: "Child 1" }), + createMockNode({ id: "child-2", task: "Child 2" }), + ], + true, // expanded + ) + + render() + + expect(screen.getByTestId("subtask-row-child-1")).toBeInTheDocument() + expect(screen.getByTestId("subtask-row-child-2")).toBeInTheDocument() + expect(screen.getByText("Child 1")).toBeInTheDocument() + expect(screen.getByText("Child 2")).toBeInTheDocument() + }) + + it("uses max-h-0 for collapsed node with children", () => { + const node = createMockNode( + { id: "parent-1", task: "Parent" }, + [createMockNode({ id: "child-1", task: "Child 1" })], + false, // collapsed + ) + + const { container } = render() + + // The children wrapper div should have max-h-0 when collapsed + const childrenWrapper = container.querySelector(".max-h-0") + expect(childrenWrapper).toBeInTheDocument() + }) + + it("does not use max-h-0 when node is expanded", () => { + const node = createMockNode( + { id: "parent-1", task: "Parent" }, + [createMockNode({ id: "child-1", task: "Child 1" })], + true, // expanded + ) + + const { container } = render() + + // The children wrapper should NOT have max-h-0 when expanded + const collapsedWrapper = container.querySelector(".max-h-0") + expect(collapsedWrapper).not.toBeInTheDocument() + }) + + it("renders deeply nested recursive structure when all levels expanded", () => { + const node = createMockNode( + { id: "root", task: "Root" }, + [ + createMockNode( + { id: "child", task: "Child" }, + [createMockNode({ id: "grandchild", task: "Grandchild" })], + true, // child expanded + ), + ], + true, // root expanded + ) + + render() + + expect(screen.getByTestId("subtask-row-root")).toBeInTheDocument() + expect(screen.getByTestId("subtask-row-child")).toBeInTheDocument() + expect(screen.getByTestId("subtask-row-grandchild")).toBeInTheDocument() + expect(screen.getByText("Grandchild")).toBeInTheDocument() + }) + }) +}) diff --git a/webview-ui/src/components/history/__tests__/TaskGroupItem.spec.tsx b/webview-ui/src/components/history/__tests__/TaskGroupItem.spec.tsx index ff40963a87..b04fac6b54 100644 --- a/webview-ui/src/components/history/__tests__/TaskGroupItem.spec.tsx +++ b/webview-ui/src/components/history/__tests__/TaskGroupItem.spec.tsx @@ -1,7 +1,7 @@ import { render, screen, fireEvent } from "@/utils/test-utils" import TaskGroupItem from "../TaskGroupItem" -import type { TaskGroup, DisplayHistoryItem } from "../types" +import type { TaskGroup, DisplayHistoryItem, SubtaskTreeNode } from "../types" vi.mock("@src/utils/vscode") vi.mock("@src/i18n/TranslationContext", () => ({ @@ -34,6 +34,16 @@ const createMockDisplayHistoryItem = (overrides: Partial = { ...overrides, }) +const createMockSubtaskNode = ( + itemOverrides: Partial = {}, + children: SubtaskTreeNode[] = [], + isExpanded = false, +): SubtaskTreeNode => ({ + item: createMockDisplayHistoryItem(itemOverrides), + children, + isExpanded, +}) + const createMockGroup = (overrides: Partial = {}): TaskGroup => ({ parent: createMockDisplayHistoryItem({ id: "parent-1", task: "Parent task" }), subtasks: [], @@ -55,7 +65,9 @@ describe("TaskGroupItem", () => { }), }) - render() + render( + , + ) expect(screen.getByText("Test parent task content")).toBeInTheDocument() }) @@ -65,7 +77,9 @@ describe("TaskGroupItem", () => { parent: createMockDisplayHistoryItem({ id: "my-parent-id" }), }) - render() + render( + , + ) expect(screen.getByTestId("task-group-my-parent-id")).toBeInTheDocument() }) @@ -75,23 +89,27 @@ describe("TaskGroupItem", () => { it("shows correct subtask count", () => { const group = createMockGroup({ subtasks: [ - createMockDisplayHistoryItem({ id: "child-1", task: "Child 1" }), - createMockDisplayHistoryItem({ id: "child-2", task: "Child 2" }), - createMockDisplayHistoryItem({ id: "child-3", task: "Child 3" }), + createMockSubtaskNode({ id: "child-1", task: "Child 1" }), + createMockSubtaskNode({ id: "child-2", task: "Child 2" }), + createMockSubtaskNode({ id: "child-3", task: "Child 3" }), ], }) - render() + render( + , + ) expect(screen.getByText("3 Subtasks")).toBeInTheDocument() }) it("shows singular subtask text for single subtask", () => { const group = createMockGroup({ - subtasks: [createMockDisplayHistoryItem({ id: "child-1", task: "Child 1" })], + subtasks: [createMockSubtaskNode({ id: "child-1", task: "Child 1" })], }) - render() + render( + , + ) expect(screen.getByText("1 Subtask")).toBeInTheDocument() }) @@ -99,20 +117,48 @@ describe("TaskGroupItem", () => { it("does not show subtask row when no subtasks", () => { const group = createMockGroup({ subtasks: [] }) - render() + render( + , + ) expect(screen.queryByTestId("subtask-collapsible-row")).not.toBeInTheDocument() }) + + it("renders correct total subtask count with nested children", () => { + const group = createMockGroup({ + subtasks: [ + createMockSubtaskNode({ id: "child-1", task: "Child 1" }, [ + createMockSubtaskNode({ id: "grandchild-1", task: "Grandchild 1" }), + createMockSubtaskNode({ id: "grandchild-2", task: "Grandchild 2" }), + ]), + createMockSubtaskNode({ id: "child-2", task: "Child 2" }), + ], + }) + + render( + , + ) + + // 2 direct children + 2 grandchildren = 4 total + expect(screen.getByText("4 Subtasks")).toBeInTheDocument() + }) }) describe("expand/collapse behavior", () => { it("calls onToggleExpand when chevron row is clicked", () => { const onToggleExpand = vi.fn() const group = createMockGroup({ - subtasks: [createMockDisplayHistoryItem({ id: "child-1", task: "Child 1" })], + subtasks: [createMockSubtaskNode({ id: "child-1", task: "Child 1" })], }) - render() + render( + , + ) const collapsibleRow = screen.getByTestId("subtask-collapsible-row") fireEvent.click(collapsibleRow) @@ -124,12 +170,14 @@ describe("TaskGroupItem", () => { const group = createMockGroup({ isExpanded: true, subtasks: [ - createMockDisplayHistoryItem({ id: "child-1", task: "Subtask content 1" }), - createMockDisplayHistoryItem({ id: "child-2", task: "Subtask content 2" }), + createMockSubtaskNode({ id: "child-1", task: "Subtask content 1" }), + createMockSubtaskNode({ id: "child-2", task: "Subtask content 2" }), ], }) - render() + render( + , + ) expect(screen.getByTestId("subtask-list")).toBeInTheDocument() expect(screen.getByText("Subtask content 1")).toBeInTheDocument() @@ -139,16 +187,39 @@ describe("TaskGroupItem", () => { it("hides subtasks when collapsed", () => { const group = createMockGroup({ isExpanded: false, - subtasks: [createMockDisplayHistoryItem({ id: "child-1", task: "Subtask content" })], + subtasks: [createMockSubtaskNode({ id: "child-1", task: "Subtask content" })], }) - render() + render( + , + ) // The subtask-list element is present but collapsed via CSS (max-h-0) const subtaskList = screen.queryByTestId("subtask-list") expect(subtaskList).toBeInTheDocument() expect(subtaskList).toHaveClass("max-h-0") }) + + it("renders nested subtask when a node has children and is expanded", () => { + const group = createMockGroup({ + isExpanded: true, + subtasks: [ + createMockSubtaskNode( + { id: "child-1", task: "Parent subtask" }, + [createMockSubtaskNode({ id: "grandchild-1", task: "Nested subtask" })], + true, // child-1 is expanded + ), + ], + }) + + render( + , + ) + + expect(screen.getByText("Parent subtask")).toBeInTheDocument() + expect(screen.getByText("Nested subtask")).toBeInTheDocument() + expect(screen.getByTestId("subtask-row-grandchild-1")).toBeInTheDocument() + }) }) describe("selection mode", () => { @@ -166,6 +237,7 @@ describe("TaskGroupItem", () => { isSelected={false} onToggleSelection={onToggleSelection} onToggleExpand={vi.fn()} + onToggleSubtaskExpand={vi.fn()} />, ) @@ -188,6 +260,7 @@ describe("TaskGroupItem", () => { isSelected={true} onToggleSelection={vi.fn()} onToggleExpand={vi.fn()} + onToggleSubtaskExpand={vi.fn()} />, ) @@ -201,7 +274,14 @@ describe("TaskGroupItem", () => { it("passes compact variant to TaskItem", () => { const group = createMockGroup() - render() + render( + , + ) // TaskItem should be rendered with compact styling const taskItem = screen.getByTestId("task-item-parent-1") @@ -211,7 +291,9 @@ describe("TaskGroupItem", () => { it("passes full variant to TaskItem", () => { const group = createMockGroup() - render() + render( + , + ) const taskItem = screen.getByTestId("task-item-parent-1") expect(taskItem).toBeInTheDocument() @@ -225,7 +307,15 @@ describe("TaskGroupItem", () => { parent: createMockDisplayHistoryItem({ id: "parent-1", task: "Parent task" }), }) - render() + render( + , + ) // Delete button uses "delete-task-button" as testid const deleteButton = screen.getByTestId("delete-task-button") @@ -244,7 +334,15 @@ describe("TaskGroupItem", () => { }), }) - render() + render( + , + ) // Workspace should be displayed in TaskItem const taskItem = screen.getByTestId("task-item-parent-1") @@ -258,7 +356,15 @@ describe("TaskGroupItem", () => { it("applies custom className to container", () => { const group = createMockGroup() - render() + render( + , + ) const container = screen.getByTestId("task-group-parent-1") expect(container).toHaveClass("custom-class") diff --git a/webview-ui/src/components/history/__tests__/useGroupedTasks.spec.ts b/webview-ui/src/components/history/__tests__/useGroupedTasks.spec.ts index 4f280e72d4..8873695c62 100644 --- a/webview-ui/src/components/history/__tests__/useGroupedTasks.spec.ts +++ b/webview-ui/src/components/history/__tests__/useGroupedTasks.spec.ts @@ -2,7 +2,8 @@ import { renderHook, act } from "@/utils/test-utils" import type { HistoryItem } from "@roo-code/types" -import { useGroupedTasks } from "../useGroupedTasks" +import { useGroupedTasks, buildSubtree } from "../useGroupedTasks" +import { countAllSubtasks } from "../types" const createMockTask = (overrides: Partial = {}): HistoryItem => ({ id: "task-1", @@ -42,8 +43,8 @@ describe("useGroupedTasks", () => { expect(result.current.groups).toHaveLength(1) expect(result.current.groups[0].parent.id).toBe("parent-1") expect(result.current.groups[0].subtasks).toHaveLength(2) - expect(result.current.groups[0].subtasks[0].id).toBe("child-2") // Newest first - expect(result.current.groups[0].subtasks[1].id).toBe("child-1") + expect(result.current.groups[0].subtasks[0].item.id).toBe("child-2") // Newest first + expect(result.current.groups[0].subtasks[1].item.id).toBe("child-1") }) it("handles tasks with no children", () => { @@ -121,7 +122,7 @@ describe("useGroupedTasks", () => { expect(result.current.isSearchMode).toBe(false) }) - it("handles deeply nested tasks (grandchildren treated as children of their direct parent)", () => { + it("handles deeply nested tasks with recursive tree structure", () => { const rootTask = createMockTask({ id: "root-1", task: "Root task", @@ -146,10 +147,12 @@ describe("useGroupedTasks", () => { expect(result.current.groups).toHaveLength(1) expect(result.current.groups[0].parent.id).toBe("root-1") expect(result.current.groups[0].subtasks).toHaveLength(1) - expect(result.current.groups[0].subtasks[0].id).toBe("child-1") + expect(result.current.groups[0].subtasks[0].item.id).toBe("child-1") - // Note: grandchild is a child of child-1, not root-1 - // The current implementation only shows direct children in subtasks + // Grandchild is nested inside child's children + expect(result.current.groups[0].subtasks[0].children).toHaveLength(1) + expect(result.current.groups[0].subtasks[0].children[0].item.id).toBe("grandchild-1") + expect(result.current.groups[0].subtasks[0].children[0].children).toHaveLength(0) }) }) @@ -395,3 +398,199 @@ describe("useGroupedTasks", () => { }) }) }) + +describe("buildSubtree", () => { + it("builds a leaf node with no children", () => { + const task = createMockTask({ id: "task-1", task: "Leaf task" }) + const childrenMap = new Map() + + const node = buildSubtree(task, childrenMap, new Set()) + + expect(node.item.id).toBe("task-1") + expect(node.children).toHaveLength(0) + expect(node.isExpanded).toBe(false) + }) + + it("builds a node with direct children sorted newest first", () => { + const parent = createMockTask({ id: "parent-1", task: "Parent" }) + const child1 = createMockTask({ + id: "child-1", + task: "Child 1", + parentTaskId: "parent-1", + ts: new Date("2024-01-15T12:00:00").getTime(), + }) + const child2 = createMockTask({ + id: "child-2", + task: "Child 2", + parentTaskId: "parent-1", + ts: new Date("2024-01-15T14:00:00").getTime(), + }) + + const childrenMap = new Map() + childrenMap.set("parent-1", [child1, child2]) + + const node = buildSubtree(parent, childrenMap, new Set()) + + expect(node.item.id).toBe("parent-1") + expect(node.children).toHaveLength(2) + expect(node.children[0].item.id).toBe("child-2") // Newest first + expect(node.children[1].item.id).toBe("child-1") + expect(node.isExpanded).toBe(false) + expect(node.children[0].isExpanded).toBe(false) + expect(node.children[1].isExpanded).toBe(false) + }) + + it("builds a deeply nested tree recursively", () => { + const root = createMockTask({ id: "root", task: "Root" }) + const child = createMockTask({ + id: "child", + task: "Child", + parentTaskId: "root", + ts: new Date("2024-01-15T13:00:00").getTime(), + }) + const grandchild = createMockTask({ + id: "grandchild", + task: "Grandchild", + parentTaskId: "child", + ts: new Date("2024-01-15T14:00:00").getTime(), + }) + const greatGrandchild = createMockTask({ + id: "great-grandchild", + task: "Great Grandchild", + parentTaskId: "grandchild", + ts: new Date("2024-01-15T15:00:00").getTime(), + }) + + const childrenMap = new Map() + childrenMap.set("root", [child]) + childrenMap.set("child", [grandchild]) + childrenMap.set("grandchild", [greatGrandchild]) + + const node = buildSubtree(root, childrenMap, new Set()) + + expect(node.item.id).toBe("root") + expect(node.children).toHaveLength(1) + expect(node.children[0].item.id).toBe("child") + expect(node.children[0].children).toHaveLength(1) + expect(node.children[0].children[0].item.id).toBe("grandchild") + expect(node.children[0].children[0].children).toHaveLength(1) + expect(node.children[0].children[0].children[0].item.id).toBe("great-grandchild") + expect(node.children[0].children[0].children[0].children).toHaveLength(0) + }) + + it("does not mutate the original childrenMap arrays", () => { + const parent = createMockTask({ id: "parent-1", task: "Parent" }) + const child1 = createMockTask({ + id: "child-1", + task: "Child 1", + parentTaskId: "parent-1", + ts: new Date("2024-01-15T12:00:00").getTime(), + }) + const child2 = createMockTask({ + id: "child-2", + task: "Child 2", + parentTaskId: "parent-1", + ts: new Date("2024-01-15T14:00:00").getTime(), + }) + + const originalChildren = [child1, child2] + const childrenMap = new Map() + childrenMap.set("parent-1", originalChildren) + + buildSubtree(parent, childrenMap, new Set()) + + // Original array should not be mutated (sort is on a slice) + expect(originalChildren[0].id).toBe("child-1") + expect(originalChildren[1].id).toBe("child-2") + }) + + it("sets isExpanded: true when task ID is in expandedIds", () => { + const parent = createMockTask({ id: "parent-1", task: "Parent" }) + const child = createMockTask({ + id: "child-1", + task: "Child", + parentTaskId: "parent-1", + ts: new Date("2024-01-15T13:00:00").getTime(), + }) + + const childrenMap = new Map() + childrenMap.set("parent-1", [child]) + + const expandedIds = new Set(["parent-1"]) + const node = buildSubtree(parent, childrenMap, expandedIds) + + expect(node.isExpanded).toBe(true) + expect(node.children[0].isExpanded).toBe(false) + }) + + it("propagates isExpanded correctly through deeply nested tree", () => { + const root = createMockTask({ id: "root", task: "Root" }) + const child = createMockTask({ + id: "child", + task: "Child", + parentTaskId: "root", + ts: new Date("2024-01-15T13:00:00").getTime(), + }) + const grandchild = createMockTask({ + id: "grandchild", + task: "Grandchild", + parentTaskId: "child", + ts: new Date("2024-01-15T14:00:00").getTime(), + }) + const greatGrandchild = createMockTask({ + id: "great-grandchild", + task: "Great Grandchild", + parentTaskId: "grandchild", + ts: new Date("2024-01-15T15:00:00").getTime(), + }) + + const childrenMap = new Map() + childrenMap.set("root", [child]) + childrenMap.set("child", [grandchild]) + childrenMap.set("grandchild", [greatGrandchild]) + + // Expand root and grandchild, but NOT child + const expandedIds = new Set(["root", "grandchild"]) + const node = buildSubtree(root, childrenMap, expandedIds) + + expect(node.isExpanded).toBe(true) + expect(node.children[0].isExpanded).toBe(false) // child not expanded + expect(node.children[0].children[0].isExpanded).toBe(true) // grandchild expanded + expect(node.children[0].children[0].children[0].isExpanded).toBe(false) // great-grandchild not expanded + }) +}) + +describe("countAllSubtasks", () => { + it("returns 0 for empty array", () => { + expect(countAllSubtasks([])).toBe(0) + }) + + it("returns count of items in flat list (no grandchildren)", () => { + const nodes = [ + { item: createMockTask({ id: "a" }), children: [], isExpanded: false }, + { item: createMockTask({ id: "b" }), children: [], isExpanded: false }, + { item: createMockTask({ id: "c" }), children: [], isExpanded: false }, + ] + expect(countAllSubtasks(nodes)).toBe(3) + }) + + it("returns total count at all nesting levels", () => { + const nodes = [ + { + item: createMockTask({ id: "a" }), + children: [ + { + item: createMockTask({ id: "a1" }), + children: [{ item: createMockTask({ id: "a1i" }), children: [], isExpanded: false }], + isExpanded: false, + }, + { item: createMockTask({ id: "a2" }), children: [], isExpanded: false }, + ], + isExpanded: false, + }, + { item: createMockTask({ id: "b" }), children: [], isExpanded: false }, + ] + // a (1) + a1 (1) + a1i (1) + a2 (1) + b (1) = 5 + expect(countAllSubtasks(nodes)).toBe(5) + }) +}) diff --git a/webview-ui/src/components/history/types.ts b/webview-ui/src/components/history/types.ts index a12dfbce63..0de5e43081 100644 --- a/webview-ui/src/components/history/types.ts +++ b/webview-ui/src/components/history/types.ts @@ -11,13 +11,36 @@ export interface DisplayHistoryItem extends HistoryItem { } /** - * A group of tasks consisting of a parent task and its subtasks + * A node in the subtask tree, representing a task and its recursively nested children. + */ +export interface SubtaskTreeNode { + /** The task at this tree node */ + item: DisplayHistoryItem + /** Recursively nested child subtasks */ + children: SubtaskTreeNode[] + /** Whether this node's children are expanded in the UI */ + isExpanded: boolean +} + +/** + * Recursively counts all subtasks in a tree of SubtaskTreeNodes. + */ +export function countAllSubtasks(nodes: SubtaskTreeNode[]): number { + let count = 0 + for (const node of nodes) { + count += 1 + countAllSubtasks(node.children) + } + return count +} + +/** + * A group of tasks consisting of a parent task and its nested subtask tree */ export interface TaskGroup { /** The parent task */ parent: DisplayHistoryItem - /** List of direct subtasks */ - subtasks: DisplayHistoryItem[] + /** Tree of subtasks (supports arbitrary nesting depth) */ + subtasks: SubtaskTreeNode[] /** Whether the subtask list is expanded */ isExpanded: boolean } diff --git a/webview-ui/src/components/history/useGroupedTasks.ts b/webview-ui/src/components/history/useGroupedTasks.ts index 9d7085881e..d3f3d4e953 100644 --- a/webview-ui/src/components/history/useGroupedTasks.ts +++ b/webview-ui/src/components/history/useGroupedTasks.ts @@ -1,6 +1,29 @@ import { useState, useMemo, useCallback } from "react" import type { HistoryItem } from "@roo-code/types" -import type { DisplayHistoryItem, TaskGroup, GroupedTasksResult } from "./types" +import type { DisplayHistoryItem, SubtaskTreeNode, TaskGroup, GroupedTasksResult } from "./types" + +/** + * Recursively builds a subtask tree node for the given task. + * Pure function — exported for independent testing. + * + * @param task - The task to build a tree node for + * @param childrenMap - Map of parentId → direct children + * @param expandedIds - Set of task IDs whose children are currently expanded + * @returns A SubtaskTreeNode with recursively built children sorted by ts (newest first) + */ +export function buildSubtree( + task: HistoryItem, + childrenMap: Map, + expandedIds: Set, +): SubtaskTreeNode { + const directChildren = (childrenMap.get(task.id) || []).slice().sort((a, b) => b.ts - a.ts) + + return { + item: task as DisplayHistoryItem, + children: directChildren.map((child) => buildSubtree(child, childrenMap, expandedIds)), + isExpanded: expandedIds.has(task.id), + } +} /** * Hook to transform a flat task list into grouped structure based on parent-child relationships. @@ -31,7 +54,7 @@ export function useGroupedTasks(tasks: HistoryItem[], searchQuery: string): Grou return [] } - // Build children map: parentId -> children[] + // Build children map: parentId -> direct children[] const childrenMap = new Map() for (const task of tasks) { @@ -44,19 +67,16 @@ export function useGroupedTasks(tasks: HistoryItem[], searchQuery: string): Grou // Identify root tasks - tasks that either: // 1. Have no parentTaskId - // 2. Have a parentTaskId that doesn't exist in our task list + // 2. Have a parentTaskId that doesn't exist in our task list (orphans promoted to root) const rootTasks = tasks.filter((task) => !task.parentTaskId || !taskMap.has(task.parentTaskId)) - // Build groups from root tasks + // Build groups from root tasks with recursively nested subtask trees const taskGroups: TaskGroup[] = rootTasks.map((parent) => { - // Get direct children (sorted by timestamp, newest first) - const subtasks = (childrenMap.get(parent.id) || []) - .slice() - .sort((a, b) => b.ts - a.ts) as DisplayHistoryItem[] + const directChildren = (childrenMap.get(parent.id) || []).slice().sort((a, b) => b.ts - a.ts) return { parent: parent as DisplayHistoryItem, - subtasks, + subtasks: directChildren.map((child) => buildSubtree(child, childrenMap, expandedIds)), isExpanded: expandedIds.has(parent.id), } }) diff --git a/webview-ui/src/components/marketplace/MarketplaceView.tsx b/webview-ui/src/components/marketplace/MarketplaceView.tsx index 94c50b80ab..0ab5430eec 100644 --- a/webview-ui/src/components/marketplace/MarketplaceView.tsx +++ b/webview-ui/src/components/marketplace/MarketplaceView.tsx @@ -108,7 +108,7 @@ export function MarketplaceView({ stateManager, onDone, targetTab }: Marketplace onClick={() => onDone?.()} aria-label={t("settings:back")}> - {t("settings:back")} + {t("settings:back")}

{t("marketplace:title")}

diff --git a/webview-ui/src/components/modes/ModesView.tsx b/webview-ui/src/components/modes/ModesView.tsx index 15e70f0ebc..eeeaf026cc 100644 --- a/webview-ui/src/components/modes/ModesView.tsx +++ b/webview-ui/src/components/modes/ModesView.tsx @@ -92,7 +92,6 @@ const ModesView = () => { const [isToolsEditMode, setIsToolsEditMode] = useState(false) const [showConfigMenu, setShowConfigMenu] = useState(false) const [isCreateModeDialogOpen, setIsCreateModeDialogOpen] = useState(false) - const [isSystemPromptDisclosureOpen, setIsSystemPromptDisclosureOpen] = useState(false) const [isExporting, setIsExporting] = useState(false) const [isImporting, setIsImporting] = useState(false) const [showImportDialog, setShowImportDialog] = useState(false) @@ -1328,67 +1327,6 @@ const ModesView = () => {
- - {/* Advanced Features Disclosure */} -
- - - {isSystemPromptDisclosureOpen && ( -
- {/* Override System Prompt Section */} -
-

- Override System Prompt -

-
- { - const currentMode = getCurrentMode() - if (!currentMode) return - - vscode.postMessage({ - type: "openFile", - text: `./.roo/system-prompt-${currentMode.slug}`, - values: { - create: true, - content: "", - }, - }) - }} - /> - ), - "1": ( - - ), - "2": , - }} - /> -
-
-
- )} -
diff --git a/webview-ui/src/components/settings/ApiOptions.tsx b/webview-ui/src/components/settings/ApiOptions.tsx index 939d2734d4..8aa14e2dc9 100644 --- a/webview-ui/src/components/settings/ApiOptions.tsx +++ b/webview-ui/src/components/settings/ApiOptions.tsx @@ -7,24 +7,20 @@ import { ExternalLinkIcon } from "@radix-ui/react-icons" import { type ProviderName, type ProviderSettings, + isRetiredProvider, DEFAULT_CONSECUTIVE_MISTAKE_LIMIT, openRouterDefaultModelId, requestyDefaultModelId, - unboundDefaultModelId, litellmDefaultModelId, openAiNativeDefaultModelId, openAiCodexDefaultModelId, anthropicDefaultModelId, - doubaoDefaultModelId, qwenCodeDefaultModelId, geminiDefaultModelId, deepSeekDefaultModelId, moonshotDefaultModelId, mistralDefaultModelId, xaiDefaultModelId, - groqDefaultModelId, - cerebrasDefaultModelId, - chutesDefaultModelId, basetenDefaultModelId, bedrockDefaultModelId, vertexDefaultModelId, @@ -32,11 +28,8 @@ import { internationalZAiDefaultModelId, mainlandZAiDefaultModelId, fireworksDefaultModelId, - featherlessDefaultModelId, - ioIntelligenceDefaultModelId, rooDefaultModelId, vercelAiGatewayDefaultModelId, - deepInfraDefaultModelId, minimaxDefaultModelId, } from "@roo-code/types" @@ -75,14 +68,8 @@ import { Anthropic, Baseten, Bedrock, - Cerebras, - Chutes, DeepSeek, - Doubao, Gemini, - Groq, - HuggingFace, - IOIntelligence, LMStudio, LiteLLM, Mistral, @@ -96,15 +83,12 @@ import { Requesty, Roo, SambaNova, - Unbound, Vertex, VSCodeLM, XAI, ZAi, Fireworks, - Featherless, VercelAiGateway, - DeepInfra, MiniMax, } from "./providers" @@ -171,7 +155,7 @@ const ApiOptions = ({ // Only update if the processed object is different from the current config. if (JSON.stringify(currentConfigHeaders) !== JSON.stringify(newHeadersObject)) { - setApiConfigurationField("openAiHeaders", newHeadersObject) + setApiConfigurationField("openAiHeaders", newHeadersObject, false) } }, 300, @@ -196,6 +180,11 @@ const ApiOptions = ({ id: selectedModelId, info: selectedModelInfo, } = useSelectedModel(apiConfiguration) + const activeSelectedProvider: ProviderName | undefined = isRetiredProvider(selectedProvider) + ? undefined + : selectedProvider + const isRetiredSelectedProvider = + typeof apiConfiguration.apiProvider === "string" && isRetiredProvider(apiConfiguration.apiProvider) const { data: routerModels, refetch: refetchRouterModels } = useRouterModels() @@ -213,12 +202,16 @@ const ApiOptions = ({ // Update `apiModelId` whenever `selectedModelId` changes. useEffect(() => { + if (isRetiredSelectedProvider) { + return + } + if (selectedModelId && apiConfiguration.apiModelId !== selectedModelId) { // Pass false as third parameter to indicate this is not a user action // This is an internal sync, not a user-initiated change setApiConfigurationField("apiModelId", selectedModelId, false) } - }, [selectedModelId, setApiConfigurationField, apiConfiguration.apiModelId]) + }, [selectedModelId, setApiConfigurationField, apiConfiguration.apiModelId, isRetiredSelectedProvider]) // Debounced refresh model updates, only executed 250ms after the user // stops typing. @@ -243,11 +236,7 @@ const ApiOptions = ({ vscode.postMessage({ type: "requestLmStudioModels" }) } else if (selectedProvider === "vscode-lm") { vscode.postMessage({ type: "requestVsCodeLmModels" }) - } else if ( - selectedProvider === "litellm" || - selectedProvider === "deepinfra" || - selectedProvider === "roo" - ) { + } else if (selectedProvider === "litellm" || selectedProvider === "roo") { vscode.postMessage({ type: "requestRouterModels" }) } }, @@ -261,20 +250,23 @@ const ApiOptions = ({ apiConfiguration?.lmStudioBaseUrl, apiConfiguration?.litellmBaseUrl, apiConfiguration?.litellmApiKey, - apiConfiguration?.deepInfraApiKey, - apiConfiguration?.deepInfraBaseUrl, customHeaders, ], ) useEffect(() => { + if (isRetiredSelectedProvider) { + setErrorMessage(undefined) + return + } + const apiValidationResult = validateApiConfigurationExcludingModelErrors( apiConfiguration, routerModels, organizationAllowList, ) setErrorMessage(apiValidationResult) - }, [apiConfiguration, routerModels, organizationAllowList, setErrorMessage]) + }, [apiConfiguration, routerModels, organizationAllowList, setErrorMessage, isRetiredSelectedProvider]) const onProviderChange = useCallback( (value: ProviderName) => { @@ -282,7 +274,7 @@ const ApiOptions = ({ // It would be much easier to have a single attribute that stores // the modelId, but we have a separate attribute for each of - // OpenRouter, Unbound, and Requesty. + // OpenRouter and Requesty. // If you switch to one of these providers and the corresponding // modelId is not set then you immediately end up in an error state. // To address that we set the modelId to the default value for th @@ -336,25 +328,19 @@ const ApiOptions = ({ } > > = { - deepinfra: { field: "deepInfraModelId", default: deepInfraDefaultModelId }, openrouter: { field: "openRouterModelId", default: openRouterDefaultModelId }, - unbound: { field: "unboundModelId", default: unboundDefaultModelId }, requesty: { field: "requestyModelId", default: requestyDefaultModelId }, litellm: { field: "litellmModelId", default: litellmDefaultModelId }, anthropic: { field: "apiModelId", default: anthropicDefaultModelId }, - cerebras: { field: "apiModelId", default: cerebrasDefaultModelId }, "openai-codex": { field: "apiModelId", default: openAiCodexDefaultModelId }, "qwen-code": { field: "apiModelId", default: qwenCodeDefaultModelId }, "openai-native": { field: "apiModelId", default: openAiNativeDefaultModelId }, gemini: { field: "apiModelId", default: geminiDefaultModelId }, deepseek: { field: "apiModelId", default: deepSeekDefaultModelId }, - doubao: { field: "apiModelId", default: doubaoDefaultModelId }, moonshot: { field: "apiModelId", default: moonshotDefaultModelId }, minimax: { field: "apiModelId", default: minimaxDefaultModelId }, mistral: { field: "apiModelId", default: mistralDefaultModelId }, xai: { field: "apiModelId", default: xaiDefaultModelId }, - groq: { field: "apiModelId", default: groqDefaultModelId }, - chutes: { field: "apiModelId", default: chutesDefaultModelId }, baseten: { field: "apiModelId", default: basetenDefaultModelId }, bedrock: { field: "apiModelId", default: bedrockDefaultModelId }, vertex: { field: "apiModelId", default: vertexDefaultModelId }, @@ -367,8 +353,6 @@ const ApiOptions = ({ : internationalZAiDefaultModelId, }, fireworks: { field: "apiModelId", default: fireworksDefaultModelId }, - featherless: { field: "apiModelId", default: featherlessDefaultModelId }, - "io-intelligence": { field: "ioIntelligenceModelId", default: ioIntelligenceDefaultModelId }, roo: { field: "apiModelId", default: rooDefaultModelId }, "vercel-ai-gateway": { field: "vercelAiGatewayModelId", default: vercelAiGatewayDefaultModelId }, openai: { field: "openAiModelId" }, @@ -500,387 +484,355 @@ const ApiOptions = ({ {errorMessage && } - {selectedProvider === "openrouter" && ( - - )} - - {selectedProvider === "requesty" && ( - - )} - - {selectedProvider === "unbound" && ( - - )} - - {selectedProvider === "deepinfra" && ( - - )} - - {selectedProvider === "anthropic" && ( - - )} - - {selectedProvider === "openai-codex" && ( - - )} - - {selectedProvider === "openai-native" && ( - - )} - - {selectedProvider === "mistral" && ( - - )} - - {selectedProvider === "baseten" && ( - - )} - - {selectedProvider === "bedrock" && ( - - )} - - {selectedProvider === "vertex" && ( - - )} - - {selectedProvider === "gemini" && ( - - )} - - {selectedProvider === "openai" && ( - - )} - - {selectedProvider === "lmstudio" && ( - - )} - - {selectedProvider === "deepseek" && ( - - )} - - {selectedProvider === "doubao" && ( - - )} - - {selectedProvider === "qwen-code" && ( - - )} - - {selectedProvider === "moonshot" && ( - - )} - - {selectedProvider === "minimax" && ( - - )} - - {selectedProvider === "vscode-lm" && ( - - )} - - {selectedProvider === "ollama" && ( - - )} - - {selectedProvider === "xai" && ( - - )} - - {selectedProvider === "groq" && ( - - )} - - {selectedProvider === "huggingface" && ( - - )} - - {selectedProvider === "cerebras" && ( - - )} - - {selectedProvider === "chutes" && ( - - )} - - {selectedProvider === "litellm" && ( - - )} - - {selectedProvider === "sambanova" && ( - - )} - - {selectedProvider === "zai" && ( - - )} - - {selectedProvider === "io-intelligence" && ( - - )} - - {selectedProvider === "vercel-ai-gateway" && ( - - )} - - {selectedProvider === "fireworks" && ( - - )} - - {selectedProvider === "roo" && ( - - )} - - {selectedProvider === "featherless" && ( - - )} - - {/* Generic model picker for providers with static models */} - {shouldUseGenericModelPicker(selectedProvider) && ( + {isRetiredSelectedProvider ? ( +
+ {t("settings:providers.retiredProviderMessage")} +
+ ) : ( <> - - handleModelChangeSideEffects(selectedProvider, modelId, setApiConfigurationField) - } - /> + {selectedProvider === "openrouter" && ( + + )} - {selectedProvider === "bedrock" && selectedModelId === "custom-arn" && ( - + )} + + {selectedProvider === "anthropic" && ( + + )} + + {selectedProvider === "openai-codex" && ( + + )} + + {selectedProvider === "openai-native" && ( + + )} + + {selectedProvider === "mistral" && ( + + )} + + {selectedProvider === "baseten" && ( + + )} + + {selectedProvider === "bedrock" && ( + + )} + + {selectedProvider === "vertex" && ( + )} - - )} - {!fromWelcomeView && ( - - )} - - {/* Gate Verbosity UI by capability flag */} - {!fromWelcomeView && selectedModelInfo?.supportsVerbosity && ( - - )} - - {!fromWelcomeView && ( - - - - {t("settings:advancedSettings.title")} - - - setApiConfigurationField(field, value)} + {selectedProvider === "gemini" && ( + - {selectedModelInfo?.supportsTemperature !== false && ( - + )} + + {selectedProvider === "lmstudio" && ( + + )} + + {selectedProvider === "deepseek" && ( + + )} + + {selectedProvider === "qwen-code" && ( + + )} + + {selectedProvider === "moonshot" && ( + + )} + + {selectedProvider === "minimax" && ( + + )} + + {selectedProvider === "vscode-lm" && ( + + )} + + {selectedProvider === "ollama" && ( + + )} + + {selectedProvider === "xai" && ( + + )} + + {selectedProvider === "litellm" && ( + + )} + + {selectedProvider === "sambanova" && ( + + )} + + {selectedProvider === "zai" && ( + + )} + + {selectedProvider === "vercel-ai-gateway" && ( + + )} + + {selectedProvider === "fireworks" && ( + + )} + + {selectedProvider === "roo" && ( + + )} + + {/* Generic model picker for providers with static models */} + {activeSelectedProvider && shouldUseGenericModelPicker(activeSelectedProvider) && ( + <> + + handleModelChangeSideEffects( + activeSelectedProvider, + modelId, + setApiConfigurationField, + ) + } /> - )} - setApiConfigurationField("rateLimitSeconds", value)} - /> - setApiConfigurationField("consecutiveMistakeLimit", value)} - /> - {selectedProvider === "openrouter" && - openRouterModelProviders && - Object.keys(openRouterModelProviders).length > 0 && ( -
-
- - - - -
- -
- {t("settings:providers.openRouter.providerRouting.description")}{" "} - - {t("settings:providers.openRouter.providerRouting.learnMore")}. - -
-
+ + {selectedProvider === "bedrock" && selectedModelId === "custom-arn" && ( + )} -
-
+ + )} + + {!fromWelcomeView && ( + + )} + + {/* Gate Verbosity UI by capability flag */} + {!fromWelcomeView && selectedModelInfo?.supportsVerbosity && ( + + )} + + {!fromWelcomeView && ( + + + + {t("settings:advancedSettings.title")} + + + setApiConfigurationField(field, value)} + /> + {selectedModelInfo?.supportsTemperature !== false && ( + + )} + setApiConfigurationField("rateLimitSeconds", value)} + /> + setApiConfigurationField("consecutiveMistakeLimit", value)} + /> + {selectedProvider === "openrouter" && + openRouterModelProviders && + Object.keys(openRouterModelProviders).length > 0 && ( +
+
+ + + + +
+ +
+ {t("settings:providers.openRouter.providerRouting.description")}{" "} + + {t("settings:providers.openRouter.providerRouting.learnMore")}. + +
+
+ )} +
+
+ )} + )}
) diff --git a/webview-ui/src/components/settings/AutoApproveSettings.tsx b/webview-ui/src/components/settings/AutoApproveSettings.tsx index daf3d7d64d..40e1658f5f 100644 --- a/webview-ui/src/components/settings/AutoApproveSettings.tsx +++ b/webview-ui/src/components/settings/AutoApproveSettings.tsx @@ -24,7 +24,6 @@ type AutoApproveSettingsProps = HTMLAttributes & { alwaysAllowWrite?: boolean alwaysAllowWriteOutsideWorkspace?: boolean alwaysAllowWriteProtected?: boolean - alwaysAllowBrowser?: boolean alwaysAllowMcp?: boolean alwaysAllowModeSwitch?: boolean alwaysAllowSubtasks?: boolean @@ -41,7 +40,6 @@ type AutoApproveSettingsProps = HTMLAttributes & { | "alwaysAllowWrite" | "alwaysAllowWriteOutsideWorkspace" | "alwaysAllowWriteProtected" - | "alwaysAllowBrowser" | "alwaysAllowMcp" | "alwaysAllowModeSwitch" | "alwaysAllowSubtasks" @@ -61,7 +59,6 @@ export const AutoApproveSettings = ({ alwaysAllowWrite, alwaysAllowWriteOutsideWorkspace, alwaysAllowWriteProtected, - alwaysAllowBrowser, alwaysAllowMcp, alwaysAllowModeSwitch, alwaysAllowSubtasks, @@ -155,7 +152,6 @@ export const AutoApproveSettings = ({ & { - browserToolEnabled?: boolean - browserViewportSize?: string - screenshotQuality?: number - remoteBrowserHost?: string - remoteBrowserEnabled?: boolean - setCachedStateField: SetCachedStateField< - | "browserToolEnabled" - | "browserViewportSize" - | "screenshotQuality" - | "remoteBrowserHost" - | "remoteBrowserEnabled" - > -} - -export const BrowserSettings = ({ - browserToolEnabled, - browserViewportSize, - screenshotQuality, - remoteBrowserHost, - remoteBrowserEnabled, - setCachedStateField, - ...props -}: BrowserSettingsProps) => { - const { t } = useAppTranslation() - - const [testingConnection, setTestingConnection] = useState(false) - const [testResult, setTestResult] = useState<{ success: boolean; text: string } | null>(null) - const [discovering, setDiscovering] = useState(false) - - // We don't need a local state for useRemoteBrowser since we're using the - // `enableRemoteBrowser` prop directly. This ensures the checkbox always - // reflects the current global state. - - // Set up message listener for browser connection results. - useEffect(() => { - const handleMessage = (event: MessageEvent) => { - const message = event.data - - if (message.type === "browserConnectionResult") { - setTestResult({ success: message.success, text: message.text }) - setTestingConnection(false) - setDiscovering(false) - } - } - - window.addEventListener("message", handleMessage) - - return () => { - window.removeEventListener("message", handleMessage) - } - }, []) - - const testConnection = async () => { - setTestingConnection(true) - setTestResult(null) - - try { - // Send a message to the extension to test the connection. - vscode.postMessage({ type: "testBrowserConnection", text: remoteBrowserHost }) - } catch (error) { - setTestResult({ - success: false, - text: `Error: ${error instanceof Error ? error.message : String(error)}`, - }) - setTestingConnection(false) - } - } - - const options = useMemo( - () => [ - { - value: "1280x800", - label: t("settings:browser.viewport.options.largeDesktop"), - }, - { - value: "900x600", - label: t("settings:browser.viewport.options.smallDesktop"), - }, - { value: "768x1024", label: t("settings:browser.viewport.options.tablet") }, - { value: "360x640", label: t("settings:browser.viewport.options.mobile") }, - ], - [t], - ) - - return ( -
- {t("settings:sections.browser")} - -
- - setCachedStateField("browserToolEnabled", e.target.checked)}> - {t("settings:browser.enable.label")} - -
- - - {" "} - - -
-
- - {browserToolEnabled && ( -
- - - -
- {t("settings:browser.viewport.description")} -
-
- - - -
- setCachedStateField("screenshotQuality", value)} - /> - {screenshotQuality ?? 75}% -
-
- {t("settings:browser.screenshotQuality.description")} -
-
- - - { - // Update the global state - remoteBrowserEnabled now means "enable remote browser connection". - setCachedStateField("remoteBrowserEnabled", e.target.checked) - - if (!e.target.checked) { - // If disabling remote browser, clear the custom URL. - setCachedStateField("remoteBrowserHost", undefined) - } - }}> - - -
- {t("settings:browser.remote.description")} -
-
- - {remoteBrowserEnabled && ( - <> -
- - setCachedStateField("remoteBrowserHost", e.target.value || undefined) - } - placeholder={t("settings:browser.remote.urlPlaceholder")} - style={{ flexGrow: 1 }} - /> - -
- {testResult && ( -
- {testResult.text} -
- )} -
- {t("settings:browser.remote.instructions")} -
- - )} -
- )} -
-
- ) -} diff --git a/webview-ui/src/components/settings/ContextManagementSettings.tsx b/webview-ui/src/components/settings/ContextManagementSettings.tsx index cef7153493..8663ea6e03 100644 --- a/webview-ui/src/components/settings/ContextManagementSettings.tsx +++ b/webview-ui/src/components/settings/ContextManagementSettings.tsx @@ -33,10 +33,8 @@ type ContextManagementSettingsProps = HTMLAttributes & { maxWorkspaceFiles: number showRooIgnoredFiles?: boolean enableSubfolderRules?: boolean - maxReadFileLine?: number maxImageFileSize?: number maxTotalImageSize?: number - maxConcurrentFileReads?: number profileThresholds?: Record includeDiagnosticMessages?: boolean maxDiagnosticMessages?: number @@ -53,10 +51,8 @@ type ContextManagementSettingsProps = HTMLAttributes & { | "maxWorkspaceFiles" | "showRooIgnoredFiles" | "enableSubfolderRules" - | "maxReadFileLine" | "maxImageFileSize" | "maxTotalImageSize" - | "maxConcurrentFileReads" | "profileThresholds" | "includeDiagnosticMessages" | "maxDiagnosticMessages" @@ -76,10 +72,8 @@ export const ContextManagementSettings = ({ showRooIgnoredFiles, enableSubfolderRules, setCachedStateField, - maxReadFileLine, maxImageFileSize, maxTotalImageSize, - maxConcurrentFileReads, profileThresholds = {}, includeDiagnosticMessages, maxDiagnosticMessages, @@ -218,29 +212,6 @@ export const ContextManagementSettings = ({
- - - {t("settings:contextManagement.maxConcurrentFileReads.label")} - -
- setCachedStateField("maxConcurrentFileReads", value)} - data-testid="max-concurrent-file-reads-slider" - /> - {Math.max(1, maxConcurrentFileReads ?? 5)} -
-
- {t("settings:contextManagement.maxConcurrentFileReads.description")} -
-
- - -
- {t("settings:contextManagement.maxReadFile.label")} -
- { - const newValue = parseInt(e.target.value, 10) - if (!isNaN(newValue) && newValue >= -1) { - setCachedStateField("maxReadFileLine", newValue) - } - }} - onClick={(e) => e.currentTarget.select()} - data-testid="max-read-file-line-input" - disabled={maxReadFileLine === -1} - /> - {t("settings:contextManagement.maxReadFile.lines")} - - setCachedStateField("maxReadFileLine", e.target.checked ? -1 : 500) - } - data-testid="max-read-file-always-full-checkbox"> - {t("settings:contextManagement.maxReadFile.always_full_read")} - -
-
-
- {t("settings:contextManagement.maxReadFile.description")} -
-
- void + onSkillCreated: () => void + hasWorkspace: boolean +} + +/** + * Map skill name validation error codes to translation keys. + */ +const getSkillNameErrorTranslationKey = (error: SkillNameValidationError): string => { + switch (error) { + case SkillNameValidationError.Empty: + return "settings:skills.validation.nameRequired" + case SkillNameValidationError.TooLong: + return "settings:skills.validation.nameTooLong" + case SkillNameValidationError.InvalidFormat: + return "settings:skills.validation.nameInvalid" + } +} + +/** + * Validate skill name using shared validation from @roo-code/types. + * Returns a translation key for the error, or null if valid. + */ +const validateSkillName = (name: string): string | null => { + const result = validateSkillNameShared(name) + if (!result.valid) { + return getSkillNameErrorTranslationKey(result.error!) + } + return null +} + +/** + * Validate description according to agentskills.io spec: + * - Required field + * - 1-1024 characters + */ +const validateDescription = (description: string): string | null => { + if (!description) return "settings:skills.validation.descriptionRequired" + if (description.length > 1024) return "settings:skills.validation.descriptionTooLong" + return null +} + +export const CreateSkillDialog: React.FC = ({ + open, + onOpenChange, + onSkillCreated, + hasWorkspace, +}) => { + const { t } = useAppTranslation() + const { customModes } = useExtensionState() + + const [name, setName] = useState("") + const [description, setDescription] = useState("") + const [source, setSource] = useState<"global" | "project">(hasWorkspace ? "project" : "global") + const [nameError, setNameError] = useState(null) + const [descriptionError, setDescriptionError] = useState(null) + + // Multi-mode selection state (same pattern as SkillsSettings mode dialog) + const [selectedModes, setSelectedModes] = useState([]) + const [isAnyMode, setIsAnyMode] = useState(true) + + // Get available modes for the checkboxes (built-in + custom modes) + const availableModes = useMemo(() => { + return getAllModes(customModes).map((m) => ({ slug: m.slug, name: m.name })) + }, [customModes]) + + const resetForm = useCallback(() => { + setName("") + setDescription("") + setSource(hasWorkspace ? "project" : "global") + setSelectedModes([]) + setIsAnyMode(true) + setNameError(null) + setDescriptionError(null) + }, [hasWorkspace]) + + const handleClose = useCallback(() => { + resetForm() + onOpenChange(false) + }, [resetForm, onOpenChange]) + + const handleNameChange = useCallback((e: React.ChangeEvent) => { + const value = e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, "") + setName(value) + setNameError(null) + }, []) + + const handleDescriptionChange = useCallback((e: React.ChangeEvent) => { + setDescription(e.target.value) + setDescriptionError(null) + }, []) + + // Handle "Any mode" toggle - mutually exclusive with specific modes + const handleAnyModeToggle = useCallback((checked: boolean) => { + if (checked) { + setIsAnyMode(true) + setSelectedModes([]) // Clear specific modes when "Any mode" is selected + } else { + setIsAnyMode(false) + } + }, []) + + // Handle specific mode toggle - unchecks "Any mode" when a specific mode is selected + const handleModeToggle = useCallback((modeSlug: string, checked: boolean) => { + if (checked) { + setIsAnyMode(false) // Uncheck "Any mode" when selecting a specific mode + setSelectedModes((prev) => [...prev, modeSlug]) + } else { + setSelectedModes((prev) => { + const newModes = prev.filter((m) => m !== modeSlug) + // If no modes selected, default back to "Any mode" + if (newModes.length === 0) { + setIsAnyMode(true) + } + return newModes + }) + } + }, []) + + const handleCreate = useCallback(() => { + // Validate fields + const nameValidationError = validateSkillName(name) + const descValidationError = validateDescription(description) + + if (nameValidationError) { + setNameError(nameValidationError) + return + } + + if (descValidationError) { + setDescriptionError(descValidationError) + return + } + + // Send message to create skill + // Convert to modeSlugs: undefined for "Any mode", or array of selected modes + const modeSlugs = isAnyMode ? undefined : selectedModes.length > 0 ? selectedModes : undefined + vscode.postMessage({ + type: "createSkill", + skillName: name, + source, + skillDescription: description, + skillModeSlugs: modeSlugs, + }) + + // Close dialog and notify parent + handleClose() + onSkillCreated() + }, [name, description, source, isAnyMode, selectedModes, handleClose, onSkillCreated]) + + return ( + + + + {t("settings:skills.createDialog.title")} + + + +
+ {/* Name Input */} +
+ + + {nameError && {t(nameError)}} +
+ + {/* Description Input */} +
+