diff --git a/.gemini/skills/docs-changelog/SKILL.md b/.gemini/skills/docs-changelog/SKILL.md index d3a2f63623..f175260abd 100644 --- a/.gemini/skills/docs-changelog/SKILL.md +++ b/.gemini/skills/docs-changelog/SKILL.md @@ -59,6 +59,10 @@ To standardize the process of updating changelog files (`latest.md`, *Use this path if the version number ends in `.0`.* +**Important:** Based on the version, you must choose to follow either section +A.1 for stable releases or A.2 for preview releases. Do not follow the +instructions for the other section. + ### A.1: Stable Release (e.g., `v0.28.0`) For a stable release, you will generate two distinct summaries from the @@ -73,7 +77,8 @@ detailed **highlights** section for the release-specific page. use the existing announcements in `docs/changelogs/index.md` and the example within `.gemini/skills/docs-changelog/references/index_template.md` as your - guide. This format includes PR links and authors. + guide. This format includes PR links and authors. Stick to 1 or 2 PR + links and authors. - Add this new announcement to the top of `docs/changelogs/index.md`. 2. **Create Highlights and Update `latest.md`**: @@ -105,6 +110,10 @@ detailed **highlights** section for the release-specific page. *Use this path if the version number does **not** end in `.0`.* +**Important:** Based on the version, you must choose to follow either section +B.1 for stable patches or B.2 for preview patches. Do not follow the +instructions for the other section. + ### B.1: Stable Patch (e.g., `v0.28.1`) - **Target File**: `docs/changelogs/latest.md` @@ -113,10 +122,12 @@ detailed **highlights** section for the release-specific page. `# Latest stable release: {{version}}` 2. Update the rease date. The line should read, `Released: {{release_date_month_dd_yyyy}}` - 3. **Prepend** the processed "What's Changed" list from the temporary file + 3. Determine if a "What's Changed" section exists in the temporary file + If so, continue to step 4. Otherwise, skip to step 5. + 4. **Prepend** the processed "What's Changed" list from the temporary file to the existing "What's Changed" list in `latest.md`. Do not change or replace the existing list, **only add** to the beginning of it. - 4. In the "Full Changelog", edit **only** the end of the URL. Identify the + 5. In the "Full Changelog", edit **only** the end of the URL. Identify the last part of the URL that looks like `...{previous_version}` and update it to be `...{version}`. @@ -133,10 +144,12 @@ detailed **highlights** section for the release-specific page. `# Preview release: {{version}}` 2. Update the rease date. The line should read, `Released: {{release_date_month_dd_yyyy}}` - 3. **Prepend** the processed "What's Changed" list from the temporary file + 3. Determine if a "What's Changed" section exists in the temporary file + If so, continue to step 4. Otherwise, skip to step 5. + 4. **Prepend** the processed "What's Changed" list from the temporary file to the existing "What's Changed" list in `preview.md`. Do not change or replace the existing list, **only add** to the beginning of it. - 4. In the "Full Changelog", edit **only** the end of the URL. Identify the + 5. In the "Full Changelog", edit **only** the end of the URL. Identify the last part of the URL that looks like `...{previous_version}` and update it to be `...{version}`. @@ -149,5 +162,5 @@ detailed **highlights** section for the release-specific page. ## Finalize -- After making changes, run `npm run format` to ensure consistency. +- After making changes, run `npm run format` ONLY to ensure consistency. - Delete any temporary files created during the process. diff --git a/.github/actions/create-pull-request/action.yml b/.github/actions/create-pull-request/action.yml index 6a6b6dbf03..fa38bd58ab 100644 --- a/.github/actions/create-pull-request/action.yml +++ b/.github/actions/create-pull-request/action.yml @@ -39,18 +39,22 @@ runs: if: "inputs.dry-run != 'true'" env: GH_TOKEN: '${{ inputs.github-token }}' + INPUTS_BRANCH_NAME: '${{ inputs.branch-name }}' + INPUTS_PR_TITLE: '${{ inputs.pr-title }}' + INPUTS_PR_BODY: '${{ inputs.pr-body }}' + INPUTS_BASE_BRANCH: '${{ inputs.base-branch }}' shell: 'bash' working-directory: '${{ inputs.working-directory }}' run: | set -e - if ! git ls-remote --exit-code --heads origin "${{ inputs.branch-name }}"; then - echo "::error::Branch '${{ inputs.branch-name }}' does not exist on the remote repository." + if ! git ls-remote --exit-code --heads origin "${INPUTS_BRANCH_NAME}"; then + echo "::error::Branch '${INPUTS_BRANCH_NAME}' does not exist on the remote repository." exit 1 fi PR_URL=$(gh pr create \ - --title "${{ inputs.pr-title }}" \ - --body "${{ inputs.pr-body }}" \ - --base "${{ inputs.base-branch }}" \ - --head "${{ inputs.branch-name }}" \ + --title "${INPUTS_PR_TITLE}" \ + --body "${INPUTS_PR_BODY}" \ + --base "${INPUTS_BASE_BRANCH}" \ + --head "${INPUTS_BRANCH_NAME}" \ --fill) gh pr merge "$PR_URL" --auto diff --git a/.github/actions/npm-auth-token/action.yml b/.github/actions/npm-auth-token/action.yml index 94249d6c51..f9fe4bd894 100644 --- a/.github/actions/npm-auth-token/action.yml +++ b/.github/actions/npm-auth-token/action.yml @@ -30,16 +30,22 @@ runs: id: 'npm_auth_token' shell: 'bash' run: | - AUTH_TOKEN="${{ inputs.github-token }}" - PACKAGE_NAME="${{ inputs.package-name }}" + AUTH_TOKEN="${INPUTS_GITHUB_TOKEN}" + PACKAGE_NAME="${INPUTS_PACKAGE_NAME}" PRIVATE_REPO="@google-gemini/" if [[ "$PACKAGE_NAME" == "$PRIVATE_REPO"* ]]; then - AUTH_TOKEN="${{ inputs.github-token }}" + AUTH_TOKEN="${INPUTS_GITHUB_TOKEN}" elif [[ "$PACKAGE_NAME" == "@google/gemini-cli" ]]; then - AUTH_TOKEN="${{ inputs.wombat-token-cli }}" + AUTH_TOKEN="${INPUTS_WOMBAT_TOKEN_CLI}" elif [[ "$PACKAGE_NAME" == "@google/gemini-cli-core" ]]; then - AUTH_TOKEN="${{ inputs.wombat-token-core }}" + AUTH_TOKEN="${INPUTS_WOMBAT_TOKEN_CORE}" elif [[ "$PACKAGE_NAME" == "@google/gemini-cli-a2a-server" ]]; then - AUTH_TOKEN="${{ inputs.wombat-token-a2a-server }}" + AUTH_TOKEN="${INPUTS_WOMBAT_TOKEN_A2A_SERVER}" fi echo "auth-token=$AUTH_TOKEN" >> $GITHUB_OUTPUT + env: + INPUTS_GITHUB_TOKEN: '${{ inputs.github-token }}' + INPUTS_PACKAGE_NAME: '${{ inputs.package-name }}' + INPUTS_WOMBAT_TOKEN_CLI: '${{ inputs.wombat-token-cli }}' + INPUTS_WOMBAT_TOKEN_CORE: '${{ inputs.wombat-token-core }}' + INPUTS_WOMBAT_TOKEN_A2A_SERVER: '${{ inputs.wombat-token-a2a-server }}' diff --git a/.github/actions/publish-release/action.yml b/.github/actions/publish-release/action.yml index 5c74524ddb..8f062205cb 100644 --- a/.github/actions/publish-release/action.yml +++ b/.github/actions/publish-release/action.yml @@ -93,15 +93,19 @@ runs: id: 'release_branch' shell: 'bash' run: | - BRANCH_NAME="release/${{ inputs.release-tag }}" + BRANCH_NAME="release/${INPUTS_RELEASE_TAG}" git switch -c "${BRANCH_NAME}" echo "BRANCH_NAME=${BRANCH_NAME}" >> "${GITHUB_OUTPUT}" + env: + INPUTS_RELEASE_TAG: '${{ inputs.release-tag }}' - name: '⬆️ Update package versions' working-directory: '${{ inputs.working-directory }}' shell: 'bash' run: | - npm run release:version "${{ inputs.release-version }}" + npm run release:version "${INPUTS_RELEASE_VERSION}" + env: + INPUTS_RELEASE_VERSION: '${{ inputs.release-version }}' - name: '💾 Commit and Conditionally Push package versions' working-directory: '${{ inputs.working-directory }}' @@ -163,23 +167,30 @@ runs: working-directory: '${{ inputs.working-directory }}' env: NODE_AUTH_TOKEN: '${{ steps.core-token.outputs.auth-token }}' + INPUTS_DRY_RUN: '${{ inputs.dry-run }}' + INPUTS_CORE_PACKAGE_NAME: '${{ inputs.core-package-name }}' shell: 'bash' run: | npm publish \ - --dry-run="${{ inputs.dry-run }}" \ - --workspace="${{ inputs.core-package-name }}" \ + --dry-run="${INPUTS_DRY_RUN}" \ + --workspace="${INPUTS_CORE_PACKAGE_NAME}" \ --no-tag - npm dist-tag rm ${{ inputs.core-package-name }} false --silent + npm dist-tag rm ${INPUTS_CORE_PACKAGE_NAME} false --silent - name: '🔗 Install latest core package' working-directory: '${{ inputs.working-directory }}' if: "${{ inputs.dry-run != 'true' }}" shell: 'bash' run: | - npm install "${{ inputs.core-package-name }}@${{ inputs.release-version }}" \ - --workspace="${{ inputs.cli-package-name }}" \ - --workspace="${{ inputs.a2a-package-name }}" \ + npm install "${INPUTS_CORE_PACKAGE_NAME}@${INPUTS_RELEASE_VERSION}" \ + --workspace="${INPUTS_CLI_PACKAGE_NAME}" \ + --workspace="${INPUTS_A2A_PACKAGE_NAME}" \ --save-exact + env: + INPUTS_CORE_PACKAGE_NAME: '${{ inputs.core-package-name }}' + INPUTS_RELEASE_VERSION: '${{ inputs.release-version }}' + INPUTS_CLI_PACKAGE_NAME: '${{ inputs.cli-package-name }}' + INPUTS_A2A_PACKAGE_NAME: '${{ inputs.a2a-package-name }}' - name: 'Get CLI Token' uses: './.github/actions/npm-auth-token' @@ -195,13 +206,15 @@ runs: working-directory: '${{ inputs.working-directory }}' env: NODE_AUTH_TOKEN: '${{ steps.cli-token.outputs.auth-token }}' + INPUTS_DRY_RUN: '${{ inputs.dry-run }}' + INPUTS_CLI_PACKAGE_NAME: '${{ inputs.cli-package-name }}' shell: 'bash' run: | npm publish \ - --dry-run="${{ inputs.dry-run }}" \ - --workspace="${{ inputs.cli-package-name }}" \ + --dry-run="${INPUTS_DRY_RUN}" \ + --workspace="${INPUTS_CLI_PACKAGE_NAME}" \ --no-tag - npm dist-tag rm ${{ inputs.cli-package-name }} false --silent + npm dist-tag rm ${INPUTS_CLI_PACKAGE_NAME} false --silent - name: 'Get a2a-server Token' uses: './.github/actions/npm-auth-token' @@ -217,14 +230,16 @@ runs: working-directory: '${{ inputs.working-directory }}' env: NODE_AUTH_TOKEN: '${{ steps.a2a-token.outputs.auth-token }}' + INPUTS_DRY_RUN: '${{ inputs.dry-run }}' + INPUTS_A2A_PACKAGE_NAME: '${{ inputs.a2a-package-name }}' shell: 'bash' # Tag staging for initial release run: | npm publish \ - --dry-run="${{ inputs.dry-run }}" \ - --workspace="${{ inputs.a2a-package-name }}" \ + --dry-run="${INPUTS_DRY_RUN}" \ + --workspace="${INPUTS_A2A_PACKAGE_NAME}" \ --no-tag - npm dist-tag rm ${{ inputs.a2a-package-name }} false --silent + npm dist-tag rm ${INPUTS_A2A_PACKAGE_NAME} false --silent - name: '🔬 Verify NPM release by version' uses: './.github/actions/verify-release' @@ -258,13 +273,16 @@ runs: if: "${{ inputs.dry-run != 'true' && inputs.skip-github-release != 'true' && inputs.npm-tag != 'dev' && inputs.npm-registry-url != 'https://npm.pkg.github.com/' }}" env: GITHUB_TOKEN: '${{ inputs.github-release-token || inputs.github-token }}' + INPUTS_RELEASE_TAG: '${{ inputs.release-tag }}' + STEPS_RELEASE_BRANCH_OUTPUTS_BRANCH_NAME: '${{ steps.release_branch.outputs.BRANCH_NAME }}' + INPUTS_PREVIOUS_TAG: '${{ inputs.previous-tag }}' shell: 'bash' run: | - gh release create "${{ inputs.release-tag }}" \ + gh release create "${INPUTS_RELEASE_TAG}" \ bundle/gemini.js \ - --target "${{ steps.release_branch.outputs.BRANCH_NAME }}" \ - --title "Release ${{ inputs.release-tag }}" \ - --notes-start-tag "${{ inputs.previous-tag }}" \ + --target "${STEPS_RELEASE_BRANCH_OUTPUTS_BRANCH_NAME}" \ + --title "Release ${INPUTS_RELEASE_TAG}" \ + --notes-start-tag "${INPUTS_PREVIOUS_TAG}" \ --generate-notes \ ${{ inputs.npm-tag != 'latest' && '--prerelease' || '' }} @@ -274,5 +292,8 @@ runs: continue-on-error: true shell: 'bash' run: | - echo "Cleaning up release branch ${{ steps.release_branch.outputs.BRANCH_NAME }}..." - git push origin --delete "${{ steps.release_branch.outputs.BRANCH_NAME }}" + echo "Cleaning up release branch ${STEPS_RELEASE_BRANCH_OUTPUTS_BRANCH_NAME}..." + git push origin --delete "${STEPS_RELEASE_BRANCH_OUTPUTS_BRANCH_NAME}" + + env: + STEPS_RELEASE_BRANCH_OUTPUTS_BRANCH_NAME: '${{ steps.release_branch.outputs.BRANCH_NAME }}' diff --git a/.github/actions/push-docker/action.yml b/.github/actions/push-docker/action.yml index 5016d76821..e660733428 100644 --- a/.github/actions/push-docker/action.yml +++ b/.github/actions/push-docker/action.yml @@ -52,8 +52,10 @@ runs: id: 'branch_name' shell: 'bash' run: | - REF_NAME="${{ inputs.ref-name }}" + REF_NAME="${INPUTS_REF_NAME}" echo "name=${REF_NAME%/merge}" >> $GITHUB_OUTPUT + env: + INPUTS_REF_NAME: '${{ inputs.ref-name }}' - name: 'Build and Push the Docker Image' uses: 'docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83' # ratchet:docker/build-push-action@v6 with: diff --git a/.github/actions/push-sandbox/action.yml b/.github/actions/push-sandbox/action.yml index db75ce10cd..e2d1ac942c 100644 --- a/.github/actions/push-sandbox/action.yml +++ b/.github/actions/push-sandbox/action.yml @@ -56,8 +56,8 @@ runs: id: 'image_tag' shell: 'bash' run: |- - SHELL_TAG_NAME="${{ inputs.github-ref-name }}" - FINAL_TAG="${{ inputs.github-sha }}" + SHELL_TAG_NAME="${INPUTS_GITHUB_REF_NAME}" + FINAL_TAG="${INPUTS_GITHUB_SHA}" if [[ "$SHELL_TAG_NAME" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.-]+)?$ ]]; then echo "Release detected." FINAL_TAG="${SHELL_TAG_NAME#v}" @@ -66,15 +66,19 @@ runs: fi echo "Determined image tag: $FINAL_TAG" echo "FINAL_TAG=$FINAL_TAG" >> $GITHUB_OUTPUT + env: + INPUTS_GITHUB_REF_NAME: '${{ inputs.github-ref-name }}' + INPUTS_GITHUB_SHA: '${{ inputs.github-sha }}' - name: 'build' id: 'docker_build' shell: 'bash' env: GEMINI_SANDBOX_IMAGE_TAG: '${{ steps.image_tag.outputs.FINAL_TAG }}' GEMINI_SANDBOX: 'docker' + STEPS_IMAGE_TAG_OUTPUTS_FINAL_TAG: '${{ steps.image_tag.outputs.FINAL_TAG }}' run: |- npm run build:sandbox -- \ - --image google/gemini-cli-sandbox:${{ steps.image_tag.outputs.FINAL_TAG }} \ + --image google/gemini-cli-sandbox:${STEPS_IMAGE_TAG_OUTPUTS_FINAL_TAG} \ --output-file final_image_uri.txt echo "uri=$(cat final_image_uri.txt)" >> $GITHUB_OUTPUT - name: 'verify' @@ -89,7 +93,9 @@ runs: shell: 'bash' if: "${{ inputs.dry-run != 'true' }}" run: |- - docker push "${{ steps.docker_build.outputs.uri }}" + docker push "${STEPS_DOCKER_BUILD_OUTPUTS_URI}" + env: + STEPS_DOCKER_BUILD_OUTPUTS_URI: '${{ steps.docker_build.outputs.uri }}' - name: 'Create issue on failure' if: |- ${{ failure() }} diff --git a/.github/actions/setup-npmrc/action.yml b/.github/actions/setup-npmrc/action.yml index fba0c14712..137451740f 100644 --- a/.github/actions/setup-npmrc/action.yml +++ b/.github/actions/setup-npmrc/action.yml @@ -18,5 +18,7 @@ runs: shell: 'bash' run: |- echo ""@google-gemini:registry=https://npm.pkg.github.com"" > ~/.npmrc - echo ""//npm.pkg.github.com/:_authToken=${{ inputs.github-token }}"" >> ~/.npmrc + echo ""//npm.pkg.github.com/:_authToken=${INPUTS_GITHUB_TOKEN}"" >> ~/.npmrc echo ""@google:registry=https://wombat-dressing-room.appspot.com"" >> ~/.npmrc + env: + INPUTS_GITHUB_TOKEN: '${{ inputs.github-token }}' diff --git a/.github/actions/tag-npm-release/action.yml b/.github/actions/tag-npm-release/action.yml index 7bcafcb6b2..085cf15e99 100644 --- a/.github/actions/tag-npm-release/action.yml +++ b/.github/actions/tag-npm-release/action.yml @@ -71,10 +71,13 @@ runs: ${{ inputs.dry-run != 'true' }} env: NODE_AUTH_TOKEN: '${{ steps.core-token.outputs.auth-token }}' + INPUTS_CORE_PACKAGE_NAME: '${{ inputs.core-package-name }}' + INPUTS_VERSION: '${{ inputs.version }}' + INPUTS_CHANNEL: '${{ inputs.channel }}' shell: 'bash' working-directory: '${{ inputs.working-directory }}' run: | - npm dist-tag add ${{ inputs.core-package-name }}@${{ inputs.version }} ${{ inputs.channel }} + npm dist-tag add ${INPUTS_CORE_PACKAGE_NAME}@${INPUTS_VERSION} ${INPUTS_CHANNEL} - name: 'Get cli Token' uses: './.github/actions/npm-auth-token' @@ -91,10 +94,13 @@ runs: ${{ inputs.dry-run != 'true' }} env: NODE_AUTH_TOKEN: '${{ steps.cli-token.outputs.auth-token }}' + INPUTS_CLI_PACKAGE_NAME: '${{ inputs.cli-package-name }}' + INPUTS_VERSION: '${{ inputs.version }}' + INPUTS_CHANNEL: '${{ inputs.channel }}' shell: 'bash' working-directory: '${{ inputs.working-directory }}' run: | - npm dist-tag add ${{ inputs.cli-package-name }}@${{ inputs.version }} ${{ inputs.channel }} + npm dist-tag add ${INPUTS_CLI_PACKAGE_NAME}@${INPUTS_VERSION} ${INPUTS_CHANNEL} - name: 'Get a2a Token' uses: './.github/actions/npm-auth-token' @@ -111,10 +117,13 @@ runs: ${{ inputs.dry-run == 'false' }} env: NODE_AUTH_TOKEN: '${{ steps.a2a-token.outputs.auth-token }}' + INPUTS_A2A_PACKAGE_NAME: '${{ inputs.a2a-package-name }}' + INPUTS_VERSION: '${{ inputs.version }}' + INPUTS_CHANNEL: '${{ inputs.channel }}' shell: 'bash' working-directory: '${{ inputs.working-directory }}' run: | - npm dist-tag add ${{ inputs.a2a-package-name }}@${{ inputs.version }} ${{ inputs.channel }} + npm dist-tag add ${INPUTS_A2A_PACKAGE_NAME}@${INPUTS_VERSION} ${INPUTS_CHANNEL} - name: 'Log dry run' if: |- @@ -122,4 +131,15 @@ runs: shell: 'bash' working-directory: '${{ inputs.working-directory }}' run: | - echo "Dry run: Would have added tag '${{ inputs.channel }}' to version '${{ inputs.version }}' for ${{ inputs.cli-package-name }}, ${{ inputs.core-package-name }}, and ${{ inputs.a2a-package-name }}." + echo "Dry run: Would have added tag '${INPUTS_CHANNEL}' to version '${INPUTS_VERSION}' for ${INPUTS_CLI_PACKAGE_NAME}, ${INPUTS_CORE_PACKAGE_NAME}, and ${INPUTS_A2A_PACKAGE_NAME}." + + env: + INPUTS_CHANNEL: '${{ inputs.channel }}' + + INPUTS_VERSION: '${{ inputs.version }}' + + INPUTS_CLI_PACKAGE_NAME: '${{ inputs.cli-package-name }}' + + INPUTS_CORE_PACKAGE_NAME: '${{ inputs.core-package-name }}' + + INPUTS_A2A_PACKAGE_NAME: '${{ inputs.a2a-package-name }}' diff --git a/.github/actions/verify-release/action.yml b/.github/actions/verify-release/action.yml index 14b595cb10..261715c1b9 100644 --- a/.github/actions/verify-release/action.yml +++ b/.github/actions/verify-release/action.yml @@ -64,10 +64,13 @@ runs: working-directory: '${{ inputs.working-directory }}' run: |- gemini_version=$(gemini --version) - if [ "$gemini_version" != "${{ inputs.expected-version }}" ]; then - echo "❌ NPM Version mismatch: Got $gemini_version from ${{ inputs.npm-package }}, expected ${{ inputs.expected-version }}" + if [ "$gemini_version" != "${INPUTS_EXPECTED_VERSION}" ]; then + echo "❌ NPM Version mismatch: Got $gemini_version from ${INPUTS_NPM_PACKAGE}, expected ${INPUTS_EXPECTED_VERSION}" exit 1 fi + env: + INPUTS_EXPECTED_VERSION: '${{ inputs.expected-version }}' + INPUTS_NPM_PACKAGE: '${{ inputs.npm-package }}' - name: 'Clear npm cache' shell: 'bash' @@ -77,11 +80,14 @@ runs: shell: 'bash' working-directory: '${{ inputs.working-directory }}' run: |- - gemini_version=$(npx --prefer-online "${{ inputs.npm-package}}" --version) - if [ "$gemini_version" != "${{ inputs.expected-version }}" ]; then - echo "❌ NPX Run Version mismatch: Got $gemini_version from ${{ inputs.npm-package }}, expected ${{ inputs.expected-version }}" + gemini_version=$(npx --prefer-online "${INPUTS_NPM_PACKAGE}" --version) + if [ "$gemini_version" != "${INPUTS_EXPECTED_VERSION}" ]; then + echo "❌ NPX Run Version mismatch: Got $gemini_version from ${INPUTS_NPM_PACKAGE}, expected ${INPUTS_EXPECTED_VERSION}" exit 1 fi + env: + INPUTS_NPM_PACKAGE: '${{ inputs.npm-package }}' + INPUTS_EXPECTED_VERSION: '${{ inputs.expected-version }}' - name: 'Install dependencies for integration tests' shell: 'bash' diff --git a/.github/workflows/chained_e2e.yml b/.github/workflows/chained_e2e.yml index 4b37d0e109..05b1fb0f1d 100644 --- a/.github/workflows/chained_e2e.yml +++ b/.github/workflows/chained_e2e.yml @@ -53,7 +53,7 @@ jobs: REPO_NAME: '${{ github.event.inputs.repo_name }}' run: | mkdir -p ./pr - echo '${{ env.REPO_NAME }}' > ./pr/repo_name + echo "${REPO_NAME}" > ./pr/repo_name - uses: 'actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02' # ratchet:actions/upload-artifact@v4 with: name: 'repo_name' @@ -320,14 +320,19 @@ jobs: steps: - name: 'Check E2E test results' run: | - if [[ ${{ needs.e2e_linux.result }} != 'success' || \ - ${{ needs.e2e_mac.result }} != 'success' || \ - ${{ needs.e2e_windows.result }} != 'success' || \ - ${{ needs.evals.result }} != 'success' ]]; then + if [[ ${NEEDS_E2E_LINUX_RESULT} != 'success' || \ + ${NEEDS_E2E_MAC_RESULT} != 'success' || \ + ${NEEDS_E2E_WINDOWS_RESULT} != 'success' || \ + ${NEEDS_EVALS_RESULT} != 'success' ]]; then echo "One or more E2E jobs failed." exit 1 fi echo "All required E2E jobs passed!" + env: + NEEDS_E2E_LINUX_RESULT: '${{ needs.e2e_linux.result }}' + NEEDS_E2E_MAC_RESULT: '${{ needs.e2e_mac.result }}' + NEEDS_E2E_WINDOWS_RESULT: '${{ needs.e2e_windows.result }}' + NEEDS_EVALS_RESULT: '${{ needs.evals.result }}' set_workflow_status: runs-on: 'gemini-cli-ubuntu-16-core' diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dd7288cde5..999eb778c4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -464,14 +464,22 @@ jobs: steps: - name: 'Check all job results' run: | - if [[ (${{ needs.lint.result }} != 'success' && ${{ needs.lint.result }} != 'skipped') || \ - (${{ needs.link_checker.result }} != 'success' && ${{ needs.link_checker.result }} != 'skipped') || \ - (${{ needs.test_linux.result }} != 'success' && ${{ needs.test_linux.result }} != 'skipped') || \ - (${{ needs.test_mac.result }} != 'success' && ${{ needs.test_mac.result }} != 'skipped') || \ - (${{ needs.test_windows.result }} != 'success' && ${{ needs.test_windows.result }} != 'skipped') || \ - (${{ needs.codeql.result }} != 'success' && ${{ needs.codeql.result }} != 'skipped') || \ - (${{ needs.bundle_size.result }} != 'success' && ${{ needs.bundle_size.result }} != 'skipped') ]]; then + if [[ (${NEEDS_LINT_RESULT} != 'success' && ${NEEDS_LINT_RESULT} != 'skipped') || \ + (${NEEDS_LINK_CHECKER_RESULT} != 'success' && ${NEEDS_LINK_CHECKER_RESULT} != 'skipped') || \ + (${NEEDS_TEST_LINUX_RESULT} != 'success' && ${NEEDS_TEST_LINUX_RESULT} != 'skipped') || \ + (${NEEDS_TEST_MAC_RESULT} != 'success' && ${NEEDS_TEST_MAC_RESULT} != 'skipped') || \ + (${NEEDS_TEST_WINDOWS_RESULT} != 'success' && ${NEEDS_TEST_WINDOWS_RESULT} != 'skipped') || \ + (${NEEDS_CODEQL_RESULT} != 'success' && ${NEEDS_CODEQL_RESULT} != 'skipped') || \ + (${NEEDS_BUNDLE_SIZE_RESULT} != 'success' && ${NEEDS_BUNDLE_SIZE_RESULT} != 'skipped') ]]; then echo "One or more CI jobs failed." exit 1 fi echo "All CI jobs passed!" + env: + NEEDS_LINT_RESULT: '${{ needs.lint.result }}' + NEEDS_LINK_CHECKER_RESULT: '${{ needs.link_checker.result }}' + NEEDS_TEST_LINUX_RESULT: '${{ needs.test_linux.result }}' + NEEDS_TEST_MAC_RESULT: '${{ needs.test_mac.result }}' + NEEDS_TEST_WINDOWS_RESULT: '${{ needs.test_windows.result }}' + NEEDS_CODEQL_RESULT: '${{ needs.codeql.result }}' + NEEDS_BUNDLE_SIZE_RESULT: '${{ needs.bundle_size.result }}' diff --git a/.github/workflows/deflake.yml b/.github/workflows/deflake.yml index c9f4c3d59f..a0eb51a7f4 100644 --- a/.github/workflows/deflake.yml +++ b/.github/workflows/deflake.yml @@ -68,10 +68,10 @@ jobs: VERBOSE: 'true' shell: 'bash' run: | - if [[ "${{ env.IS_DOCKER }}" == "true" ]]; then - npm run deflake:test:integration:sandbox:docker -- --runs="${{ env.RUNS }}" -- --testNamePattern "'${{ env.TEST_NAME_PATTERN }}'" + if [[ "${IS_DOCKER}" == "true" ]]; then + npm run deflake:test:integration:sandbox:docker -- --runs="${RUNS}" -- --testNamePattern "'${TEST_NAME_PATTERN}'" else - npm run deflake:test:integration:sandbox:none -- --runs="${{ env.RUNS }}" -- --testNamePattern "'${{ env.TEST_NAME_PATTERN }}'" + npm run deflake:test:integration:sandbox:none -- --runs="${RUNS}" -- --testNamePattern "'${TEST_NAME_PATTERN}'" fi deflake_e2e_mac: @@ -109,7 +109,7 @@ jobs: TEST_NAME_PATTERN: '${{ github.event.inputs.test_name_pattern }}' VERBOSE: 'true' run: | - npm run deflake:test:integration:sandbox:none -- --runs="${{ env.RUNS }}" -- --testNamePattern "'${{ env.TEST_NAME_PATTERN }}'" + npm run deflake:test:integration:sandbox:none -- --runs="${RUNS}" -- --testNamePattern "'${TEST_NAME_PATTERN}'" deflake_e2e_windows: name: 'Slow E2E - Win' @@ -167,4 +167,4 @@ jobs: TEST_NAME_PATTERN: '${{ github.event.inputs.test_name_pattern }}' shell: 'pwsh' run: | - npm run deflake:test:integration:sandbox:none -- --runs="${{ env.RUNS }}" -- --testNamePattern "'${{ env.TEST_NAME_PATTERN }}'" + npm run deflake:test:integration:sandbox:none -- --runs="$env:RUNS" -- --testNamePattern "'$env:TEST_NAME_PATTERN'" diff --git a/.github/workflows/eval.yml b/.github/workflows/eval.yml index d5616a3419..23dc1cfdfb 100644 --- a/.github/workflows/eval.yml +++ b/.github/workflows/eval.yml @@ -44,5 +44,5 @@ jobs: - name: 'Run evaluation' working-directory: '/app' run: | - poetry run exp_run --experiment-mode=on-demand --branch-or-commit=${{ github.ref_name }} --model-name=gemini-2.5-pro --dataset=swebench_verified --concurrency=15 + poetry run exp_run --experiment-mode=on-demand --branch-or-commit="${GITHUB_REF_NAME}" --model-name=gemini-2.5-pro --dataset=swebench_verified --concurrency=15 poetry run python agent_prototypes/scripts/parse_gcli_logs_experiment.py --experiment_dir=experiments/adhoc/gcli_temp_exp --gcs-bucket="${EVAL_GCS_BUCKET}" --gcs-path=gh_action_artifacts diff --git a/.github/workflows/evals-nightly.yml b/.github/workflows/evals-nightly.yml index 6f6767ebfe..1ed9448c03 100644 --- a/.github/workflows/evals-nightly.yml +++ b/.github/workflows/evals-nightly.yml @@ -62,7 +62,7 @@ jobs: TEST_NAME_PATTERN: '${{ github.event.inputs.test_name_pattern }}' run: | CMD="npm run test:all_evals" - PATTERN="${{ env.TEST_NAME_PATTERN }}" + PATTERN="${TEST_NAME_PATTERN}" if [[ -n "$PATTERN" ]]; then if [[ "$PATTERN" == *.ts || "$PATTERN" == *.js || "$PATTERN" == */* ]]; then diff --git a/.github/workflows/gemini-automated-issue-triage.yml b/.github/workflows/gemini-automated-issue-triage.yml index 9e50f11433..fe4c52292a 100644 --- a/.github/workflows/gemini-automated-issue-triage.yml +++ b/.github/workflows/gemini-automated-issue-triage.yml @@ -158,7 +158,7 @@ jobs: }, "coreTools": [ "run_shell_command(echo)" - ], + ] } prompt: |- ## Role diff --git a/.github/workflows/release-notes.yml b/.github/workflows/release-notes.yml index 08a3625822..8a681dadf6 100644 --- a/.github/workflows/release-notes.yml +++ b/.github/workflows/release-notes.yml @@ -56,7 +56,18 @@ jobs: GH_TOKEN: '${{ secrets.GEMINI_CLI_ROBOT_GITHUB_PAT }}' BODY: '${{ github.event.inputs.body || github.event.release.body }}' + - name: 'Validate version' + id: 'validate_version' + run: | + if echo "${{ steps.release_info.outputs.VERSION }}" | grep -q "nightly"; then + echo "Nightly release detected. Stopping workflow." + echo "CONTINUE=false" >> "$GITHUB_OUTPUT" + else + echo "CONTINUE=true" >> "$GITHUB_OUTPUT" + fi + - name: 'Generate Changelog with Gemini' + if: "steps.validate_version.outputs.CONTINUE == 'true'" uses: 'google-github-actions/run-gemini-cli@a3bf79042542528e91937b3a3a6fbc4967ee3c31' # ratchet:google-github-actions/run-gemini-cli@v0 with: gemini_api_key: '${{ secrets.GEMINI_API_KEY }}' @@ -70,7 +81,10 @@ jobs: Execute the release notes generation process using the information provided. + When you are done, please output your thought process and the steps you took for future debugging purposes. + - name: 'Create Pull Request' + if: "steps.validate_version.outputs.CONTINUE == 'true'" uses: 'peter-evans/create-pull-request@v6' with: token: '${{ secrets.GEMINI_CLI_ROBOT_GITHUB_PAT }}' diff --git a/.github/workflows/release-patch-3-release.yml b/.github/workflows/release-patch-3-release.yml index b0d459f256..6680362a16 100644 --- a/.github/workflows/release-patch-3-release.yml +++ b/.github/workflows/release-patch-3-release.yml @@ -118,6 +118,7 @@ jobs: ORIGINAL_RELEASE_VERSION: '${{ steps.patch_version.outputs.RELEASE_VERSION }}' ORIGINAL_RELEASE_TAG: '${{ steps.patch_version.outputs.RELEASE_TAG }}' ORIGINAL_PREVIOUS_TAG: '${{ steps.patch_version.outputs.PREVIOUS_TAG }}' + VARS_CLI_PACKAGE_NAME: '${{ vars.CLI_PACKAGE_NAME }}' run: | echo "🔍 Verifying no concurrent patch releases have occurred..." @@ -129,7 +130,7 @@ jobs: # Re-run the same version calculation script echo "Re-calculating version to check for changes..." - CURRENT_PATCH_JSON=$(node scripts/get-release-version.js --cli-package-name="${{vars.CLI_PACKAGE_NAME}}" --type=patch --patch-from="${CHANNEL}") + CURRENT_PATCH_JSON=$(node scripts/get-release-version.js --cli-package-name="${VARS_CLI_PACKAGE_NAME}" --type=patch --patch-from="${CHANNEL}") CURRENT_RELEASE_VERSION=$(echo "${CURRENT_PATCH_JSON}" | jq -r .releaseVersion) CURRENT_RELEASE_TAG=$(echo "${CURRENT_PATCH_JSON}" | jq -r .releaseTag) CURRENT_PREVIOUS_TAG=$(echo "${CURRENT_PATCH_JSON}" | jq -r .previousReleaseTag) @@ -162,10 +163,15 @@ jobs: - name: 'Print Calculated Version' run: |- echo "Patch Release Summary:" - echo " Release Version: ${{ steps.patch_version.outputs.RELEASE_VERSION }}" - echo " Release Tag: ${{ steps.patch_version.outputs.RELEASE_TAG }}" - echo " NPM Tag: ${{ steps.patch_version.outputs.NPM_TAG }}" - echo " Previous Tag: ${{ steps.patch_version.outputs.PREVIOUS_TAG }}" + echo " Release Version: ${STEPS_PATCH_VERSION_OUTPUTS_RELEASE_VERSION}" + echo " Release Tag: ${STEPS_PATCH_VERSION_OUTPUTS_RELEASE_TAG}" + echo " NPM Tag: ${STEPS_PATCH_VERSION_OUTPUTS_NPM_TAG}" + echo " Previous Tag: ${STEPS_PATCH_VERSION_OUTPUTS_PREVIOUS_TAG}" + env: + STEPS_PATCH_VERSION_OUTPUTS_RELEASE_VERSION: '${{ steps.patch_version.outputs.RELEASE_VERSION }}' + STEPS_PATCH_VERSION_OUTPUTS_RELEASE_TAG: '${{ steps.patch_version.outputs.RELEASE_TAG }}' + STEPS_PATCH_VERSION_OUTPUTS_NPM_TAG: '${{ steps.patch_version.outputs.NPM_TAG }}' + STEPS_PATCH_VERSION_OUTPUTS_PREVIOUS_TAG: '${{ steps.patch_version.outputs.PREVIOUS_TAG }}' - name: 'Run Tests' if: "${{github.event.inputs.force_skip_tests != 'true'}}" diff --git a/.github/workflows/release-promote.yml b/.github/workflows/release-promote.yml index ebe16b1a39..d5c16b94fe 100644 --- a/.github/workflows/release-promote.yml +++ b/.github/workflows/release-promote.yml @@ -362,23 +362,28 @@ jobs: - name: 'Create and switch to a new branch' id: 'release_branch' run: | - BRANCH_NAME="chore/nightly-version-bump-${{ needs.calculate-versions.outputs.NEXT_NIGHTLY_VERSION }}" + BRANCH_NAME="chore/nightly-version-bump-${NEEDS_CALCULATE_VERSIONS_OUTPUTS_NEXT_NIGHTLY_VERSION}" git switch -c "${BRANCH_NAME}" echo "BRANCH_NAME=${BRANCH_NAME}" >> "${GITHUB_OUTPUT}" + env: + NEEDS_CALCULATE_VERSIONS_OUTPUTS_NEXT_NIGHTLY_VERSION: '${{ needs.calculate-versions.outputs.NEXT_NIGHTLY_VERSION }}' - name: 'Update package versions' - run: 'npm run release:version "${{ needs.calculate-versions.outputs.NEXT_NIGHTLY_VERSION }}"' + run: 'npm run release:version "${NEEDS_CALCULATE_VERSIONS_OUTPUTS_NEXT_NIGHTLY_VERSION}"' + env: + NEEDS_CALCULATE_VERSIONS_OUTPUTS_NEXT_NIGHTLY_VERSION: '${{ needs.calculate-versions.outputs.NEXT_NIGHTLY_VERSION }}' - name: 'Commit and Push package versions' env: BRANCH_NAME: '${{ steps.release_branch.outputs.BRANCH_NAME }}' DRY_RUN: '${{ github.event.inputs.dry_run }}' + NEEDS_CALCULATE_VERSIONS_OUTPUTS_NEXT_NIGHTLY_VERSION: '${{ needs.calculate-versions.outputs.NEXT_NIGHTLY_VERSION }}' run: |- git add package.json packages/*/package.json if [ -f package-lock.json ]; then git add package-lock.json fi - git commit -m "chore(release): bump version to ${{ needs.calculate-versions.outputs.NEXT_NIGHTLY_VERSION }}" + git commit -m "chore(release): bump version to ${NEEDS_CALCULATE_VERSIONS_OUTPUTS_NEXT_NIGHTLY_VERSION}" if [[ "${DRY_RUN}" == "false" ]]; then echo "Pushing release branch to remote..." git push --set-upstream origin "${BRANCH_NAME}" diff --git a/.github/workflows/release-rollback.yml b/.github/workflows/release-rollback.yml index 75c2d0c799..8840b65721 100644 --- a/.github/workflows/release-rollback.yml +++ b/.github/workflows/release-rollback.yml @@ -203,7 +203,7 @@ jobs: run: | ROLLBACK_COMMIT=$(git rev-parse -q --verify "$TARGET_TAG") if [ "$ROLLBACK_COMMIT" != "$TARGET_HASH" ]; then - echo '❌ Failed to add tag $TARGET_TAG to commit $TARGET_HASH' + echo "❌ Failed to add tag ${TARGET_TAG} to commit ${TARGET_HASH}" echo '❌ This means the tag was not added, and the workflow should fail.' exit 1 fi diff --git a/.github/workflows/trigger_e2e.yml b/.github/workflows/trigger_e2e.yml index 52b3a26f6f..babe08e4e3 100644 --- a/.github/workflows/trigger_e2e.yml +++ b/.github/workflows/trigger_e2e.yml @@ -23,8 +23,8 @@ jobs: HEAD_SHA: '${{ github.event.inputs.head_sha || github.event.pull_request.head.sha }}' run: | mkdir -p ./pr - echo '${{ env.REPO_NAME }}' > ./pr/repo_name - echo '${{ env.HEAD_SHA }}' > ./pr/head_sha + echo "${REPO_NAME}" > ./pr/repo_name + echo "${HEAD_SHA}" > ./pr/head_sha - uses: 'actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02' # ratchet:actions/upload-artifact@v4 with: name: 'repo_name' diff --git a/.gitignore b/.gitignore index a2a6553cd3..0438549485 100644 --- a/.gitignore +++ b/.gitignore @@ -61,4 +61,4 @@ gemini-debug.log .genkit .gemini-clipboard/ .eslintcache -evals/logs/ +evals/logs/ \ No newline at end of file diff --git a/docs/changelogs/index.md b/docs/changelogs/index.md index 3cff4c123b..4a20557df7 100644 --- a/docs/changelogs/index.md +++ b/docs/changelogs/index.md @@ -18,6 +18,28 @@ on GitHub. | [Preview](preview.md) | Experimental features ready for early feedback. | | [Stable](latest.md) | Stable, recommended for general use. | +## Announcements: v0.30.0 - 2026-02-25 + +- **SDK & Custom Skills:** Introduced the initial SDK package, enabling dynamic + system instructions, `SessionContext` for SDK tool calls, and support for + custom skills + ([#18861](https://github.com/google-gemini/gemini-cli/pull/18861) by + @mbleigh). +- **Policy Engine Enhancements:** Added a new `--policy` flag for user-defined + policies, introduced strict seatbelt profiles, and deprecated + `--allowed-tools` in favor of the policy engine + ([#18500](https://github.com/google-gemini/gemini-cli/pull/18500) by + @allenhutchison). +- **UI & Themes:** Added a generic searchable list for settings and extensions, + new Solarized themes, text wrapping for markdown tables, and a clean UI toggle + prototype ([#19064](https://github.com/google-gemini/gemini-cli/pull/19064) by + @rmedranollamas). +- **Vim & Terminal Interaction:** Improved Vim support to feel more complete and + added support for Ctrl-Z terminal suspension + ([#18755](https://github.com/google-gemini/gemini-cli/pull/18755) by + @ppgranger, [#18931](https://github.com/google-gemini/gemini-cli/pull/18931) + by @scidomino). + ## Announcements: v0.29.0 - 2026-02-17 - **Plan Mode:** A new comprehensive planning capability with `/plan`, diff --git a/docs/changelogs/latest.md b/docs/changelogs/latest.md index 91d669ba77..8fb3f6aa87 100644 --- a/docs/changelogs/latest.md +++ b/docs/changelogs/latest.md @@ -1,6 +1,6 @@ -# Latest stable release: v0.29.0 +# Latest stable release: v0.30.0 -Released: February 17, 2026 +Released: February 25, 2026 For most users, our latest stable release is the recommended release. Install the latest stable version with: @@ -11,371 +11,323 @@ npm install -g @google/gemini-cli ## Highlights -- **Plan Mode:** Introduce a dedicated "Plan Mode" to help you architect complex - changes before implementation. Use `/plan` to get started. -- **Gemini 3 by Default:** Gemini 3 is now the default model family, bringing - improved performance and reasoning capabilities to all users without needing a - feature flag. -- **Extension Discovery:** Easily discover and install extensions with the new - exploration features and registry client. -- **Enhanced Admin Controls:** New administrative capabilities allow for - allowlisting MCP server configurations, giving organizations more control over - available tools. -- **Sub-agent Improvements:** Sub-agents have been transitioned to a new format - with improved definitions and system prompts for better reliability. +- **SDK & Custom Skills**: Introduced the initial SDK package, dynamic system + instructions, `SessionContext` for SDK tool calls, and support for custom + skills. +- **Policy Engine Enhancements**: Added a `--policy` flag for user-defined + policies, strict seatbelt profiles, and transitioned away from + `--allowed-tools`. +- **UI & Themes**: Introduced a generic searchable list for settings and + extensions, added Solarized Dark and Light themes, text wrapping capabilities + to markdown tables, and a clean UI toggle prototype. +- **Vim Support & Ctrl-Z**: Improved Vim support to provide a more complete + experience and added support for Ctrl-Z suspension. +- **Plan Mode & Tools**: Plan Mode now supports project exploration without + planning and skills can be enabled in plan mode. Tool output masking is + enabled by default, and core tool definitions have been centralized. ## What's Changed -- fix: remove `ask_user` tool from non-interactive modes by @jackwotherspoon in - [#18154](https://github.com/google-gemini/gemini-cli/pull/18154) -- fix(cli): allow restricted .env loading in untrusted sandboxed folders by - @galz10 in [#17806](https://github.com/google-gemini/gemini-cli/pull/17806) -- Encourage agent to utilize ecosystem tools to perform work by @gundermanc in - [#17881](https://github.com/google-gemini/gemini-cli/pull/17881) -- feat(plan): unify workflow location in system prompt to optimize caching by - @jerop in [#18258](https://github.com/google-gemini/gemini-cli/pull/18258) -- feat(core): enable getUserTierName in config by @sehoon38 in - [#18265](https://github.com/google-gemini/gemini-cli/pull/18265) -- feat(core): add default execution limits for subagents by @abhipatel12 in - [#18274](https://github.com/google-gemini/gemini-cli/pull/18274) -- Fix issue where agent gets stuck at interactive commands. by @gundermanc in - [#18272](https://github.com/google-gemini/gemini-cli/pull/18272) -- chore(release): bump version to 0.29.0-nightly.20260203.71f46f116 by +- feat(ux): added text wrapping capabilities to markdown tables by @devr0306 in + [#18240](https://github.com/google-gemini/gemini-cli/pull/18240) +- Revert "fix(mcp): ensure MCP transport is closed to prevent memory leaks" by + @skeshive in [#18771](https://github.com/google-gemini/gemini-cli/pull/18771) +- chore(release): bump version to 0.30.0-nightly.20260210.a2174751d by @gemini-cli-robot in - [#18243](https://github.com/google-gemini/gemini-cli/pull/18243) -- feat(core): remove hardcoded policy bypass for local subagents by @abhipatel12 - in [#18153](https://github.com/google-gemini/gemini-cli/pull/18153) -- feat(plan): implement `plan` slash command by @Adib234 in - [#17698](https://github.com/google-gemini/gemini-cli/pull/17698) -- feat: increase `ask_user` label limit to 16 characters by @jackwotherspoon in - [#18320](https://github.com/google-gemini/gemini-cli/pull/18320) -- Add information about the agent skills lifecycle and clarify docs-writer skill - metadata. by @g-samroberts in - [#18234](https://github.com/google-gemini/gemini-cli/pull/18234) -- feat(core): add `enter_plan_mode` tool by @jerop in - [#18324](https://github.com/google-gemini/gemini-cli/pull/18324) -- Stop showing an error message in `/plan` by @Adib234 in - [#18333](https://github.com/google-gemini/gemini-cli/pull/18333) -- fix(hooks): remove unnecessary logging for hook registration by @abhipatel12 - in [#18332](https://github.com/google-gemini/gemini-cli/pull/18332) -- fix(mcp): ensure MCP transport is closed to prevent memory leaks by - @cbcoutinho in - [#18054](https://github.com/google-gemini/gemini-cli/pull/18054) -- feat(skills): implement linking for agent skills by @MushuEE in - [#18295](https://github.com/google-gemini/gemini-cli/pull/18295) -- Changelogs for 0.27.0 and 0.28.0-preview0 by @g-samroberts in - [#18336](https://github.com/google-gemini/gemini-cli/pull/18336) -- chore: correct docs as skills and hooks are stable by @jackwotherspoon in - [#18358](https://github.com/google-gemini/gemini-cli/pull/18358) -- feat(admin): Implement admin allowlist for MCP server configurations by - @skeshive in [#18311](https://github.com/google-gemini/gemini-cli/pull/18311) -- fix(core): add retry logic for transient SSL/TLS errors (#17318) by @ppgranger - in [#18310](https://github.com/google-gemini/gemini-cli/pull/18310) -- Add support for /extensions config command by @chrstnb in - [#17895](https://github.com/google-gemini/gemini-cli/pull/17895) -- fix(core): handle non-compliant mcpbridge responses from Xcode 26.3 by - @peterfriese in - [#18376](https://github.com/google-gemini/gemini-cli/pull/18376) -- feat(cli): Add W, B, E Vim motions and operator support by @ademuri in - [#16209](https://github.com/google-gemini/gemini-cli/pull/16209) -- fix: Windows Specific Agent Quality & System Prompt by @scidomino in - [#18351](https://github.com/google-gemini/gemini-cli/pull/18351) -- feat(plan): support `replace` tool in plan mode to edit plans by @jerop in - [#18379](https://github.com/google-gemini/gemini-cli/pull/18379) -- Improving memory tool instructions and eval testing by @alisa-alisa in - [#18091](https://github.com/google-gemini/gemini-cli/pull/18091) -- fix(cli): color extension link success message green by @MushuEE in - [#18386](https://github.com/google-gemini/gemini-cli/pull/18386) -- undo by @jacob314 in - [#18147](https://github.com/google-gemini/gemini-cli/pull/18147) -- feat(plan): add guidance on iterating on approved plans vs creating new plans - by @jerop in [#18346](https://github.com/google-gemini/gemini-cli/pull/18346) -- feat(plan): fix invalid tool calls in plan mode by @Adib234 in - [#18352](https://github.com/google-gemini/gemini-cli/pull/18352) -- feat(plan): integrate planning artifacts and tools into primary workflows by - @jerop in [#18375](https://github.com/google-gemini/gemini-cli/pull/18375) -- Fix permission check by @scidomino in - [#18395](https://github.com/google-gemini/gemini-cli/pull/18395) -- ux(polish) autocomplete in the input prompt by @jacob314 in - [#18181](https://github.com/google-gemini/gemini-cli/pull/18181) -- fix: resolve infinite loop when using 'Modify with external editor' by - @ppgranger in [#17453](https://github.com/google-gemini/gemini-cli/pull/17453) -- feat: expand verify-release to macOS and Windows by @yunaseoul in - [#18145](https://github.com/google-gemini/gemini-cli/pull/18145) -- feat(plan): implement support for MCP servers in Plan mode by @Adib234 in - [#18229](https://github.com/google-gemini/gemini-cli/pull/18229) -- chore: update folder trust error messaging by @galz10 in - [#18402](https://github.com/google-gemini/gemini-cli/pull/18402) -- feat(plan): create a metric for execution of plans generated in plan mode by - @Adib234 in [#18236](https://github.com/google-gemini/gemini-cli/pull/18236) -- perf(ui): optimize stripUnsafeCharacters with regex by @gsquared94 in - [#18413](https://github.com/google-gemini/gemini-cli/pull/18413) -- feat(context): implement observation masking for tool outputs by @abhipatel12 - in [#18389](https://github.com/google-gemini/gemini-cli/pull/18389) -- feat(core,cli): implement session-linked tool output storage and cleanup by - @abhipatel12 in - [#18416](https://github.com/google-gemini/gemini-cli/pull/18416) -- Shorten temp directory by @joshualitt in - [#17901](https://github.com/google-gemini/gemini-cli/pull/17901) -- feat(plan): add behavioral evals for plan mode by @jerop in - [#18437](https://github.com/google-gemini/gemini-cli/pull/18437) -- Add extension registry client by @chrstnb in - [#18396](https://github.com/google-gemini/gemini-cli/pull/18396) -- Enable extension config by default by @chrstnb in - [#18447](https://github.com/google-gemini/gemini-cli/pull/18447) -- Automatically generate change logs on release by @g-samroberts in - [#18401](https://github.com/google-gemini/gemini-cli/pull/18401) -- Remove previewFeatures and default to Gemini 3 by @sehoon38 in - [#18414](https://github.com/google-gemini/gemini-cli/pull/18414) -- feat(admin): apply MCP allowlist to extensions & gemini mcp list command by - @skeshive in [#18442](https://github.com/google-gemini/gemini-cli/pull/18442) -- fix(cli): improve focus navigation for interactive and background shells by - @galz10 in [#18343](https://github.com/google-gemini/gemini-cli/pull/18343) -- Add shortcuts hint and panel for discoverability by @LyalinDotCom in - [#18035](https://github.com/google-gemini/gemini-cli/pull/18035) -- fix(config): treat system settings as read-only during migration and warn user - by @spencer426 in - [#18277](https://github.com/google-gemini/gemini-cli/pull/18277) -- feat(plan): add positive test case and update eval stability policy by @jerop - in [#18457](https://github.com/google-gemini/gemini-cli/pull/18457) -- fix- windows: add shell: true for spawnSync to fix EINVAL with .cmd editors by - @zackoch in [#18408](https://github.com/google-gemini/gemini-cli/pull/18408) -- bug(core): Fix bug when saving plans. by @joshualitt in - [#18465](https://github.com/google-gemini/gemini-cli/pull/18465) -- Refactor atCommandProcessor by @scidomino in - [#18461](https://github.com/google-gemini/gemini-cli/pull/18461) -- feat(core): implement persistence and resumption for masked tool outputs by - @abhipatel12 in - [#18451](https://github.com/google-gemini/gemini-cli/pull/18451) -- refactor: simplify tool output truncation to single config by @SandyTao520 in - [#18446](https://github.com/google-gemini/gemini-cli/pull/18446) -- bug(core): Ensure storage is initialized early, even if config is not. by - @joshualitt in - [#18471](https://github.com/google-gemini/gemini-cli/pull/18471) -- chore: Update build-and-start script to support argument forwarding by - @Abhijit-2592 in - [#18241](https://github.com/google-gemini/gemini-cli/pull/18241) -- fix(core): prevent subagent bypass in plan mode by @jerop in - [#18484](https://github.com/google-gemini/gemini-cli/pull/18484) -- feat(cli): add WebSocket-based network logging and streaming chunk support by - @SandyTao520 in - [#18383](https://github.com/google-gemini/gemini-cli/pull/18383) -- feat(cli): update approval modes UI by @jerop in - [#18476](https://github.com/google-gemini/gemini-cli/pull/18476) -- fix(cli): reload skills and agents on extension restart by @NTaylorMullen in - [#18411](https://github.com/google-gemini/gemini-cli/pull/18411) -- fix(core): expand excludeTools with legacy aliases for renamed tools by - @SandyTao520 in - [#18498](https://github.com/google-gemini/gemini-cli/pull/18498) -- feat(core): overhaul system prompt for rigor, integrity, and intent alignment - by @NTaylorMullen in - [#17263](https://github.com/google-gemini/gemini-cli/pull/17263) -- Patch for generate changelog docs yaml file by @g-samroberts in - [#18496](https://github.com/google-gemini/gemini-cli/pull/18496) -- Code review fixes for show question mark pr. by @jacob314 in - [#18480](https://github.com/google-gemini/gemini-cli/pull/18480) -- fix(cli): add SS3 Shift+Tab support for Windows terminals by @ThanhNguyxn in - [#18187](https://github.com/google-gemini/gemini-cli/pull/18187) -- chore: remove redundant planning prompt from final shell by @jerop in - [#18528](https://github.com/google-gemini/gemini-cli/pull/18528) -- docs: require pr-creator skill for PR generation by @NTaylorMullen in - [#18536](https://github.com/google-gemini/gemini-cli/pull/18536) -- chore: update colors for ask_user dialog by @jackwotherspoon in - [#18543](https://github.com/google-gemini/gemini-cli/pull/18543) -- feat(core): exempt high-signal tools from output masking by @abhipatel12 in - [#18545](https://github.com/google-gemini/gemini-cli/pull/18545) -- refactor(core): remove memory tool instructions from Gemini 3 prompt by - @NTaylorMullen in - [#18559](https://github.com/google-gemini/gemini-cli/pull/18559) -- chore: remove feedback instruction from system prompt by @NTaylorMullen in - [#18560](https://github.com/google-gemini/gemini-cli/pull/18560) -- feat(context): add remote configuration for tool output masking thresholds by - @abhipatel12 in - [#18553](https://github.com/google-gemini/gemini-cli/pull/18553) -- feat(core): pause agent timeout budget while waiting for tool confirmation by - @abhipatel12 in - [#18415](https://github.com/google-gemini/gemini-cli/pull/18415) -- refactor(config): remove experimental.enableEventDrivenScheduler setting by - @abhipatel12 in - [#17924](https://github.com/google-gemini/gemini-cli/pull/17924) -- feat(cli): truncate shell output in UI history and improve active shell - display by @jwhelangoog in - [#17438](https://github.com/google-gemini/gemini-cli/pull/17438) -- refactor(cli): switch useToolScheduler to event-driven engine by @abhipatel12 - in [#18565](https://github.com/google-gemini/gemini-cli/pull/18565) -- fix(core): correct escaped interpolation in system prompt by @NTaylorMullen in - [#18557](https://github.com/google-gemini/gemini-cli/pull/18557) -- propagate abortSignal by @scidomino in - [#18477](https://github.com/google-gemini/gemini-cli/pull/18477) -- feat(core): conditionally include ctrl+f prompt based on interactive shell - setting by @NTaylorMullen in - [#18561](https://github.com/google-gemini/gemini-cli/pull/18561) -- fix(core): ensure `enter_plan_mode` tool registration respects - `experimental.plan` by @jerop in - [#18587](https://github.com/google-gemini/gemini-cli/pull/18587) -- feat(core): transition sub-agents to XML format and improve definitions by - @NTaylorMullen in - [#18555](https://github.com/google-gemini/gemini-cli/pull/18555) -- docs: Add Plan Mode documentation by @jerop in - [#18582](https://github.com/google-gemini/gemini-cli/pull/18582) -- chore: strengthen validation guidance in system prompt by @NTaylorMullen in - [#18544](https://github.com/google-gemini/gemini-cli/pull/18544) -- Fix newline insertion bug in replace tool by @werdnum in - [#18595](https://github.com/google-gemini/gemini-cli/pull/18595) -- fix(evals): update save_memory evals and simplify tool description by - @NTaylorMullen in - [#18610](https://github.com/google-gemini/gemini-cli/pull/18610) -- chore(evals): update validation_fidelity_pre_existing_errors to USUALLY_PASSES - by @NTaylorMullen in - [#18617](https://github.com/google-gemini/gemini-cli/pull/18617) -- fix: shorten tool call IDs and fix duplicate tool name in truncated output - filenames by @SandyTao520 in - [#18600](https://github.com/google-gemini/gemini-cli/pull/18600) -- feat(cli): implement atomic writes and safety checks for trusted folders by - @galz10 in [#18406](https://github.com/google-gemini/gemini-cli/pull/18406) -- Remove relative docs links by @chrstnb in - [#18650](https://github.com/google-gemini/gemini-cli/pull/18650) -- docs: add legacy snippets convention to GEMINI.md by @NTaylorMullen in - [#18597](https://github.com/google-gemini/gemini-cli/pull/18597) -- fix(chore): Support linting for cjs by @aswinashok44 in - [#18639](https://github.com/google-gemini/gemini-cli/pull/18639) -- feat: move shell efficiency guidelines to tool description by @NTaylorMullen - in [#18614](https://github.com/google-gemini/gemini-cli/pull/18614) -- Added "" as default value, since getText() used to expect a string only and - thus crashed when undefined... Fixes #18076 by @019-Abhi in - [#18099](https://github.com/google-gemini/gemini-cli/pull/18099) -- Allow @-includes outside of workspaces (with permission) by @scidomino in - [#18470](https://github.com/google-gemini/gemini-cli/pull/18470) -- chore: make `ask_user` header description more clear by @jackwotherspoon in - [#18657](https://github.com/google-gemini/gemini-cli/pull/18657) -- refactor(core): model-dependent tool definitions by @aishaneeshah in - [#18563](https://github.com/google-gemini/gemini-cli/pull/18563) -- Harded code assist converter. by @jacob314 in - [#18656](https://github.com/google-gemini/gemini-cli/pull/18656) -- bug(core): Fix minor bug in migration logic. by @joshualitt in - [#18661](https://github.com/google-gemini/gemini-cli/pull/18661) -- feat: enable plan mode experiment in settings by @jerop in - [#18636](https://github.com/google-gemini/gemini-cli/pull/18636) -- refactor: push isValidPath() into parsePastedPaths() by @scidomino in - [#18664](https://github.com/google-gemini/gemini-cli/pull/18664) -- fix(cli): correct 'esc to cancel' position and restore duration display by - @NTaylorMullen in - [#18534](https://github.com/google-gemini/gemini-cli/pull/18534) -- feat(cli): add DevTools integration with gemini-cli-devtools by @SandyTao520 - in [#18648](https://github.com/google-gemini/gemini-cli/pull/18648) -- chore: remove unused exports and redundant hook files by @SandyTao520 in - [#18681](https://github.com/google-gemini/gemini-cli/pull/18681) -- Fix number of lines being reported in rewind confirmation dialog by @Adib234 - in [#18675](https://github.com/google-gemini/gemini-cli/pull/18675) -- feat(cli): disable folder trust in headless mode by @galz10 in - [#18407](https://github.com/google-gemini/gemini-cli/pull/18407) -- Disallow unsafe type assertions by @gundermanc in - [#18688](https://github.com/google-gemini/gemini-cli/pull/18688) -- Change event type for release by @g-samroberts in - [#18693](https://github.com/google-gemini/gemini-cli/pull/18693) -- feat: handle multiple dynamic context filenames in system prompt by - @NTaylorMullen in - [#18598](https://github.com/google-gemini/gemini-cli/pull/18598) -- Properly parse at-commands with narrow non-breaking spaces by @scidomino in - [#18677](https://github.com/google-gemini/gemini-cli/pull/18677) -- refactor(core): centralize core tool definitions and support model-specific - schemas by @aishaneeshah in - [#18662](https://github.com/google-gemini/gemini-cli/pull/18662) -- feat(core): Render memory hierarchically in context. by @joshualitt in - [#18350](https://github.com/google-gemini/gemini-cli/pull/18350) -- feat: Ctrl+O to expand paste placeholder by @jackwotherspoon in - [#18103](https://github.com/google-gemini/gemini-cli/pull/18103) -- fix(cli): Improve header spacing by @NTaylorMullen in - [#18531](https://github.com/google-gemini/gemini-cli/pull/18531) -- Feature/quota visibility 16795 by @spencer426 in - [#18203](https://github.com/google-gemini/gemini-cli/pull/18203) -- Inline thinking bubbles with summary/full modes by @LyalinDotCom in - [#18033](https://github.com/google-gemini/gemini-cli/pull/18033) -- docs: remove TOC marker from Plan Mode header by @jerop in - [#18678](https://github.com/google-gemini/gemini-cli/pull/18678) -- fix(ui): remove redundant newlines in Gemini messages by @NTaylorMullen in - [#18538](https://github.com/google-gemini/gemini-cli/pull/18538) -- test(cli): fix AppContainer act() warnings and improve waitFor resilience by - @NTaylorMullen in - [#18676](https://github.com/google-gemini/gemini-cli/pull/18676) -- refactor(core): refine Security & System Integrity section in system prompt by - @NTaylorMullen in - [#18601](https://github.com/google-gemini/gemini-cli/pull/18601) -- Fix layout rounding. by @gundermanc in - [#18667](https://github.com/google-gemini/gemini-cli/pull/18667) -- docs(skills): enhance pr-creator safety and interactivity by @NTaylorMullen in - [#18616](https://github.com/google-gemini/gemini-cli/pull/18616) -- test(core): remove hardcoded model from TestRig by @NTaylorMullen in - [#18710](https://github.com/google-gemini/gemini-cli/pull/18710) -- feat(core): optimize sub-agents system prompt intro by @NTaylorMullen in - [#18608](https://github.com/google-gemini/gemini-cli/pull/18608) -- feat(cli): update approval mode labels and shortcuts per latest UX spec by - @jerop in [#18698](https://github.com/google-gemini/gemini-cli/pull/18698) -- fix(plan): update persistent approval mode setting by @Adib234 in - [#18638](https://github.com/google-gemini/gemini-cli/pull/18638) -- fix: move toasts location to left side by @jackwotherspoon in - [#18705](https://github.com/google-gemini/gemini-cli/pull/18705) -- feat(routing): restrict numerical routing to Gemini 3 family by @mattKorwel in - [#18478](https://github.com/google-gemini/gemini-cli/pull/18478) -- fix(ide): fix ide nudge setting by @skeshive in - [#18733](https://github.com/google-gemini/gemini-cli/pull/18733) -- fix(core): standardize tool formatting in system prompts by @NTaylorMullen in - [#18615](https://github.com/google-gemini/gemini-cli/pull/18615) -- chore: consolidate to green in ask user dialog by @jackwotherspoon in - [#18734](https://github.com/google-gemini/gemini-cli/pull/18734) -- feat: add `extensionsExplore` setting to enable extensions explore UI. by - @sripasg in [#18686](https://github.com/google-gemini/gemini-cli/pull/18686) -- feat(cli): defer devtools startup and integrate with F12 by @SandyTao520 in - [#18695](https://github.com/google-gemini/gemini-cli/pull/18695) -- ui: update & subdue footer colors and animate progress indicator by - @keithguerin in - [#18570](https://github.com/google-gemini/gemini-cli/pull/18570) -- test: add model-specific snapshots for coreTools by @aishaneeshah in - [#18707](https://github.com/google-gemini/gemini-cli/pull/18707) -- ci: shard windows tests and fix event listener leaks by @NTaylorMullen in - [#18670](https://github.com/google-gemini/gemini-cli/pull/18670) -- fix: allow `ask_user` tool in yolo mode by @jackwotherspoon in - [#18541](https://github.com/google-gemini/gemini-cli/pull/18541) -- feat: redact disabled tools from system prompt (#13597) by @NTaylorMullen in - [#18613](https://github.com/google-gemini/gemini-cli/pull/18613) -- Update Gemini.md to use the curent year on creating new files by @sehoon38 in - [#18460](https://github.com/google-gemini/gemini-cli/pull/18460) -- Code review cleanup for thinking display by @jacob314 in - [#18720](https://github.com/google-gemini/gemini-cli/pull/18720) -- fix(cli): hide scrollbars when in alternate buffer copy mode by @werdnum in - [#18354](https://github.com/google-gemini/gemini-cli/pull/18354) -- Fix issues with rip grep by @gundermanc in - [#18756](https://github.com/google-gemini/gemini-cli/pull/18756) -- fix(cli): fix history navigation regression after prompt autocomplete by - @sehoon38 in [#18752](https://github.com/google-gemini/gemini-cli/pull/18752) -- chore: cleanup unused and add unlisted dependencies in packages/cli by + [#18772](https://github.com/google-gemini/gemini-cli/pull/18772) +- chore: cleanup unused and add unlisted dependencies in packages/core by @adamfweidman in - [#18749](https://github.com/google-gemini/gemini-cli/pull/18749) -- Fix issue where Gemini CLI creates tests in a new file by @gundermanc in - [#18409](https://github.com/google-gemini/gemini-cli/pull/18409) -- feat(telemetry): Ensure experiment IDs are included in OpenTelemetry logs by + [#18762](https://github.com/google-gemini/gemini-cli/pull/18762) +- chore(core): update activate_skill prompt verbiage to be more direct by + @NTaylorMullen in + [#18605](https://github.com/google-gemini/gemini-cli/pull/18605) +- Add autoconfigure memory usage setting to the dialog by @jacob314 in + [#18510](https://github.com/google-gemini/gemini-cli/pull/18510) +- fix(core): prevent race condition in policy persistence by @braddux in + [#18506](https://github.com/google-gemini/gemini-cli/pull/18506) +- fix(evals): prevent false positive in hierarchical memory test by + @Abhijit-2592 in + [#18777](https://github.com/google-gemini/gemini-cli/pull/18777) +- test(evals): mark all `save_memory` evals as `USUALLY_PASSES` due to + unreliability by @jerop in + [#18786](https://github.com/google-gemini/gemini-cli/pull/18786) +- feat(cli): add setting to hide shortcuts hint UI by @LyalinDotCom in + [#18562](https://github.com/google-gemini/gemini-cli/pull/18562) +- feat(core): formalize 5-phase sequential planning workflow by @jerop in + [#18759](https://github.com/google-gemini/gemini-cli/pull/18759) +- Introduce limits for search results. by @gundermanc in + [#18767](https://github.com/google-gemini/gemini-cli/pull/18767) +- fix(cli): allow closing debug console after auto-open via flicker by + @SandyTao520 in + [#18795](https://github.com/google-gemini/gemini-cli/pull/18795) +- feat(masking): enable tool output masking by default by @abhipatel12 in + [#18564](https://github.com/google-gemini/gemini-cli/pull/18564) +- perf(ui): optimize table rendering by memoizing styled characters by @devr0306 + in [#18770](https://github.com/google-gemini/gemini-cli/pull/18770) +- feat: multi-line text answers in ask-user tool by @jackwotherspoon in + [#18741](https://github.com/google-gemini/gemini-cli/pull/18741) +- perf(cli): truncate large debug logs and limit message history by @mattKorwel + in [#18663](https://github.com/google-gemini/gemini-cli/pull/18663) +- fix(core): complete MCP discovery when configured servers are skipped by + @LyalinDotCom in + [#18586](https://github.com/google-gemini/gemini-cli/pull/18586) +- fix(core): cache CLI version to ensure consistency during sessions by + @sehoon38 in [#18793](https://github.com/google-gemini/gemini-cli/pull/18793) +- fix(cli): resolve double rendering in shpool and address vscode lint warnings + by @braddux in + [#18704](https://github.com/google-gemini/gemini-cli/pull/18704) +- feat(plan): document and validate Plan Mode policy overrides by @jerop in + [#18825](https://github.com/google-gemini/gemini-cli/pull/18825) +- Fix pressing any key to exit select mode. by @jacob314 in + [#18421](https://github.com/google-gemini/gemini-cli/pull/18421) +- fix(cli): update F12 behavior to only open drawer if browser fails by + @SandyTao520 in + [#18829](https://github.com/google-gemini/gemini-cli/pull/18829) +- feat(plan): allow skills to be enabled in plan mode by @Adib234 in + [#18817](https://github.com/google-gemini/gemini-cli/pull/18817) +- docs(plan): add documentation for plan mode tools by @jerop in + [#18827](https://github.com/google-gemini/gemini-cli/pull/18827) +- Remove experimental note in extension settings docs by @chrstnb in + [#18822](https://github.com/google-gemini/gemini-cli/pull/18822) +- Update prompt and grep tool definition to limit context size by @gundermanc in + [#18780](https://github.com/google-gemini/gemini-cli/pull/18780) +- docs(plan): add `ask_user` tool documentation by @jerop in + [#18830](https://github.com/google-gemini/gemini-cli/pull/18830) +- Revert unintended credentials exposure by @Adib234 in + [#18840](https://github.com/google-gemini/gemini-cli/pull/18840) +- feat(core): update internal utility models to Gemini 3 by @SandyTao520 in + [#18773](https://github.com/google-gemini/gemini-cli/pull/18773) +- feat(a2a): add value-resolver for auth credential resolution by @adamfweidman + in [#18653](https://github.com/google-gemini/gemini-cli/pull/18653) +- Removed getPlainTextLength by @devr0306 in + [#18848](https://github.com/google-gemini/gemini-cli/pull/18848) +- More grep prompt tweaks by @gundermanc in + [#18846](https://github.com/google-gemini/gemini-cli/pull/18846) +- refactor(cli): Reactive useSettingsStore hook by @psinha40898 in + [#14915](https://github.com/google-gemini/gemini-cli/pull/14915) +- fix(mcp): Ensure that stdio MCP server execution has the `GEMINI_CLI=1` env + variable populated. by @richieforeman in + [#18832](https://github.com/google-gemini/gemini-cli/pull/18832) +- fix(core): improve headless mode detection for flags and query args by @galz10 + in [#18855](https://github.com/google-gemini/gemini-cli/pull/18855) +- refactor(cli): simplify UI and remove legacy inline tool confirmation logic by + @abhipatel12 in + [#18566](https://github.com/google-gemini/gemini-cli/pull/18566) +- feat(cli): deprecate --allowed-tools and excludeTools in favor of policy + engine by @Abhijit-2592 in + [#18508](https://github.com/google-gemini/gemini-cli/pull/18508) +- fix(workflows): improve maintainer detection for automated PR actions by + @bdmorgan in [#18869](https://github.com/google-gemini/gemini-cli/pull/18869) +- refactor(cli): consolidate useToolScheduler and delete legacy implementation + by @abhipatel12 in + [#18567](https://github.com/google-gemini/gemini-cli/pull/18567) +- Update changelog for v0.28.0 and v0.29.0-preview0 by @g-samroberts in + [#18819](https://github.com/google-gemini/gemini-cli/pull/18819) +- fix(core): ensure sub-agents are registered regardless of tools.allowed by + @mattKorwel in + [#18870](https://github.com/google-gemini/gemini-cli/pull/18870) +- Show notification when there's a conflict with an extensions command by + @chrstnb in [#17890](https://github.com/google-gemini/gemini-cli/pull/17890) +- fix(cli): dismiss '?' shortcuts help on hotkeys and active states by + @LyalinDotCom in + [#18583](https://github.com/google-gemini/gemini-cli/pull/18583) +- fix(core): prioritize conditional policy rules and harden Plan Mode by + @Abhijit-2592 in + [#18882](https://github.com/google-gemini/gemini-cli/pull/18882) +- feat(core): refine Plan Mode system prompt for agentic execution by + @NTaylorMullen in + [#18799](https://github.com/google-gemini/gemini-cli/pull/18799) +- feat(plan): create metrics for usage of `AskUser` tool by @Adib234 in + [#18820](https://github.com/google-gemini/gemini-cli/pull/18820) +- feat(cli): support Ctrl-Z suspension by @scidomino in + [#18931](https://github.com/google-gemini/gemini-cli/pull/18931) +- fix(github-actions): use robot PAT for release creation to trigger release + notes by @SandyTao520 in + [#18794](https://github.com/google-gemini/gemini-cli/pull/18794) +- feat: add strict seatbelt profiles and remove unusable closed profiles by + @SandyTao520 in + [#18876](https://github.com/google-gemini/gemini-cli/pull/18876) +- chore: cleanup unused and add unlisted dependencies in packages/a2a-server by + @adamfweidman in + [#18916](https://github.com/google-gemini/gemini-cli/pull/18916) +- fix(plan): isolate plan files per session by @Adib234 in + [#18757](https://github.com/google-gemini/gemini-cli/pull/18757) +- fix: character truncation in raw markdown mode by @jackwotherspoon in + [#18938](https://github.com/google-gemini/gemini-cli/pull/18938) +- feat(cli): prototype clean UI toggle and minimal-mode bleed-through by + @LyalinDotCom in + [#18683](https://github.com/google-gemini/gemini-cli/pull/18683) +- ui(polish) blend background color with theme by @jacob314 in + [#18802](https://github.com/google-gemini/gemini-cli/pull/18802) +- Add generic searchable list to back settings and extensions by @chrstnb in + [#18838](https://github.com/google-gemini/gemini-cli/pull/18838) +- feat(ui): align `AskUser` color scheme with UX spec by @jerop in + [#18943](https://github.com/google-gemini/gemini-cli/pull/18943) +- Hide AskUser tool validation errors from UI (agent self-corrects) by @jerop in + [#18954](https://github.com/google-gemini/gemini-cli/pull/18954) +- bug(cli) fix flicker due to AppContainer continuous initialization by + @jacob314 in [#18958](https://github.com/google-gemini/gemini-cli/pull/18958) +- feat(admin): Add admin controls documentation by @skeshive in + [#18644](https://github.com/google-gemini/gemini-cli/pull/18644) +- feat(cli): disable ctrl-s shortcut outside of alternate buffer mode by + @jacob314 in [#18887](https://github.com/google-gemini/gemini-cli/pull/18887) +- fix(vim): vim support that feels (more) complete by @ppgranger in + [#18755](https://github.com/google-gemini/gemini-cli/pull/18755) +- feat(policy): add --policy flag for user defined policies by @allenhutchison + in [#18500](https://github.com/google-gemini/gemini-cli/pull/18500) +- Update installation guide by @g-samroberts in + [#18823](https://github.com/google-gemini/gemini-cli/pull/18823) +- refactor(core): centralize tool definitions (Group 1: replace, search, grep) + by @aishaneeshah in + [#18944](https://github.com/google-gemini/gemini-cli/pull/18944) +- refactor(cli): finalize event-driven transition and remove interaction bridge + by @abhipatel12 in + [#18569](https://github.com/google-gemini/gemini-cli/pull/18569) +- Fix drag and drop escaping by @scidomino in + [#18965](https://github.com/google-gemini/gemini-cli/pull/18965) +- feat(sdk): initial package bootstrap for SDK by @mbleigh in + [#18861](https://github.com/google-gemini/gemini-cli/pull/18861) +- feat(sdk): implements SessionContext for SDK tool calls by @mbleigh in + [#18862](https://github.com/google-gemini/gemini-cli/pull/18862) +- fix(plan): make question type required in AskUser tool by @Adib234 in + [#18959](https://github.com/google-gemini/gemini-cli/pull/18959) +- fix(core): ensure --yolo does not force headless mode by @NTaylorMullen in + [#18976](https://github.com/google-gemini/gemini-cli/pull/18976) +- refactor(core): adopt `CoreToolCallStatus` enum for type safety by @jerop in + [#18998](https://github.com/google-gemini/gemini-cli/pull/18998) +- Enable in-CLI extension management commands for team by @chrstnb in + [#18957](https://github.com/google-gemini/gemini-cli/pull/18957) +- Adjust lint rules to avoid unnecessary warning. by @scidomino in + [#18970](https://github.com/google-gemini/gemini-cli/pull/18970) +- fix(vscode): resolve unsafe type assertion lint errors by @ehedlund in + [#19006](https://github.com/google-gemini/gemini-cli/pull/19006) +- Remove unnecessary eslint config file by @scidomino in + [#19015](https://github.com/google-gemini/gemini-cli/pull/19015) +- fix(core): Prevent loop detection false positives on lists with long shared + prefixes by @SandyTao520 in + [#18975](https://github.com/google-gemini/gemini-cli/pull/18975) +- feat(core): fallback to chat-base when using unrecognized models for chat by + @SandyTao520 in + [#19016](https://github.com/google-gemini/gemini-cli/pull/19016) +- docs: fix inconsistent commandRegex example in policy engine by @NTaylorMullen + in [#19027](https://github.com/google-gemini/gemini-cli/pull/19027) +- fix(plan): persist the approval mode in UI even when agent is thinking by + @Adib234 in [#18955](https://github.com/google-gemini/gemini-cli/pull/18955) +- feat(sdk): Implement dynamic system instructions by @mbleigh in + [#18863](https://github.com/google-gemini/gemini-cli/pull/18863) +- Docs: Refresh docs to organize and standardize reference materials. by + @jkcinouye in [#18403](https://github.com/google-gemini/gemini-cli/pull/18403) +- fix windows escaping (and broken tests) by @scidomino in + [#19011](https://github.com/google-gemini/gemini-cli/pull/19011) +- refactor: use `CoreToolCallStatus` in the the history data model by @jerop in + [#19033](https://github.com/google-gemini/gemini-cli/pull/19033) +- feat(cleanup): enable 30-day session retention by default by @skeshive in + [#18854](https://github.com/google-gemini/gemini-cli/pull/18854) +- feat(plan): hide plan write and edit operations on plans in Plan Mode by + @jerop in [#19012](https://github.com/google-gemini/gemini-cli/pull/19012) +- bug(ui) fix flicker refreshing background color by @jacob314 in + [#19041](https://github.com/google-gemini/gemini-cli/pull/19041) +- chore: fix dep vulnerabilities by @scidomino in + [#19036](https://github.com/google-gemini/gemini-cli/pull/19036) +- Revamp automated changelog skill by @g-samroberts in + [#18974](https://github.com/google-gemini/gemini-cli/pull/18974) +- feat(sdk): implement support for custom skills by @mbleigh in + [#19031](https://github.com/google-gemini/gemini-cli/pull/19031) +- refactor(core): complete centralization of core tool definitions by + @aishaneeshah in + [#18991](https://github.com/google-gemini/gemini-cli/pull/18991) +- feat: add /commands reload to refresh custom TOML commands by @korade-krushna + in [#19078](https://github.com/google-gemini/gemini-cli/pull/19078) +- fix(cli): wrap terminal capability queries in hidden sequence by @srithreepo + in [#19080](https://github.com/google-gemini/gemini-cli/pull/19080) +- fix(workflows): fix GitHub App token permissions for maintainer detection by + @bdmorgan in [#19139](https://github.com/google-gemini/gemini-cli/pull/19139) +- test: fix hook integration test flakiness on Windows CI by @NTaylorMullen in + [#18665](https://github.com/google-gemini/gemini-cli/pull/18665) +- fix(core): Encourage non-interactive flags for scaffolding commands by + @NTaylorMullen in + [#18804](https://github.com/google-gemini/gemini-cli/pull/18804) +- fix(core): propagate User-Agent header to setup-phase CodeAssist API calls by + @gsquared94 in + [#19182](https://github.com/google-gemini/gemini-cli/pull/19182) +- docs: document .agents/skills alias and discovery precedence by @kevmoo in + [#19166](https://github.com/google-gemini/gemini-cli/pull/19166) +- feat(cli): add loading state to new agents notification by @sehoon38 in + [#19190](https://github.com/google-gemini/gemini-cli/pull/19190) +- Add base branch to workflow. by @g-samroberts in + [#19189](https://github.com/google-gemini/gemini-cli/pull/19189) +- feat(cli): handle invalid model names in useQuotaAndFallback by @sehoon38 in + [#19222](https://github.com/google-gemini/gemini-cli/pull/19222) +- docs: custom themes in extensions by @jackwotherspoon in + [#19219](https://github.com/google-gemini/gemini-cli/pull/19219) +- Disable workspace settings when starting GCLI in the home directory. by + @kevinjwang1 in + [#19034](https://github.com/google-gemini/gemini-cli/pull/19034) +- feat(cli): refactor model command to support set and manage subcommands by + @sehoon38 in [#19221](https://github.com/google-gemini/gemini-cli/pull/19221) +- Add refresh/reload aliases to slash command subcommands by @korade-krushna in + [#19218](https://github.com/google-gemini/gemini-cli/pull/19218) +- refactor: consolidate development rules and add cli guidelines by @jacob314 in + [#19214](https://github.com/google-gemini/gemini-cli/pull/19214) +- chore(ui): remove outdated tip about model routing by @sehoon38 in + [#19226](https://github.com/google-gemini/gemini-cli/pull/19226) +- feat(core): support custom reasoning models by default by @NTaylorMullen in + [#19227](https://github.com/google-gemini/gemini-cli/pull/19227) +- Add Solarized Dark and Solarized Light themes by @rmedranollamas in + [#19064](https://github.com/google-gemini/gemini-cli/pull/19064) +- fix(telemetry): replace JSON.stringify with safeJsonStringify in file + exporters by @gsquared94 in + [#19244](https://github.com/google-gemini/gemini-cli/pull/19244) +- feat(telemetry): add keychain availability and token storage metrics by + @abhipatel12 in + [#18971](https://github.com/google-gemini/gemini-cli/pull/18971) +- feat(cli): update approval mode cycle order by @jerop in + [#19254](https://github.com/google-gemini/gemini-cli/pull/19254) +- refactor(cli): code review cleanup fix for tab+tab by @jacob314 in + [#18967](https://github.com/google-gemini/gemini-cli/pull/18967) +- feat(plan): support project exploration without planning when in plan mode by + @Adib234 in [#18992](https://github.com/google-gemini/gemini-cli/pull/18992) +- feat: add role-specific statistics to telemetry and UI (cont. #15234) by + @yunaseoul in [#18824](https://github.com/google-gemini/gemini-cli/pull/18824) +- feat(cli): remove Plan Mode from rotation when actively working by @jerop in + [#19262](https://github.com/google-gemini/gemini-cli/pull/19262) +- Fix side breakage where anchors don't work in slugs. by @g-samroberts in + [#19261](https://github.com/google-gemini/gemini-cli/pull/19261) +- feat(config): add setting to make directory tree context configurable by @kevin-ramdass in - [#18747](https://github.com/google-gemini/gemini-cli/pull/18747) -- fix(patch): cherry-pick e9a9474 to release/v0.29.0-preview.0-pr-18840 to patch - version v0.29.0-preview.0 and create version 0.29.0-preview.1 by + [#19053](https://github.com/google-gemini/gemini-cli/pull/19053) +- fix(acp): Wait for mcp initialization in acp (#18893) by @Mervap in + [#18894](https://github.com/google-gemini/gemini-cli/pull/18894) +- docs: format UTC times in releases doc by @pavan-sh in + [#18169](https://github.com/google-gemini/gemini-cli/pull/18169) +- Docs: Clarify extensions documentation. by @jkcinouye in + [#19277](https://github.com/google-gemini/gemini-cli/pull/19277) +- refactor(core): modularize tool definitions by model family by @aishaneeshah + in [#19269](https://github.com/google-gemini/gemini-cli/pull/19269) +- fix(paths): Add cross-platform path normalization by @spencer426 in + [#18939](https://github.com/google-gemini/gemini-cli/pull/18939) +- feat(core): experimental in-progress steering hints (1 of 3) by @joshualitt in + [#19008](https://github.com/google-gemini/gemini-cli/pull/19008) +- fix(patch): cherry-pick 261788c to release/v0.30.0-preview.0-pr-19453 to patch + version v0.30.0-preview.0 and create version 0.30.0-preview.1 by @gemini-cli-robot in - [#18841](https://github.com/google-gemini/gemini-cli/pull/18841) -- fix(patch): cherry-pick 08e8eea to release/v0.29.0-preview.1-pr-18855 to patch - version v0.29.0-preview.1 and create version 0.29.0-preview.2 by + [#19490](https://github.com/google-gemini/gemini-cli/pull/19490) +- fix(patch): cherry-pick c43500c to release/v0.30.0-preview.1-pr-19502 to patch + version v0.30.0-preview.1 and create version 0.30.0-preview.2 by @gemini-cli-robot in - [#18905](https://github.com/google-gemini/gemini-cli/pull/18905) -- fix(patch): cherry-pick d0c6a56 to release/v0.29.0-preview.2-pr-18976 to patch - version v0.29.0-preview.2 and create version 0.29.0-preview.3 by + [#19521](https://github.com/google-gemini/gemini-cli/pull/19521) +- fix(patch): cherry-pick aa9163d to release/v0.30.0-preview.3-pr-19991 to patch + version v0.30.0-preview.3 and create version 0.30.0-preview.4 by @gemini-cli-robot in - [#19023](https://github.com/google-gemini/gemini-cli/pull/19023) -- fix(patch): cherry-pick e5ff202 to release/v0.29.0-preview.3-pr-19254 to patch - version v0.29.0-preview.3 and create version 0.29.0-preview.4 by + [#20040](https://github.com/google-gemini/gemini-cli/pull/20040) +- fix(patch): cherry-pick 2c1d6f8 to release/v0.30.0-preview.4-pr-19369 to patch + version v0.30.0-preview.4 and create version 0.30.0-preview.5 by @gemini-cli-robot in - [#19264](https://github.com/google-gemini/gemini-cli/pull/19264) -- fix(patch): cherry-pick 9590a09 to release/v0.29.0-preview.4-pr-18771 to patch - version v0.29.0-preview.4 and create version 0.29.0-preview.5 by + [#20086](https://github.com/google-gemini/gemini-cli/pull/20086) +- fix(patch): cherry-pick d96bd05 to release/v0.30.0-preview.5-pr-19867 to patch + version v0.30.0-preview.5 and create version 0.30.0-preview.6 by @gemini-cli-robot in - [#19274](https://github.com/google-gemini/gemini-cli/pull/19274) + [#20112](https://github.com/google-gemini/gemini-cli/pull/20112) **Full Changelog**: -https://github.com/google-gemini/gemini-cli/compare/v0.28.2...v0.29.0 +https://github.com/google-gemini/gemini-cli/compare/v0.29.7...v0.30.0 diff --git a/docs/changelogs/preview.md b/docs/changelogs/preview.md index 646106fa50..588573a37c 100644 --- a/docs/changelogs/preview.md +++ b/docs/changelogs/preview.md @@ -1,6 +1,6 @@ -# Preview release: v0.30.0-preview.5 +# Preview release: v0.31.0-preview.0 -Released: February 24, 2026 +Released: February 25, 2026 Our preview release includes the latest, new, and experimental features. This release may not be as stable as our [latest weekly release](latest.md). @@ -13,306 +13,400 @@ npm install -g @google/gemini-cli@preview ## Highlights -- **Initial SDK Package:** Introduced the initial SDK package with support for - custom skills and dynamic system instructions. -- **Refined Plan Mode:** Refined Plan Mode with support for enabling skills, - improved agentic execution, and project exploration without planning. -- **Enhanced CLI UI:** Enhanced CLI UI with a new clean UI toggle, minimal-mode - bleed-through, and support for Ctrl-Z suspension. -- **`--policy` flag:** Added the `--policy` flag to support user-defined - policies. -- **New Themes:** Added Solarized Dark and Solarized Light themes. +- **Plan Mode Enhancements**: Numerous additions including automatic model + switching, custom storage directory configuration, message injection upon + manual exit, enforcement of read-only constraints, and centralized tool + visibility in the policy engine. +- **Policy Engine Updates**: Project-level policy support added, alongside MCP + server wildcard support, tool annotation propagation and matching, and + workspace-level "Always Allow" persistence. +- **MCP Integration Improvements**: Better integration through support for MCP + progress updates with input validation and throttling, environment variable + expansion for servers, and full details expansion on tool approval. +- **CLI & Core UX Enhancements**: Several UI and quality-of-life updates such as + Alt+D for forward word deletion, macOS run-event notifications, enhanced + folder trust configurations with security warnings, improved startup warnings, + and a new experimental browser agent. +- **Security & Stability**: Introduced the Conseca framework, deceptive URL and + Unicode character detection, stricter access checks, rate limits on web fetch, + and resolved multiple dependency vulnerabilities. ## What's Changed -- fix(patch): cherry-pick 2c1d6f8 to release/v0.30.0-preview.4-pr-19369 to patch - version v0.30.0-preview.4 and create version 0.30.0-preview.5 by - @gemini-cli-robot in - [#20086](https://github.com/google-gemini/gemini-cli/pull/20086) -- fix(patch): cherry-pick 261788c to release/v0.30.0-preview.0-pr-19453 to patch - version v0.30.0-preview.0 and create version 0.30.0-preview.1 by - @gemini-cli-robot in - [#19490](https://github.com/google-gemini/gemini-cli/pull/19490) -- feat(ux): added text wrapping capabilities to markdown tables by @devr0306 in - [#18240](https://github.com/google-gemini/gemini-cli/pull/18240) -- Revert "fix(mcp): ensure MCP transport is closed to prevent memory leaks" by - @skeshive in [#18771](https://github.com/google-gemini/gemini-cli/pull/18771) -- chore(release): bump version to 0.30.0-nightly.20260210.a2174751d by - @gemini-cli-robot in - [#18772](https://github.com/google-gemini/gemini-cli/pull/18772) -- chore: cleanup unused and add unlisted dependencies in packages/core by - @adamfweidman in - [#18762](https://github.com/google-gemini/gemini-cli/pull/18762) -- chore(core): update activate_skill prompt verbiage to be more direct by - @NTaylorMullen in - [#18605](https://github.com/google-gemini/gemini-cli/pull/18605) -- Add autoconfigure memory usage setting to the dialog by @jacob314 in - [#18510](https://github.com/google-gemini/gemini-cli/pull/18510) -- fix(core): prevent race condition in policy persistence by @braddux in - [#18506](https://github.com/google-gemini/gemini-cli/pull/18506) -- fix(evals): prevent false positive in hierarchical memory test by - @Abhijit-2592 in - [#18777](https://github.com/google-gemini/gemini-cli/pull/18777) -- test(evals): mark all `save_memory` evals as `USUALLY_PASSES` due to - unreliability by @jerop in - [#18786](https://github.com/google-gemini/gemini-cli/pull/18786) -- feat(cli): add setting to hide shortcuts hint UI by @LyalinDotCom in - [#18562](https://github.com/google-gemini/gemini-cli/pull/18562) -- feat(core): formalize 5-phase sequential planning workflow by @jerop in - [#18759](https://github.com/google-gemini/gemini-cli/pull/18759) -- Introduce limits for search results. by @gundermanc in - [#18767](https://github.com/google-gemini/gemini-cli/pull/18767) -- fix(cli): allow closing debug console after auto-open via flicker by +- Use ranged reads and limited searches and fuzzy editing improvements by + @gundermanc in + [#19240](https://github.com/google-gemini/gemini-cli/pull/19240) +- Fix bottom border color by @jacob314 in + [#19266](https://github.com/google-gemini/gemini-cli/pull/19266) +- Release note generator fix by @g-samroberts in + [#19363](https://github.com/google-gemini/gemini-cli/pull/19363) +- test(evals): add behavioral tests for tool output masking by @NTaylorMullen in + [#19172](https://github.com/google-gemini/gemini-cli/pull/19172) +- docs: clarify preflight instructions in GEMINI.md by @NTaylorMullen in + [#19377](https://github.com/google-gemini/gemini-cli/pull/19377) +- feat(cli): add gemini --resume hint on exit by @Mag1ck in + [#16285](https://github.com/google-gemini/gemini-cli/pull/16285) +- fix: optimize height calculations for ask_user dialog by @jackwotherspoon in + [#19017](https://github.com/google-gemini/gemini-cli/pull/19017) +- feat(cli): add Alt+D for forward word deletion by @scidomino in + [#19300](https://github.com/google-gemini/gemini-cli/pull/19300) +- Disable failing eval test by @chrstnb in + [#19455](https://github.com/google-gemini/gemini-cli/pull/19455) +- fix(cli): support legacy onConfirm callback in ToolActionsContext by @SandyTao520 in - [#18795](https://github.com/google-gemini/gemini-cli/pull/18795) -- feat(masking): enable tool output masking by default by @abhipatel12 in - [#18564](https://github.com/google-gemini/gemini-cli/pull/18564) -- perf(ui): optimize table rendering by memoizing styled characters by @devr0306 - in [#18770](https://github.com/google-gemini/gemini-cli/pull/18770) -- feat: multi-line text answers in ask-user tool by @jackwotherspoon in - [#18741](https://github.com/google-gemini/gemini-cli/pull/18741) -- perf(cli): truncate large debug logs and limit message history by @mattKorwel - in [#18663](https://github.com/google-gemini/gemini-cli/pull/18663) -- fix(core): complete MCP discovery when configured servers are skipped by + [#19369](https://github.com/google-gemini/gemini-cli/pull/19369) +- chore(deps): bump tar from 7.5.7 to 7.5.8 by dependabot[bot] in + [#19367](https://github.com/google-gemini/gemini-cli/pull/19367) +- fix(plan): allow safe fallback when experiment setting for plan is not enabled + but approval mode at startup is plan by @Adib234 in + [#19439](https://github.com/google-gemini/gemini-cli/pull/19439) +- Add explicit color-convert dependency by @chrstnb in + [#19460](https://github.com/google-gemini/gemini-cli/pull/19460) +- feat(devtools): migrate devtools package into monorepo by @SandyTao520 in + [#18936](https://github.com/google-gemini/gemini-cli/pull/18936) +- fix(core): clarify plan mode constraints and exit mechanism by @jerop in + [#19438](https://github.com/google-gemini/gemini-cli/pull/19438) +- feat(cli): add macOS run-event notifications (interactive only) by @LyalinDotCom in - [#18586](https://github.com/google-gemini/gemini-cli/pull/18586) -- fix(core): cache CLI version to ensure consistency during sessions by - @sehoon38 in [#18793](https://github.com/google-gemini/gemini-cli/pull/18793) -- fix(cli): resolve double rendering in shpool and address vscode lint warnings - by @braddux in - [#18704](https://github.com/google-gemini/gemini-cli/pull/18704) -- feat(plan): document and validate Plan Mode policy overrides by @jerop in - [#18825](https://github.com/google-gemini/gemini-cli/pull/18825) -- Fix pressing any key to exit select mode. by @jacob314 in - [#18421](https://github.com/google-gemini/gemini-cli/pull/18421) -- fix(cli): update F12 behavior to only open drawer if browser fails by + [#19056](https://github.com/google-gemini/gemini-cli/pull/19056) +- Changelog for v0.29.0 by @gemini-cli-robot in + [#19361](https://github.com/google-gemini/gemini-cli/pull/19361) +- fix(ui): preventing empty history items from being added by @devr0306 in + [#19014](https://github.com/google-gemini/gemini-cli/pull/19014) +- Changelog for v0.30.0-preview.0 by @gemini-cli-robot in + [#19364](https://github.com/google-gemini/gemini-cli/pull/19364) +- feat(core): add support for MCP progress updates by @NTaylorMullen in + [#19046](https://github.com/google-gemini/gemini-cli/pull/19046) +- fix(core): ensure directory exists before writing conversation file by + @godwiniheuwa in + [#18429](https://github.com/google-gemini/gemini-cli/pull/18429) +- fix(ui): move margin from top to bottom in ToolGroupMessage by @imadraude in + [#17198](https://github.com/google-gemini/gemini-cli/pull/17198) +- fix(cli): treat unknown slash commands as regular input instead of showing + error by @skyvanguard in + [#17393](https://github.com/google-gemini/gemini-cli/pull/17393) +- feat(core): experimental in-progress steering hints (2 of 2) by @joshualitt in + [#19307](https://github.com/google-gemini/gemini-cli/pull/19307) +- docs(plan): add documentation for plan mode command by @Adib234 in + [#19467](https://github.com/google-gemini/gemini-cli/pull/19467) +- fix(core): ripgrep fails when pattern looks like ripgrep flag by @syvb in + [#18858](https://github.com/google-gemini/gemini-cli/pull/18858) +- fix(cli): disable auto-completion on Shift+Tab to preserve mode cycling by + @NTaylorMullen in + [#19451](https://github.com/google-gemini/gemini-cli/pull/19451) +- use issuer instead of authorization_endpoint for oauth discovery by + @garrettsparks in + [#17332](https://github.com/google-gemini/gemini-cli/pull/17332) +- feat(cli): include `/dir add` directories in @ autocomplete suggestions by + @jasmeetsb in [#19246](https://github.com/google-gemini/gemini-cli/pull/19246) +- feat(admin): Admin settings should only apply if adminControlsApplicable = + true and fetch errors should be fatal by @skeshive in + [#19453](https://github.com/google-gemini/gemini-cli/pull/19453) +- Format strict-development-rules command by @g-samroberts in + [#19484](https://github.com/google-gemini/gemini-cli/pull/19484) +- feat(core): centralize compatibility checks and add TrueColor detection by + @spencer426 in + [#19478](https://github.com/google-gemini/gemini-cli/pull/19478) +- Remove unused files and update index and sidebar. by @g-samroberts in + [#19479](https://github.com/google-gemini/gemini-cli/pull/19479) +- Migrate core render util to use xterm.js as part of the rendering loop. by + @jacob314 in [#19044](https://github.com/google-gemini/gemini-cli/pull/19044) +- Changelog for v0.30.0-preview.1 by @gemini-cli-robot in + [#19496](https://github.com/google-gemini/gemini-cli/pull/19496) +- build: replace deprecated built-in punycode with userland package by @jacob314 + in [#19502](https://github.com/google-gemini/gemini-cli/pull/19502) +- Speculative fixes to try to fix react error. by @jacob314 in + [#19508](https://github.com/google-gemini/gemini-cli/pull/19508) +- fix spacing by @jacob314 in + [#19494](https://github.com/google-gemini/gemini-cli/pull/19494) +- fix(core): ensure user rejections update tool outcome for telemetry by + @abhiasap in [#18982](https://github.com/google-gemini/gemini-cli/pull/18982) +- fix(acp): Initialize config (#18897) by @Mervap in + [#18898](https://github.com/google-gemini/gemini-cli/pull/18898) +- fix(core): add error logging for IDE fetch failures by @yuvrajangadsingh in + [#17981](https://github.com/google-gemini/gemini-cli/pull/17981) +- feat(acp): support set_mode interface (#18890) by @Mervap in + [#18891](https://github.com/google-gemini/gemini-cli/pull/18891) +- fix(core): robust workspace-based IDE connection discovery by @ehedlund in + [#18443](https://github.com/google-gemini/gemini-cli/pull/18443) +- Deflake windows tests. by @jacob314 in + [#19511](https://github.com/google-gemini/gemini-cli/pull/19511) +- Fix: Avoid tool confirmation timeout when no UI listeners are present by + @pdHaku0 in [#17955](https://github.com/google-gemini/gemini-cli/pull/17955) +- format md file by @scidomino in + [#19474](https://github.com/google-gemini/gemini-cli/pull/19474) +- feat(cli): add experimental.useOSC52Copy setting by @scidomino in + [#19488](https://github.com/google-gemini/gemini-cli/pull/19488) +- feat(cli): replace loading phrases boolean with enum setting by @LyalinDotCom + in [#19347](https://github.com/google-gemini/gemini-cli/pull/19347) +- Update skill to adjust for generated results. by @g-samroberts in + [#19500](https://github.com/google-gemini/gemini-cli/pull/19500) +- Fix message too large issue. by @gundermanc in + [#19499](https://github.com/google-gemini/gemini-cli/pull/19499) +- fix(core): prevent duplicate tool approval entries in auto-saved.toml by + @Abhijit-2592 in + [#19487](https://github.com/google-gemini/gemini-cli/pull/19487) +- fix(core): resolve crash in ClearcutLogger when os.cpus() is empty by @Adib234 + in [#19555](https://github.com/google-gemini/gemini-cli/pull/19555) +- chore(core): improve encapsulation and remove unused exports by @adamfweidman + in [#19556](https://github.com/google-gemini/gemini-cli/pull/19556) +- Revert "Add generic searchable list to back settings and extensions (… by + @chrstnb in [#19434](https://github.com/google-gemini/gemini-cli/pull/19434) +- fix(core): improve error type extraction for telemetry by @yunaseoul in + [#19565](https://github.com/google-gemini/gemini-cli/pull/19565) +- fix: remove extra padding in Composer by @jackwotherspoon in + [#19529](https://github.com/google-gemini/gemini-cli/pull/19529) +- feat(plan): support configuring custom plans storage directory by @jerop in + [#19577](https://github.com/google-gemini/gemini-cli/pull/19577) +- Migrate files to resource or references folder. by @g-samroberts in + [#19503](https://github.com/google-gemini/gemini-cli/pull/19503) +- feat(policy): implement project-level policy support by @Abhijit-2592 in + [#18682](https://github.com/google-gemini/gemini-cli/pull/18682) +- feat(core): Implement parallel FC for read only tools. by @joshualitt in + [#18791](https://github.com/google-gemini/gemini-cli/pull/18791) +- chore(skills): adds pr-address-comments skill to work on PR feedback by + @mbleigh in [#19576](https://github.com/google-gemini/gemini-cli/pull/19576) +- refactor(sdk): introduce session-based architecture by @mbleigh in + [#19180](https://github.com/google-gemini/gemini-cli/pull/19180) +- fix(ci): add fallback JSON extraction to issue triage workflow by @bdmorgan in + [#19593](https://github.com/google-gemini/gemini-cli/pull/19593) +- feat(core): refine Edit and WriteFile tool schemas for Gemini 3 by @SandyTao520 in - [#18829](https://github.com/google-gemini/gemini-cli/pull/18829) -- feat(plan): allow skills to be enabled in plan mode by @Adib234 in - [#18817](https://github.com/google-gemini/gemini-cli/pull/18817) -- docs(plan): add documentation for plan mode tools by @jerop in - [#18827](https://github.com/google-gemini/gemini-cli/pull/18827) -- Remove experimental note in extension settings docs by @chrstnb in - [#18822](https://github.com/google-gemini/gemini-cli/pull/18822) -- Update prompt and grep tool definition to limit context size by @gundermanc in - [#18780](https://github.com/google-gemini/gemini-cli/pull/18780) -- docs(plan): add `ask_user` tool documentation by @jerop in - [#18830](https://github.com/google-gemini/gemini-cli/pull/18830) -- Revert unintended credentials exposure by @Adib234 in - [#18840](https://github.com/google-gemini/gemini-cli/pull/18840) -- feat(core): update internal utility models to Gemini 3 by @SandyTao520 in - [#18773](https://github.com/google-gemini/gemini-cli/pull/18773) -- feat(a2a): add value-resolver for auth credential resolution by @adamfweidman - in [#18653](https://github.com/google-gemini/gemini-cli/pull/18653) -- Removed getPlainTextLength by @devr0306 in - [#18848](https://github.com/google-gemini/gemini-cli/pull/18848) -- More grep prompt tweaks by @gundermanc in - [#18846](https://github.com/google-gemini/gemini-cli/pull/18846) -- refactor(cli): Reactive useSettingsStore hook by @psinha40898 in - [#14915](https://github.com/google-gemini/gemini-cli/pull/14915) -- fix(mcp): Ensure that stdio MCP server execution has the `GEMINI_CLI=1` env - variable populated. by @richieforeman in - [#18832](https://github.com/google-gemini/gemini-cli/pull/18832) -- fix(core): improve headless mode detection for flags and query args by @galz10 - in [#18855](https://github.com/google-gemini/gemini-cli/pull/18855) -- refactor(cli): simplify UI and remove legacy inline tool confirmation logic by - @abhipatel12 in - [#18566](https://github.com/google-gemini/gemini-cli/pull/18566) -- feat(cli): deprecate --allowed-tools and excludeTools in favor of policy - engine by @Abhijit-2592 in - [#18508](https://github.com/google-gemini/gemini-cli/pull/18508) -- fix(workflows): improve maintainer detection for automated PR actions by - @bdmorgan in [#18869](https://github.com/google-gemini/gemini-cli/pull/18869) -- refactor(cli): consolidate useToolScheduler and delete legacy implementation - by @abhipatel12 in - [#18567](https://github.com/google-gemini/gemini-cli/pull/18567) -- Update changelog for v0.28.0 and v0.29.0-preview0 by @g-samroberts in - [#18819](https://github.com/google-gemini/gemini-cli/pull/18819) -- fix(core): ensure sub-agents are registered regardless of tools.allowed by + [#19476](https://github.com/google-gemini/gemini-cli/pull/19476) +- Changelog for v0.30.0-preview.3 by @gemini-cli-robot in + [#19585](https://github.com/google-gemini/gemini-cli/pull/19585) +- fix(plan): exclude EnterPlanMode tool from YOLO mode by @Adib234 in + [#19570](https://github.com/google-gemini/gemini-cli/pull/19570) +- chore: resolve build warnings and update dependencies by @mattKorwel in + [#18880](https://github.com/google-gemini/gemini-cli/pull/18880) +- feat(ui): add source indicators to slash commands by @ehedlund in + [#18839](https://github.com/google-gemini/gemini-cli/pull/18839) +- docs: refine Plan Mode documentation structure and workflow by @jerop in + [#19644](https://github.com/google-gemini/gemini-cli/pull/19644) +- Docs: Update release information regarding Gemini 3.1 by @jkcinouye in + [#19568](https://github.com/google-gemini/gemini-cli/pull/19568) +- fix(security): rate limit web_fetch tool to mitigate DDoS via prompt injection + by @mattKorwel in + [#19567](https://github.com/google-gemini/gemini-cli/pull/19567) +- Add initial implementation of /extensions explore command by @chrstnb in + [#19029](https://github.com/google-gemini/gemini-cli/pull/19029) +- fix: use discoverOAuthFromWWWAuthenticate for reactive OAuth flow (#18760) by + @maximus12793 in + [#19038](https://github.com/google-gemini/gemini-cli/pull/19038) +- Search updates by @alisa-alisa in + [#19482](https://github.com/google-gemini/gemini-cli/pull/19482) +- feat(cli): add support for numpad SS3 sequences by @scidomino in + [#19659](https://github.com/google-gemini/gemini-cli/pull/19659) +- feat(cli): enhance folder trust with configuration discovery and security + warnings by @galz10 in + [#19492](https://github.com/google-gemini/gemini-cli/pull/19492) +- feat(ui): improve startup warnings UX with dismissal and show-count limits by + @spencer426 in + [#19584](https://github.com/google-gemini/gemini-cli/pull/19584) +- feat(a2a): Add API key authentication provider by @adamfweidman in + [#19548](https://github.com/google-gemini/gemini-cli/pull/19548) +- Send accepted/removed lines with ACCEPT_FILE telemetry. by @gundermanc in + [#19670](https://github.com/google-gemini/gemini-cli/pull/19670) +- feat(models): support Gemini 3.1 Pro Preview and fixes by @sehoon38 in + [#19676](https://github.com/google-gemini/gemini-cli/pull/19676) +- feat(plan): enforce read-only constraints in Plan Mode by @mattKorwel in + [#19433](https://github.com/google-gemini/gemini-cli/pull/19433) +- fix(cli): allow perfect match @scripts/test-windows-paths.js completions to + submit on Enter by @spencer426 in + [#19562](https://github.com/google-gemini/gemini-cli/pull/19562) +- fix(core): treat 503 Service Unavailable as retryable quota error by @sehoon38 + in [#19642](https://github.com/google-gemini/gemini-cli/pull/19642) +- Update sidebar.json for to allow top nav tabs. by @g-samroberts in + [#19595](https://github.com/google-gemini/gemini-cli/pull/19595) +- security: strip deceptive Unicode characters from terminal output by @ehedlund + in [#19026](https://github.com/google-gemini/gemini-cli/pull/19026) +- Fixes 'input.on' is not a function error in Gemini CLI by @gundermanc in + [#19691](https://github.com/google-gemini/gemini-cli/pull/19691) +- Revert "feat(ui): add source indicators to slash commands" by @ehedlund in + [#19695](https://github.com/google-gemini/gemini-cli/pull/19695) +- security: implement deceptive URL detection and disclosure in tool + confirmations by @ehedlund in + [#19288](https://github.com/google-gemini/gemini-cli/pull/19288) +- fix(core): restore auth consent in headless mode and add unit tests by + @ehedlund in [#19689](https://github.com/google-gemini/gemini-cli/pull/19689) +- Fix unsafe assertions in code_assist folder. by @gundermanc in + [#19706](https://github.com/google-gemini/gemini-cli/pull/19706) +- feat(cli): make JetBrains warning more specific by @jacob314 in + [#19687](https://github.com/google-gemini/gemini-cli/pull/19687) +- fix(cli): extensions dialog UX polish by @jacob314 in + [#19685](https://github.com/google-gemini/gemini-cli/pull/19685) +- fix(cli): use getDisplayString for manual model selection in dialog by + @sehoon38 in [#19726](https://github.com/google-gemini/gemini-cli/pull/19726) +- feat(policy): repurpose "Always Allow" persistence to workspace level by + @Abhijit-2592 in + [#19707](https://github.com/google-gemini/gemini-cli/pull/19707) +- fix(cli): re-enable CLI banner by @sehoon38 in + [#19741](https://github.com/google-gemini/gemini-cli/pull/19741) +- Disallow and suppress unsafe assignment by @gundermanc in + [#19736](https://github.com/google-gemini/gemini-cli/pull/19736) +- feat(core): migrate read_file to 1-based start_line/end_line parameters by + @adamfweidman in + [#19526](https://github.com/google-gemini/gemini-cli/pull/19526) +- feat(cli): improve CTRL+O experience for both standard and alternate screen + buffer (ASB) modes by @jwhelangoog in + [#19010](https://github.com/google-gemini/gemini-cli/pull/19010) +- Utilize pipelining of grep_search -> read_file to eliminate turns by + @gundermanc in + [#19574](https://github.com/google-gemini/gemini-cli/pull/19574) +- refactor(core): remove unsafe type assertions in error utils (Phase 1.1) by @mattKorwel in - [#18870](https://github.com/google-gemini/gemini-cli/pull/18870) -- Show notification when there's a conflict with an extensions command by - @chrstnb in [#17890](https://github.com/google-gemini/gemini-cli/pull/17890) -- fix(cli): dismiss '?' shortcuts help on hotkeys and active states by - @LyalinDotCom in - [#18583](https://github.com/google-gemini/gemini-cli/pull/18583) -- fix(core): prioritize conditional policy rules and harden Plan Mode by - @Abhijit-2592 in - [#18882](https://github.com/google-gemini/gemini-cli/pull/18882) -- feat(core): refine Plan Mode system prompt for agentic execution by + [#19750](https://github.com/google-gemini/gemini-cli/pull/19750) +- Disallow unsafe returns. by @gundermanc in + [#19767](https://github.com/google-gemini/gemini-cli/pull/19767) +- fix(cli): filter subagent sessions from resume history by @abhipatel12 in + [#19698](https://github.com/google-gemini/gemini-cli/pull/19698) +- chore(lint): fix lint errors seen when running npm run lint by @abhipatel12 in + [#19844](https://github.com/google-gemini/gemini-cli/pull/19844) +- feat(core): remove unnecessary login verbiage from Code Assist auth by @NTaylorMullen in - [#18799](https://github.com/google-gemini/gemini-cli/pull/18799) -- feat(plan): create metrics for usage of `AskUser` tool by @Adib234 in - [#18820](https://github.com/google-gemini/gemini-cli/pull/18820) -- feat(cli): support Ctrl-Z suspension by @scidomino in - [#18931](https://github.com/google-gemini/gemini-cli/pull/18931) -- fix(github-actions): use robot PAT for release creation to trigger release - notes by @SandyTao520 in - [#18794](https://github.com/google-gemini/gemini-cli/pull/18794) -- feat: add strict seatbelt profiles and remove unusable closed profiles by + [#19861](https://github.com/google-gemini/gemini-cli/pull/19861) +- fix(plan): time share by approval mode dashboard reporting negative time + shares by @Adib234 in + [#19847](https://github.com/google-gemini/gemini-cli/pull/19847) +- fix(core): allow any preview model in quota access check by @bdmorgan in + [#19867](https://github.com/google-gemini/gemini-cli/pull/19867) +- fix(core): prevent omission placeholder deletions in replace/write_file by + @nsalerni in [#19870](https://github.com/google-gemini/gemini-cli/pull/19870) +- fix(core): add uniqueness guard to edit tool by @Shivangisharma4 in + [#19890](https://github.com/google-gemini/gemini-cli/pull/19890) +- refactor(config): remove enablePromptCompletion from settings by @sehoon38 in + [#19974](https://github.com/google-gemini/gemini-cli/pull/19974) +- refactor(core): move session conversion logic to core by @abhipatel12 in + [#19972](https://github.com/google-gemini/gemini-cli/pull/19972) +- Fix: Persist manual model selection on restart #19864 by @Nixxx19 in + [#19891](https://github.com/google-gemini/gemini-cli/pull/19891) +- fix(core): increase default retry attempts and add quota error backoff by + @sehoon38 in [#19949](https://github.com/google-gemini/gemini-cli/pull/19949) +- feat(core): add policy chain support for Gemini 3.1 by @sehoon38 in + [#19991](https://github.com/google-gemini/gemini-cli/pull/19991) +- Updates command reference and /stats command. by @g-samroberts in + [#19794](https://github.com/google-gemini/gemini-cli/pull/19794) +- Fix for silent failures in non-interactive mode by @owenofbrien in + [#19905](https://github.com/google-gemini/gemini-cli/pull/19905) +- fix(plan): allow plan mode writes on Windows and fix prompt paths by @Adib234 + in [#19658](https://github.com/google-gemini/gemini-cli/pull/19658) +- fix(core): prevent OAuth server crash on unexpected requests by @reyyanxahmed + in [#19668](https://github.com/google-gemini/gemini-cli/pull/19668) +- feat: Map tool kinds to explicit ACP.ToolKind values and update test … by + @sripasg in [#19547](https://github.com/google-gemini/gemini-cli/pull/19547) +- chore: restrict gemini-automted-issue-triage to only allow echo by @galz10 in + [#20047](https://github.com/google-gemini/gemini-cli/pull/20047) +- Allow ask headers longer than 16 chars by @scidomino in + [#20041](https://github.com/google-gemini/gemini-cli/pull/20041) +- fix(core): prevent state corruption in McpClientManager during collis by @h30s + in [#19782](https://github.com/google-gemini/gemini-cli/pull/19782) +- fix(bundling): copy devtools package to bundle for runtime resolution by @SandyTao520 in - [#18876](https://github.com/google-gemini/gemini-cli/pull/18876) -- chore: cleanup unused and add unlisted dependencies in packages/a2a-server by - @adamfweidman in - [#18916](https://github.com/google-gemini/gemini-cli/pull/18916) -- fix(plan): isolate plan files per session by @Adib234 in - [#18757](https://github.com/google-gemini/gemini-cli/pull/18757) -- fix: character truncation in raw markdown mode by @jackwotherspoon in - [#18938](https://github.com/google-gemini/gemini-cli/pull/18938) -- feat(cli): prototype clean UI toggle and minimal-mode bleed-through by - @LyalinDotCom in - [#18683](https://github.com/google-gemini/gemini-cli/pull/18683) -- ui(polish) blend background color with theme by @jacob314 in - [#18802](https://github.com/google-gemini/gemini-cli/pull/18802) -- Add generic searchable list to back settings and extensions by @chrstnb in - [#18838](https://github.com/google-gemini/gemini-cli/pull/18838) -- feat(ui): align `AskUser` color scheme with UX spec by @jerop in - [#18943](https://github.com/google-gemini/gemini-cli/pull/18943) -- Hide AskUser tool validation errors from UI (agent self-corrects) by @jerop in - [#18954](https://github.com/google-gemini/gemini-cli/pull/18954) -- bug(cli) fix flicker due to AppContainer continuous initialization by - @jacob314 in [#18958](https://github.com/google-gemini/gemini-cli/pull/18958) -- feat(admin): Add admin controls documentation by @skeshive in - [#18644](https://github.com/google-gemini/gemini-cli/pull/18644) -- feat(cli): disable ctrl-s shortcut outside of alternate buffer mode by - @jacob314 in [#18887](https://github.com/google-gemini/gemini-cli/pull/18887) -- fix(vim): vim support that feels (more) complete by @ppgranger in - [#18755](https://github.com/google-gemini/gemini-cli/pull/18755) -- feat(policy): add --policy flag for user defined policies by @allenhutchison - in [#18500](https://github.com/google-gemini/gemini-cli/pull/18500) -- Update installation guide by @g-samroberts in - [#18823](https://github.com/google-gemini/gemini-cli/pull/18823) -- refactor(core): centralize tool definitions (Group 1: replace, search, grep) - by @aishaneeshah in - [#18944](https://github.com/google-gemini/gemini-cli/pull/18944) -- refactor(cli): finalize event-driven transition and remove interaction bridge - by @abhipatel12 in - [#18569](https://github.com/google-gemini/gemini-cli/pull/18569) -- Fix drag and drop escaping by @scidomino in - [#18965](https://github.com/google-gemini/gemini-cli/pull/18965) -- feat(sdk): initial package bootstrap for SDK by @mbleigh in - [#18861](https://github.com/google-gemini/gemini-cli/pull/18861) -- feat(sdk): implements SessionContext for SDK tool calls by @mbleigh in - [#18862](https://github.com/google-gemini/gemini-cli/pull/18862) -- fix(plan): make question type required in AskUser tool by @Adib234 in - [#18959](https://github.com/google-gemini/gemini-cli/pull/18959) -- fix(core): ensure --yolo does not force headless mode by @NTaylorMullen in - [#18976](https://github.com/google-gemini/gemini-cli/pull/18976) -- refactor(core): adopt `CoreToolCallStatus` enum for type safety by @jerop in - [#18998](https://github.com/google-gemini/gemini-cli/pull/18998) -- Enable in-CLI extension management commands for team by @chrstnb in - [#18957](https://github.com/google-gemini/gemini-cli/pull/18957) -- Adjust lint rules to avoid unnecessary warning. by @scidomino in - [#18970](https://github.com/google-gemini/gemini-cli/pull/18970) -- fix(vscode): resolve unsafe type assertion lint errors by @ehedlund in - [#19006](https://github.com/google-gemini/gemini-cli/pull/19006) -- Remove unnecessary eslint config file by @scidomino in - [#19015](https://github.com/google-gemini/gemini-cli/pull/19015) -- fix(core): Prevent loop detection false positives on lists with long shared - prefixes by @SandyTao520 in - [#18975](https://github.com/google-gemini/gemini-cli/pull/18975) -- feat(core): fallback to chat-base when using unrecognized models for chat by - @SandyTao520 in - [#19016](https://github.com/google-gemini/gemini-cli/pull/19016) -- docs: fix inconsistent commandRegex example in policy engine by @NTaylorMullen - in [#19027](https://github.com/google-gemini/gemini-cli/pull/19027) -- fix(plan): persist the approval mode in UI even when agent is thinking by - @Adib234 in [#18955](https://github.com/google-gemini/gemini-cli/pull/18955) -- feat(sdk): Implement dynamic system instructions by @mbleigh in - [#18863](https://github.com/google-gemini/gemini-cli/pull/18863) -- Docs: Refresh docs to organize and standardize reference materials. by - @jkcinouye in [#18403](https://github.com/google-gemini/gemini-cli/pull/18403) -- fix windows escaping (and broken tests) by @scidomino in - [#19011](https://github.com/google-gemini/gemini-cli/pull/19011) -- refactor: use `CoreToolCallStatus` in the the history data model by @jerop in - [#19033](https://github.com/google-gemini/gemini-cli/pull/19033) -- feat(cleanup): enable 30-day session retention by default by @skeshive in - [#18854](https://github.com/google-gemini/gemini-cli/pull/18854) -- feat(plan): hide plan write and edit operations on plans in Plan Mode by - @jerop in [#19012](https://github.com/google-gemini/gemini-cli/pull/19012) -- bug(ui) fix flicker refreshing background color by @jacob314 in - [#19041](https://github.com/google-gemini/gemini-cli/pull/19041) -- chore: fix dep vulnerabilities by @scidomino in - [#19036](https://github.com/google-gemini/gemini-cli/pull/19036) -- Revamp automated changelog skill by @g-samroberts in - [#18974](https://github.com/google-gemini/gemini-cli/pull/18974) -- feat(sdk): implement support for custom skills by @mbleigh in - [#19031](https://github.com/google-gemini/gemini-cli/pull/19031) -- refactor(core): complete centralization of core tool definitions by + [#19766](https://github.com/google-gemini/gemini-cli/pull/19766) +- feat(policy): Support MCP Server Wildcards in Policy Engine by @jerop in + [#20024](https://github.com/google-gemini/gemini-cli/pull/20024) +- docs(CONTRIBUTING): update React DevTools version to 6 by @mmgok in + [#20014](https://github.com/google-gemini/gemini-cli/pull/20014) +- feat(core): optimize tool descriptions and schemas for Gemini 3 by @aishaneeshah in - [#18991](https://github.com/google-gemini/gemini-cli/pull/18991) -- feat: add /commands reload to refresh custom TOML commands by @korade-krushna - in [#19078](https://github.com/google-gemini/gemini-cli/pull/19078) -- fix(cli): wrap terminal capability queries in hidden sequence by @srithreepo - in [#19080](https://github.com/google-gemini/gemini-cli/pull/19080) -- fix(workflows): fix GitHub App token permissions for maintainer detection by - @bdmorgan in [#19139](https://github.com/google-gemini/gemini-cli/pull/19139) -- test: fix hook integration test flakiness on Windows CI by @NTaylorMullen in - [#18665](https://github.com/google-gemini/gemini-cli/pull/18665) -- fix(core): Encourage non-interactive flags for scaffolding commands by - @NTaylorMullen in - [#18804](https://github.com/google-gemini/gemini-cli/pull/18804) -- fix(core): propagate User-Agent header to setup-phase CodeAssist API calls by - @gsquared94 in - [#19182](https://github.com/google-gemini/gemini-cli/pull/19182) -- docs: document .agents/skills alias and discovery precedence by @kevmoo in - [#19166](https://github.com/google-gemini/gemini-cli/pull/19166) -- feat(cli): add loading state to new agents notification by @sehoon38 in - [#19190](https://github.com/google-gemini/gemini-cli/pull/19190) -- Add base branch to workflow. by @g-samroberts in - [#19189](https://github.com/google-gemini/gemini-cli/pull/19189) -- feat(cli): handle invalid model names in useQuotaAndFallback by @sehoon38 in - [#19222](https://github.com/google-gemini/gemini-cli/pull/19222) -- docs: custom themes in extensions by @jackwotherspoon in - [#19219](https://github.com/google-gemini/gemini-cli/pull/19219) -- Disable workspace settings when starting GCLI in the home directory. by - @kevinjwang1 in - [#19034](https://github.com/google-gemini/gemini-cli/pull/19034) -- feat(cli): refactor model command to support set and manage subcommands by - @sehoon38 in [#19221](https://github.com/google-gemini/gemini-cli/pull/19221) -- Add refresh/reload aliases to slash command subcommands by @korade-krushna in - [#19218](https://github.com/google-gemini/gemini-cli/pull/19218) -- refactor: consolidate development rules and add cli guidelines by @jacob314 in - [#19214](https://github.com/google-gemini/gemini-cli/pull/19214) -- chore(ui): remove outdated tip about model routing by @sehoon38 in - [#19226](https://github.com/google-gemini/gemini-cli/pull/19226) -- feat(core): support custom reasoning models by default by @NTaylorMullen in - [#19227](https://github.com/google-gemini/gemini-cli/pull/19227) -- Add Solarized Dark and Solarized Light themes by @rmedranollamas in - [#19064](https://github.com/google-gemini/gemini-cli/pull/19064) -- fix(telemetry): replace JSON.stringify with safeJsonStringify in file - exporters by @gsquared94 in - [#19244](https://github.com/google-gemini/gemini-cli/pull/19244) -- feat(telemetry): add keychain availability and token storage metrics by - @abhipatel12 in - [#18971](https://github.com/google-gemini/gemini-cli/pull/18971) -- feat(cli): update approval mode cycle order by @jerop in - [#19254](https://github.com/google-gemini/gemini-cli/pull/19254) -- refactor(cli): code review cleanup fix for tab+tab by @jacob314 in - [#18967](https://github.com/google-gemini/gemini-cli/pull/18967) -- feat(plan): support project exploration without planning when in plan mode by - @Adib234 in [#18992](https://github.com/google-gemini/gemini-cli/pull/18992) -- feat: add role-specific statistics to telemetry and UI (cont. #15234) by - @yunaseoul in [#18824](https://github.com/google-gemini/gemini-cli/pull/18824) -- feat(cli): remove Plan Mode from rotation when actively working by @jerop in - [#19262](https://github.com/google-gemini/gemini-cli/pull/19262) -- Fix side breakage where anchors don't work in slugs. by @g-samroberts in - [#19261](https://github.com/google-gemini/gemini-cli/pull/19261) -- feat(config): add setting to make directory tree context configurable by - @kevin-ramdass in - [#19053](https://github.com/google-gemini/gemini-cli/pull/19053) -- fix(acp): Wait for mcp initialization in acp (#18893) by @Mervap in - [#18894](https://github.com/google-gemini/gemini-cli/pull/18894) -- docs: format UTC times in releases doc by @pavan-sh in - [#18169](https://github.com/google-gemini/gemini-cli/pull/18169) -- Docs: Clarify extensions documentation. by @jkcinouye in - [#19277](https://github.com/google-gemini/gemini-cli/pull/19277) -- refactor(core): modularize tool definitions by model family by @aishaneeshah - in [#19269](https://github.com/google-gemini/gemini-cli/pull/19269) -- fix(paths): Add cross-platform path normalization by @spencer426 in - [#18939](https://github.com/google-gemini/gemini-cli/pull/18939) -- feat(core): experimental in-progress steering hints (1 of 3) by @joshualitt in - [#19008](https://github.com/google-gemini/gemini-cli/pull/19008) + [#19643](https://github.com/google-gemini/gemini-cli/pull/19643) +- feat(core): implement experimental direct web fetch by @mbleigh in + [#19557](https://github.com/google-gemini/gemini-cli/pull/19557) +- feat(core): replace expected_replacements with allow_multiple in replace tool + by @SandyTao520 in + [#20033](https://github.com/google-gemini/gemini-cli/pull/20033) +- fix(sandbox): harden image packaging integrity checks by @aviralgarg05 in + [#19552](https://github.com/google-gemini/gemini-cli/pull/19552) +- fix(core): allow environment variable expansion and explicit overrides for MCP + servers by @galz10 in + [#18837](https://github.com/google-gemini/gemini-cli/pull/18837) +- feat(policy): Implement Tool Annotation Matching in Policy Engine by @jerop in + [#20029](https://github.com/google-gemini/gemini-cli/pull/20029) +- fix(core): prevent utility calls from changing session active model by + @adamfweidman in + [#20035](https://github.com/google-gemini/gemini-cli/pull/20035) +- fix(cli): skip workspace policy loading when in home directory by + @Abhijit-2592 in + [#20054](https://github.com/google-gemini/gemini-cli/pull/20054) +- fix(scripts): Add Windows (win32/x64) support to lint.js by @ZafeerMahmood in + [#16193](https://github.com/google-gemini/gemini-cli/pull/16193) +- fix(a2a-server): Remove unsafe type assertions in agent by @Nixxx19 in + [#19723](https://github.com/google-gemini/gemini-cli/pull/19723) +- Fix: Handle corrupted token file gracefully when switching auth types (#19845) + by @Nixxx19 in + [#19850](https://github.com/google-gemini/gemini-cli/pull/19850) +- fix critical dep vulnerability by @scidomino in + [#20087](https://github.com/google-gemini/gemini-cli/pull/20087) +- Add new setting to configure maxRetries by @kevinjwang1 in + [#20064](https://github.com/google-gemini/gemini-cli/pull/20064) +- Stabilize tests. by @gundermanc in + [#20095](https://github.com/google-gemini/gemini-cli/pull/20095) +- make windows tests mandatory by @scidomino in + [#20096](https://github.com/google-gemini/gemini-cli/pull/20096) +- Add 3.1 pro preview to behavioral evals. by @gundermanc in + [#20088](https://github.com/google-gemini/gemini-cli/pull/20088) +- feat:PR-rate-limit by @JagjeevanAK in + [#19804](https://github.com/google-gemini/gemini-cli/pull/19804) +- feat(cli): allow expanding full details of MCP tool on approval by @y-okt in + [#19916](https://github.com/google-gemini/gemini-cli/pull/19916) +- feat(security): Introduce Conseca framework by @shrishabh in + [#13193](https://github.com/google-gemini/gemini-cli/pull/13193) +- fix(cli): Remove unsafe type assertions in activityLogger #19713 by @Nixxx19 + in [#19745](https://github.com/google-gemini/gemini-cli/pull/19745) +- feat: implement AfterTool tail tool calls by @googlestrobe in + [#18486](https://github.com/google-gemini/gemini-cli/pull/18486) +- ci(actions): fix PR rate limiter excluding maintainers by @scidomino in + [#20117](https://github.com/google-gemini/gemini-cli/pull/20117) +- Shortcuts: Move SectionHeader title below top line and refine styling by + @keithguerin in + [#18721](https://github.com/google-gemini/gemini-cli/pull/18721) +- refactor(ui): Update and simplify use of gray colors in themes by @keithguerin + in [#20141](https://github.com/google-gemini/gemini-cli/pull/20141) +- fix punycode2 by @jacob314 in + [#20154](https://github.com/google-gemini/gemini-cli/pull/20154) +- feat(ide): add GEMINI_CLI_IDE_PID env var to override IDE process detection by + @kiryltech in [#15842](https://github.com/google-gemini/gemini-cli/pull/15842) +- feat(policy): Propagate Tool Annotations for MCP Servers by @jerop in + [#20083](https://github.com/google-gemini/gemini-cli/pull/20083) +- fix(a2a-server): pass allowedTools settings to core Config by @reyyanxahmed in + [#19680](https://github.com/google-gemini/gemini-cli/pull/19680) +- feat(mcp): add progress bar, throttling, and input validation for MCP tool + progress by @jasmeetsb in + [#19772](https://github.com/google-gemini/gemini-cli/pull/19772) +- feat(policy): centralize plan mode tool visibility in policy engine by @jerop + in [#20178](https://github.com/google-gemini/gemini-cli/pull/20178) +- feat(browser): implement experimental browser agent by @gsquared94 in + [#19284](https://github.com/google-gemini/gemini-cli/pull/19284) +- feat(plan): summarize work after executing a plan by @jerop in + [#19432](https://github.com/google-gemini/gemini-cli/pull/19432) +- fix(core): create new McpClient on restart to apply updated config by @h30s in + [#20126](https://github.com/google-gemini/gemini-cli/pull/20126) +- Changelog for v0.30.0-preview.5 by @gemini-cli-robot in + [#20107](https://github.com/google-gemini/gemini-cli/pull/20107) +- Update packages. by @jacob314 in + [#20152](https://github.com/google-gemini/gemini-cli/pull/20152) +- Fix extension env dir loading issue by @chrstnb in + [#20198](https://github.com/google-gemini/gemini-cli/pull/20198) +- restrict /assign to help-wanted issues by @scidomino in + [#20207](https://github.com/google-gemini/gemini-cli/pull/20207) +- feat(plan): inject message when user manually exits Plan mode by @jerop in + [#20203](https://github.com/google-gemini/gemini-cli/pull/20203) +- feat(extensions): enforce folder trust for local extension install by @galz10 + in [#19703](https://github.com/google-gemini/gemini-cli/pull/19703) +- feat(hooks): adds support for RuntimeHook functions. by @mbleigh in + [#19598](https://github.com/google-gemini/gemini-cli/pull/19598) +- Docs: Update UI links. by @jkcinouye in + [#20224](https://github.com/google-gemini/gemini-cli/pull/20224) +- feat: prompt users to run /terminal-setup with yes/no by @ishaanxgupta in + [#16235](https://github.com/google-gemini/gemini-cli/pull/16235) +- fix: additional high vulnerabilities (minimatch, cross-spawn) by @adamfweidman + in [#20221](https://github.com/google-gemini/gemini-cli/pull/20221) +- feat(telemetry): Add context breakdown to API response event by @SandyTao520 + in [#19699](https://github.com/google-gemini/gemini-cli/pull/19699) +- Docs: Add nested sub-folders for related topics by @g-samroberts in + [#20235](https://github.com/google-gemini/gemini-cli/pull/20235) +- feat(plan): support automatic model switching for Plan Mode by @jerop in + [#20240](https://github.com/google-gemini/gemini-cli/pull/20240) -**Full changelog**: -https://github.com/google-gemini/gemini-cli/compare/v0.29.0-preview.5...v0.30.0-preview.5 +**Full Changelog**: +https://github.com/google-gemini/gemini-cli/compare/v0.30.0-preview.6...v0.31.0-preview.0 diff --git a/docs/cli/plan-mode.md b/docs/cli/plan-mode.md index ef41631302..03dd92967f 100644 --- a/docs/cli/plan-mode.md +++ b/docs/cli/plan-mode.md @@ -80,18 +80,37 @@ manually during a session. ### Planning Workflow +Plan Mode uses an adaptive planning workflow where the research depth, plan +structure, and consultation level are proportional to the task's complexity: + 1. **Explore & Analyze:** Analyze requirements and use read-only tools to map - the codebase and validate assumptions. For complex tasks, identify at least - two viable implementation approaches. -2. **Consult:** Present a summary of the identified approaches via [`ask_user`] - to obtain a selection. For simple or canonical tasks, this step may be - skipped. -3. **Draft:** Once an approach is selected, write a detailed implementation - plan to the plans directory. + affected modules and identify dependencies. +2. **Consult:** The depth of consultation is proportional to the task's + complexity: + - **Simple Tasks:** Proceed directly to drafting. + - **Standard Tasks:** Present a summary of viable approaches via + [`ask_user`] for selection. + - **Complex Tasks:** Present detailed trade-offs for at least two viable + approaches via [`ask_user`] and obtain approval before drafting. +3. **Draft:** Write a detailed implementation plan to the + [plans directory](#custom-plan-directory-and-policies). The plan's structure + adapts to the task: + - **Simple Tasks:** Focused on specific **Changes** and **Verification** + steps. + - **Standard Tasks:** Includes an **Objective**, **Key Files & Context**, + **Implementation Steps**, and **Verification & Testing**. + - **Complex Tasks:** Comprehensive plans including **Background & + Motivation**, **Scope & Impact**, **Proposed Solution**, **Alternatives + Considered**, a phased **Implementation Plan**, **Verification**, and + **Migration & Rollback** strategies. 4. **Review & Approval:** Use the [`exit_plan_mode`] tool to present the plan and formally request approval. - **Approve:** Exit Plan Mode and start implementation. - **Iterate:** Provide feedback to refine the plan. + - **Refine manually:** Press **Ctrl + X** to open the plan file in your + [preferred external editor]. This allows you to manually refine the plan + steps before approval. The CLI will automatically refresh and show the + updated plan after you save and close the editor. For more complex or specialized planning tasks, you can [customize the planning workflow with skills](#customizing-planning-with-skills). @@ -119,6 +138,7 @@ These are the only allowed tools: - **Planning (Write):** [`write_file`] and [`replace`] only allowed for `.md` files in the `~/.gemini/tmp///plans/` directory or your [custom plans directory](#custom-plan-directory-and-policies). +- **Memory:** [`save_memory`] - **Skills:** [`activate_skill`] (allows loading specialized instructions and resources in a read-only manner) @@ -277,6 +297,7 @@ performance. You can disable this automatic switching in your settings: [`google_web_search`]: /docs/tools/web-search.md [`replace`]: /docs/tools/file-system.md#6-replace-edit [MCP tools]: /docs/tools/mcp-server.md +[`save_memory`]: /docs/tools/memory.md [`activate_skill`]: /docs/cli/skills.md [subagents]: /docs/core/subagents.md [policy engine]: /docs/reference/policy-engine.md @@ -288,3 +309,4 @@ performance. You can disable this automatic switching in your settings: https://github.com/google-gemini/gemini-cli/blob/main/packages/core/src/policy/policies/plan.toml [auto model]: /docs/reference/configuration.md#model-settings [model routing]: /docs/cli/telemetry.md#model-routing +[preferred external editor]: /docs/reference/configuration.md#general diff --git a/docs/cli/settings.md b/docs/cli/settings.md index 8adccba6ae..b0c12116d6 100644 --- a/docs/cli/settings.md +++ b/docs/cli/settings.md @@ -140,6 +140,7 @@ they appear in the UI. | Plan | `experimental.plan` | Enable planning features (Plan Mode and tools). | `false` | | Model Steering | `experimental.modelSteering` | Enable model steering (user hints) to guide the model during tool execution. | `false` | | Direct Web Fetch | `experimental.directWebFetch` | Enable web fetch behavior that bypasses LLM summarization. | `false` | +| Enable Gemma Model Router | `experimental.gemmaModelRouter.enabled` | Enable the Gemma Model Router. Requires a local endpoint serving Gemma via the Gemini API using LiteRT-LM shim. | `false` | ### Skills diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index 5337d973b8..c1c67803b0 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -1014,6 +1014,23 @@ their corresponding top-level category object in your `settings.json` file. - **Default:** `false` - **Requires restart:** Yes +- **`experimental.gemmaModelRouter.enabled`** (boolean): + - **Description:** Enable the Gemma Model Router. Requires a local endpoint + serving Gemma via the Gemini API using LiteRT-LM shim. + - **Default:** `false` + - **Requires restart:** Yes + +- **`experimental.gemmaModelRouter.classifier.host`** (string): + - **Description:** The host of the classifier. + - **Default:** `"http://localhost:9379"` + - **Requires restart:** Yes + +- **`experimental.gemmaModelRouter.classifier.model`** (string): + - **Description:** The model to use for the classifier. Only tested on + `gemma3-1b-gpu-custom`. + - **Default:** `"gemma3-1b-gpu-custom"` + - **Requires restart:** Yes + #### `skills` - **`skills.enabled`** (boolean): diff --git a/docs/reference/keyboard-shortcuts.md b/docs/reference/keyboard-shortcuts.md index 1402422c6b..4fc28804f7 100644 --- a/docs/reference/keyboard-shortcuts.md +++ b/docs/reference/keyboard-shortcuts.md @@ -87,12 +87,12 @@ available combinations. #### Text Input -| Action | Keys | -| ---------------------------------------------- | ----------------------------------------------------------------------------------------- | -| Submit the current prompt. | `Enter (no Shift, Alt, Ctrl, Cmd)` | -| Insert a newline without submitting. | `Ctrl + Enter`
`Cmd + Enter`
`Alt + Enter`
`Shift + Enter`
`Ctrl + J` | -| Open the current prompt in an external editor. | `Ctrl + X` | -| Paste from the clipboard. | `Ctrl + V`
`Cmd + V`
`Alt + V` | +| Action | Keys | +| ---------------------------------------------------------- | ----------------------------------------------------------------------------------------- | +| Submit the current prompt. | `Enter (no Shift, Alt, Ctrl, Cmd)` | +| Insert a newline without submitting. | `Ctrl + Enter`
`Cmd + Enter`
`Alt + Enter`
`Shift + Enter`
`Ctrl + J` | +| Open the current prompt or the plan in an external editor. | `Ctrl + X` | +| Paste from the clipboard. | `Ctrl + V`
`Cmd + V`
`Alt + V` | #### App Controls diff --git a/evals/generalist_delegation.eval.ts b/evals/generalist_delegation.eval.ts new file mode 100644 index 0000000000..7e6358ae1f --- /dev/null +++ b/evals/generalist_delegation.eval.ts @@ -0,0 +1,165 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect } from 'vitest'; +import { appEvalTest } from './app-test-helper.js'; + +describe('generalist_delegation', () => { + // --- Positive Evals (Should Delegate) --- + + appEvalTest('USUALLY_PASSES', { + name: 'should delegate batch error fixing to generalist agent', + configOverrides: { + agents: { + overrides: { + generalist: { enabled: true }, + }, + }, + experimental: { + enableAgents: true, + }, + excludeTools: ['run_shell_command'], + }, + files: { + 'file1.ts': 'console.log("no semi")', + 'file2.ts': 'console.log("no semi")', + 'file3.ts': 'console.log("no semi")', + 'file4.ts': 'console.log("no semi")', + 'file5.ts': 'console.log("no semi")', + 'file6.ts': 'console.log("no semi")', + 'file7.ts': 'console.log("no semi")', + 'file8.ts': 'console.log("no semi")', + 'file9.ts': 'console.log("no semi")', + 'file10.ts': 'console.log("no semi")', + }, + prompt: + 'I have 10 files (file1.ts to file10.ts) that are missing semicolons. Can you fix them?', + setup: async (rig) => { + rig.setBreakpoint(['generalist']); + }, + assert: async (rig) => { + const confirmation = await rig.waitForPendingConfirmation( + 'generalist', + 60000, + ); + expect( + confirmation, + 'Expected a tool call for generalist agent', + ).toBeTruthy(); + await rig.resolveTool(confirmation); + await rig.waitForIdle(60000); + }, + }); + + appEvalTest('USUALLY_PASSES', { + name: 'should autonomously delegate complex batch task to generalist agent', + configOverrides: { + agents: { + overrides: { + generalist: { enabled: true }, + }, + }, + experimental: { + enableAgents: true, + }, + excludeTools: ['run_shell_command'], + }, + files: { + 'src/a.ts': 'export const a = 1;', + 'src/b.ts': 'export const b = 2;', + 'src/c.ts': 'export const c = 3;', + 'src/d.ts': 'export const d = 4;', + 'src/e.ts': 'export const e = 5;', + }, + prompt: + 'Please update all files in the src directory. For each file, add a comment at the top that says "Processed by Gemini".', + setup: async (rig) => { + rig.setBreakpoint(['generalist']); + }, + assert: async (rig) => { + const confirmation = await rig.waitForPendingConfirmation( + 'generalist', + 60000, + ); + expect( + confirmation, + 'Expected autonomously delegate to generalist for batch task', + ).toBeTruthy(); + await rig.resolveTool(confirmation); + await rig.waitForIdle(60000); + }, + }); + + // --- Negative Evals (Should NOT Delegate - Assertive Handling) --- + + appEvalTest('USUALLY_PASSES', { + name: 'should NOT delegate simple read and fix to generalist agent', + configOverrides: { + agents: { + overrides: { + generalist: { enabled: true }, + }, + }, + experimental: { + enableAgents: true, + }, + excludeTools: ['run_shell_command'], + }, + files: { + 'README.md': 'This is a proyect.', + }, + prompt: + 'There is a typo in README.md ("proyect"). Please fix it to "project".', + setup: async (rig) => { + // Break on everything to see what it calls + rig.setBreakpoint(['*']); + }, + assert: async (rig) => { + await rig.drainBreakpointsUntilIdle((confirmation) => { + expect( + confirmation.toolName, + `Agent should NOT have delegated to generalist.`, + ).not.toBe('generalist'); + }); + + const output = rig.getStaticOutput(); + expect(output).toMatch(/project/i); + }, + }); + + appEvalTest('USUALLY_PASSES', { + name: 'should NOT delegate simple direct question to generalist agent', + configOverrides: { + agents: { + overrides: { + generalist: { enabled: true }, + }, + }, + experimental: { + enableAgents: true, + }, + excludeTools: ['run_shell_command'], + }, + files: { + 'src/VERSION': '1.2.3', + }, + prompt: 'Can you tell me the version number in the src folder?', + setup: async (rig) => { + rig.setBreakpoint(['*']); + }, + assert: async (rig) => { + await rig.drainBreakpointsUntilIdle((confirmation) => { + expect( + confirmation.toolName, + `Agent should NOT have delegated to generalist.`, + ).not.toBe('generalist'); + }); + + const output = rig.getStaticOutput(); + expect(output).toMatch(/1\.2\.3/); + }, + }); +}); diff --git a/evals/grep_search_functionality.eval.ts b/evals/grep_search_functionality.eval.ts index 77df3b950f..f1224b8221 100644 --- a/evals/grep_search_functionality.eval.ts +++ b/evals/grep_search_functionality.eval.ts @@ -93,7 +93,7 @@ describe('grep_search_functionality', () => { }); evalTest('USUALLY_PASSES', { - name: 'should search only within the specified include glob', + name: 'should search only within the specified include_pattern glob', files: { 'file.js': 'my_function();', 'file.ts': 'my_function();', @@ -105,19 +105,19 @@ describe('grep_search_functionality', () => { undefined, (args) => { const params = JSON.parse(args); - return params.include === '*.js'; + return params.include_pattern === '*.js'; }, ); expect( wasToolCalled, - 'Expected grep_search to be called with include: "*.js"', + 'Expected grep_search to be called with include_pattern: "*.js"', ).toBe(true); assertModelHasOutput(result); checkModelOutputContent(result, { expectedContent: [/file.js/], forbiddenContent: [/file.ts/], - testName: `${TEST_PREFIX}include glob search`, + testName: `${TEST_PREFIX}include_pattern glob search`, }); }, }); diff --git a/integration-tests/file-system.test.ts b/integration-tests/file-system.test.ts index bdcffedaf8..64481068c2 100644 --- a/integration-tests/file-system.test.ts +++ b/integration-tests/file-system.test.ts @@ -55,8 +55,8 @@ describe('file-system', () => { }); }); - it('should be able to write a file', async () => { - await rig.setup('should be able to write a file', { + it('should be able to write a hello world message to a file', async () => { + await rig.setup('should be able to write a hello world message to a file', { settings: { tools: { core: ['write_file', 'replace', 'read_file'] } }, }); rig.createFile('test.txt', ''); diff --git a/integration-tests/plan-mode.test.ts b/integration-tests/plan-mode.test.ts index 784bb890a0..f71006a36c 100644 --- a/integration-tests/plan-mode.test.ts +++ b/integration-tests/plan-mode.test.ts @@ -62,7 +62,7 @@ describe('Plan Mode', () => { }); }); - it('should allow write_file only in the plans directory in plan mode', async () => { + it.skip('should allow write_file only in the plans directory in plan mode', async () => { await rig.setup( 'should allow write_file only in the plans directory in plan mode', { diff --git a/integration-tests/ripgrep-real.test.ts b/integration-tests/ripgrep-real.test.ts index 3ac8a0f16e..60f99c8a84 100644 --- a/integration-tests/ripgrep-real.test.ts +++ b/integration-tests/ripgrep-real.test.ts @@ -102,7 +102,10 @@ describe('ripgrep-real-direct', () => { 'console.log("hello");\n', ); - const invocation = tool.build({ pattern: 'hello', include: '*.js' }); + const invocation = tool.build({ + pattern: 'hello', + include_pattern: '*.js', + }); const result = await invocation.execute(new AbortController().signal); expect(result.llmContent).toContain('Found 1 match'); diff --git a/integration-tests/write_file.test.ts b/integration-tests/write_file.test.ts index 8069b1ca87..ece2a11aa4 100644 --- a/integration-tests/write_file.test.ts +++ b/integration-tests/write_file.test.ts @@ -22,8 +22,8 @@ describe('write_file', () => { afterEach(async () => await rig.cleanup()); - it('should be able to write a file', async () => { - await rig.setup('should be able to write a file', { + it('should be able to write a joke to a file', async () => { + await rig.setup('should be able to write a joke to a file', { settings: { tools: { core: ['write_file', 'read_file'] } }, }); const prompt = `show me an example of using the write tool. put a dad joke in dad.txt`; diff --git a/package-lock.json b/package-lock.json index 5f0c5f058d..82bf1c2221 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2292,7 +2292,6 @@ "integrity": "sha512-t54CUOsFMappY1Jbzb7fetWeO0n6K0k/4+/ZpkS+3Joz8I4VcvY9OiEBFRYISqaI2fq5sCiPtAjRDOzVYG8m+Q==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@octokit/auth-token": "^6.0.0", "@octokit/graphql": "^9.0.2", @@ -2473,7 +2472,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", "license": "Apache-2.0", - "peer": true, "engines": { "node": ">=8.0.0" } @@ -2523,7 +2521,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.5.0.tgz", "integrity": "sha512-ka4H8OM6+DlUhSAZpONu0cPBtPPTQKxbxVzC4CzVx5+K4JnroJVBtDzLAMx4/3CDTJXRvVFhpFjtl4SaiTNoyQ==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, @@ -2898,7 +2895,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.5.0.tgz", "integrity": "sha512-F8W52ApePshpoSrfsSk1H2yJn9aKjCrbpQF1M9Qii0GHzbfVeFUB+rc3X4aggyZD8x9Gu3Slua+s6krmq6Dt8g==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@opentelemetry/core": "2.5.0", "@opentelemetry/semantic-conventions": "^1.29.0" @@ -2932,7 +2928,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.5.0.tgz", "integrity": "sha512-BeJLtU+f5Gf905cJX9vXFQorAr6TAfK3SPvTFqP+scfIpDQEJfRaGJWta7sJgP+m4dNtBf9y3yvBKVAZZtJQVA==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@opentelemetry/core": "2.5.0", "@opentelemetry/resources": "2.5.0" @@ -2987,7 +2982,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.5.0.tgz", "integrity": "sha512-VzRf8LzotASEyNDUxTdaJ9IRJ1/h692WyArDBInf5puLCjxbICD6XkHgpuudis56EndyS7LYFmtTMny6UABNdQ==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@opentelemetry/core": "2.5.0", "@opentelemetry/resources": "2.5.0", @@ -4184,7 +4178,6 @@ "integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -4458,7 +4451,6 @@ "integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.56.1", "@typescript-eslint/types": "8.56.1", @@ -5306,7 +5298,6 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -7860,7 +7851,6 @@ "integrity": "sha512-VmQ+sifHUbI/IcSopBCF/HO3YiHQx/AVd3UVyYL6weuwW+HvON9VYn5l6Zl1WZzPWXPNZrSQpxwkkZ/VuvJZzg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -8493,7 +8483,6 @@ "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "license": "MIT", - "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -9788,7 +9777,6 @@ "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.2.tgz", "integrity": "sha512-gJnaDHXKDayjt8ue0n8Gs0A007yKXj4Xzb8+cNjZeYsSzzwKc0Lr+OZgYwVfB0pHfUs17EPoLvrOsEaJ9mj+Tg==", "license": "MIT", - "peer": true, "engines": { "node": ">=16.9.0" } @@ -10068,7 +10056,6 @@ "resolved": "https://registry.npmjs.org/@jrichman/ink/-/ink-6.4.11.tgz", "integrity": "sha512-93LQlzT7vvZ1XJcmOMwN4s+6W334QegendeHOMnEJBlhnpIzr8bws6/aOEHG8ZCuVD/vNeeea5m1msHIdAY6ig==", "license": "MIT", - "peer": true, "dependencies": { "@alcalzone/ansi-tokenize": "^0.2.1", "ansi-escapes": "^7.0.0", @@ -13718,7 +13705,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -13729,7 +13715,6 @@ "integrity": "sha512-ePrwPfxAnB+7hgnEr8vpKxL9cmnp7F322t8oqcPshbIQQhDKgFDW4tjhF2wjVbdXF9O/nyuy3sQWd9JGpiLPvA==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "shell-quote": "^1.6.1", "ws": "^7" @@ -15689,7 +15674,6 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -15913,8 +15897,7 @@ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "dev": true, - "license": "0BSD", - "peer": true + "license": "0BSD" }, "node_modules/tsx": { "version": "4.20.3", @@ -15922,7 +15905,6 @@ "integrity": "sha512-qjbnuR9Tr+FJOMBqJCW5ehvIo/buZq7vH7qD7JziU98h6l3qGy0a/yPFjwO+y0/T7GFpNgNAvEcPPVfyT8rrPQ==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "~0.25.0", "get-tsconfig": "^4.7.5" @@ -16082,7 +16064,6 @@ "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -16291,7 +16272,6 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-7.2.2.tgz", "integrity": "sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==", "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -16405,7 +16385,6 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -16418,7 +16397,6 @@ "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", "license": "MIT", - "peer": true, "dependencies": { "@types/chai": "^5.2.2", "@vitest/expect": "3.2.4", @@ -17063,7 +17041,6 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } @@ -17463,7 +17440,6 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, diff --git a/packages/cli/src/commands/extensions/link.test.ts b/packages/cli/src/commands/extensions/link.test.ts index bdd8c06b49..67351a5456 100644 --- a/packages/cli/src/commands/extensions/link.test.ts +++ b/packages/cli/src/commands/extensions/link.test.ts @@ -13,34 +13,21 @@ import { afterEach, type Mock, } from 'vitest'; -import { format } from 'node:util'; +import { coreEvents } from '@google/gemini-cli-core'; import { type Argv } from 'yargs'; import { handleLink, linkCommand } from './link.js'; import { ExtensionManager } from '../../config/extension-manager.js'; import { loadSettings, type LoadedSettings } from '../../config/settings.js'; import { getErrorMessage } from '../../utils/errors.js'; -// Mock dependencies -const emitConsoleLog = vi.hoisted(() => vi.fn()); -const debugLogger = vi.hoisted(() => ({ - log: vi.fn((message, ...args) => { - emitConsoleLog('log', format(message, ...args)); - }), - error: vi.fn((message, ...args) => { - emitConsoleLog('error', format(message, ...args)); - }), -})); - vi.mock('@google/gemini-cli-core', async (importOriginal) => { - const actual = - await importOriginal(); - return { - ...actual, - coreEvents: { - emitConsoleLog, - }, - debugLogger, - }; + const { mockCoreDebugLogger } = await import( + '../../test-utils/mockDebugLogger.js' + ); + return mockCoreDebugLogger( + await importOriginal(), + { stripAnsi: true }, + ); }); vi.mock('../../config/extension-manager.js'); @@ -95,7 +82,7 @@ describe('extensions link command', () => { source: '/local/path/to/extension', type: 'link', }); - expect(emitConsoleLog).toHaveBeenCalledWith( + expect(coreEvents.emitConsoleLog).toHaveBeenCalledWith( 'log', 'Extension "my-linked-extension" linked successfully and enabled.', ); @@ -116,7 +103,7 @@ describe('extensions link command', () => { await handleLink({ path: '/local/path/to/extension' }); - expect(emitConsoleLog).toHaveBeenCalledWith( + expect(coreEvents.emitConsoleLog).toHaveBeenCalledWith( 'error', 'Link failed message', ); diff --git a/packages/cli/src/commands/extensions/list.test.ts b/packages/cli/src/commands/extensions/list.test.ts index 6967719be8..f0f0168f79 100644 --- a/packages/cli/src/commands/extensions/list.test.ts +++ b/packages/cli/src/commands/extensions/list.test.ts @@ -5,33 +5,22 @@ */ import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { format } from 'node:util'; +import { coreEvents } from '@google/gemini-cli-core'; import { handleList, listCommand } from './list.js'; import { ExtensionManager } from '../../config/extension-manager.js'; import { loadSettings, type LoadedSettings } from '../../config/settings.js'; import { getErrorMessage } from '../../utils/errors.js'; -// Mock dependencies -const emitConsoleLog = vi.hoisted(() => vi.fn()); -const debugLogger = vi.hoisted(() => ({ - log: vi.fn((message, ...args) => { - emitConsoleLog('log', format(message, ...args)); - }), - error: vi.fn((message, ...args) => { - emitConsoleLog('error', format(message, ...args)); - }), -})); - vi.mock('@google/gemini-cli-core', async (importOriginal) => { - const actual = - await importOriginal(); - return { - ...actual, - coreEvents: { - emitConsoleLog, + const { mockCoreDebugLogger } = await import( + '../../test-utils/mockDebugLogger.js' + ); + return mockCoreDebugLogger( + await importOriginal(), + { + stripAnsi: false, }, - debugLogger, - }; + ); }); vi.mock('../../config/extension-manager.js'); @@ -71,7 +60,7 @@ describe('extensions list command', () => { .mockResolvedValue([]); await handleList(); - expect(emitConsoleLog).toHaveBeenCalledWith( + expect(coreEvents.emitConsoleLog).toHaveBeenCalledWith( 'log', 'No extensions installed.', ); @@ -85,7 +74,7 @@ describe('extensions list command', () => { .mockResolvedValue([]); await handleList({ outputFormat: 'json' }); - expect(emitConsoleLog).toHaveBeenCalledWith('log', '[]'); + expect(coreEvents.emitConsoleLog).toHaveBeenCalledWith('log', '[]'); mockCwd.mockRestore(); }); @@ -103,7 +92,7 @@ describe('extensions list command', () => { ); await handleList(); - expect(emitConsoleLog).toHaveBeenCalledWith( + expect(coreEvents.emitConsoleLog).toHaveBeenCalledWith( 'log', 'ext1@1.0.0\n\next2@2.0.0', ); @@ -121,7 +110,7 @@ describe('extensions list command', () => { .mockResolvedValue(extensions); await handleList({ outputFormat: 'json' }); - expect(emitConsoleLog).toHaveBeenCalledWith( + expect(coreEvents.emitConsoleLog).toHaveBeenCalledWith( 'log', JSON.stringify(extensions, null, 2), ); @@ -142,7 +131,7 @@ describe('extensions list command', () => { await handleList(); - expect(emitConsoleLog).toHaveBeenCalledWith( + expect(coreEvents.emitConsoleLog).toHaveBeenCalledWith( 'error', 'List failed message', ); diff --git a/packages/cli/src/commands/skills/disable.test.ts b/packages/cli/src/commands/skills/disable.test.ts index 4a5097471b..531a08d21b 100644 --- a/packages/cli/src/commands/skills/disable.test.ts +++ b/packages/cli/src/commands/skills/disable.test.ts @@ -5,7 +5,6 @@ */ import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { format } from 'node:util'; import { handleDisable, disableCommand } from './disable.js'; import { loadSettings, @@ -14,12 +13,12 @@ import { type LoadableSettingScope, } from '../../config/settings.js'; -const emitConsoleLog = vi.hoisted(() => vi.fn()); -const debugLogger = vi.hoisted(() => ({ - log: vi.fn((message, ...args) => { - emitConsoleLog('log', format(message, ...args)); - }), -})); +const { emitConsoleLog, debugLogger } = await vi.hoisted(async () => { + const { createMockDebugLogger } = await import( + '../../test-utils/mockDebugLogger.js' + ); + return createMockDebugLogger({ stripAnsi: true }); +}); vi.mock('@google/gemini-cli-core', async (importOriginal) => { const actual = diff --git a/packages/cli/src/commands/skills/enable.test.ts b/packages/cli/src/commands/skills/enable.test.ts index e204da2f66..d34737d2df 100644 --- a/packages/cli/src/commands/skills/enable.test.ts +++ b/packages/cli/src/commands/skills/enable.test.ts @@ -5,7 +5,6 @@ */ import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { format } from 'node:util'; import { handleEnable, enableCommand } from './enable.js'; import { loadSettings, @@ -13,12 +12,12 @@ import { type LoadedSettings, } from '../../config/settings.js'; -const emitConsoleLog = vi.hoisted(() => vi.fn()); -const debugLogger = vi.hoisted(() => ({ - log: vi.fn((message, ...args) => { - emitConsoleLog('log', format(message, ...args)); - }), -})); +const { emitConsoleLog, debugLogger } = await vi.hoisted(async () => { + const { createMockDebugLogger } = await import( + '../../test-utils/mockDebugLogger.js' + ); + return createMockDebugLogger({ stripAnsi: true }); +}); vi.mock('@google/gemini-cli-core', async (importOriginal) => { const actual = diff --git a/packages/cli/src/commands/skills/install.test.ts b/packages/cli/src/commands/skills/install.test.ts index 9fd05affcd..faaa7f31c6 100644 --- a/packages/cli/src/commands/skills/install.test.ts +++ b/packages/cli/src/commands/skills/install.test.ts @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { vi, describe, it, expect, beforeEach } from 'vitest'; const mockInstallSkill = vi.hoisted(() => vi.fn()); const mockRequestConsentNonInteractive = vi.hoisted(() => vi.fn()); @@ -19,11 +19,17 @@ vi.mock('../../config/extensions/consent.js', () => ({ skillsConsentString: mockSkillsConsentString, })); +const { debugLogger, emitConsoleLog } = await vi.hoisted(async () => { + const { createMockDebugLogger } = await import( + '../../test-utils/mockDebugLogger.js' + ); + return createMockDebugLogger({ stripAnsi: true }); +}); + vi.mock('@google/gemini-cli-core', () => ({ - debugLogger: { log: vi.fn(), error: vi.fn() }, + debugLogger, })); -import { debugLogger } from '@google/gemini-cli-core'; import { handleInstall, installCommand } from './install.js'; describe('skill install command', () => { @@ -63,10 +69,12 @@ describe('skill install command', () => { expect.any(Function), expect.any(Function), ); - expect(debugLogger.log).toHaveBeenCalledWith( + expect(emitConsoleLog).toHaveBeenCalledWith( + 'log', expect.stringContaining('Successfully installed skill: test-skill'), ); - expect(debugLogger.log).toHaveBeenCalledWith( + expect(emitConsoleLog).toHaveBeenCalledWith( + 'log', expect.stringContaining('location: /mock/user/skills/test-skill'), ); expect(mockRequestConsentNonInteractive).toHaveBeenCalledWith( @@ -86,10 +94,11 @@ describe('skill install command', () => { }); expect(mockRequestConsentNonInteractive).not.toHaveBeenCalled(); - expect(debugLogger.log).toHaveBeenCalledWith( + expect(emitConsoleLog).toHaveBeenCalledWith( + 'log', 'You have consented to the following:', ); - expect(debugLogger.log).toHaveBeenCalledWith('Mock Consent String'); + expect(emitConsoleLog).toHaveBeenCalledWith('log', 'Mock Consent String'); expect(mockInstallSkill).toHaveBeenCalled(); }); @@ -106,7 +115,8 @@ describe('skill install command', () => { source: 'https://example.com/repo.git', }); - expect(debugLogger.error).toHaveBeenCalledWith( + expect(emitConsoleLog).toHaveBeenCalledWith( + 'error', 'Skill installation cancelled by user.', ); expect(process.exit).toHaveBeenCalledWith(1); @@ -137,7 +147,7 @@ describe('skill install command', () => { await handleInstall({ source: '/local/path' }); - expect(debugLogger.error).toHaveBeenCalledWith('Install failed'); + expect(emitConsoleLog).toHaveBeenCalledWith('error', 'Install failed'); expect(process.exit).toHaveBeenCalledWith(1); }); }); diff --git a/packages/cli/src/commands/skills/link.test.ts b/packages/cli/src/commands/skills/link.test.ts index 404c1d9f66..24c3d3ff64 100644 --- a/packages/cli/src/commands/skills/link.test.ts +++ b/packages/cli/src/commands/skills/link.test.ts @@ -15,8 +15,15 @@ vi.mock('../../utils/skillUtils.js', () => ({ linkSkill: mockLinkSkill, })); +const { debugLogger } = await vi.hoisted(async () => { + const { createMockDebugLogger } = await import( + '../../test-utils/mockDebugLogger.js' + ); + return createMockDebugLogger({ stripAnsi: false }); +}); + vi.mock('@google/gemini-cli-core', () => ({ - debugLogger: { log: vi.fn(), error: vi.fn() }, + debugLogger, })); vi.mock('../../config/extensions/consent.js', () => ({ @@ -24,8 +31,6 @@ vi.mock('../../config/extensions/consent.js', () => ({ skillsConsentString: mockSkillsConsentString, })); -import { debugLogger } from '@google/gemini-cli-core'; - describe('skills link command', () => { beforeEach(() => { vi.clearAllMocks(); diff --git a/packages/cli/src/commands/skills/list.test.ts b/packages/cli/src/commands/skills/list.test.ts index e7e25a2736..c330af75ba 100644 --- a/packages/cli/src/commands/skills/list.test.ts +++ b/packages/cli/src/commands/skills/list.test.ts @@ -5,33 +5,23 @@ */ import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { format } from 'node:util'; +import { coreEvents } from '@google/gemini-cli-core'; import { handleList, listCommand } from './list.js'; import { loadSettings, type LoadedSettings } from '../../config/settings.js'; import { loadCliConfig } from '../../config/config.js'; import type { Config } from '@google/gemini-cli-core'; import chalk from 'chalk'; -const emitConsoleLog = vi.hoisted(() => vi.fn()); -const debugLogger = vi.hoisted(() => ({ - log: vi.fn((message, ...args) => { - emitConsoleLog('log', format(message, ...args)); - }), - error: vi.fn((message, ...args) => { - emitConsoleLog('error', format(message, ...args)); - }), -})); - vi.mock('@google/gemini-cli-core', async (importOriginal) => { - const actual = - await importOriginal(); - return { - ...actual, - coreEvents: { - emitConsoleLog, + const { mockCoreDebugLogger } = await import( + '../../test-utils/mockDebugLogger.js' + ); + return mockCoreDebugLogger( + await importOriginal(), + { + stripAnsi: false, }, - debugLogger, - }; + ); }); vi.mock('../../config/settings.js'); @@ -67,7 +57,7 @@ describe('skills list command', () => { await handleList({}); - expect(emitConsoleLog).toHaveBeenCalledWith( + expect(coreEvents.emitConsoleLog).toHaveBeenCalledWith( 'log', 'No skills discovered.', ); @@ -98,23 +88,23 @@ describe('skills list command', () => { await handleList({}); - expect(emitConsoleLog).toHaveBeenCalledWith( + expect(coreEvents.emitConsoleLog).toHaveBeenCalledWith( 'log', chalk.bold('Discovered Agent Skills:'), ); - expect(emitConsoleLog).toHaveBeenCalledWith( + expect(coreEvents.emitConsoleLog).toHaveBeenCalledWith( 'log', expect.stringContaining('skill1'), ); - expect(emitConsoleLog).toHaveBeenCalledWith( + expect(coreEvents.emitConsoleLog).toHaveBeenCalledWith( 'log', expect.stringContaining(chalk.green('[Enabled]')), ); - expect(emitConsoleLog).toHaveBeenCalledWith( + expect(coreEvents.emitConsoleLog).toHaveBeenCalledWith( 'log', expect.stringContaining('skill2'), ); - expect(emitConsoleLog).toHaveBeenCalledWith( + expect(coreEvents.emitConsoleLog).toHaveBeenCalledWith( 'log', expect.stringContaining(chalk.red('[Disabled]')), ); @@ -146,11 +136,11 @@ describe('skills list command', () => { // Default await handleList({ all: false }); - expect(emitConsoleLog).toHaveBeenCalledWith( + expect(coreEvents.emitConsoleLog).toHaveBeenCalledWith( 'log', expect.stringContaining('regular'), ); - expect(emitConsoleLog).not.toHaveBeenCalledWith( + expect(coreEvents.emitConsoleLog).not.toHaveBeenCalledWith( 'log', expect.stringContaining('builtin'), ); @@ -159,15 +149,15 @@ describe('skills list command', () => { // With all: true await handleList({ all: true }); - expect(emitConsoleLog).toHaveBeenCalledWith( + expect(coreEvents.emitConsoleLog).toHaveBeenCalledWith( 'log', expect.stringContaining('regular'), ); - expect(emitConsoleLog).toHaveBeenCalledWith( + expect(coreEvents.emitConsoleLog).toHaveBeenCalledWith( 'log', expect.stringContaining('builtin'), ); - expect(emitConsoleLog).toHaveBeenCalledWith( + expect(coreEvents.emitConsoleLog).toHaveBeenCalledWith( 'log', expect.stringContaining(chalk.gray(' [Built-in]')), ); diff --git a/packages/cli/src/commands/skills/uninstall.test.ts b/packages/cli/src/commands/skills/uninstall.test.ts index 74f1730590..ab51db5b53 100644 --- a/packages/cli/src/commands/skills/uninstall.test.ts +++ b/packages/cli/src/commands/skills/uninstall.test.ts @@ -12,11 +12,17 @@ vi.mock('../../utils/skillUtils.js', () => ({ uninstallSkill: mockUninstallSkill, })); +const { debugLogger, emitConsoleLog } = await vi.hoisted(async () => { + const { createMockDebugLogger } = await import( + '../../test-utils/mockDebugLogger.js' + ); + return createMockDebugLogger({ stripAnsi: true }); +}); + vi.mock('@google/gemini-cli-core', () => ({ - debugLogger: { log: vi.fn(), error: vi.fn() }, + debugLogger, })); -import { debugLogger } from '@google/gemini-cli-core'; import { handleUninstall, uninstallCommand } from './uninstall.js'; describe('skill uninstall command', () => { @@ -45,10 +51,12 @@ describe('skill uninstall command', () => { }); expect(mockUninstallSkill).toHaveBeenCalledWith('test-skill', 'user'); - expect(debugLogger.log).toHaveBeenCalledWith( + expect(emitConsoleLog).toHaveBeenCalledWith( + 'log', expect.stringContaining('Successfully uninstalled skill: test-skill'), ); - expect(debugLogger.log).toHaveBeenCalledWith( + expect(emitConsoleLog).toHaveBeenCalledWith( + 'log', expect.stringContaining('location: /mock/user/skills/test-skill'), ); }); @@ -71,7 +79,8 @@ describe('skill uninstall command', () => { await handleUninstall({ name: 'test-skill' }); - expect(debugLogger.error).toHaveBeenCalledWith( + expect(emitConsoleLog).toHaveBeenCalledWith( + 'error', 'Skill "test-skill" is not installed in the user scope.', ); }); @@ -81,7 +90,7 @@ describe('skill uninstall command', () => { await handleUninstall({ name: 'test-skill' }); - expect(debugLogger.error).toHaveBeenCalledWith('Uninstall failed'); + expect(emitConsoleLog).toHaveBeenCalledWith('error', 'Uninstall failed'); expect(process.exit).toHaveBeenCalledWith(1); }); }); diff --git a/packages/cli/src/config/config.test.ts b/packages/cli/src/config/config.test.ts index 75812e4442..919ad86c51 100644 --- a/packages/cli/src/config/config.test.ts +++ b/packages/cli/src/config/config.test.ts @@ -2765,6 +2765,66 @@ describe('loadCliConfig approval mode', () => { }); }); +describe('loadCliConfig gemmaModelRouter', () => { + beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(os.homedir).mockReturnValue('/mock/home/user'); + vi.stubEnv('GEMINI_API_KEY', 'test-api-key'); + vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([]); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + vi.restoreAllMocks(); + }); + + it('should have gemmaModelRouter disabled by default', async () => { + process.argv = ['node', 'script.js']; + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings(); + const config = await loadCliConfig(settings, 'test-session', argv); + expect(config.getGemmaModelRouterEnabled()).toBe(false); + }); + + it('should load gemmaModelRouter settings from merged settings', async () => { + process.argv = ['node', 'script.js']; + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings({ + experimental: { + gemmaModelRouter: { + enabled: true, + classifier: { + host: 'http://custom:1234', + model: 'custom-gemma', + }, + }, + }, + }); + const config = await loadCliConfig(settings, 'test-session', argv); + expect(config.getGemmaModelRouterEnabled()).toBe(true); + const gemmaSettings = config.getGemmaModelRouterSettings(); + expect(gemmaSettings.classifier?.host).toBe('http://custom:1234'); + expect(gemmaSettings.classifier?.model).toBe('custom-gemma'); + }); + + it('should handle partial gemmaModelRouter settings', async () => { + process.argv = ['node', 'script.js']; + const argv = await parseArguments(createTestMergedSettings()); + const settings = createTestMergedSettings({ + experimental: { + gemmaModelRouter: { + enabled: true, + }, + }, + }); + const config = await loadCliConfig(settings, 'test-session', argv); + expect(config.getGemmaModelRouterEnabled()).toBe(true); + const gemmaSettings = config.getGemmaModelRouterSettings(); + expect(gemmaSettings.classifier?.host).toBe('http://localhost:9379'); + expect(gemmaSettings.classifier?.model).toBe('gemma3-1b-gpu-custom'); + }); +}); + describe('loadCliConfig fileFiltering', () => { const originalArgv = process.argv; diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index 6a4bd09470..f2870a5f57 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -856,6 +856,7 @@ export async function loadCliConfig( // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion format: (argv.outputFormat ?? settings.output?.format) as OutputFormat, }, + gemmaModelRouter: settings.experimental?.gemmaModelRouter, fakeResponses: argv.fakeResponses, recordResponses: argv.recordResponses, retryFetchErrors: settings.general?.retryFetchErrors, diff --git a/packages/cli/src/config/extensions/__snapshots__/consent-consent-maybeRequestConsentOrFail-consent-string-generation-should-generate-a-consent-string-with-all-fields.snap.svg b/packages/cli/src/config/extensions/__snapshots__/consent-consent-maybeRequestConsentOrFail-consent-string-generation-should-generate-a-consent-string-with-all-fields.snap.svg new file mode 100644 index 0000000000..d42af4490c --- /dev/null +++ b/packages/cli/src/config/extensions/__snapshots__/consent-consent-maybeRequestConsentOrFail-consent-string-generation-should-generate-a-consent-string-with-all-fields.snap.svg @@ -0,0 +1,18 @@ + + + + + Installing extension "test-ext". + This extension will run the following MCP servers: + * server1 (local): npm start + * server2 (remote): https://remote.com + This extension will append info to your gemini.md context using my-context.md + This extension will exclude the following core tools: tool1,tool2 + The extension you are about to install may have been created by a third-party developer and sourced + from a public repository. Google does not vet, endorse, or guarantee the functionality or security + of extensions. Please carefully inspect any extension and its source code before installing to + understand the permissions it requires and the actions it may perform. + + \ No newline at end of file diff --git a/packages/cli/src/config/extensions/__snapshots__/consent-consent-maybeRequestConsentOrFail-consent-string-generation-should-include-warning-when-hooks-are-present.snap.svg b/packages/cli/src/config/extensions/__snapshots__/consent-consent-maybeRequestConsentOrFail-consent-string-generation-should-include-warning-when-hooks-are-present.snap.svg new file mode 100644 index 0000000000..9f4866dbdd --- /dev/null +++ b/packages/cli/src/config/extensions/__snapshots__/consent-consent-maybeRequestConsentOrFail-consent-string-generation-should-include-warning-when-hooks-are-present.snap.svg @@ -0,0 +1,14 @@ + + + + + Installing extension "test-ext". + ⚠️ This extension contains Hooks which can automatically execute commands. + The extension you are about to install may have been created by a third-party developer and sourced + from a public repository. Google does not vet, endorse, or guarantee the functionality or security + of extensions. Please carefully inspect any extension and its source code before installing to + understand the permissions it requires and the actions it may perform. + + \ No newline at end of file diff --git a/packages/cli/src/config/extensions/__snapshots__/consent-consent-maybeRequestConsentOrFail-consent-string-generation-should-request-consent-if-skills-change.snap.svg b/packages/cli/src/config/extensions/__snapshots__/consent-consent-maybeRequestConsentOrFail-consent-string-generation-should-request-consent-if-skills-change.snap.svg new file mode 100644 index 0000000000..6f5879df4c --- /dev/null +++ b/packages/cli/src/config/extensions/__snapshots__/consent-consent-maybeRequestConsentOrFail-consent-string-generation-should-request-consent-if-skills-change.snap.svg @@ -0,0 +1,28 @@ + + + + + Installing extension "test-ext". + This extension will run the following MCP servers: + * server1 (local): npm start + * server2 (remote): https://remote.com + This extension will append info to your gemini.md context using my-context.md + This extension will exclude the following core tools: tool1,tool2 + Agent Skills: + This extension will install the following agent skills: + * skill1: desc1 + (Source: /mock/temp/dir/skill1/SKILL.md) (2 items in directory) + * skill2: desc2 + (Source: /mock/temp/dir/skill2/SKILL.md) (1 items in directory) + The extension you are about to install may have been created by a third-party developer and sourced + from a public repository. Google does not vet, endorse, or guarantee the functionality or security + of extensions. Please carefully inspect any extension and its source code before installing to + understand the permissions it requires and the actions it may perform. + Agent skills inject specialized instructions and domain-specific knowledge into the agent's system + prompt. This can change how the agent interprets your requests and interacts with your environment. + Review the skill definitions at the location(s) provided below to ensure they meet your security + standards. + + \ No newline at end of file diff --git a/packages/cli/src/config/extensions/__snapshots__/consent-consent-maybeRequestConsentOrFail-consent-string-generation-should-show-a-warning-if-the-skill-directory-cannot-be-read.snap.svg b/packages/cli/src/config/extensions/__snapshots__/consent-consent-maybeRequestConsentOrFail-consent-string-generation-should-show-a-warning-if-the-skill-directory-cannot-be-read.snap.svg new file mode 100644 index 0000000000..3fff32664a --- /dev/null +++ b/packages/cli/src/config/extensions/__snapshots__/consent-consent-maybeRequestConsentOrFail-consent-string-generation-should-show-a-warning-if-the-skill-directory-cannot-be-read.snap.svg @@ -0,0 +1,22 @@ + + + + + Installing extension "test-ext". + Agent Skills: + This extension will install the following agent skills: + * locked-skill: A skill in a locked dir + (Source: /mock/temp/dir/locked/SKILL.md) + ⚠️ (Could not count items in directory) + The extension you are about to install may have been created by a third-party developer and sourced + from a public repository. Google does not vet, endorse, or guarantee the functionality or security + of extensions. Please carefully inspect any extension and its source code before installing to + understand the permissions it requires and the actions it may perform. + Agent skills inject specialized instructions and domain-specific knowledge into the agent's system + prompt. This can change how the agent interprets your requests and interacts with your environment. + Review the skill definitions at the location(s) provided below to ensure they meet your security + standards. + + \ No newline at end of file diff --git a/packages/cli/src/config/extensions/__snapshots__/consent-consent-skillsConsentString-should-generate-a-consent-string-for-skills.snap.svg b/packages/cli/src/config/extensions/__snapshots__/consent-consent-skillsConsentString-should-generate-a-consent-string-for-skills.snap.svg new file mode 100644 index 0000000000..c52724836e --- /dev/null +++ b/packages/cli/src/config/extensions/__snapshots__/consent-consent-skillsConsentString-should-generate-a-consent-string-for-skills.snap.svg @@ -0,0 +1,17 @@ + + + + + Installing agent skill(s) from "https://example.com/repo.git". + The following agent skill(s) will be installing: + * skill1: desc1 + (Source: /mock/temp/dir/skill1/SKILL.md) (1 items in directory) + Install Destination: /mock/target/dir + Agent skills inject specialized instructions and domain-specific knowledge into the agent's system + prompt. This can change how the agent interprets your requests and interacts with your environment. + Review the skill definitions at the location(s) provided below to ensure they meet your security + standards. + + \ No newline at end of file diff --git a/packages/cli/src/config/extensions/__snapshots__/consent.test.ts.snap b/packages/cli/src/config/extensions/__snapshots__/consent.test.ts.snap new file mode 100644 index 0000000000..d8fe99d004 --- /dev/null +++ b/packages/cli/src/config/extensions/__snapshots__/consent.test.ts.snap @@ -0,0 +1,93 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`consent > maybeRequestConsentOrFail > consent string generation > should generate a consent string with all fields 1`] = ` +"Installing extension "test-ext". +This extension will run the following MCP servers: + * server1 (local): npm start + * server2 (remote): https://remote.com +This extension will append info to your gemini.md context using my-context.md +This extension will exclude the following core tools: tool1,tool2 + +The extension you are about to install may have been created by a third-party developer and sourced +from a public repository. Google does not vet, endorse, or guarantee the functionality or security +of extensions. Please carefully inspect any extension and its source code before installing to +understand the permissions it requires and the actions it may perform." +`; + +exports[`consent > maybeRequestConsentOrFail > consent string generation > should include warning when hooks are present 1`] = ` +"Installing extension "test-ext". +⚠️ This extension contains Hooks which can automatically execute commands. + +The extension you are about to install may have been created by a third-party developer and sourced +from a public repository. Google does not vet, endorse, or guarantee the functionality or security +of extensions. Please carefully inspect any extension and its source code before installing to +understand the permissions it requires and the actions it may perform." +`; + +exports[`consent > maybeRequestConsentOrFail > consent string generation > should request consent if skills change 1`] = ` +"Installing extension "test-ext". +This extension will run the following MCP servers: + * server1 (local): npm start + * server2 (remote): https://remote.com +This extension will append info to your gemini.md context using my-context.md +This extension will exclude the following core tools: tool1,tool2 + +Agent Skills: + +This extension will install the following agent skills: + + * skill1: desc1 + (Source: /mock/temp/dir/skill1/SKILL.md) (2 items in directory) + + * skill2: desc2 + (Source: /mock/temp/dir/skill2/SKILL.md) (1 items in directory) + + +The extension you are about to install may have been created by a third-party developer and sourced +from a public repository. Google does not vet, endorse, or guarantee the functionality or security +of extensions. Please carefully inspect any extension and its source code before installing to +understand the permissions it requires and the actions it may perform. + +Agent skills inject specialized instructions and domain-specific knowledge into the agent's system +prompt. This can change how the agent interprets your requests and interacts with your environment. +Review the skill definitions at the location(s) provided below to ensure they meet your security +standards." +`; + +exports[`consent > maybeRequestConsentOrFail > consent string generation > should show a warning if the skill directory cannot be read 1`] = ` +"Installing extension "test-ext". + +Agent Skills: + +This extension will install the following agent skills: + + * locked-skill: A skill in a locked dir + (Source: /mock/temp/dir/locked/SKILL.md) ⚠️ (Could not count items in directory) + + +The extension you are about to install may have been created by a third-party developer and sourced +from a public repository. Google does not vet, endorse, or guarantee the functionality or security +of extensions. Please carefully inspect any extension and its source code before installing to +understand the permissions it requires and the actions it may perform. + +Agent skills inject specialized instructions and domain-specific knowledge into the agent's system +prompt. This can change how the agent interprets your requests and interacts with your environment. +Review the skill definitions at the location(s) provided below to ensure they meet your security +standards." +`; + +exports[`consent > skillsConsentString > should generate a consent string for skills 1`] = ` +"Installing agent skill(s) from "https://example.com/repo.git". + +The following agent skill(s) will be installing: + + * skill1: desc1 + (Source: /mock/temp/dir/skill1/SKILL.md) (1 items in directory) + +Install Destination: /mock/target/dir + +Agent skills inject specialized instructions and domain-specific knowledge into the agent's system +prompt. This can change how the agent interprets your requests and interacts with your environment. +Review the skill definitions at the location(s) provided below to ensure they meet your security +standards." +`; diff --git a/packages/cli/src/config/extensions/consent.test.ts b/packages/cli/src/config/extensions/consent.test.ts index a7c07413b4..04e6cae69f 100644 --- a/packages/cli/src/config/extensions/consent.test.ts +++ b/packages/cli/src/config/extensions/consent.test.ts @@ -4,17 +4,17 @@ * SPDX-License-Identifier: Apache-2.0 */ +import React from 'react'; +import { Text } from 'ink'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import chalk from 'chalk'; import * as fs from 'node:fs/promises'; import * as path from 'node:path'; import * as os from 'node:os'; +import { render, cleanup } from '../../test-utils/render.js'; import { requestConsentNonInteractive, requestConsentInteractive, maybeRequestConsentOrFail, - INSTALL_WARNING_MESSAGE, - SKILLS_WARNING_MESSAGE, } from './consent.js'; import type { ConfirmationRequest } from '../../ui/types.js'; import type { ExtensionConfig } from '../extension.js'; @@ -58,6 +58,21 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => { }; }); +async function expectConsentSnapshot(consentString: string) { + const renderResult = render(React.createElement(Text, null, consentString)); + await renderResult.waitUntilReady(); + await expect(renderResult).toMatchSvgSnapshot(); +} + +/** + * Normalizes a consent string for snapshot testing by: + * 1. Replacing the dynamic temp directory path with a static placeholder. + * 2. Converting Windows backslashes to forward slashes for platform-agnosticism. + */ +function normalizePathsForSnapshot(str: string, tempDir: string): string { + return str.replaceAll(tempDir, '/mock/temp/dir').replaceAll('\\', '/'); +} + describe('consent', () => { let tempDir: string; @@ -75,6 +90,7 @@ describe('consent', () => { if (tempDir) { await fs.rm(tempDir, { recursive: true, force: true }); } + cleanup(); }); describe('requestConsentNonInteractive', () => { @@ -189,18 +205,9 @@ describe('consent', () => { undefined, ); - const expectedConsentString = [ - 'Installing extension "test-ext".', - 'This extension will run the following MCP servers:', - ' * server1 (local): npm start', - ' * server2 (remote): https://remote.com', - 'This extension will append info to your gemini.md context using my-context.md', - 'This extension will exclude the following core tools: tool1,tool2', - '', - INSTALL_WARNING_MESSAGE, - ].join('\n'); - - expect(requestConsent).toHaveBeenCalledWith(expectedConsentString); + expect(requestConsent).toHaveBeenCalledTimes(1); + const consentString = requestConsent.mock.calls[0][0] as string; + await expectConsentSnapshot(consentString); }); it('should request consent if mcpServers change', async () => { @@ -263,11 +270,9 @@ describe('consent', () => { undefined, ); - expect(requestConsent).toHaveBeenCalledWith( - expect.stringContaining( - '⚠️ This extension contains Hooks which can automatically execute commands.', - ), - ); + expect(requestConsent).toHaveBeenCalledTimes(1); + const consentString = requestConsent.mock.calls[0][0] as string; + await expectConsentSnapshot(consentString); }); it('should request consent if hooks status changes', async () => { @@ -323,29 +328,10 @@ describe('consent', () => { [skill1, skill2], ); - const expectedConsentString = [ - 'Installing extension "test-ext".', - 'This extension will run the following MCP servers:', - ' * server1 (local): npm start', - ' * server2 (remote): https://remote.com', - 'This extension will append info to your gemini.md context using my-context.md', - 'This extension will exclude the following core tools: tool1,tool2', - '', - chalk.bold('Agent Skills:'), - '\nThis extension will install the following agent skills:\n', - ` * ${chalk.bold('skill1')}: desc1`, - chalk.dim(` (Source: ${skill1.location}) (2 items in directory)`), - '', - ` * ${chalk.bold('skill2')}: desc2`, - chalk.dim(` (Source: ${skill2.location}) (1 items in directory)`), - '', - '', - INSTALL_WARNING_MESSAGE, - '', - SKILLS_WARNING_MESSAGE, - ].join('\n'); - - expect(requestConsent).toHaveBeenCalledWith(expectedConsentString); + expect(requestConsent).toHaveBeenCalledTimes(1); + let consentString = requestConsent.mock.calls[0][0] as string; + consentString = normalizePathsForSnapshot(consentString, tempDir); + await expectConsentSnapshot(consentString); }); it('should show a warning if the skill directory cannot be read', async () => { @@ -377,11 +363,10 @@ describe('consent', () => { [skill], ); - expect(requestConsent).toHaveBeenCalledWith( - expect.stringContaining( - ` (Source: ${skill.location}) ${chalk.red('⚠️ (Could not count items in directory)')}`, - ), - ); + expect(requestConsent).toHaveBeenCalledTimes(1); + let consentString = requestConsent.mock.calls[0][0] as string; + consentString = normalizePathsForSnapshot(consentString, tempDir); + await expectConsentSnapshot(consentString); }); }); }); @@ -400,21 +385,14 @@ describe('consent', () => { }; const { skillsConsentString } = await import('./consent.js'); - const consentString = await skillsConsentString( + let consentString = await skillsConsentString( [skill1], 'https://example.com/repo.git', '/mock/target/dir', ); - expect(consentString).toContain( - 'Installing agent skill(s) from "https://example.com/repo.git".', - ); - expect(consentString).toContain('Install Destination: /mock/target/dir'); - expect(consentString).toContain('\n' + SKILLS_WARNING_MESSAGE); - expect(consentString).toContain(` * ${chalk.bold('skill1')}: desc1`); - expect(consentString).toContain( - chalk.dim(`(Source: ${skill1.location}) (1 items in directory)`), - ); + consentString = normalizePathsForSnapshot(consentString, tempDir); + await expectConsentSnapshot(consentString); }); }); }); diff --git a/packages/cli/src/config/keyBindings.ts b/packages/cli/src/config/keyBindings.ts index 4813abd368..3122acef1d 100644 --- a/packages/cli/src/config/keyBindings.ts +++ b/packages/cli/src/config/keyBindings.ts @@ -489,7 +489,7 @@ export const commandDescriptions: Readonly> = { [Command.SUBMIT]: 'Submit the current prompt.', [Command.NEWLINE]: 'Insert a newline without submitting.', [Command.OPEN_EXTERNAL_EDITOR]: - 'Open the current prompt in an external editor.', + 'Open the current prompt or the plan in an external editor.', [Command.PASTE_CLIPBOARD]: 'Paste from the clipboard.', // App Controls diff --git a/packages/cli/src/config/policy.test.ts b/packages/cli/src/config/policy.test.ts index 1a773d56a7..10d53e56ef 100644 --- a/packages/cli/src/config/policy.test.ts +++ b/packages/cli/src/config/policy.test.ts @@ -8,7 +8,11 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import * as fs from 'node:fs'; import * as path from 'node:path'; import * as os from 'node:os'; -import { resolveWorkspacePolicyState } from './policy.js'; +import { + resolveWorkspacePolicyState, + autoAcceptWorkspacePolicies, + setAutoAcceptWorkspacePolicies, +} from './policy.js'; import { writeToStderr } from '@google/gemini-cli-core'; // Mock debugLogger to avoid noise in test output @@ -68,24 +72,18 @@ describe('resolveWorkspacePolicyState', () => { fs.mkdirSync(policiesDir, { recursive: true }); fs.writeFileSync(path.join(policiesDir, 'policy.toml'), 'rules = []'); - // First call to establish integrity (interactive accept) + // First call to establish integrity (interactive auto-accept) const firstResult = await resolveWorkspacePolicyState({ cwd: workspaceDir, trustedFolder: true, interactive: true, }); - expect(firstResult.policyUpdateConfirmationRequest).toBeDefined(); - - // Establish integrity manually as if accepted - const { PolicyIntegrityManager } = await import('@google/gemini-cli-core'); - const integrityManager = new PolicyIntegrityManager(); - await integrityManager.acceptIntegrity( - 'workspace', - workspaceDir, - firstResult.policyUpdateConfirmationRequest!.newHash, - ); + expect(firstResult.workspacePoliciesDir).toBe(policiesDir); + expect(firstResult.policyUpdateConfirmationRequest).toBeUndefined(); + expect(writeToStderr).not.toHaveBeenCalled(); // Second call should match + const result = await resolveWorkspacePolicyState({ cwd: workspaceDir, trustedFolder: true, @@ -107,26 +105,33 @@ describe('resolveWorkspacePolicyState', () => { expect(result.policyUpdateConfirmationRequest).toBeUndefined(); }); - it('should return confirmation request if changed in interactive mode', async () => { - fs.mkdirSync(policiesDir, { recursive: true }); - fs.writeFileSync(path.join(policiesDir, 'policy.toml'), 'rules = []'); + it('should return confirmation request if changed in interactive mode when AUTO_ACCEPT is false', async () => { + const originalValue = autoAcceptWorkspacePolicies; + setAutoAcceptWorkspacePolicies(false); - const result = await resolveWorkspacePolicyState({ - cwd: workspaceDir, - trustedFolder: true, - interactive: true, - }); + try { + fs.mkdirSync(policiesDir, { recursive: true }); + fs.writeFileSync(path.join(policiesDir, 'policy.toml'), 'rules = []'); - expect(result.workspacePoliciesDir).toBeUndefined(); - expect(result.policyUpdateConfirmationRequest).toEqual({ - scope: 'workspace', - identifier: workspaceDir, - policyDir: policiesDir, - newHash: expect.any(String), - }); + const result = await resolveWorkspacePolicyState({ + cwd: workspaceDir, + trustedFolder: true, + interactive: true, + }); + + expect(result.workspacePoliciesDir).toBeUndefined(); + expect(result.policyUpdateConfirmationRequest).toEqual({ + scope: 'workspace', + identifier: workspaceDir, + policyDir: policiesDir, + newHash: expect.any(String), + }); + } finally { + setAutoAcceptWorkspacePolicies(originalValue); + } }); - it('should warn and auto-accept if changed in non-interactive mode', async () => { + it('should warn and auto-accept if changed in non-interactive mode when AUTO_ACCEPT is true', async () => { fs.mkdirSync(policiesDir, { recursive: true }); fs.writeFileSync(path.join(policiesDir, 'policy.toml'), 'rules = []'); @@ -143,6 +148,30 @@ describe('resolveWorkspacePolicyState', () => { ); }); + it('should warn and auto-accept if changed in non-interactive mode when AUTO_ACCEPT is false', async () => { + const originalValue = autoAcceptWorkspacePolicies; + setAutoAcceptWorkspacePolicies(false); + + try { + fs.mkdirSync(policiesDir, { recursive: true }); + fs.writeFileSync(path.join(policiesDir, 'policy.toml'), 'rules = []'); + + const result = await resolveWorkspacePolicyState({ + cwd: workspaceDir, + trustedFolder: true, + interactive: false, + }); + + expect(result.workspacePoliciesDir).toBe(policiesDir); + expect(result.policyUpdateConfirmationRequest).toBeUndefined(); + expect(writeToStderr).toHaveBeenCalledWith( + expect.stringContaining('Automatically accepting and loading'), + ); + } finally { + setAutoAcceptWorkspacePolicies(originalValue); + } + }); + it('should not return workspace policies if cwd is the home directory', async () => { const policiesDir = path.join(tempDir, '.gemini', 'policies'); fs.mkdirSync(policiesDir, { recursive: true }); diff --git a/packages/cli/src/config/policy.ts b/packages/cli/src/config/policy.ts index 3b85d0b4b6..6ce44020f5 100644 --- a/packages/cli/src/config/policy.ts +++ b/packages/cli/src/config/policy.ts @@ -17,9 +17,24 @@ import { Storage, type PolicyUpdateConfirmationRequest, writeToStderr, + debugLogger, } from '@google/gemini-cli-core'; import { type Settings } from './settings.js'; +/** + * Temporary flag to automatically accept workspace policies to reduce friction. + * Exported as 'let' to allow monkey patching in tests via the setter. + */ +export let autoAcceptWorkspacePolicies = true; + +/** + * Sets the autoAcceptWorkspacePolicies flag. + * Used primarily for testing purposes. + */ +export function setAutoAcceptWorkspacePolicies(value: boolean) { + autoAcceptWorkspacePolicies = value; +} + export async function createPolicyEngineConfig( settings: Settings, approvalMode: ApprovalMode, @@ -91,8 +106,8 @@ export async function resolveWorkspacePolicyState(options: { ) { // No workspace policies found workspacePoliciesDir = undefined; - } else if (interactive) { - // Policies changed or are new, and we are in interactive mode + } else if (interactive && !autoAcceptWorkspacePolicies) { + // Policies changed or are new, and we are in interactive mode and auto-accept is disabled policyUpdateConfirmationRequest = { scope: 'workspace', identifier: cwd, @@ -100,17 +115,23 @@ export async function resolveWorkspacePolicyState(options: { newHash: integrityResult.hash, }; } else { - // Non-interactive mode: warn and automatically accept/load + // Non-interactive mode or auto-accept is enabled: automatically accept/load await integrityManager.acceptIntegrity( 'workspace', cwd, integrityResult.hash, ); workspacePoliciesDir = potentialWorkspacePoliciesDir; - // debugLogger.warn here doesn't show up in the terminal. It is showing up only in debug mode on the debug console - writeToStderr( - 'WARNING: Workspace policies changed or are new. Automatically accepting and loading them in non-interactive mode.\n', - ); + + if (!interactive) { + writeToStderr( + 'WARNING: Workspace policies changed or are new. Automatically accepting and loading them.\n', + ); + } else { + debugLogger.warn( + 'Workspace policies changed or are new. Automatically accepting and loading them.', + ); + } } } diff --git a/packages/cli/src/config/settingsSchema.test.ts b/packages/cli/src/config/settingsSchema.test.ts index ffe1dd2ac5..cf9dfc992f 100644 --- a/packages/cli/src/config/settingsSchema.test.ts +++ b/packages/cli/src/config/settingsSchema.test.ts @@ -444,6 +444,60 @@ describe('SettingsSchema', () => { expect(hookItemProperties.description).toBeDefined(); expect(hookItemProperties.description.type).toBe('string'); }); + + it('should have gemmaModelRouter setting in schema', () => { + const gemmaModelRouter = + getSettingsSchema().experimental.properties.gemmaModelRouter; + expect(gemmaModelRouter).toBeDefined(); + expect(gemmaModelRouter.type).toBe('object'); + expect(gemmaModelRouter.category).toBe('Experimental'); + expect(gemmaModelRouter.default).toEqual({}); + expect(gemmaModelRouter.requiresRestart).toBe(true); + expect(gemmaModelRouter.showInDialog).toBe(true); + expect(gemmaModelRouter.description).toBe( + 'Enable Gemma model router (experimental).', + ); + + const enabled = gemmaModelRouter.properties.enabled; + expect(enabled).toBeDefined(); + expect(enabled.type).toBe('boolean'); + expect(enabled.category).toBe('Experimental'); + expect(enabled.default).toBe(false); + expect(enabled.requiresRestart).toBe(true); + expect(enabled.showInDialog).toBe(true); + expect(enabled.description).toBe( + 'Enable the Gemma Model Router. Requires a local endpoint serving Gemma via the Gemini API using LiteRT-LM shim.', + ); + + const classifier = gemmaModelRouter.properties.classifier; + expect(classifier).toBeDefined(); + expect(classifier.type).toBe('object'); + expect(classifier.category).toBe('Experimental'); + expect(classifier.default).toEqual({}); + expect(classifier.requiresRestart).toBe(true); + expect(classifier.showInDialog).toBe(false); + expect(classifier.description).toBe('Classifier configuration.'); + + const host = classifier.properties.host; + expect(host).toBeDefined(); + expect(host.type).toBe('string'); + expect(host.category).toBe('Experimental'); + expect(host.default).toBe('http://localhost:9379'); + expect(host.requiresRestart).toBe(true); + expect(host.showInDialog).toBe(false); + expect(host.description).toBe('The host of the classifier.'); + + const model = classifier.properties.model; + expect(model).toBeDefined(); + expect(model.type).toBe('string'); + expect(model.category).toBe('Experimental'); + expect(model.default).toBe('gemma3-1b-gpu-custom'); + expect(model.requiresRestart).toBe(true); + expect(model.showInDialog).toBe(false); + expect(model.description).toBe( + 'The model to use for the classifier. Only tested on `gemma3-1b-gpu-custom`.', + ); + }); }); it('has JSON schema definitions for every referenced ref', () => { diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index 26faaafda7..48a7641766 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -1787,6 +1787,57 @@ const SETTINGS_SCHEMA = { 'Enable web fetch behavior that bypasses LLM summarization.', showInDialog: true, }, + gemmaModelRouter: { + type: 'object', + label: 'Gemma Model Router', + category: 'Experimental', + requiresRestart: true, + default: {}, + description: 'Enable Gemma model router (experimental).', + showInDialog: true, + properties: { + enabled: { + type: 'boolean', + label: 'Enable Gemma Model Router', + category: 'Experimental', + requiresRestart: true, + default: false, + description: + 'Enable the Gemma Model Router. Requires a local endpoint serving Gemma via the Gemini API using LiteRT-LM shim.', + showInDialog: true, + }, + classifier: { + type: 'object', + label: 'Classifier', + category: 'Experimental', + requiresRestart: true, + default: {}, + description: 'Classifier configuration.', + showInDialog: false, + properties: { + host: { + type: 'string', + label: 'Host', + category: 'Experimental', + requiresRestart: true, + default: 'http://localhost:9379', + description: 'The host of the classifier.', + showInDialog: false, + }, + model: { + type: 'string', + label: 'Model', + category: 'Experimental', + requiresRestart: true, + default: 'gemma3-1b-gpu-custom', + description: + 'The model to use for the classifier. Only tested on `gemma3-1b-gpu-custom`.', + showInDialog: false, + }, + }, + }, + }, + }, }, }, @@ -2532,7 +2583,9 @@ type InferSettings = { : T[K]['default'] : T[K]['default'] extends boolean ? boolean - : T[K]['default']; + : T[K]['default'] extends string + ? string + : T[K]['default']; }; type InferMergedSettings = { @@ -2544,7 +2597,9 @@ type InferMergedSettings = { : T[K]['default'] : T[K]['default'] extends boolean ? boolean - : T[K]['default']; + : T[K]['default'] extends string + ? string + : T[K]['default']; }; export type Settings = InferSettings; diff --git a/packages/cli/src/config/workspace-policy-cli.test.ts b/packages/cli/src/config/workspace-policy-cli.test.ts index 98cbe05bce..a7ab9d69b1 100644 --- a/packages/cli/src/config/workspace-policy-cli.test.ts +++ b/packages/cli/src/config/workspace-policy-cli.test.ts @@ -10,6 +10,7 @@ import { loadCliConfig, type CliArgs } from './config.js'; import { createTestMergedSettings } from './settings.js'; import * as ServerConfig from '@google/gemini-cli-core'; import { isWorkspaceTrusted } from './trustedFolders.js'; +import * as Policy from './policy.js'; // Mock dependencies vi.mock('./trustedFolders.js', () => ({ @@ -164,7 +165,7 @@ describe('Workspace-Level Policy CLI Integration', () => { ); }); - it('should set policyUpdateConfirmationRequest if integrity MISMATCH in interactive mode', async () => { + it('should automatically accept and load workspacePoliciesDir if integrity MISMATCH in interactive mode when AUTO_ACCEPT is true', async () => { vi.mocked(isWorkspaceTrusted).mockReturnValue({ isTrusted: true, source: 'file', @@ -186,24 +187,23 @@ describe('Workspace-Level Policy CLI Integration', () => { cwd: MOCK_CWD, }); - expect(config.getPolicyUpdateConfirmationRequest()).toEqual({ - scope: 'workspace', - identifier: MOCK_CWD, - policyDir: expect.stringContaining(path.join('.gemini', 'policies')), - newHash: 'new-hash', - }); - // In interactive mode without accept flag, it waits for user confirmation (handled by UI), - // so it currently DOES NOT pass the directory to createPolicyEngineConfig yet. - // The UI will handle the confirmation and reload/update. + expect(config.getPolicyUpdateConfirmationRequest()).toBeUndefined(); + expect(mockAcceptIntegrity).toHaveBeenCalledWith( + 'workspace', + MOCK_CWD, + 'new-hash', + ); expect(ServerConfig.createPolicyEngineConfig).toHaveBeenCalledWith( expect.objectContaining({ - workspacePoliciesDir: undefined, + workspacePoliciesDir: expect.stringContaining( + path.join('.gemini', 'policies'), + ), }), expect.anything(), ); }); - it('should set policyUpdateConfirmationRequest if integrity is NEW with files (first time seen) in interactive mode', async () => { + it('should automatically accept and load workspacePoliciesDir if integrity is NEW in interactive mode when AUTO_ACCEPT is true', async () => { vi.mocked(isWorkspaceTrusted).mockReturnValue({ isTrusted: true, source: 'file', @@ -222,18 +222,65 @@ describe('Workspace-Level Policy CLI Integration', () => { cwd: MOCK_CWD, }); - expect(config.getPolicyUpdateConfirmationRequest()).toEqual({ - scope: 'workspace', - identifier: MOCK_CWD, - policyDir: expect.stringContaining(path.join('.gemini', 'policies')), - newHash: 'new-hash', - }); + expect(config.getPolicyUpdateConfirmationRequest()).toBeUndefined(); + expect(mockAcceptIntegrity).toHaveBeenCalledWith( + 'workspace', + MOCK_CWD, + 'new-hash', + ); expect(ServerConfig.createPolicyEngineConfig).toHaveBeenCalledWith( expect.objectContaining({ - workspacePoliciesDir: undefined, + workspacePoliciesDir: expect.stringContaining( + path.join('.gemini', 'policies'), + ), }), expect.anything(), ); }); + + it('should set policyUpdateConfirmationRequest if integrity MISMATCH in interactive mode when AUTO_ACCEPT is false', async () => { + // Monkey patch autoAcceptWorkspacePolicies using setter + const originalValue = Policy.autoAcceptWorkspacePolicies; + Policy.setAutoAcceptWorkspacePolicies(false); + + try { + vi.mocked(isWorkspaceTrusted).mockReturnValue({ + isTrusted: true, + source: 'file', + }); + mockCheckIntegrity.mockResolvedValue({ + status: 'mismatch', + hash: 'new-hash', + fileCount: 1, + }); + vi.mocked(ServerConfig.isHeadlessMode).mockReturnValue(false); // Interactive + + const settings = createTestMergedSettings(); + const argv = { + query: 'test', + promptInteractive: 'test', + } as unknown as CliArgs; + + const config = await loadCliConfig(settings, 'test-session', argv, { + cwd: MOCK_CWD, + }); + + expect(config.getPolicyUpdateConfirmationRequest()).toEqual({ + scope: 'workspace', + identifier: MOCK_CWD, + policyDir: expect.stringContaining(path.join('.gemini', 'policies')), + newHash: 'new-hash', + }); + expect(ServerConfig.createPolicyEngineConfig).toHaveBeenCalledWith( + expect.objectContaining({ + workspacePoliciesDir: undefined, + }), + expect.anything(), + ); + } finally { + // Restore for other tests + Policy.setAutoAcceptWorkspacePolicies(originalValue); + } + }); }); diff --git a/packages/cli/src/gemini.test.tsx b/packages/cli/src/gemini.test.tsx index 538fb8ee4e..dae249a8ac 100644 --- a/packages/cli/src/gemini.test.tsx +++ b/packages/cli/src/gemini.test.tsx @@ -1216,6 +1216,8 @@ describe('startInteractiveUI', () => { runExitCleanup: vi.fn(), registerSyncCleanup: vi.fn(), registerTelemetryConfig: vi.fn(), + setupSignalHandlers: vi.fn(), + setupTtyCheck: vi.fn(() => vi.fn()), })); beforeEach(() => { @@ -1322,7 +1324,8 @@ describe('startInteractiveUI', () => { // Verify all startup tasks were called expect(getVersion).toHaveBeenCalledTimes(1); - expect(registerCleanup).toHaveBeenCalledTimes(4); + // 5 cleanups: mouseEvents, consolePatcher, lineWrapping, instance.unmount, and TTY check + expect(registerCleanup).toHaveBeenCalledTimes(5); // Verify cleanup handler is registered with unmount function const cleanupFn = vi.mocked(registerCleanup).mock.calls[0][0]; diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx index aa830c0250..8cd7048a7e 100644 --- a/packages/cli/src/gemini.tsx +++ b/packages/cli/src/gemini.tsx @@ -32,6 +32,8 @@ import { registerSyncCleanup, runExitCleanup, registerTelemetryConfig, + setupSignalHandlers, + setupTtyCheck, } from './utils/cleanup.js'; import { cleanupToolOutputFiles, @@ -319,6 +321,8 @@ export async function startInteractiveUI( }); registerCleanup(() => instance.unmount()); + + registerCleanup(setupTtyCheck()); } export async function main() { @@ -340,6 +344,8 @@ export async function main() { setupUnhandledRejectionHandler(); + setupSignalHandlers(); + const slashCommandConflictHandler = new SlashCommandConflictHandler(); slashCommandConflictHandler.start(); registerCleanup(() => slashCommandConflictHandler.stop()); @@ -646,10 +652,7 @@ export async function main() { process.stdin.setRawMode(true); // This cleanup isn't strictly needed but may help in certain situations. - process.on('SIGTERM', () => { - process.stdin.setRawMode(wasRaw); - }); - process.on('SIGINT', () => { + registerSyncCleanup(() => { process.stdin.setRawMode(wasRaw); }); } diff --git a/packages/cli/src/test-utils/AppRig.tsx b/packages/cli/src/test-utils/AppRig.tsx index 018ce1502b..d953be0ff6 100644 --- a/packages/cli/src/test-utils/AppRig.tsx +++ b/packages/cli/src/test-utils/AppRig.tsx @@ -29,6 +29,7 @@ import { createContentGenerator, IdeClient, debugLogger, + CoreToolCallStatus, } from '@google/gemini-cli-core'; import { type MockShellCommand, @@ -36,7 +37,47 @@ import { } from './MockShellExecutionService.js'; import { createMockSettings } from './settings.js'; import { type LoadedSettings } from '../config/settings.js'; -import { AuthState } from '../ui/types.js'; +import { AuthState, StreamingState } from '../ui/types.js'; +import { randomUUID } from 'node:crypto'; +import type { + TrackedCancelledToolCall, + TrackedCompletedToolCall, + TrackedToolCall, +} from '../ui/hooks/useToolScheduler.js'; + +// Global state observer for React-based signals +const sessionStateMap = new Map(); +const activeRigs = new Map(); + +// Mock StreamingContext to report state changes back to the observer +vi.mock('../ui/contexts/StreamingContext.js', async (importOriginal) => { + const original = + await importOriginal(); + const { useConfig } = await import('../ui/contexts/ConfigContext.js'); + const React = await import('react'); + + return { + ...original, + useStreamingContext: () => { + const state = original.useStreamingContext(); + const config = useConfig(); + const sessionId = config.getSessionId(); + + React.useEffect(() => { + sessionStateMap.set(sessionId, state); + // If we see activity, we are no longer "awaiting" the start of a response + if (state !== StreamingState.Idle) { + const rig = activeRigs.get(sessionId); + if (rig) { + rig.awaitingResponse = false; + } + } + }, [sessionId, state]); + + return state; + }, + }; +}); // Mock core functions globally for tests using AppRig. vi.mock('@google/gemini-cli-core', async (importOriginal) => { @@ -112,9 +153,18 @@ export class AppRig { private breakpointTools = new Set(); private lastAwaitedConfirmation: PendingConfirmation | undefined; + /** + * True if a message was just sent but React hasn't yet reported a non-idle state. + */ + awaitingResponse = false; + constructor(private options: AppRigOptions = {}) { - this.testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gemini-app-rig-')); - this.sessionId = `test-session-${Math.random().toString(36).slice(2, 9)}`; + const uniqueId = randomUUID(); + this.testDir = fs.mkdtempSync( + path.join(os.tmpdir(), `gemini-app-rig-${uniqueId.slice(0, 8)}-`), + ); + this.sessionId = `test-session-${uniqueId}`; + activeRigs.set(this.sessionId, this); } async initialize() { @@ -245,6 +295,8 @@ export class AppRig { }; } + private toolCalls: TrackedToolCall[] = []; + private setupMessageBusListeners() { if (!this.config) return; const messageBus = this.config.getMessageBus(); @@ -252,6 +304,7 @@ export class AppRig { messageBus.subscribe( MessageBusType.TOOL_CALLS_UPDATE, (message: ToolCallsUpdateMessage) => { + this.toolCalls = message.toolCalls; for (const call of message.toolCalls) { if (call.status === 'awaiting_approval' && call.correlationId) { const details = call.confirmationDetails; @@ -281,6 +334,48 @@ export class AppRig { ); } + /** + * Returns true if the agent is currently busy (responding or executing tools). + */ + isBusy(): boolean { + if (this.awaitingResponse) { + return true; + } + + const reactState = sessionStateMap.get(this.sessionId); + // If we have a React-based state, use it as the definitive signal. + // 'responding' and 'waiting-for-confirmation' both count as busy for the overall task. + if (reactState !== undefined) { + return reactState !== StreamingState.Idle; + } + + // Fallback to tool tracking if React hasn't reported yet + const isAnyToolActive = this.toolCalls.some((tc) => { + if ( + tc.status === CoreToolCallStatus.Executing || + tc.status === CoreToolCallStatus.Scheduled || + tc.status === CoreToolCallStatus.Validating + ) { + return true; + } + if ( + tc.status === CoreToolCallStatus.Success || + tc.status === CoreToolCallStatus.Error || + tc.status === CoreToolCallStatus.Cancelled + ) { + return !(tc as TrackedCompletedToolCall | TrackedCancelledToolCall) + .responseSubmittedToGemini; + } + return false; + }); + + const isAwaitingConfirmation = this.toolCalls.some( + (tc) => tc.status === CoreToolCallStatus.AwaitingApproval, + ); + + return isAnyToolActive || isAwaitingConfirmation; + } + render() { if (!this.config || !this.settings) throw new Error('AppRig not initialized'); @@ -334,17 +429,21 @@ export class AppRig { this.setBreakpoint(name); } } else { - this.setToolPolicy(toolName, PolicyDecision.ASK_USER, 100); + // Use undefined toolName to create a global rule if '*' is provided + const actualToolName = toolName === '*' ? undefined : toolName; + this.setToolPolicy(actualToolName, PolicyDecision.ASK_USER, 100); this.breakpointTools.add(toolName); } } removeToolPolicy(toolName?: string, source = 'AppRig Override') { if (!this.config) throw new Error('AppRig not initialized'); + // Map '*' back to undefined for policy removal + const actualToolName = toolName === '*' ? undefined : toolName; this.config .getPolicyEngine() // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - .removeRulesForTool(toolName as string, source); + .removeRulesForTool(actualToolName as string, source); this.breakpointTools.delete(toolName); } @@ -416,6 +515,44 @@ export class AppRig { return matched!; } + /** + * Waits for either a tool confirmation request OR for the agent to go idle. + */ + async waitForNextEvent( + timeout = 60000, + ): Promise< + | { type: 'confirmation'; confirmation: PendingConfirmation } + | { type: 'idle' } + > { + let confirmation: PendingConfirmation | undefined; + let isIdle = false; + + await this.waitUntil( + async () => { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + confirmation = this.getPendingConfirmations()[0]; + // Now that we have a code-powered signal, this should be perfectly deterministic. + isIdle = !this.isBusy(); + return !!confirmation || isIdle; + }, + { + timeout, + message: 'Timed out waiting for next event (confirmation or idle).', + }, + ); + + if (confirmation) { + this.lastAwaitedConfirmation = confirmation; + return { type: 'confirmation', confirmation }; + } + + // Ensure all renders are flushed before returning 'idle' + await this.renderResult?.waitUntilReady(); + return { type: 'idle' }; + } + async resolveTool( toolNameOrDisplayName: string | RegExp | PendingConfirmation, outcome: ToolConfirmationOutcome = ToolConfirmationOutcome.ProceedOnce, @@ -471,6 +608,32 @@ export class AppRig { }); } + /** + * Drains all pending tool calls that hit a breakpoint until the agent is idle. + * Useful for negative tests to ensure no unwanted tools (like generalist) are called. + * + * @param onConfirmation Optional callback to inspect each confirmation before resolving. + * Return true to skip the default resolveTool call (e.g. if you handled it). + */ + async drainBreakpointsUntilIdle( + onConfirmation?: (confirmation: PendingConfirmation) => void | boolean, + timeout = 60000, + ) { + while (true) { + const event = await this.waitForNextEvent(timeout); + if (event.type === 'idle') { + break; + } + + const confirmation = event.confirmation; + const handled = onConfirmation?.(confirmation); + + if (!handled) { + await this.resolveTool(confirmation); + } + } + } + getConfig(): Config { if (!this.config) throw new Error('AppRig not initialized'); return this.config; @@ -530,11 +693,16 @@ export class AppRig { } async sendMessage(text: string) { + this.awaitingResponse = true; await this.type(text); await this.pressEnter(); } async unmount() { + // Clean up global state for this session + sessionStateMap.delete(this.sessionId); + activeRigs.delete(this.sessionId); + // Poison the chat recording service to prevent late writes to the test directory if (this.config) { const recordingService = this.config diff --git a/packages/cli/src/test-utils/customMatchers.ts b/packages/cli/src/test-utils/customMatchers.ts index 0259c064a6..ae9b44ee44 100644 --- a/packages/cli/src/test-utils/customMatchers.ts +++ b/packages/cli/src/test-utils/customMatchers.ts @@ -6,20 +6,78 @@ /// -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import type { Assertion } from 'vitest'; -import { expect } from 'vitest'; +import { expect, type Assertion } from 'vitest'; +import path from 'node:path'; +import stripAnsi from 'strip-ansi'; import type { TextBuffer } from '../ui/components/shared/text-buffer.js'; // RegExp to detect invalid characters: backspace, and ANSI escape codes // eslint-disable-next-line no-control-regex const invalidCharsRegex = /[\b\x1b]/; +const callCountByTest = new Map(); + +export async function toMatchSvgSnapshot( + this: Assertion, + renderInstance: { + lastFrameRaw?: (options?: { allowEmpty?: boolean }) => string; + lastFrame?: (options?: { allowEmpty?: boolean }) => string; + generateSvg: () => string; + }, + options?: { allowEmpty?: boolean; name?: string }, +) { + const currentTestName = expect.getState().currentTestName; + if (!currentTestName) { + throw new Error('toMatchSvgSnapshot must be called within a test'); + } + const testPath = expect.getState().testPath; + if (!testPath) { + throw new Error('toMatchSvgSnapshot requires testPath'); + } + + let textContent: string; + if (renderInstance.lastFrameRaw) { + textContent = renderInstance.lastFrameRaw({ + allowEmpty: options?.allowEmpty, + }); + } else if (renderInstance.lastFrame) { + textContent = renderInstance.lastFrame({ allowEmpty: options?.allowEmpty }); + } else { + throw new Error( + 'toMatchSvgSnapshot requires a renderInstance with either lastFrameRaw or lastFrame', + ); + } + const svgContent = renderInstance.generateSvg(); + + const sanitize = (name: string) => + name.replace(/[^a-zA-Z0-9_-]/g, '-').replace(/-+/g, '-'); + + const testId = testPath + ':' + currentTestName; + let count = callCountByTest.get(testId) ?? 0; + count++; + callCountByTest.set(testId, count); + + const snapshotName = + options?.name ?? + (count > 1 ? `${currentTestName}-${count}` : currentTestName); + + const svgFileName = + sanitize(path.basename(testPath).replace(/\.test\.tsx?$/, '')) + + '-' + + sanitize(snapshotName) + + '.snap.svg'; + const svgDir = path.join(path.dirname(testPath), '__snapshots__'); + const svgFilePath = path.join(svgDir, svgFileName); + + // Assert the text matches standard snapshot, stripping ANSI for stability + expect(stripAnsi(textContent)).toMatchSnapshot(); + + // Assert the SVG matches the file snapshot + await expect(svgContent).toMatchFileSnapshot(svgFilePath); + + return { pass: true, message: () => '' }; +} + function toHaveOnlyValidCharacters(this: Assertion, buffer: TextBuffer) { // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-unsafe-assignment const { isNot } = this as any; @@ -53,15 +111,22 @@ function toHaveOnlyValidCharacters(this: Assertion, buffer: TextBuffer) { // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion expect.extend({ toHaveOnlyValidCharacters, + toMatchSvgSnapshot, // eslint-disable-next-line @typescript-eslint/no-explicit-any } as any); // Extend Vitest's `expect` interface with the custom matcher's type definition. declare module 'vitest' { - interface Assertion { + // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-empty-object-type + interface Assertion extends CustomMatchers {} + // eslint-disable-next-line @typescript-eslint/no-empty-object-type + interface AsymmetricMatchersContaining extends CustomMatchers {} + + interface CustomMatchers { toHaveOnlyValidCharacters(): T; - } - interface AsymmetricMatchersContaining { - toHaveOnlyValidCharacters(): void; + toMatchSvgSnapshot(options?: { + allowEmpty?: boolean; + name?: string; + }): Promise; } } diff --git a/packages/cli/src/test-utils/mockDebugLogger.ts b/packages/cli/src/test-utils/mockDebugLogger.ts new file mode 100644 index 0000000000..02eb3b05d9 --- /dev/null +++ b/packages/cli/src/test-utils/mockDebugLogger.ts @@ -0,0 +1,76 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { vi } from 'vitest'; +import stripAnsi from 'strip-ansi'; +import { format } from 'node:util'; + +export function createMockDebugLogger(options: { stripAnsi?: boolean } = {}) { + const emitConsoleLog = vi.fn(); + const debugLogger = { + log: vi.fn((message: unknown, ...args: unknown[]) => { + let formatted = + typeof message === 'string' ? format(message, ...args) : message; + if (options.stripAnsi && typeof formatted === 'string') { + formatted = stripAnsi(formatted); + } + emitConsoleLog('log', formatted); + }), + error: vi.fn((message: unknown, ...args: unknown[]) => { + let formatted = + typeof message === 'string' ? format(message, ...args) : message; + if (options.stripAnsi && typeof formatted === 'string') { + formatted = stripAnsi(formatted); + } + emitConsoleLog('error', formatted); + }), + warn: vi.fn((message: unknown, ...args: unknown[]) => { + let formatted = + typeof message === 'string' ? format(message, ...args) : message; + if (options.stripAnsi && typeof formatted === 'string') { + formatted = stripAnsi(formatted); + } + emitConsoleLog('warn', formatted); + }), + debug: vi.fn(), + info: vi.fn(), + }; + + return { emitConsoleLog, debugLogger }; +} + +/** + * A helper specifically designed for `vi.mock('@google/gemini-cli-core', ...)` to easily + * mock both `debugLogger` and `coreEvents.emitConsoleLog`. + * + * Example: + * ```typescript + * vi.mock('@google/gemini-cli-core', async (importOriginal) => { + * const { mockCoreDebugLogger } = await import('../../test-utils/mockDebugLogger.js'); + * return mockCoreDebugLogger( + * await importOriginal(), + * { stripAnsi: true } + * ); + * }); + * ``` + */ +export function mockCoreDebugLogger>( + actual: T, + options?: { stripAnsi?: boolean }, +): T { + const { emitConsoleLog, debugLogger } = createMockDebugLogger(options); + return { + ...actual, + coreEvents: { + ...(typeof actual['coreEvents'] === 'object' && + actual['coreEvents'] !== null + ? actual['coreEvents'] + : {}), + emitConsoleLog, + }, + debugLogger, + } as T; +} diff --git a/packages/cli/src/test-utils/render.tsx b/packages/cli/src/test-utils/render.tsx index 73ec9af2d3..1b64c07d7b 100644 --- a/packages/cli/src/test-utils/render.tsx +++ b/packages/cli/src/test-utils/render.tsx @@ -51,6 +51,7 @@ import { SessionStatsProvider } from '../ui/contexts/SessionContext.js'; import { themeManager, DEFAULT_THEME } from '../ui/themes/theme-manager.js'; import { DefaultLight } from '../ui/themes/default-light.js'; import { pickDefaultThemeName } from '../ui/themes/theme.js'; +import { generateSvgForTerminal } from './svg.js'; export const persistentStateMock = new FakePersistentState(); @@ -105,7 +106,12 @@ class XtermStdout extends EventEmitter { private queue: { promise: Promise }; isTTY = true; + getColorDepth(): number { + return 24; + } + private lastRenderOutput: string | undefined = undefined; + private lastRenderStaticContent: string | undefined = undefined; constructor(state: TerminalState, queue: { promise: Promise }) { super(); @@ -138,6 +144,7 @@ class XtermStdout extends EventEmitter { clear = () => { this.state.terminal.reset(); this.lastRenderOutput = undefined; + this.lastRenderStaticContent = undefined; }; dispose = () => { @@ -146,10 +153,32 @@ class XtermStdout extends EventEmitter { onRender = (staticContent: string, output: string) => { this.renderCount++; + this.lastRenderStaticContent = staticContent; this.lastRenderOutput = output; this.emit('render'); }; + private normalizeFrame = (text: string): string => + text.replace(/\r\n/g, '\n'); + + generateSvg = (): string => generateSvgForTerminal(this.state.terminal); + + lastFrameRaw = (options: { allowEmpty?: boolean } = {}) => { + const result = + (this.lastRenderStaticContent ?? '') + (this.lastRenderOutput ?? ''); + + const normalized = this.normalizeFrame(result); + + if (normalized === '' && !options.allowEmpty) { + throw new Error( + 'lastFrameRaw() returned an empty string. If this is intentional, use lastFrameRaw({ allowEmpty: true }). ' + + 'Otherwise, ensure you are calling await waitUntilReady() and that the component is rendering correctly.', + ); + } + + return normalized; + }; + lastFrame = (options: { allowEmpty?: boolean } = {}) => { const buffer = this.state.terminal.buffer.active; const allLines: string[] = []; @@ -163,9 +192,7 @@ class XtermStdout extends EventEmitter { } const result = trimmed.join('\n'); - // Normalize for cross-platform snapshot stability: - // Normalize any \r\n to \n - const normalized = result.replace(/\r\n/g, '\n'); + const normalized = this.normalizeFrame(result); if (normalized === '' && !options.allowEmpty) { throw new Error( @@ -213,9 +240,11 @@ class XtermStdout extends EventEmitter { const currentFrame = stripAnsi( this.lastFrame({ allowEmpty: true }), ).trim(); - const expectedFrame = stripAnsi(this.lastRenderOutput ?? '') - .trim() - .replace(/\r\n/g, '\n'); + const expectedFrame = this.normalizeFrame( + stripAnsi( + (this.lastRenderStaticContent ?? '') + (this.lastRenderOutput ?? ''), + ), + ).trim(); lastCurrent = currentFrame; lastExpected = expectedFrame; @@ -340,6 +369,8 @@ export type RenderInstance = { stdin: XtermStdin; frames: string[]; lastFrame: (options?: { allowEmpty?: boolean }) => string; + lastFrameRaw: (options?: { allowEmpty?: boolean }) => string; + generateSvg: () => string; terminal: Terminal; waitUntilReady: () => Promise; capturedOverflowState: OverflowState | undefined; @@ -424,6 +455,8 @@ export const render = ( stdin, frames: stdout.frames, lastFrame: stdout.lastFrame, + lastFrameRaw: stdout.lastFrameRaw, + generateSvg: stdout.generateSvg, terminal: state.terminal, waitUntilReady: () => stdout.waitUntilReady(), }; @@ -574,6 +607,7 @@ const mockUIActions: UIActions = { onHintSubmit: vi.fn(), handleRestart: vi.fn(), handleNewAgentsSelect: vi.fn(), + getPreferredEditor: vi.fn(), }; let capturedOverflowState: OverflowState | undefined; @@ -767,6 +801,7 @@ export function renderHook( rerender: (props?: Props) => void; unmount: () => void; waitUntilReady: () => Promise; + generateSvg: () => string; } { // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion const result = { current: undefined as unknown as Result }; @@ -789,6 +824,7 @@ export function renderHook( let inkRerender: (tree: React.ReactElement) => void = () => {}; let unmount: () => void = () => {}; let waitUntilReady: () => Promise = async () => {}; + let generateSvg: () => string = () => ''; act(() => { const renderResult = render( @@ -799,6 +835,7 @@ export function renderHook( inkRerender = renderResult.rerender; unmount = renderResult.unmount; waitUntilReady = renderResult.waitUntilReady; + generateSvg = renderResult.generateSvg; }); function rerender(props?: Props) { @@ -815,7 +852,7 @@ export function renderHook( }); } - return { result, rerender, unmount, waitUntilReady }; + return { result, rerender, unmount, waitUntilReady, generateSvg }; } export function renderHookWithProviders( @@ -837,6 +874,7 @@ export function renderHookWithProviders( rerender: (props?: Props) => void; unmount: () => void; waitUntilReady: () => Promise; + generateSvg: () => string; } { // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion const result = { current: undefined as unknown as Result }; @@ -887,5 +925,6 @@ export function renderHookWithProviders( }); }, waitUntilReady: () => renderResult.waitUntilReady(), + generateSvg: () => renderResult.generateSvg(), }; } diff --git a/packages/cli/src/test-utils/svg.ts b/packages/cli/src/test-utils/svg.ts new file mode 100644 index 0000000000..10528ca6b7 --- /dev/null +++ b/packages/cli/src/test-utils/svg.ts @@ -0,0 +1,190 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { Terminal } from '@xterm/headless'; + +export const generateSvgForTerminal = (terminal: Terminal): string => { + const activeBuffer = terminal.buffer.active; + + const getHexColor = ( + isRGB: boolean, + isPalette: boolean, + isDefault: boolean, + colorCode: number, + ): string | null => { + if (isDefault) return null; + if (isRGB) { + return `#${colorCode.toString(16).padStart(6, '0')}`; + } + if (isPalette) { + if (colorCode >= 0 && colorCode <= 15) { + return ( + [ + '#000000', + '#cd0000', + '#00cd00', + '#cdcd00', + '#0000ee', + '#cd00cd', + '#00cdcd', + '#e5e5e5', + '#7f7f7f', + '#ff0000', + '#00ff00', + '#ffff00', + '#5c5cff', + '#ff00ff', + '#00ffff', + '#ffffff', + ][colorCode] || null + ); + } else if (colorCode >= 16 && colorCode <= 231) { + const v = [0, 95, 135, 175, 215, 255]; + const c = colorCode - 16; + const b = v[c % 6]; + const g = v[Math.floor(c / 6) % 6]; + const r = v[Math.floor(c / 36) % 6]; + return `#${[r, g, b].map((x) => x?.toString(16).padStart(2, '0')).join('')}`; + } else if (colorCode >= 232 && colorCode <= 255) { + const gray = 8 + (colorCode - 232) * 10; + const hex = gray.toString(16).padStart(2, '0'); + return `#${hex}${hex}${hex}`; + } + } + return null; + }; + + const escapeXml = (unsafe: string): string => + // eslint-disable-next-line no-control-regex + unsafe.replace(/[<>&'"\x00-\x08\x0B-\x0C\x0E-\x1F]/g, (c) => { + switch (c) { + case '<': + return '<'; + case '>': + return '>'; + case '&': + return '&'; + case "'": + return '''; + case '"': + return '"'; + default: + return ''; + } + }); + + const charWidth = 9; + const charHeight = 17; + const padding = 10; + + // Find the actual number of rows with content to avoid rendering trailing blank space. + let contentRows = terminal.rows; + for (let y = terminal.rows - 1; y >= 0; y--) { + const line = activeBuffer.getLine(y); + if (line && line.translateToString(true).trim().length > 0) { + contentRows = y + 1; + break; + } + } + if (contentRows === 0) contentRows = 1; // Minimum 1 row + + const width = terminal.cols * charWidth + padding * 2; + const height = contentRows * charHeight + padding * 2; + + let svg = ` +`; + svg += ` +`; + svg += ` +`; // Terminal background + svg += ` +`; + + for (let y = 0; y < contentRows; y++) { + const line = activeBuffer.getLine(y); + if (!line) continue; + + let currentFgHex: string | null = null; + let currentBgHex: string | null = null; + let currentBlockStartCol = -1; + let currentBlockText = ''; + let currentBlockNumCells = 0; + + const finalizeBlock = (_endCol: number) => { + if (currentBlockStartCol !== -1) { + if (currentBlockText.length > 0) { + const xPos = currentBlockStartCol * charWidth; + const yPos = y * charHeight; + + if (currentBgHex) { + const rectWidth = currentBlockNumCells * charWidth; + svg += ` +`; + } + if (currentBlockText.trim().length > 0) { + const fill = currentFgHex || '#ffffff'; // Default text color + const textWidth = currentBlockNumCells * charWidth; + // Use textLength to ensure the block fits exactly into its designated cells + svg += ` ${escapeXml(currentBlockText)} +`; + } + } + } + }; + + for (let x = 0; x < line.length; x++) { + const cell = line.getCell(x); + if (!cell) continue; + const cellWidth = cell.getWidth(); + if (cellWidth === 0) continue; // Skip continuation cells of wide characters + + let fgHex = getHexColor( + cell.isFgRGB(), + cell.isFgPalette(), + cell.isFgDefault(), + cell.getFgColor(), + ); + let bgHex = getHexColor( + cell.isBgRGB(), + cell.isBgPalette(), + cell.isBgDefault(), + cell.getBgColor(), + ); + + if (cell.isInverse()) { + const tempFgHex = fgHex; + fgHex = bgHex || '#000000'; + bgHex = tempFgHex || '#ffffff'; + } + + let chars = cell.getChars(); + if (chars === '') chars = ' '.repeat(cellWidth); + + if ( + fgHex !== currentFgHex || + bgHex !== currentBgHex || + currentBlockStartCol === -1 + ) { + finalizeBlock(x); + currentFgHex = fgHex; + currentBgHex = bgHex; + currentBlockStartCol = x; + currentBlockText = chars; + currentBlockNumCells = cellWidth; + } else { + currentBlockText += chars; + currentBlockNumCells += cellWidth; + } + } + finalizeBlock(line.length); + } + svg += ` \n`; + return svg; +}; diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 86a4938a66..b89d0b83c0 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -2554,6 +2554,7 @@ Logging in with Google... Restarting Gemini CLI to continue. } setNewAgents(null); }, + getPreferredEditor, }), [ handleThemeSelect, @@ -2605,6 +2606,7 @@ Logging in with Google... Restarting Gemini CLI to continue. newAgents, config, historyManager, + getPreferredEditor, ], ); diff --git a/packages/cli/src/ui/__snapshots__/App.test.tsx.snap b/packages/cli/src/ui/__snapshots__/App.test.tsx.snap index d95adcda95..450da8362e 100644 --- a/packages/cli/src/ui/__snapshots__/App.test.tsx.snap +++ b/packages/cli/src/ui/__snapshots__/App.test.tsx.snap @@ -2,14 +2,14 @@ exports[`App > Snapshots > renders default layout correctly 1`] = ` " - ███ █████████ + ███ █████████ ░░░███ ███░░░░░███ - ░░░███ ███ ░░░ - ░░░███░███ + ░░░███ ███ ░░░ + ░░░███░███ ███░ ░███ █████ - ███░ ░░███ ░░███ - ███░ ░░█████████ -░░░ ░░░░░░░░░ + ███░ ░░███ ░░███ + ███░ ░░█████████ +░░░ ░░░░░░░░░ Tips for getting started: 1. Ask questions, edit files, or run commands. @@ -47,14 +47,14 @@ exports[`App > Snapshots > renders screen reader layout correctly 1`] = ` "Notifications Footer - ███ █████████ + ███ █████████ ░░░███ ███░░░░░███ - ░░░███ ███ ░░░ - ░░░███░███ + ░░░███ ███ ░░░ + ░░░███░███ ███░ ░███ █████ - ███░ ░░███ ░░███ - ███░ ░░█████████ -░░░ ░░░░░░░░░ + ███░ ░░███ ░░███ + ███░ ░░█████████ +░░░ ░░░░░░░░░ Tips for getting started: 1. Ask questions, edit files, or run commands. @@ -67,14 +67,14 @@ Composer exports[`App > Snapshots > renders with dialogs visible 1`] = ` " - ███ █████████ + ███ █████████ ░░░███ ███░░░░░███ - ░░░███ ███ ░░░ - ░░░███░███ + ░░░███ ███ ░░░ + ░░░███░███ ███░ ░███ █████ - ███░ ░░███ ░░███ - ███░ ░░█████████ -░░░ ░░░░░░░░░ + ███░ ░░███ ░░███ + ███░ ░░█████████ +░░░ ░░░░░░░░░ @@ -110,14 +110,14 @@ DialogManager exports[`App > should render ToolConfirmationQueue along with Composer when tool is confirming and experiment is on 1`] = ` " - ███ █████████ + ███ █████████ ░░░███ ███░░░░░███ - ░░░███ ███ ░░░ - ░░░███░███ + ░░░███ ███ ░░░ + ░░░███░███ ███░ ░███ █████ - ███░ ░░███ ░░███ - ███░ ░░█████████ -░░░ ░░░░░░░░░ + ███░ ░░███ ░░███ + ███░ ░░█████████ +░░░ ░░░░░░░░░ Tips for getting started: 1. Ask questions, edit files, or run commands. diff --git a/packages/cli/src/ui/components/AnsiOutput.test.tsx b/packages/cli/src/ui/components/AnsiOutput.test.tsx index 770eb9b056..ac824fefe6 100644 --- a/packages/cli/src/ui/components/AnsiOutput.test.tsx +++ b/packages/cli/src/ui/components/AnsiOutput.test.tsx @@ -33,7 +33,7 @@ describe('', () => { , ); await waitUntilReady(); - expect(lastFrame()).toBe('Hello, world!\n'); + expect(lastFrame().trim()).toBe('Hello, world!'); unmount(); }); @@ -51,7 +51,7 @@ describe('', () => { , ); await waitUntilReady(); - expect(lastFrame()).toBe(text + '\n'); + expect(lastFrame().trim()).toBe(text); unmount(); }); @@ -65,7 +65,7 @@ describe('', () => { , ); await waitUntilReady(); - expect(lastFrame()).toBe(text + '\n'); + expect(lastFrame().trim()).toBe(text); unmount(); }); diff --git a/packages/cli/src/ui/components/AskUserDialog.test.tsx b/packages/cli/src/ui/components/AskUserDialog.test.tsx index ef04e51499..1bd29241db 100644 --- a/packages/cli/src/ui/components/AskUserDialog.test.tsx +++ b/packages/cli/src/ui/components/AskUserDialog.test.tsx @@ -10,7 +10,6 @@ import { renderWithProviders } from '../../test-utils/render.js'; import { waitFor } from '../../test-utils/async.js'; import { AskUserDialog } from './AskUserDialog.js'; import { QuestionType, type Question } from '@google/gemini-cli-core'; -import chalk from 'chalk'; import { UIStateContext, type UIState } from '../contexts/UIStateContext.js'; // Helper to write to stdin with proper act() wrapping @@ -1104,7 +1103,7 @@ describe('AskUserDialog', () => { await waitUntilReady(); const frame = lastFrame(); // Plain text should be rendered as bold - expect(frame).toContain(chalk.bold('Which option do you prefer?')); + expect(frame).toContain('Which option do you prefer?'); }); }); @@ -1136,7 +1135,7 @@ describe('AskUserDialog', () => { // Should NOT have double-bold (the whole question bolded AND "this" bolded) // "Is " should not be bold, only "this" should be bold expect(frame).toContain('Is '); - expect(frame).toContain(chalk.bold('this')); + expect(frame).toContain('this'); expect(frame).not.toContain('**this**'); }); }); @@ -1166,8 +1165,8 @@ describe('AskUserDialog', () => { await waitFor(async () => { await waitUntilReady(); const frame = lastFrame(); - // Check for chalk.bold('this') - asterisks should be gone, text should be bold - expect(frame).toContain(chalk.bold('this')); + // Check for 'this' - asterisks should be gone + expect(frame).toContain('this'); expect(frame).not.toContain('**this**'); }); }); @@ -1198,8 +1197,8 @@ describe('AskUserDialog', () => { await waitUntilReady(); const frame = lastFrame(); // Backticks should be removed - expect(frame).toContain('npm start'); - expect(frame).not.toContain('`npm start`'); + expect(frame).toContain('Run npm start?'); + expect(frame).not.toContain('`'); }); }); }); diff --git a/packages/cli/src/ui/components/AskUserDialog.tsx b/packages/cli/src/ui/components/AskUserDialog.tsx index 1d31b1a1f4..9606513510 100644 --- a/packages/cli/src/ui/components/AskUserDialog.tsx +++ b/packages/cli/src/ui/components/AskUserDialog.tsx @@ -183,6 +183,10 @@ interface AskUserDialogProps { * Height constraint for scrollable content. */ availableHeight?: number; + /** + * Custom keyboard shortcut hints (e.g., ["Ctrl+P to edit"]) + */ + extraParts?: string[]; } interface ReviewViewProps { @@ -190,6 +194,7 @@ interface ReviewViewProps { answers: { [key: string]: string }; onSubmit: () => void; progressHeader?: React.ReactNode; + extraParts?: string[]; } const ReviewView: React.FC = ({ @@ -197,6 +202,7 @@ const ReviewView: React.FC = ({ answers, onSubmit, progressHeader, + extraParts, }) => { const unansweredCount = questions.length - Object.keys(answers).length; const hasUnanswered = unansweredCount > 0; @@ -247,6 +253,7 @@ const ReviewView: React.FC = ({ ); @@ -925,6 +932,7 @@ export const AskUserDialog: React.FC = ({ onActiveTextInputChange, width, availableHeight: availableHeightProp, + extraParts, }) => { const uiState = useContext(UIStateContext); const availableHeight = @@ -1120,6 +1128,7 @@ export const AskUserDialog: React.FC = ({ answers={answers} onSubmit={handleReviewSubmit} progressHeader={progressHeader} + extraParts={extraParts} /> ); @@ -1143,6 +1152,7 @@ export const AskUserDialog: React.FC = ({ ? undefined : '↑/↓ to navigate' } + extraParts={extraParts} /> ); diff --git a/packages/cli/src/ui/components/ConfigInitDisplay.test.tsx b/packages/cli/src/ui/components/ConfigInitDisplay.test.tsx index d942f8c55f..36ecbcbe5f 100644 --- a/packages/cli/src/ui/components/ConfigInitDisplay.test.tsx +++ b/packages/cli/src/ui/components/ConfigInitDisplay.test.tsx @@ -6,7 +6,7 @@ import { act } from 'react'; import type { EventEmitter } from 'node:events'; -import { render } from '../../test-utils/render.js'; +import { renderWithProviders } from '../../test-utils/render.js'; import { waitFor } from '../../test-utils/async.js'; import { ConfigInitDisplay } from './ConfigInitDisplay.js'; import { @@ -27,7 +27,7 @@ import { import { Text } from 'ink'; // Mock GeminiSpinner -vi.mock('./GeminiRespondingSpinner.js', () => ({ +vi.mock('./GeminiSpinner.js', () => ({ GeminiSpinner: () => Spinner, })); @@ -43,7 +43,9 @@ describe('ConfigInitDisplay', () => { }); it('renders initial state', async () => { - const { lastFrame, waitUntilReady } = render(); + const { lastFrame, waitUntilReady } = renderWithProviders( + , + ); await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); }); @@ -57,7 +59,7 @@ describe('ConfigInitDisplay', () => { return coreEvents; }); - const { lastFrame } = render(); + const { lastFrame } = renderWithProviders(); // Wait for listener to be registered await waitFor(() => { @@ -95,7 +97,7 @@ describe('ConfigInitDisplay', () => { return coreEvents; }); - const { lastFrame } = render(); + const { lastFrame } = renderWithProviders(); await waitFor(() => { if (!listener) throw new Error('Listener not registered yet'); @@ -131,7 +133,7 @@ describe('ConfigInitDisplay', () => { return coreEvents; }); - const { lastFrame } = render(); + const { lastFrame } = renderWithProviders(); await waitFor(() => { if (!listener) throw new Error('Listener not registered yet'); diff --git a/packages/cli/src/ui/components/ConfigInitDisplay.tsx b/packages/cli/src/ui/components/ConfigInitDisplay.tsx index a47e16daff..d421da211e 100644 --- a/packages/cli/src/ui/components/ConfigInitDisplay.tsx +++ b/packages/cli/src/ui/components/ConfigInitDisplay.tsx @@ -12,7 +12,7 @@ import { type McpClient, MCPServerStatus, } from '@google/gemini-cli-core'; -import { GeminiSpinner } from './GeminiRespondingSpinner.js'; +import { GeminiSpinner } from './GeminiSpinner.js'; import { theme } from '../semantic-colors.js'; export const ConfigInitDisplay = ({ diff --git a/packages/cli/src/ui/components/DialogManager.tsx b/packages/cli/src/ui/components/DialogManager.tsx index 3d56c68e5b..c90194052a 100644 --- a/packages/cli/src/ui/components/DialogManager.tsx +++ b/packages/cli/src/ui/components/DialogManager.tsx @@ -135,6 +135,7 @@ export const DialogManager = ({ isModelNotFoundError={ !!uiState.quota.proQuotaRequest.isModelNotFoundError } + authType={uiState.quota.proQuotaRequest.authType} onChoice={uiActions.handleProQuotaChoice} /> ); diff --git a/packages/cli/src/ui/components/ExitPlanModeDialog.test.tsx b/packages/cli/src/ui/components/ExitPlanModeDialog.test.tsx index 26b61829a0..c9def1a8c2 100644 --- a/packages/cli/src/ui/components/ExitPlanModeDialog.test.tsx +++ b/packages/cli/src/ui/components/ExitPlanModeDialog.test.tsx @@ -11,6 +11,7 @@ import { waitFor } from '../../test-utils/async.js'; import { ExitPlanModeDialog } from './ExitPlanModeDialog.js'; import { useKeypress } from '../hooks/useKeypress.js'; import { keyMatchers, Command } from '../keyMatchers.js'; +import { openFileInEditor } from '../utils/editorUtils.js'; import { ApprovalMode, validatePlanContent, @@ -19,6 +20,10 @@ import { } from '@google/gemini-cli-core'; import * as fs from 'node:fs'; +vi.mock('../utils/editorUtils.js', () => ({ + openFileInEditor: vi.fn(), +})); + vi.mock('@google/gemini-cli-core', async (importOriginal) => { const actual = await importOriginal(); @@ -144,6 +149,7 @@ Implement a comprehensive authentication system with multiple providers. onApprove={onApprove} onFeedback={onFeedback} onCancel={onCancel} + getPreferredEditor={vi.fn()} width={80} availableHeight={24} />, @@ -153,6 +159,7 @@ Implement a comprehensive authentication system with multiple providers. getTargetDir: () => mockTargetDir, getIdeMode: () => false, isTrustedFolder: () => true, + getPreferredEditor: () => undefined, storage: { getPlansDir: () => mockPlansDir, }, @@ -418,6 +425,7 @@ Implement a comprehensive authentication system with multiple providers. onApprove={onApprove} onFeedback={onFeedback} onCancel={onCancel} + getPreferredEditor={vi.fn()} width={80} availableHeight={24} /> @@ -535,6 +543,40 @@ Implement a comprehensive authentication system with multiple providers. }); expect(onFeedback).not.toHaveBeenCalled(); }); + + it('opens plan in external editor when Ctrl+X is pressed', async () => { + const { stdin, lastFrame } = renderDialog({ useAlternateBuffer }); + + await act(async () => { + vi.runAllTimers(); + }); + + await waitFor(() => { + expect(lastFrame()).toContain('Add user authentication'); + }); + + // Reset the mock to track the second call during refresh + vi.mocked(processSingleFileContent).mockClear(); + + // Press Ctrl+X + await act(async () => { + writeKey(stdin, '\x18'); // Ctrl+X + }); + + await waitFor(() => { + expect(openFileInEditor).toHaveBeenCalledWith( + mockPlanFullPath, + expect.anything(), + expect.anything(), + undefined, + ); + }); + + // Verify that content is refreshed (processSingleFileContent called again) + await waitFor(() => { + expect(processSingleFileContent).toHaveBeenCalled(); + }); + }); }, ); }); diff --git a/packages/cli/src/ui/components/ExitPlanModeDialog.tsx b/packages/cli/src/ui/components/ExitPlanModeDialog.tsx index 8777136d86..6a5da1c299 100644 --- a/packages/cli/src/ui/components/ExitPlanModeDialog.tsx +++ b/packages/cli/src/ui/components/ExitPlanModeDialog.tsx @@ -5,25 +5,32 @@ */ import type React from 'react'; -import { useEffect, useState } from 'react'; -import { Box, Text } from 'ink'; +import { useEffect, useState, useCallback } from 'react'; +import { Box, Text, useStdin } from 'ink'; import { ApprovalMode, validatePlanPath, validatePlanContent, QuestionType, type Config, + type EditorType, processSingleFileContent, + debugLogger, } from '@google/gemini-cli-core'; import { theme } from '../semantic-colors.js'; import { useConfig } from '../contexts/ConfigContext.js'; import { AskUserDialog } from './AskUserDialog.js'; +import { openFileInEditor } from '../utils/editorUtils.js'; +import { useKeypress } from '../hooks/useKeypress.js'; +import { keyMatchers, Command } from '../keyMatchers.js'; +import { formatCommand } from '../utils/keybindingUtils.js'; export interface ExitPlanModeDialogProps { planPath: string; onApprove: (approvalMode: ApprovalMode) => void; onFeedback: (feedback: string) => void; onCancel: () => void; + getPreferredEditor: () => EditorType | undefined; width: number; availableHeight?: number; } @@ -38,6 +45,7 @@ interface PlanContentState { status: PlanStatus; content?: string; error?: string; + refresh: () => void; } enum ApprovalOption { @@ -53,10 +61,15 @@ const StatusMessage: React.FC<{ }> = ({ children }) => {children}; function usePlanContent(planPath: string, config: Config): PlanContentState { - const [state, setState] = useState({ + const [version, setVersion] = useState(0); + const [state, setState] = useState>({ status: PlanStatus.Loading, }); + const refresh = useCallback(() => { + setVersion((v) => v + 1); + }, []); + useEffect(() => { let ignore = false; setState({ status: PlanStatus.Loading }); @@ -120,9 +133,9 @@ function usePlanContent(planPath: string, config: Config): PlanContentState { return () => { ignore = true; }; - }, [planPath, config]); + }, [planPath, config, version]); - return state; + return { ...state, refresh }; } export const ExitPlanModeDialog: React.FC = ({ @@ -130,13 +143,36 @@ export const ExitPlanModeDialog: React.FC = ({ onApprove, onFeedback, onCancel, + getPreferredEditor, width, availableHeight, }) => { const config = useConfig(); + const { stdin, setRawMode } = useStdin(); const planState = usePlanContent(planPath, config); + const { refresh } = planState; const [showLoading, setShowLoading] = useState(false); + const handleOpenEditor = useCallback(async () => { + try { + await openFileInEditor(planPath, stdin, setRawMode, getPreferredEditor()); + refresh(); + } catch (err) { + debugLogger.error('Failed to open plan in editor:', err); + } + }, [planPath, stdin, setRawMode, getPreferredEditor, refresh]); + + useKeypress( + (key) => { + if (keyMatchers[Command.OPEN_EXTERNAL_EDITOR](key)) { + void handleOpenEditor(); + return true; + } + return false; + }, + { isActive: true, priority: true }, + ); + useEffect(() => { if (planState.status !== PlanStatus.Loading) { setShowLoading(false); @@ -183,6 +219,8 @@ export const ExitPlanModeDialog: React.FC = ({ ); } + const editHint = formatCommand(Command.OPEN_EXTERNAL_EDITOR); + return ( = ({ onCancel={onCancel} width={width} availableHeight={availableHeight} + extraParts={[`${editHint} to edit plan`]} /> ); diff --git a/packages/cli/src/ui/components/FolderTrustDialog.test.tsx b/packages/cli/src/ui/components/FolderTrustDialog.test.tsx index 07693db151..bbda51d8f0 100644 --- a/packages/cli/src/ui/components/FolderTrustDialog.test.tsx +++ b/packages/cli/src/ui/components/FolderTrustDialog.test.tsx @@ -7,7 +7,7 @@ import { renderWithProviders } from '../../test-utils/render.js'; import { waitFor } from '../../test-utils/async.js'; import { act } from 'react'; -import { vi, describe, it, expect, beforeEach } from 'vitest'; +import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; import { FolderTrustDialog } from './FolderTrustDialog.js'; import { ExitCodes } from '@google/gemini-cli-core'; import * as processUtils from '../../utils/processUtils.js'; diff --git a/packages/cli/src/ui/components/GeminiRespondingSpinner.test.tsx b/packages/cli/src/ui/components/GeminiRespondingSpinner.test.tsx index 84241b05ce..a60f91cd80 100644 --- a/packages/cli/src/ui/components/GeminiRespondingSpinner.test.tsx +++ b/packages/cli/src/ui/components/GeminiRespondingSpinner.test.tsx @@ -8,7 +8,7 @@ import { render } from '../../test-utils/render.js'; import { GeminiRespondingSpinner } from './GeminiRespondingSpinner.js'; import { describe, it, expect, vi, beforeEach } from 'vitest'; import { useStreamingContext } from '../contexts/StreamingContext.js'; -import { useIsScreenReaderEnabled } from 'ink'; +import { Text, useIsScreenReaderEnabled } from 'ink'; import { StreamingState } from '../types.js'; import { SCREEN_READER_LOADING, @@ -24,8 +24,10 @@ vi.mock('ink', async (importOriginal) => { }; }); -vi.mock('./CliSpinner.js', () => ({ - CliSpinner: () => 'Spinner', +vi.mock('./GeminiSpinner.js', () => ({ + GeminiSpinner: ({ altText }: { altText?: string }) => ( + GeminiSpinner {altText} + ), })); describe('GeminiRespondingSpinner', () => { @@ -33,23 +35,17 @@ describe('GeminiRespondingSpinner', () => { const mockUseIsScreenReaderEnabled = vi.mocked(useIsScreenReaderEnabled); beforeEach(() => { - vi.useFakeTimers(); vi.clearAllMocks(); mockUseIsScreenReaderEnabled.mockReturnValue(false); }); - afterEach(() => { - vi.useRealTimers(); - }); - it('renders spinner when responding', async () => { mockUseStreamingContext.mockReturnValue(StreamingState.Responding); const { lastFrame, waitUntilReady, unmount } = render( , ); await waitUntilReady(); - // Spinner output varies, but it shouldn't be empty - expect(lastFrame()).not.toBe(''); + expect(lastFrame()).toContain('GeminiSpinner'); unmount(); }); diff --git a/packages/cli/src/ui/components/GeminiRespondingSpinner.tsx b/packages/cli/src/ui/components/GeminiRespondingSpinner.tsx index da2fef686a..2e6821355f 100644 --- a/packages/cli/src/ui/components/GeminiRespondingSpinner.tsx +++ b/packages/cli/src/ui/components/GeminiRespondingSpinner.tsx @@ -5,9 +5,7 @@ */ import type React from 'react'; -import { useState, useEffect, useMemo } from 'react'; import { Text, useIsScreenReaderEnabled } from 'ink'; -import { CliSpinner } from './CliSpinner.js'; import type { SpinnerName } from 'cli-spinners'; import { useStreamingContext } from '../contexts/StreamingContext.js'; import { StreamingState } from '../types.js'; @@ -16,10 +14,7 @@ import { SCREEN_READER_RESPONDING, } from '../textConstants.js'; import { theme } from '../semantic-colors.js'; -import { Colors } from '../colors.js'; -import tinygradient from 'tinygradient'; - -const COLOR_CYCLE_DURATION_MS = 4000; +import { GeminiSpinner } from './GeminiSpinner.js'; interface GeminiRespondingSpinnerProps { /** @@ -54,51 +49,3 @@ export const GeminiRespondingSpinner: React.FC< return null; }; - -interface GeminiSpinnerProps { - spinnerType?: SpinnerName; - altText?: string; -} - -export const GeminiSpinner: React.FC = ({ - spinnerType = 'dots', - altText, -}) => { - const isScreenReaderEnabled = useIsScreenReaderEnabled(); - const [time, setTime] = useState(0); - - const googleGradient = useMemo(() => { - const brandColors = [ - Colors.AccentPurple, - Colors.AccentBlue, - Colors.AccentCyan, - Colors.AccentGreen, - Colors.AccentYellow, - Colors.AccentRed, - ]; - return tinygradient([...brandColors, brandColors[0]]); - }, []); - - useEffect(() => { - if (isScreenReaderEnabled) { - return; - } - - const interval = setInterval(() => { - setTime((prevTime) => prevTime + 30); - }, 30); // ~33fps for smooth color transitions - - return () => clearInterval(interval); - }, [isScreenReaderEnabled]); - - const progress = (time % COLOR_CYCLE_DURATION_MS) / COLOR_CYCLE_DURATION_MS; - const currentColor = googleGradient.rgbAt(progress).toHexString(); - - return isScreenReaderEnabled ? ( - {altText} - ) : ( - - - - ); -}; diff --git a/packages/cli/src/ui/components/GeminiSpinner.tsx b/packages/cli/src/ui/components/GeminiSpinner.tsx new file mode 100644 index 0000000000..37d1930625 --- /dev/null +++ b/packages/cli/src/ui/components/GeminiSpinner.tsx @@ -0,0 +1,63 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type React from 'react'; +import { useState, useEffect, useMemo } from 'react'; +import { Text, useIsScreenReaderEnabled } from 'ink'; +import { CliSpinner } from './CliSpinner.js'; +import type { SpinnerName } from 'cli-spinners'; +import { Colors } from '../colors.js'; +import tinygradient from 'tinygradient'; + +const COLOR_CYCLE_DURATION_MS = 4000; + +interface GeminiSpinnerProps { + spinnerType?: SpinnerName; + altText?: string; +} + +export const GeminiSpinner: React.FC = ({ + spinnerType = 'dots', + altText, +}) => { + const isScreenReaderEnabled = useIsScreenReaderEnabled(); + const [time, setTime] = useState(0); + + const googleGradient = useMemo(() => { + const brandColors = [ + Colors.AccentPurple, + Colors.AccentBlue, + Colors.AccentCyan, + Colors.AccentGreen, + Colors.AccentYellow, + Colors.AccentRed, + ]; + return tinygradient([...brandColors, brandColors[0]]); + }, []); + + useEffect(() => { + if (isScreenReaderEnabled) { + return; + } + + const interval = setInterval(() => { + setTime((prevTime) => prevTime + 30); + }, 30); // ~33fps for smooth color transitions + + return () => clearInterval(interval); + }, [isScreenReaderEnabled]); + + const progress = (time % COLOR_CYCLE_DURATION_MS) / COLOR_CYCLE_DURATION_MS; + const currentColor = googleGradient.rgbAt(progress).toHexString(); + + return isScreenReaderEnabled ? ( + {altText} + ) : ( + + + + ); +}; diff --git a/packages/cli/src/ui/components/InputPrompt.test.tsx b/packages/cli/src/ui/components/InputPrompt.test.tsx index 1576cef2e8..65a4440d77 100644 --- a/packages/cli/src/ui/components/InputPrompt.test.tsx +++ b/packages/cli/src/ui/components/InputPrompt.test.tsx @@ -411,6 +411,73 @@ describe('InputPrompt', () => { unmount(); }); + it('should submit command in shell mode when Enter pressed with suggestions visible but no arrow navigation', async () => { + props.shellModeActive = true; + props.buffer.setText('ls '); + + mockedUseCommandCompletion.mockReturnValue({ + ...mockCommandCompletion, + showSuggestions: true, + suggestions: [ + { label: 'dir1', value: 'dir1' }, + { label: 'dir2', value: 'dir2' }, + ], + activeSuggestionIndex: 0, + }); + + const { stdin, unmount } = renderWithProviders(, { + uiActions, + }); + + // Press Enter without navigating — should dismiss suggestions and fall + // through to the main submit handler. + await act(async () => { + stdin.write('\r'); + }); + await waitFor(() => { + expect(mockCommandCompletion.resetCompletionState).toHaveBeenCalled(); + expect(props.onSubmit).toHaveBeenCalledWith('ls'); // Assert fall-through (text is trimmed) + }); + expect(mockCommandCompletion.handleAutocomplete).not.toHaveBeenCalled(); + unmount(); + }); + + it('should accept suggestion in shell mode when Enter pressed after arrow navigation', async () => { + props.shellModeActive = true; + props.buffer.setText('ls '); + + mockedUseCommandCompletion.mockReturnValue({ + ...mockCommandCompletion, + showSuggestions: true, + suggestions: [ + { label: 'dir1', value: 'dir1' }, + { label: 'dir2', value: 'dir2' }, + ], + activeSuggestionIndex: 1, + }); + + const { stdin, unmount } = renderWithProviders(, { + uiActions, + }); + + // Press ArrowDown to navigate, then Enter to accept + await act(async () => { + stdin.write('\u001B[B'); // ArrowDown — sets hasUserNavigatedSuggestions + }); + await waitFor(() => + expect(mockCommandCompletion.navigateDown).toHaveBeenCalled(), + ); + + await act(async () => { + stdin.write('\r'); // Enter — should accept navigated suggestion + }); + await waitFor(() => { + expect(mockCommandCompletion.handleAutocomplete).toHaveBeenCalledWith(1); + }); + expect(props.onSubmit).not.toHaveBeenCalled(); + unmount(); + }); + it('should NOT call shell history methods when not in shell mode', async () => { props.buffer.setText('some text'); const { stdin, unmount } = renderWithProviders(, { @@ -1537,7 +1604,7 @@ describe('InputPrompt', () => { const { stdout, unmount } = renderWithProviders(); await waitFor(() => { - const frame = stdout.lastFrame(); + const frame = stdout.lastFrameRaw(); // In plan mode it uses '>' but with success color. // We check that it contains '>' and not '*' or '!'. expect(frame).toContain('>'); @@ -1593,7 +1660,7 @@ describe('InputPrompt', () => { ); await waitFor(() => { - const frame = stdout.lastFrame(); + const frame = stdout.lastFrameRaw(); expect(frame).toContain('▀'); expect(frame).toContain('▄'); }); @@ -1626,7 +1693,7 @@ describe('InputPrompt', () => { const expectedBgColor = isWhite ? '#eeeeee' : '#1c1c1c'; await waitFor(() => { - const frame = stdout.lastFrame(); + const frame = stdout.lastFrameRaw(); // Use chalk to get the expected background color escape sequence const bgCheck = chalk.bgHex(expectedBgColor)(' '); @@ -1658,7 +1725,7 @@ describe('InputPrompt', () => { ); await waitFor(() => { - const frame = stdout.lastFrame(); + const frame = stdout.lastFrameRaw(); expect(frame).not.toContain('▀'); expect(frame).not.toContain('▄'); // It SHOULD have horizontal fallback lines @@ -1681,7 +1748,7 @@ describe('InputPrompt', () => { ); await waitFor(() => { - const frame = stdout.lastFrame(); + const frame = stdout.lastFrameRaw(); expect(frame).toContain('▀'); @@ -1705,7 +1772,7 @@ describe('InputPrompt', () => { ); await waitFor(() => { - const frame = stdout.lastFrame(); + const frame = stdout.lastFrameRaw(); // Should NOT have background characters @@ -1734,7 +1801,7 @@ describe('InputPrompt', () => { ); await waitFor(() => { - const frame = stdout.lastFrame(); + const frame = stdout.lastFrameRaw(); expect(frame).not.toContain('▀'); expect(frame).not.toContain('▄'); // Check for Box borders (round style uses unicode box chars) @@ -1974,7 +2041,7 @@ describe('InputPrompt', () => { name: 'at the end of a line with unicode characters', text: 'hello 👍', visualCursor: [0, 8], - expected: `hello 👍${chalk.inverse(' ')}`, + expected: `hello 👍`, // skip checking inverse ansi due to ink truncation bug }, { name: 'at the end of a short line with unicode characters', @@ -1996,7 +2063,7 @@ describe('InputPrompt', () => { }, ])( 'should display cursor correctly $name', - async ({ text, visualCursor, expected }) => { + async ({ name, text, visualCursor, expected }) => { mockBuffer.text = text; mockBuffer.lines = [text]; mockBuffer.viewportVisualLines = [text]; @@ -2007,8 +2074,14 @@ describe('InputPrompt', () => { , ); await waitFor(() => { - const frame = stdout.lastFrame(); - expect(frame).toContain(expected); + const frame = stdout.lastFrameRaw(); + expect(stripAnsi(frame)).toContain(stripAnsi(expected)); + if ( + name !== 'at the end of a line with unicode characters' && + name !== 'on a highlighted token' + ) { + expect(frame).toContain('\u001b[7m'); + } }); unmount(); }, @@ -2050,7 +2123,7 @@ describe('InputPrompt', () => { }, ])( 'should display cursor correctly $name in a multiline block', - async ({ text, visualCursor, expected, visualToLogicalMap }) => { + async ({ name, text, visualCursor, expected, visualToLogicalMap }) => { mockBuffer.text = text; mockBuffer.lines = text.split('\n'); mockBuffer.viewportVisualLines = text.split('\n'); @@ -2064,8 +2137,14 @@ describe('InputPrompt', () => { , ); await waitFor(() => { - const frame = stdout.lastFrame(); - expect(frame).toContain(expected); + const frame = stdout.lastFrameRaw(); + expect(stripAnsi(frame)).toContain(stripAnsi(expected)); + if ( + name !== 'at the end of a line with unicode characters' && + name !== 'on a highlighted token' + ) { + expect(frame).toContain('\u001b[7m'); + } }); unmount(); }, @@ -2088,7 +2167,7 @@ describe('InputPrompt', () => { , ); await waitFor(() => { - const frame = stdout.lastFrame(); + const frame = stdout.lastFrameRaw(); const lines = frame.split('\n'); // The line with the cursor should just be an inverted space inside the box border expect( @@ -2120,7 +2199,7 @@ describe('InputPrompt', () => { , ); await waitFor(() => { - const frame = stdout.lastFrame(); + const frame = stdout.lastFrameRaw(); // Check that all lines, including the empty one, are rendered. // This implicitly tests that the Box wrapper provides height for the empty line. expect(frame).toContain('hello'); @@ -2655,7 +2734,7 @@ describe('InputPrompt', () => { }); await waitFor(() => { - const frame = stdout.lastFrame(); + const frame = stdout.lastFrameRaw(); expect(frame).toContain('(r:)'); expect(frame).toContain('echo hello'); expect(frame).toContain('echo world'); @@ -2926,7 +3005,7 @@ describe('InputPrompt', () => { }); await waitFor(() => { - const frame = stdout.lastFrame() ?? ''; + const frame = stdout.lastFrameRaw() ?? ''; expect(frame).toContain('(r:)'); expect(frame).toContain('git commit'); expect(frame).toContain('git push'); diff --git a/packages/cli/src/ui/components/InputPrompt.tsx b/packages/cli/src/ui/components/InputPrompt.tsx index ad84dd27f6..ac17284189 100644 --- a/packages/cli/src/ui/components/InputPrompt.tsx +++ b/packages/cli/src/ui/components/InputPrompt.tsx @@ -254,6 +254,7 @@ export const InputPrompt: React.FC = ({ >(null); const pasteTimeoutRef = useRef(null); const innerBoxRef = useRef(null); + const hasUserNavigatedSuggestions = useRef(false); const [reverseSearchActive, setReverseSearchActive] = useState(false); const [commandSearchActive, setCommandSearchActive] = useState(false); @@ -610,6 +611,7 @@ export const InputPrompt: React.FC = ({ setSuppressCompletion( isHistoryNav || isCursorMovement || keyMatchers[Command.ESCAPE](key), ); + hasUserNavigatedSuggestions.current = false; } // TODO(jacobr): this special case is likely not needed anymore. @@ -643,7 +645,13 @@ export const InputPrompt: React.FC = ({ Boolean(completion.promptCompletion.text) || reverseSearchActive || commandSearchActive; - if (isPlainTab) { + + if (isPlainTab && shellModeActive) { + resetPlainTabPress(); + if (!completion.showSuggestions) { + setSuppressCompletion(false); + } + } else if (isPlainTab) { if (!hasTabCompletionInteraction) { if (registerPlainTabPress() === 2) { toggleCleanUiDetailsVisible(); @@ -903,11 +911,13 @@ export const InputPrompt: React.FC = ({ if (completion.suggestions.length > 1) { if (keyMatchers[Command.COMPLETION_UP](key)) { completion.navigateUp(); + hasUserNavigatedSuggestions.current = true; setExpandedSuggestionIndex(-1); // Reset expansion when navigating return true; } if (keyMatchers[Command.COMPLETION_DOWN](key)) { completion.navigateDown(); + hasUserNavigatedSuggestions.current = true; setExpandedSuggestionIndex(-1); // Reset expansion when navigating return true; } @@ -925,6 +935,24 @@ export const InputPrompt: React.FC = ({ const isEnterKey = key.name === 'return' && !key.ctrl; + if (isEnterKey && shellModeActive) { + if (hasUserNavigatedSuggestions.current) { + completion.handleAutocomplete( + completion.activeSuggestionIndex, + ); + setExpandedSuggestionIndex(-1); + hasUserNavigatedSuggestions.current = false; + return true; + } + completion.resetCompletionState(); + setExpandedSuggestionIndex(-1); + hasUserNavigatedSuggestions.current = false; + if (buffer.text.trim()) { + handleSubmit(buffer.text); + } + return true; + } + if (isEnterKey && buffer.text.startsWith('/')) { const { isArgumentCompletion, leafCommand } = completion.slashCompletionRange; @@ -1381,7 +1409,8 @@ export const InputPrompt: React.FC = ({ scrollOffset={activeCompletion.visibleStartIndex} userInput={buffer.text} mode={ - completion.completionMode === CompletionMode.AT + completion.completionMode === CompletionMode.AT || + completion.completionMode === CompletionMode.SHELL ? 'reverse' : buffer.text.startsWith('/') && !reverseSearchActive && diff --git a/packages/cli/src/ui/components/ProQuotaDialog.test.tsx b/packages/cli/src/ui/components/ProQuotaDialog.test.tsx index f74f5fa447..d97d53314e 100644 --- a/packages/cli/src/ui/components/ProQuotaDialog.test.tsx +++ b/packages/cli/src/ui/components/ProQuotaDialog.test.tsx @@ -13,6 +13,7 @@ import { RadioButtonSelect } from './shared/RadioButtonSelect.js'; import { PREVIEW_GEMINI_MODEL, DEFAULT_GEMINI_FLASH_MODEL, + AuthType, } from '@google/gemini-cli-core'; // Mock the child component to make it easier to test the parent @@ -62,7 +63,7 @@ describe('ProQuotaDialog', () => { describe('for non-flash model failures', () => { describe('when it is a terminal quota error', () => { - it('should render switch, upgrade, and stop options for paid tiers', () => { + it('should render switch, upgrade, and stop options for LOGIN_WITH_GOOGLE', () => { const { unmount } = render( { message="paid tier quota error" isTerminalQuotaError={true} isModelNotFoundError={false} + authType={AuthType.LOGIN_WITH_GOOGLE} onChoice={mockOnChoice} />, ); @@ -99,6 +101,39 @@ describe('ProQuotaDialog', () => { unmount(); }); + it('should NOT render upgrade option for USE_GEMINI', () => { + const { unmount } = render( + , + ); + + expect(RadioButtonSelect).toHaveBeenCalledWith( + expect.objectContaining({ + items: [ + { + label: 'Switch to gemini-2.5-flash', + value: 'retry_always', + key: 'retry_always', + }, + { + label: 'Stop', + value: 'retry_later', + key: 'retry_later', + }, + ], + }), + undefined, + ); + unmount(); + }); + it('should render "Keep trying" and "Stop" options when failed model and fallback model are the same', () => { const { unmount } = render( { unmount(); }); - it('should render switch, upgrade, and stop options for free tier', () => { + it('should render switch, upgrade, and stop options for LOGIN_WITH_GOOGLE (free tier)', () => { const { unmount } = render( { message="free tier quota error" isTerminalQuotaError={true} isModelNotFoundError={false} + authType={AuthType.LOGIN_WITH_GOOGLE} onChoice={mockOnChoice} />, ); @@ -204,7 +240,7 @@ describe('ProQuotaDialog', () => { }); describe('when it is a model not found error', () => { - it('should render switch and stop options regardless of tier', () => { + it('should render switch, upgrade, and stop options for LOGIN_WITH_GOOGLE', () => { const { unmount } = render( { message="You don't have access to gemini-3-pro-preview yet." isTerminalQuotaError={false} isModelNotFoundError={true} + authType={AuthType.LOGIN_WITH_GOOGLE} onChoice={mockOnChoice} />, ); @@ -241,7 +278,7 @@ describe('ProQuotaDialog', () => { unmount(); }); - it('should render switch and stop options for paid tier as well', () => { + it('should NOT render upgrade option for USE_GEMINI', () => { const { unmount } = render( { message="You don't have access to gemini-3-pro-preview yet." isTerminalQuotaError={false} isModelNotFoundError={true} + authType={AuthType.USE_GEMINI} onChoice={mockOnChoice} />, ); @@ -261,11 +299,6 @@ describe('ProQuotaDialog', () => { value: 'retry_always', key: 'retry_always', }, - { - label: 'Upgrade for higher limits', - value: 'upgrade', - key: 'upgrade', - }, { label: 'Stop', value: 'retry_later', diff --git a/packages/cli/src/ui/components/ProQuotaDialog.tsx b/packages/cli/src/ui/components/ProQuotaDialog.tsx index ccc20b3e75..82a679db8c 100644 --- a/packages/cli/src/ui/components/ProQuotaDialog.tsx +++ b/packages/cli/src/ui/components/ProQuotaDialog.tsx @@ -8,6 +8,7 @@ import type React from 'react'; import { Box, Text } from 'ink'; import { RadioButtonSelect } from './shared/RadioButtonSelect.js'; import { theme } from '../semantic-colors.js'; +import { AuthType } from '@google/gemini-cli-core'; interface ProQuotaDialogProps { failedModel: string; @@ -15,6 +16,7 @@ interface ProQuotaDialogProps { message: string; isTerminalQuotaError: boolean; isModelNotFoundError?: boolean; + authType?: AuthType; onChoice: ( choice: 'retry_later' | 'retry_once' | 'retry_always' | 'upgrade', ) => void; @@ -26,6 +28,7 @@ export function ProQuotaDialog({ message, isTerminalQuotaError, isModelNotFoundError, + authType, onChoice, }: ProQuotaDialogProps): React.JSX.Element { let items; @@ -51,11 +54,15 @@ export function ProQuotaDialog({ value: 'retry_always' as const, key: 'retry_always', }, - { - label: 'Upgrade for higher limits', - value: 'upgrade' as const, - key: 'upgrade', - }, + ...(authType === AuthType.LOGIN_WITH_GOOGLE + ? [ + { + label: 'Upgrade for higher limits', + value: 'upgrade' as const, + key: 'upgrade', + }, + ] + : []), { label: `Stop`, value: 'retry_later' as const, diff --git a/packages/cli/src/ui/components/SettingsDialog.test.tsx b/packages/cli/src/ui/components/SettingsDialog.test.tsx index 72ef839ea3..3dd5374a18 100644 --- a/packages/cli/src/ui/components/SettingsDialog.test.tsx +++ b/packages/cli/src/ui/components/SettingsDialog.test.tsx @@ -263,16 +263,11 @@ describe('SettingsDialog', () => { const settings = createMockSettings(); const onSelect = vi.fn(); - const { lastFrame, waitUntilReady, unmount } = renderDialog( - settings, - onSelect, - ); - await waitUntilReady(); + const renderResult = renderDialog(settings, onSelect); + await renderResult.waitUntilReady(); - const output = lastFrame(); - // Use snapshot to capture visual layout including indicators - expect(output).toMatchSnapshot(); - unmount(); + await expect(renderResult).toMatchSvgSnapshot(); + renderResult.unmount(); }); it('should use almost full height of the window but no more when the window height is 25 rows', async () => { @@ -1830,18 +1825,15 @@ describe('SettingsDialog', () => { }); const onSelect = vi.fn(); - const { lastFrame, stdin, waitUntilReady, unmount } = renderDialog( - settings, - onSelect, - ); - await waitUntilReady(); + const renderResult = renderDialog(settings, onSelect); + await renderResult.waitUntilReady(); if (stdinActions) { - await stdinActions(stdin, waitUntilReady); + await stdinActions(renderResult.stdin, renderResult.waitUntilReady); } - expect(lastFrame()).toMatchSnapshot(); - unmount(); + await expect(renderResult).toMatchSvgSnapshot(); + renderResult.unmount(); }, ); }); diff --git a/packages/cli/src/ui/components/Table.test.tsx b/packages/cli/src/ui/components/Table.test.tsx index 889872f35e..e8f312d9af 100644 --- a/packages/cli/src/ui/components/Table.test.tsx +++ b/packages/cli/src/ui/components/Table.test.tsx @@ -19,10 +19,8 @@ describe('Table', () => { { id: 2, name: 'Bob' }, ]; - const { lastFrame, waitUntilReady } = render( - , - 100, - ); + const renderResult = render(
, 100); + const { lastFrame, waitUntilReady } = renderResult; await waitUntilReady?.(); const output = lastFrame(); @@ -32,7 +30,7 @@ describe('Table', () => { expect(output).toContain('Alice'); expect(output).toContain('2'); expect(output).toContain('Bob'); - expect(lastFrame()).toMatchSnapshot(); + await expect(renderResult).toMatchSvgSnapshot(); }); it('should support custom cell rendering', async () => { @@ -48,15 +46,13 @@ describe('Table', () => { ]; const data = [{ value: 10 }]; - const { lastFrame, waitUntilReady } = render( -
, - 100, - ); + const renderResult = render(
, 100); + const { lastFrame, waitUntilReady } = renderResult; await waitUntilReady?.(); const output = lastFrame(); expect(output).toContain('20'); - expect(lastFrame()).toMatchSnapshot(); + await expect(renderResult).toMatchSvgSnapshot(); }); it('should handle undefined values gracefully', async () => { @@ -70,4 +66,26 @@ describe('Table', () => { const output = lastFrame(); expect(output).toContain('undefined'); }); + + it('should support inverse text rendering', async () => { + const columns = [ + { + key: 'status', + header: 'Status', + flexGrow: 1, + renderCell: (item: { status: string }) => ( + {item.status} + ), + }, + ]; + const data = [{ status: 'Active' }]; + + const renderResult = render(
, 100); + const { lastFrame, waitUntilReady } = renderResult; + await waitUntilReady?.(); + const output = lastFrame(); + + expect(output).toContain('Active'); + await expect(renderResult).toMatchSvgSnapshot(); + }); }); diff --git a/packages/cli/src/ui/components/ToolConfirmationQueue.test.tsx b/packages/cli/src/ui/components/ToolConfirmationQueue.test.tsx index ab7d080b37..75612add4c 100644 --- a/packages/cli/src/ui/components/ToolConfirmationQueue.test.tsx +++ b/packages/cli/src/ui/components/ToolConfirmationQueue.test.tsx @@ -255,7 +255,11 @@ describe('ToolConfirmationQueue', () => { total: 1, }; - const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + const { + lastFrame, + waitUntilReady, + unmount = vi.fn(), + } = renderWithProviders( , diff --git a/packages/cli/src/ui/components/ToolConfirmationQueue.tsx b/packages/cli/src/ui/components/ToolConfirmationQueue.tsx index c89c98f8d4..3fb1cc8c6f 100644 --- a/packages/cli/src/ui/components/ToolConfirmationQueue.tsx +++ b/packages/cli/src/ui/components/ToolConfirmationQueue.tsx @@ -17,6 +17,7 @@ import { ShowMoreLines } from './ShowMoreLines.js'; import { StickyHeader } from './StickyHeader.js'; import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js'; import type { SerializableConfirmationDetails } from '@google/gemini-cli-core'; +import { useUIActions } from '../contexts/UIActionsContext.js'; function getConfirmationHeader( details: SerializableConfirmationDetails | undefined, @@ -41,6 +42,7 @@ export const ToolConfirmationQueue: React.FC = ({ confirmingTool, }) => { const config = useConfig(); + const { getPreferredEditor } = useUIActions(); const isAlternateBuffer = useAlternateBuffer(); const { mainAreaWidth, @@ -134,6 +136,7 @@ export const ToolConfirmationQueue: React.FC = ({ callId={tool.callId} confirmationDetails={tool.confirmationDetails} config={config} + getPreferredEditor={getPreferredEditor} terminalWidth={mainAreaWidth - 4} // Adjust for parent border/padding availableTerminalHeight={availableContentHeight} isFocused={true} diff --git a/packages/cli/src/ui/components/__snapshots__/AlternateBufferQuittingDisplay.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/AlternateBufferQuittingDisplay.test.tsx.snap index 8fb49b8b71..18e75b75e2 100644 --- a/packages/cli/src/ui/components/__snapshots__/AlternateBufferQuittingDisplay.test.tsx.snap +++ b/packages/cli/src/ui/components/__snapshots__/AlternateBufferQuittingDisplay.test.tsx.snap @@ -2,14 +2,14 @@ exports[`AlternateBufferQuittingDisplay > renders with a tool awaiting confirmation > with_confirming_tool 1`] = ` " - ███ █████████ + ███ █████████ ░░░███ ███░░░░░███ - ░░░███ ███ ░░░ - ░░░███░███ + ░░░███ ███ ░░░ + ░░░███░███ ███░ ░███ █████ - ███░ ░░███ ░░███ - ███░ ░░█████████ -░░░ ░░░░░░░░░ + ███░ ░░███ ░░███ + ███░ ░░█████████ +░░░ ░░░░░░░░░ Tips for getting started: 1. Ask questions, edit files, or run commands. @@ -25,14 +25,14 @@ Action Required (was prompted): exports[`AlternateBufferQuittingDisplay > renders with active and pending tool messages > with_history_and_pending 1`] = ` " - ███ █████████ + ███ █████████ ░░░███ ███░░░░░███ - ░░░███ ███ ░░░ - ░░░███░███ + ░░░███ ███ ░░░ + ░░░███░███ ███░ ░███ █████ - ███░ ░░███ ░░███ - ███░ ░░█████████ -░░░ ░░░░░░░░░ + ███░ ░░███ ░░███ + ███░ ░░█████████ +░░░ ░░░░░░░░░ Tips for getting started: 1. Ask questions, edit files, or run commands. @@ -52,14 +52,14 @@ Tips for getting started: exports[`AlternateBufferQuittingDisplay > renders with empty history and no pending items > empty 1`] = ` " - ███ █████████ + ███ █████████ ░░░███ ███░░░░░███ - ░░░███ ███ ░░░ - ░░░███░███ + ░░░███ ███ ░░░ + ░░░███░███ ███░ ░███ █████ - ███░ ░░███ ░░███ - ███░ ░░█████████ -░░░ ░░░░░░░░░ + ███░ ░░███ ░░███ + ███░ ░░█████████ +░░░ ░░░░░░░░░ Tips for getting started: 1. Ask questions, edit files, or run commands. @@ -71,14 +71,14 @@ Tips for getting started: exports[`AlternateBufferQuittingDisplay > renders with history but no pending items > with_history_no_pending 1`] = ` " - ███ █████████ + ███ █████████ ░░░███ ███░░░░░███ - ░░░███ ███ ░░░ - ░░░███░███ + ░░░███ ███ ░░░ + ░░░███░███ ███░ ░███ █████ - ███░ ░░███ ░░███ - ███░ ░░█████████ -░░░ ░░░░░░░░░ + ███░ ░░███ ░░███ + ███░ ░░█████████ +░░░ ░░░░░░░░░ Tips for getting started: 1. Ask questions, edit files, or run commands. @@ -98,14 +98,14 @@ Tips for getting started: exports[`AlternateBufferQuittingDisplay > renders with pending items but no history > with_pending_no_history 1`] = ` " - ███ █████████ + ███ █████████ ░░░███ ███░░░░░███ - ░░░███ ███ ░░░ - ░░░███░███ + ░░░███ ███ ░░░ + ░░░███░███ ███░ ░███ █████ - ███░ ░░███ ░░███ - ███░ ░░█████████ -░░░ ░░░░░░░░░ + ███░ ░░███ ░░███ + ███░ ░░█████████ +░░░ ░░░░░░░░░ Tips for getting started: 1. Ask questions, edit files, or run commands. @@ -117,14 +117,14 @@ Tips for getting started: exports[`AlternateBufferQuittingDisplay > renders with user and gemini messages > with_user_gemini_messages 1`] = ` " - ███ █████████ + ███ █████████ ░░░███ ███░░░░░███ - ░░░███ ███ ░░░ - ░░░███░███ + ░░░███ ███ ░░░ + ░░░███░███ ███░ ░███ █████ - ███░ ░░███ ░░███ - ███░ ░░█████████ -░░░ ░░░░░░░░░ + ███░ ░░███ ░░███ + ███░ ░░█████████ +░░░ ░░░░░░░░░ Tips for getting started: 1. Ask questions, edit files, or run commands. @@ -132,7 +132,7 @@ Tips for getting started: 3. Create GEMINI.md files to customize your interactions with Gemini. 4. /help for more information. ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ - > Hello Gemini + > Hello Gemini ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ ✦ Hello User! " diff --git a/packages/cli/src/ui/components/__snapshots__/AppHeader.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/AppHeader.test.tsx.snap index 59cf561759..324274fddd 100644 --- a/packages/cli/src/ui/components/__snapshots__/AppHeader.test.tsx.snap +++ b/packages/cli/src/ui/components/__snapshots__/AppHeader.test.tsx.snap @@ -2,14 +2,14 @@ exports[` > should not render the banner when no flags are set 1`] = ` " - ███ █████████ + ███ █████████ ░░░███ ███░░░░░███ - ░░░███ ███ ░░░ - ░░░███░███ + ░░░███ ███ ░░░ + ░░░███░███ ███░ ░███ █████ - ███░ ░░███ ░░███ - ███░ ░░█████████ -░░░ ░░░░░░░░░ + ███░ ░░███ ░░███ + ███░ ░░█████████ +░░░ ░░░░░░░░░ Tips for getting started: 1. Ask questions, edit files, or run commands. @@ -21,14 +21,14 @@ Tips for getting started: exports[` > should not render the default banner if shown count is 5 or more 1`] = ` " - ███ █████████ + ███ █████████ ░░░███ ███░░░░░███ - ░░░███ ███ ░░░ - ░░░███░███ + ░░░███ ███ ░░░ + ░░░███░███ ███░ ░███ █████ - ███░ ░░███ ░░███ - ███░ ░░█████████ -░░░ ░░░░░░░░░ + ███░ ░░███ ░░███ + ███░ ░░█████████ +░░░ ░░░░░░░░░ Tips for getting started: 1. Ask questions, edit files, or run commands. @@ -40,14 +40,14 @@ Tips for getting started: exports[` > should render the banner with default text 1`] = ` " - ███ █████████ + ███ █████████ ░░░███ ███░░░░░███ - ░░░███ ███ ░░░ - ░░░███░███ + ░░░███ ███ ░░░ + ░░░███░███ ███░ ░███ █████ - ███░ ░░███ ░░███ - ███░ ░░█████████ -░░░ ░░░░░░░░░ + ███░ ░░███ ░░███ + ███░ ░░█████████ +░░░ ░░░░░░░░░ ╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ │ This is the default banner │ @@ -62,14 +62,14 @@ Tips for getting started: exports[` > should render the banner with warning text 1`] = ` " - ███ █████████ + ███ █████████ ░░░███ ███░░░░░███ - ░░░███ ███ ░░░ - ░░░███░███ + ░░░███ ███ ░░░ + ░░░███░███ ███░ ░███ █████ - ███░ ░░███ ░░███ - ███░ ░░█████████ -░░░ ░░░░░░░░░ + ███░ ░░███ ░░███ + ███░ ░░█████████ +░░░ ░░░░░░░░░ ╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ │ There are capacity issues │ diff --git a/packages/cli/src/ui/components/__snapshots__/ExitPlanModeDialog.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/ExitPlanModeDialog.test.tsx.snap index 587ded8f29..0cd4553c77 100644 --- a/packages/cli/src/ui/components/__snapshots__/ExitPlanModeDialog.test.tsx.snap +++ b/packages/cli/src/ui/components/__snapshots__/ExitPlanModeDialog.test.tsx.snap @@ -23,7 +23,7 @@ Files to Modify Approves plan but requires confirmation for each tool 3. Type your feedback... -Enter to select · ↑/↓ to navigate · Esc to cancel +Enter to select · ↑/↓ to navigate · Ctrl+X to edit plan · Esc to cancel " `; @@ -50,7 +50,7 @@ Files to Modify Approves plan but requires confirmation for each tool 3. Type your feedback... -Enter to select · ↑/↓ to navigate · Esc to cancel +Enter to select · ↑/↓ to navigate · Ctrl+X to edit plan · Esc to cancel " `; @@ -82,7 +82,7 @@ Implementation Steps Approves plan but requires confirmation for each tool 3. Type your feedback... -Enter to select · ↑/↓ to navigate · Esc to cancel +Enter to select · ↑/↓ to navigate · Ctrl+X to edit plan · Esc to cancel " `; @@ -109,7 +109,7 @@ Files to Modify Approves plan but requires confirmation for each tool 3. Type your feedback... -Enter to select · ↑/↓ to navigate · Esc to cancel +Enter to select · ↑/↓ to navigate · Ctrl+X to edit plan · Esc to cancel " `; @@ -136,7 +136,7 @@ Files to Modify Approves plan but requires confirmation for each tool 3. Type your feedback... -Enter to select · ↑/↓ to navigate · Esc to cancel +Enter to select · ↑/↓ to navigate · Ctrl+X to edit plan · Esc to cancel " `; @@ -163,7 +163,7 @@ Files to Modify Approves plan but requires confirmation for each tool 3. Type your feedback... -Enter to select · ↑/↓ to navigate · Esc to cancel +Enter to select · ↑/↓ to navigate · Ctrl+X to edit plan · Esc to cancel " `; @@ -216,7 +216,7 @@ Testing Strategy Approves plan but requires confirmation for each tool 3. Type your feedback... -Enter to select · ↑/↓ to navigate · Esc to cancel +Enter to select · ↑/↓ to navigate · Ctrl+X to edit plan · Esc to cancel " `; @@ -243,6 +243,6 @@ Files to Modify Approves plan but requires confirmation for each tool 3. Type your feedback... -Enter to select · ↑/↓ to navigate · Esc to cancel +Enter to select · ↑/↓ to navigate · Ctrl+X to edit plan · Esc to cancel " `; diff --git a/packages/cli/src/ui/components/__snapshots__/InputPrompt.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/InputPrompt.test.tsx.snap index 6a9bf5aeac..88a1b0486f 100644 --- a/packages/cli/src/ui/components/__snapshots__/InputPrompt.test.tsx.snap +++ b/packages/cli/src/ui/components/__snapshots__/InputPrompt.test.tsx.snap @@ -2,16 +2,16 @@ exports[`InputPrompt > History Navigation and Completion Suppression > should not render suggestions during history navigation 1`] = ` "▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ - > second message + > second message ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ " `; exports[`InputPrompt > command search (Ctrl+R when not in shell) > expands and collapses long suggestion via Right/Left arrows > command-search-render-collapsed-match 1`] = ` "▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ - (r:) Type your message or @path/to/file + (r:) Type your message or @path/to/file ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ - lllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllll → + lllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllll → lllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllll ... " @@ -19,9 +19,9 @@ exports[`InputPrompt > command search (Ctrl+R when not in shell) > expands and c exports[`InputPrompt > command search (Ctrl+R when not in shell) > expands and collapses long suggestion via Right/Left arrows > command-search-render-expanded-match 1`] = ` "▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ - (r:) Type your message or @path/to/file + (r:) Type your message or @path/to/file ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ - lllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllll ← + lllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllll ← lllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllllll llllllllllllllllllllllllllllllllllllllllllllllllll " @@ -29,7 +29,7 @@ exports[`InputPrompt > command search (Ctrl+R when not in shell) > expands and c exports[`InputPrompt > command search (Ctrl+R when not in shell) > renders match window and expanded view (snapshots) > command-search-render-collapsed-match 1`] = ` "▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ - (r:) commit + (r:) commit ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ git commit -m "feat: add search" in src/app " @@ -37,7 +37,7 @@ exports[`InputPrompt > command search (Ctrl+R when not in shell) > renders match exports[`InputPrompt > command search (Ctrl+R when not in shell) > renders match window and expanded view (snapshots) > command-search-render-expanded-match 1`] = ` "▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ - (r:) commit + (r:) commit ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ git commit -m "feat: add search" in src/app " @@ -45,63 +45,63 @@ exports[`InputPrompt > command search (Ctrl+R when not in shell) > renders match exports[`InputPrompt > image path transformation snapshots > should snapshot collapsed image path 1`] = ` "▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ - > [Image ...reenshot2x.png] + > [Image ...reenshot2x.png] ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ " `; exports[`InputPrompt > image path transformation snapshots > should snapshot expanded image path when cursor is on it 1`] = ` "▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ - > @/path/to/screenshots/screenshot2x.png + > @/path/to/screenshots/screenshot2x.png ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ " `; exports[`InputPrompt > mouse interaction > should toggle paste expansion on double-click 1`] = ` "▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ - > [Pasted Text: 10 lines] + > [Pasted Text: 10 lines] ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ " `; exports[`InputPrompt > mouse interaction > should toggle paste expansion on double-click 2`] = ` "▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ - > [Pasted Text: 10 lines] + > [Pasted Text: 10 lines] ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ " `; exports[`InputPrompt > mouse interaction > should toggle paste expansion on double-click 3`] = ` "▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ - > [Pasted Text: 10 lines] + > [Pasted Text: 10 lines] ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ " `; exports[`InputPrompt > snapshots > should not show inverted cursor when shell is focused 1`] = ` "▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ - > Type your message or @path/to/file + > Type your message or @path/to/file ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ " `; exports[`InputPrompt > snapshots > should render correctly in shell mode 1`] = ` "▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ - ! Type your message or @path/to/file + ! Type your message or @path/to/file ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ " `; exports[`InputPrompt > snapshots > should render correctly in yolo mode 1`] = ` "▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ - * Type your message or @path/to/file + * Type your message or @path/to/file ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ " `; exports[`InputPrompt > snapshots > should render correctly when accepting edits 1`] = ` "▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ - > Type your message or @path/to/file + > Type your message or @path/to/file ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ " `; diff --git a/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Initial-Rendering-should-render-settings-list-with-visual-indicators.snap.svg b/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Initial-Rendering-should-render-settings-list-with-visual-indicators.snap.svg new file mode 100644 index 0000000000..b7ad1d10db --- /dev/null +++ b/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Initial-Rendering-should-render-settings-list-with-visual-indicators.snap.svg @@ -0,0 +1,133 @@ + + + + + ╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ + + + + > Settings + + + + + ╭──────────────────────────────────────────────────────────────────────────────────────────────╮ + + + + + S + earch to filter + + + + ╰──────────────────────────────────────────────────────────────────────────────────────────────╯ + + + + + + + + + Vim Mode + false + + + Enable Vim keybindings + + + + + Default Approval Mode + Default + + + The default approval mode for tool execution. 'default' prompts for approval, 'au… + + + + + Enable Auto Update + true + + + Enable automatic updates. + + + + + Enable Notifications + false + + + Enable run-event notifications for action-required prompts and session completion. … + + + + + Plan Directory + undefined + + + The directory where planning artifacts are stored. If not specified, defaults t… + + + + + Plan Model Routing + true + + + Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr… + + + + + Max Chat Model Attempts + 10 + + + Maximum number of attempts for requests to the main chat model. Cannot exceed 10. + + + + + Debug Keystroke Logging + false + + + Enable debug logging of keystrokes to the console. + + + + + + + + + + Apply To + + + + User Settings + + + Workspace Settings + + + System Settings + + + + + (Use Enter to select, Ctrl+L to reset, Tab to change focus, Esc to close) + + + + ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ + + \ No newline at end of file diff --git a/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Snapshot-Tests-should-render-accessibility-settings-enabled-correctly.snap.svg b/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Snapshot-Tests-should-render-accessibility-settings-enabled-correctly.snap.svg new file mode 100644 index 0000000000..c088c69139 --- /dev/null +++ b/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Snapshot-Tests-should-render-accessibility-settings-enabled-correctly.snap.svg @@ -0,0 +1,133 @@ + + + + + ╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ + + + + > Settings + + + + + ╭──────────────────────────────────────────────────────────────────────────────────────────────╮ + + + + + S + earch to filter + + + + ╰──────────────────────────────────────────────────────────────────────────────────────────────╯ + + + + + + + + + Vim Mode + true* + + + Enable Vim keybindings + + + + + Default Approval Mode + Default + + + The default approval mode for tool execution. 'default' prompts for approval, 'au… + + + + + Enable Auto Update + true + + + Enable automatic updates. + + + + + Enable Notifications + false + + + Enable run-event notifications for action-required prompts and session completion. … + + + + + Plan Directory + undefined + + + The directory where planning artifacts are stored. If not specified, defaults t… + + + + + Plan Model Routing + true + + + Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr… + + + + + Max Chat Model Attempts + 10 + + + Maximum number of attempts for requests to the main chat model. Cannot exceed 10. + + + + + Debug Keystroke Logging + false + + + Enable debug logging of keystrokes to the console. + + + + + + + + + + Apply To + + + + User Settings + + + Workspace Settings + + + System Settings + + + + + (Use Enter to select, Ctrl+L to reset, Tab to change focus, Esc to close) + + + + ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ + + \ No newline at end of file diff --git a/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Snapshot-Tests-should-render-all-boolean-settings-disabled-correctly.snap.svg b/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Snapshot-Tests-should-render-all-boolean-settings-disabled-correctly.snap.svg new file mode 100644 index 0000000000..0b981a31c8 --- /dev/null +++ b/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Snapshot-Tests-should-render-all-boolean-settings-disabled-correctly.snap.svg @@ -0,0 +1,131 @@ + + + + + ╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ + + + + > Settings + + + + + ╭──────────────────────────────────────────────────────────────────────────────────────────────╮ + + + + + S + earch to filter + + + + ╰──────────────────────────────────────────────────────────────────────────────────────────────╯ + + + + + + + + + Vim Mode + false* + + + Enable Vim keybindings + + + + + Default Approval Mode + Default + + + The default approval mode for tool execution. 'default' prompts for approval, 'au… + + + + + Enable Auto Update true* + + + Enable automatic updates. + + + + + Enable Notifications + false + + + Enable run-event notifications for action-required prompts and session completion. … + + + + + Plan Directory + undefined + + + The directory where planning artifacts are stored. If not specified, defaults t… + + + + + Plan Model Routing + true + + + Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr… + + + + + Max Chat Model Attempts + 10 + + + Maximum number of attempts for requests to the main chat model. Cannot exceed 10. + + + + + Debug Keystroke Logging false* + + + Enable debug logging of keystrokes to the console. + + + + + + + + + + Apply To + + + + User Settings + + + Workspace Settings + + + System Settings + + + + + (Use Enter to select, Ctrl+L to reset, Tab to change focus, Esc to close) + + + + ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ + + \ No newline at end of file diff --git a/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Snapshot-Tests-should-render-default-state-correctly.snap.svg b/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Snapshot-Tests-should-render-default-state-correctly.snap.svg new file mode 100644 index 0000000000..b7ad1d10db --- /dev/null +++ b/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Snapshot-Tests-should-render-default-state-correctly.snap.svg @@ -0,0 +1,133 @@ + + + + + ╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ + + + + > Settings + + + + + ╭──────────────────────────────────────────────────────────────────────────────────────────────╮ + + + + + S + earch to filter + + + + ╰──────────────────────────────────────────────────────────────────────────────────────────────╯ + + + + + + + + + Vim Mode + false + + + Enable Vim keybindings + + + + + Default Approval Mode + Default + + + The default approval mode for tool execution. 'default' prompts for approval, 'au… + + + + + Enable Auto Update + true + + + Enable automatic updates. + + + + + Enable Notifications + false + + + Enable run-event notifications for action-required prompts and session completion. … + + + + + Plan Directory + undefined + + + The directory where planning artifacts are stored. If not specified, defaults t… + + + + + Plan Model Routing + true + + + Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr… + + + + + Max Chat Model Attempts + 10 + + + Maximum number of attempts for requests to the main chat model. Cannot exceed 10. + + + + + Debug Keystroke Logging + false + + + Enable debug logging of keystrokes to the console. + + + + + + + + + + Apply To + + + + User Settings + + + Workspace Settings + + + System Settings + + + + + (Use Enter to select, Ctrl+L to reset, Tab to change focus, Esc to close) + + + + ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ + + \ No newline at end of file diff --git a/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Snapshot-Tests-should-render-file-filtering-settings-configured-correctly.snap.svg b/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Snapshot-Tests-should-render-file-filtering-settings-configured-correctly.snap.svg new file mode 100644 index 0000000000..b7ad1d10db --- /dev/null +++ b/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Snapshot-Tests-should-render-file-filtering-settings-configured-correctly.snap.svg @@ -0,0 +1,133 @@ + + + + + ╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ + + + + > Settings + + + + + ╭──────────────────────────────────────────────────────────────────────────────────────────────╮ + + + + + S + earch to filter + + + + ╰──────────────────────────────────────────────────────────────────────────────────────────────╯ + + + + + + + + + Vim Mode + false + + + Enable Vim keybindings + + + + + Default Approval Mode + Default + + + The default approval mode for tool execution. 'default' prompts for approval, 'au… + + + + + Enable Auto Update + true + + + Enable automatic updates. + + + + + Enable Notifications + false + + + Enable run-event notifications for action-required prompts and session completion. … + + + + + Plan Directory + undefined + + + The directory where planning artifacts are stored. If not specified, defaults t… + + + + + Plan Model Routing + true + + + Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr… + + + + + Max Chat Model Attempts + 10 + + + Maximum number of attempts for requests to the main chat model. Cannot exceed 10. + + + + + Debug Keystroke Logging + false + + + Enable debug logging of keystrokes to the console. + + + + + + + + + + Apply To + + + + User Settings + + + Workspace Settings + + + System Settings + + + + + (Use Enter to select, Ctrl+L to reset, Tab to change focus, Esc to close) + + + + ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ + + \ No newline at end of file diff --git a/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Snapshot-Tests-should-render-focused-on-scope-selector-correctly.snap.svg b/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Snapshot-Tests-should-render-focused-on-scope-selector-correctly.snap.svg new file mode 100644 index 0000000000..81d4868518 --- /dev/null +++ b/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Snapshot-Tests-should-render-focused-on-scope-selector-correctly.snap.svg @@ -0,0 +1,131 @@ + + + + + ╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ + + + + Settings + + + + + ╭──────────────────────────────────────────────────────────────────────────────────────────────╮ + + + + Search to filter + + + + ╰──────────────────────────────────────────────────────────────────────────────────────────────╯ + + + + + + + + Vim Mode + false + + + Enable Vim keybindings + + + + + Default Approval Mode + Default + + + The default approval mode for tool execution. 'default' prompts for approval, 'au… + + + + + Enable Auto Update + true + + + Enable automatic updates. + + + + + Enable Notifications + false + + + Enable run-event notifications for action-required prompts and session completion. … + + + + + Plan Directory + undefined + + + The directory where planning artifacts are stored. If not specified, defaults t… + + + + + Plan Model Routing + true + + + Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr… + + + + + Max Chat Model Attempts + 10 + + + Maximum number of attempts for requests to the main chat model. Cannot exceed 10. + + + + + Debug Keystroke Logging + false + + + Enable debug logging of keystrokes to the console. + + + + + + + + + + > Apply To + + + + 1. + User Settings + + + 2. Workspace Settings + + + 3. System Settings + + + + + (Use Enter to select, Ctrl+L to reset, Tab to change focus, Esc to close) + + + + ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ + + \ No newline at end of file diff --git a/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Snapshot-Tests-should-render-mixed-boolean-and-number-settings-correctly.snap.svg b/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Snapshot-Tests-should-render-mixed-boolean-and-number-settings-correctly.snap.svg new file mode 100644 index 0000000000..324ed5c2cb --- /dev/null +++ b/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Snapshot-Tests-should-render-mixed-boolean-and-number-settings-correctly.snap.svg @@ -0,0 +1,132 @@ + + + + + ╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ + + + + > Settings + + + + + ╭──────────────────────────────────────────────────────────────────────────────────────────────╮ + + + + + S + earch to filter + + + + ╰──────────────────────────────────────────────────────────────────────────────────────────────╯ + + + + + + + + + Vim Mode + false* + + + Enable Vim keybindings + + + + + Default Approval Mode + Default + + + The default approval mode for tool execution. 'default' prompts for approval, 'au… + + + + + Enable Auto Update false* + + + Enable automatic updates. + + + + + Enable Notifications + false + + + Enable run-event notifications for action-required prompts and session completion. … + + + + + Plan Directory + undefined + + + The directory where planning artifacts are stored. If not specified, defaults t… + + + + + Plan Model Routing + true + + + Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr… + + + + + Max Chat Model Attempts + 10 + + + Maximum number of attempts for requests to the main chat model. Cannot exceed 10. + + + + + Debug Keystroke Logging + false + + + Enable debug logging of keystrokes to the console. + + + + + + + + + + Apply To + + + + User Settings + + + Workspace Settings + + + System Settings + + + + + (Use Enter to select, Ctrl+L to reset, Tab to change focus, Esc to close) + + + + ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ + + \ No newline at end of file diff --git a/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Snapshot-Tests-should-render-tools-and-security-settings-correctly.snap.svg b/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Snapshot-Tests-should-render-tools-and-security-settings-correctly.snap.svg new file mode 100644 index 0000000000..b7ad1d10db --- /dev/null +++ b/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Snapshot-Tests-should-render-tools-and-security-settings-correctly.snap.svg @@ -0,0 +1,133 @@ + + + + + ╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ + + + + > Settings + + + + + ╭──────────────────────────────────────────────────────────────────────────────────────────────╮ + + + + + S + earch to filter + + + + ╰──────────────────────────────────────────────────────────────────────────────────────────────╯ + + + + + + + + + Vim Mode + false + + + Enable Vim keybindings + + + + + Default Approval Mode + Default + + + The default approval mode for tool execution. 'default' prompts for approval, 'au… + + + + + Enable Auto Update + true + + + Enable automatic updates. + + + + + Enable Notifications + false + + + Enable run-event notifications for action-required prompts and session completion. … + + + + + Plan Directory + undefined + + + The directory where planning artifacts are stored. If not specified, defaults t… + + + + + Plan Model Routing + true + + + Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr… + + + + + Max Chat Model Attempts + 10 + + + Maximum number of attempts for requests to the main chat model. Cannot exceed 10. + + + + + Debug Keystroke Logging + false + + + Enable debug logging of keystrokes to the console. + + + + + + + + + + Apply To + + + + User Settings + + + Workspace Settings + + + System Settings + + + + + (Use Enter to select, Ctrl+L to reset, Tab to change focus, Esc to close) + + + + ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ + + \ No newline at end of file diff --git a/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Snapshot-Tests-should-render-various-boolean-settings-enabled-correctly.snap.svg b/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Snapshot-Tests-should-render-various-boolean-settings-enabled-correctly.snap.svg new file mode 100644 index 0000000000..e99a5b4cdd --- /dev/null +++ b/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Snapshot-Tests-should-render-various-boolean-settings-enabled-correctly.snap.svg @@ -0,0 +1,131 @@ + + + + + ╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ + + + + > Settings + + + + + ╭──────────────────────────────────────────────────────────────────────────────────────────────╮ + + + + + S + earch to filter + + + + ╰──────────────────────────────────────────────────────────────────────────────────────────────╯ + + + + + + + + + Vim Mode + true* + + + Enable Vim keybindings + + + + + Default Approval Mode + Default + + + The default approval mode for tool execution. 'default' prompts for approval, 'au… + + + + + Enable Auto Update false* + + + Enable automatic updates. + + + + + Enable Notifications + false + + + Enable run-event notifications for action-required prompts and session completion. … + + + + + Plan Directory + undefined + + + The directory where planning artifacts are stored. If not specified, defaults t… + + + + + Plan Model Routing + true + + + Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr… + + + + + Max Chat Model Attempts + 10 + + + Maximum number of attempts for requests to the main chat model. Cannot exceed 10. + + + + + Debug Keystroke Logging true* + + + Enable debug logging of keystrokes to the console. + + + + + + + + + + Apply To + + + + User Settings + + + Workspace Settings + + + System Settings + + + + + (Use Enter to select, Ctrl+L to reset, Tab to change focus, Esc to close) + + + + ╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ + + \ No newline at end of file diff --git a/packages/cli/src/ui/components/__snapshots__/SettingsDialog.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/SettingsDialog.test.tsx.snap index f1bd8d3852..be2dd8d9a2 100644 --- a/packages/cli/src/ui/components/__snapshots__/SettingsDialog.test.tsx.snap +++ b/packages/cli/src/ui/components/__snapshots__/SettingsDialog.test.tsx.snap @@ -43,8 +43,7 @@ exports[`SettingsDialog > Initial Rendering > should render settings list with v │ │ │ (Use Enter to select, Ctrl+L to reset, Tab to change focus, Esc to close) │ │ │ -╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ -" +╰──────────────────────────────────────────────────────────────────────────────────────────────────╯" `; exports[`SettingsDialog > Snapshot Tests > should render 'accessibility settings enabled' correctly 1`] = ` @@ -90,8 +89,7 @@ exports[`SettingsDialog > Snapshot Tests > should render 'accessibility settings │ │ │ (Use Enter to select, Ctrl+L to reset, Tab to change focus, Esc to close) │ │ │ -╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ -" +╰──────────────────────────────────────────────────────────────────────────────────────────────────╯" `; exports[`SettingsDialog > Snapshot Tests > should render 'all boolean settings disabled' correctly 1`] = ` @@ -137,8 +135,7 @@ exports[`SettingsDialog > Snapshot Tests > should render 'all boolean settings d │ │ │ (Use Enter to select, Ctrl+L to reset, Tab to change focus, Esc to close) │ │ │ -╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ -" +╰──────────────────────────────────────────────────────────────────────────────────────────────────╯" `; exports[`SettingsDialog > Snapshot Tests > should render 'default state' correctly 1`] = ` @@ -184,8 +181,7 @@ exports[`SettingsDialog > Snapshot Tests > should render 'default state' correct │ │ │ (Use Enter to select, Ctrl+L to reset, Tab to change focus, Esc to close) │ │ │ -╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ -" +╰──────────────────────────────────────────────────────────────────────────────────────────────────╯" `; exports[`SettingsDialog > Snapshot Tests > should render 'file filtering settings configured' correctly 1`] = ` @@ -231,8 +227,7 @@ exports[`SettingsDialog > Snapshot Tests > should render 'file filtering setting │ │ │ (Use Enter to select, Ctrl+L to reset, Tab to change focus, Esc to close) │ │ │ -╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ -" +╰──────────────────────────────────────────────────────────────────────────────────────────────────╯" `; exports[`SettingsDialog > Snapshot Tests > should render 'focused on scope selector' correctly 1`] = ` @@ -278,8 +273,7 @@ exports[`SettingsDialog > Snapshot Tests > should render 'focused on scope selec │ │ │ (Use Enter to select, Ctrl+L to reset, Tab to change focus, Esc to close) │ │ │ -╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ -" +╰──────────────────────────────────────────────────────────────────────────────────────────────────╯" `; exports[`SettingsDialog > Snapshot Tests > should render 'mixed boolean and number settings' correctly 1`] = ` @@ -325,8 +319,7 @@ exports[`SettingsDialog > Snapshot Tests > should render 'mixed boolean and numb │ │ │ (Use Enter to select, Ctrl+L to reset, Tab to change focus, Esc to close) │ │ │ -╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ -" +╰──────────────────────────────────────────────────────────────────────────────────────────────────╯" `; exports[`SettingsDialog > Snapshot Tests > should render 'tools and security settings' correctly 1`] = ` @@ -372,8 +365,7 @@ exports[`SettingsDialog > Snapshot Tests > should render 'tools and security set │ │ │ (Use Enter to select, Ctrl+L to reset, Tab to change focus, Esc to close) │ │ │ -╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ -" +╰──────────────────────────────────────────────────────────────────────────────────────────────────╯" `; exports[`SettingsDialog > Snapshot Tests > should render 'various boolean settings enabled' correctly 1`] = ` @@ -419,6 +411,5 @@ exports[`SettingsDialog > Snapshot Tests > should render 'various boolean settin │ │ │ (Use Enter to select, Ctrl+L to reset, Tab to change focus, Esc to close) │ │ │ -╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ -" +╰──────────────────────────────────────────────────────────────────────────────────────────────────╯" `; diff --git a/packages/cli/src/ui/components/__snapshots__/Table-Table-should-render-headers-and-data-correctly.snap.svg b/packages/cli/src/ui/components/__snapshots__/Table-Table-should-render-headers-and-data-correctly.snap.svg new file mode 100644 index 0000000000..6042642abd --- /dev/null +++ b/packages/cli/src/ui/components/__snapshots__/Table-Table-should-render-headers-and-data-correctly.snap.svg @@ -0,0 +1,12 @@ + + + + + ID Name + ──────────────────────────────────────────────────────────────────────────────────────────────────── + 1 Alice + 2 Bob + + \ No newline at end of file diff --git a/packages/cli/src/ui/components/__snapshots__/Table-Table-should-support-custom-cell-rendering.snap.svg b/packages/cli/src/ui/components/__snapshots__/Table-Table-should-support-custom-cell-rendering.snap.svg new file mode 100644 index 0000000000..359b4ee76d --- /dev/null +++ b/packages/cli/src/ui/components/__snapshots__/Table-Table-should-support-custom-cell-rendering.snap.svg @@ -0,0 +1,11 @@ + + + + + Value + ──────────────────────────────────────────────────────────────────────────────────────────────────── + 20 + + \ No newline at end of file diff --git a/packages/cli/src/ui/components/__snapshots__/Table-Table-should-support-inverse-text-rendering.snap.svg b/packages/cli/src/ui/components/__snapshots__/Table-Table-should-support-inverse-text-rendering.snap.svg new file mode 100644 index 0000000000..4473a2e810 --- /dev/null +++ b/packages/cli/src/ui/components/__snapshots__/Table-Table-should-support-inverse-text-rendering.snap.svg @@ -0,0 +1,12 @@ + + + + + Status + ──────────────────────────────────────────────────────────────────────────────────────────────────── + + Active + + \ No newline at end of file diff --git a/packages/cli/src/ui/components/__snapshots__/Table.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/Table.test.tsx.snap index 27a1e6e6f6..8356ef4345 100644 --- a/packages/cli/src/ui/components/__snapshots__/Table.test.tsx.snap +++ b/packages/cli/src/ui/components/__snapshots__/Table.test.tsx.snap @@ -4,13 +4,17 @@ exports[`Table > should render headers and data correctly 1`] = ` "ID Name ──────────────────────────────────────────────────────────────────────────────────────────────────── 1 Alice -2 Bob -" +2 Bob" `; exports[`Table > should support custom cell rendering 1`] = ` "Value ──────────────────────────────────────────────────────────────────────────────────────────────────── -20 -" +20" +`; + +exports[`Table > should support inverse text rendering 1`] = ` +"Status +──────────────────────────────────────────────────────────────────────────────────────────────────── +Active" `; diff --git a/packages/cli/src/ui/components/__snapshots__/ToolConfirmationQueue.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/ToolConfirmationQueue.test.tsx.snap index b5e013ef48..ad7e046465 100644 --- a/packages/cli/src/ui/components/__snapshots__/ToolConfirmationQueue.test.tsx.snap +++ b/packages/cli/src/ui/components/__snapshots__/ToolConfirmationQueue.test.tsx.snap @@ -85,7 +85,7 @@ exports[`ToolConfirmationQueue > renders ExitPlanMode tool confirmation with Suc │ Approves plan but requires confirmation for each tool │ │ 3. Type your feedback... │ │ │ -│ Enter to select · ↑/↓ to navigate · Esc to cancel │ +│ Enter to select · ↑/↓ to navigate · Ctrl+X to edit plan · Esc to cancel │ ╰──────────────────────────────────────────────────────────────────────────────╯ " `; diff --git a/packages/cli/src/ui/components/messages/RedirectionConfirmation.test.tsx b/packages/cli/src/ui/components/messages/RedirectionConfirmation.test.tsx index 1c95a526f5..15763bdae7 100644 --- a/packages/cli/src/ui/components/messages/RedirectionConfirmation.test.tsx +++ b/packages/cli/src/ui/components/messages/RedirectionConfirmation.test.tsx @@ -37,6 +37,7 @@ describe('ToolConfirmationMessage Redirection', () => { callId="test-call-id" confirmationDetails={confirmationDetails} config={mockConfig} + getPreferredEditor={vi.fn()} availableTerminalHeight={30} terminalWidth={100} />, diff --git a/packages/cli/src/ui/components/messages/ToolConfirmationMessage.test.tsx b/packages/cli/src/ui/components/messages/ToolConfirmationMessage.test.tsx index ec1fd3d4db..b3b34ae0a8 100644 --- a/packages/cli/src/ui/components/messages/ToolConfirmationMessage.test.tsx +++ b/packages/cli/src/ui/components/messages/ToolConfirmationMessage.test.tsx @@ -52,6 +52,7 @@ describe('ToolConfirmationMessage', () => { callId="test-call-id" confirmationDetails={confirmationDetails} config={mockConfig} + getPreferredEditor={vi.fn()} availableTerminalHeight={30} terminalWidth={80} />, @@ -78,6 +79,7 @@ describe('ToolConfirmationMessage', () => { callId="test-call-id" confirmationDetails={confirmationDetails} config={mockConfig} + getPreferredEditor={vi.fn()} availableTerminalHeight={30} terminalWidth={80} />, @@ -101,6 +103,7 @@ describe('ToolConfirmationMessage', () => { callId="test-call-id" confirmationDetails={confirmationDetails} config={mockConfig} + getPreferredEditor={vi.fn()} availableTerminalHeight={30} terminalWidth={80} />, @@ -131,6 +134,7 @@ describe('ToolConfirmationMessage', () => { callId="test-call-id" confirmationDetails={confirmationDetails} config={mockConfig} + getPreferredEditor={vi.fn()} availableTerminalHeight={30} terminalWidth={80} />, @@ -161,6 +165,7 @@ describe('ToolConfirmationMessage', () => { callId="test-call-id" confirmationDetails={confirmationDetails} config={mockConfig} + getPreferredEditor={vi.fn()} availableTerminalHeight={30} terminalWidth={80} />, @@ -190,6 +195,7 @@ describe('ToolConfirmationMessage', () => { callId="test-call-id" confirmationDetails={confirmationDetails} config={mockConfig} + getPreferredEditor={vi.fn()} availableTerminalHeight={30} terminalWidth={80} />, @@ -219,6 +225,7 @@ describe('ToolConfirmationMessage', () => { callId="test-call-id" confirmationDetails={confirmationDetails} config={mockConfig} + getPreferredEditor={vi.fn()} availableTerminalHeight={30} terminalWidth={80} />, @@ -300,6 +307,7 @@ describe('ToolConfirmationMessage', () => { callId="test-call-id" confirmationDetails={details} config={mockConfig} + getPreferredEditor={vi.fn()} availableTerminalHeight={30} terminalWidth={80} />, @@ -321,6 +329,7 @@ describe('ToolConfirmationMessage', () => { callId="test-call-id" confirmationDetails={details} config={mockConfig} + getPreferredEditor={vi.fn()} availableTerminalHeight={30} terminalWidth={80} />, @@ -355,6 +364,7 @@ describe('ToolConfirmationMessage', () => { callId="test-call-id" confirmationDetails={editConfirmationDetails} config={mockConfig} + getPreferredEditor={vi.fn()} availableTerminalHeight={30} terminalWidth={80} />, @@ -381,6 +391,7 @@ describe('ToolConfirmationMessage', () => { callId="test-call-id" confirmationDetails={editConfirmationDetails} config={mockConfig} + getPreferredEditor={vi.fn()} availableTerminalHeight={30} terminalWidth={80} />, @@ -425,6 +436,7 @@ describe('ToolConfirmationMessage', () => { callId="test-call-id" confirmationDetails={editConfirmationDetails} config={mockConfig} + getPreferredEditor={vi.fn()} availableTerminalHeight={30} terminalWidth={80} />, @@ -452,6 +464,7 @@ describe('ToolConfirmationMessage', () => { callId="test-call-id" confirmationDetails={editConfirmationDetails} config={mockConfig} + getPreferredEditor={vi.fn()} availableTerminalHeight={30} terminalWidth={80} />, @@ -479,6 +492,7 @@ describe('ToolConfirmationMessage', () => { callId="test-call-id" confirmationDetails={editConfirmationDetails} config={mockConfig} + getPreferredEditor={vi.fn()} availableTerminalHeight={30} terminalWidth={80} />, @@ -505,6 +519,7 @@ describe('ToolConfirmationMessage', () => { callId="test-call-id" confirmationDetails={confirmationDetails} config={mockConfig} + getPreferredEditor={vi.fn()} availableTerminalHeight={30} terminalWidth={80} />, @@ -550,6 +565,7 @@ describe('ToolConfirmationMessage', () => { callId="test-call-id" confirmationDetails={confirmationDetails} config={mockConfig} + getPreferredEditor={vi.fn()} availableTerminalHeight={30} terminalWidth={80} />, @@ -581,6 +597,7 @@ describe('ToolConfirmationMessage', () => { callId="test-call-id" confirmationDetails={confirmationDetails} config={mockConfig} + getPreferredEditor={vi.fn()} availableTerminalHeight={30} terminalWidth={80} />, diff --git a/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx b/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx index 9a49e2aa5a..022a68e953 100644 --- a/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx +++ b/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx @@ -14,6 +14,7 @@ import { type Config, type ToolConfirmationPayload, ToolConfirmationOutcome, + type EditorType, hasRedirection, debugLogger, } from '@google/gemini-cli-core'; @@ -49,6 +50,7 @@ export interface ToolConfirmationMessageProps { callId: string; confirmationDetails: SerializableConfirmationDetails; config: Config; + getPreferredEditor: () => EditorType | undefined; isFocused?: boolean; availableTerminalHeight?: number; terminalWidth: number; @@ -60,6 +62,7 @@ export const ToolConfirmationMessage: React.FC< callId, confirmationDetails, config, + getPreferredEditor, isFocused = true, availableTerminalHeight, terminalWidth, @@ -424,6 +427,7 @@ export const ToolConfirmationMessage: React.FC< bodyContent = ( { handleConfirm(ToolConfirmationOutcome.ProceedOnce, { approved: true, @@ -629,6 +633,7 @@ export const ToolConfirmationMessage: React.FC< hasMcpToolDetails, mcpToolDetailsText, expandDetailsHintKey, + getPreferredEditor, ]); const bodyOverflowDirection: 'top' | 'bottom' = diff --git a/packages/cli/src/ui/components/messages/ToolResultDisplayOverflow.test.tsx b/packages/cli/src/ui/components/messages/ToolResultDisplayOverflow.test.tsx index a196b8d989..a1d4106cea 100644 --- a/packages/cli/src/ui/components/messages/ToolResultDisplayOverflow.test.tsx +++ b/packages/cli/src/ui/components/messages/ToolResultDisplayOverflow.test.tsx @@ -8,7 +8,6 @@ import { describe, it, expect } from 'vitest'; import { ToolGroupMessage } from './ToolGroupMessage.js'; import { renderWithProviders } from '../../../test-utils/render.js'; import { StreamingState, type IndividualToolCallDisplay } from '../../types.js'; -import { OverflowProvider } from '../../contexts/OverflowContext.js'; import { waitFor } from '../../../test-utils/async.js'; import { CoreToolCallStatus } from '@google/gemini-cli-core'; @@ -32,16 +31,14 @@ describe('ToolResultDisplay Overflow', () => { }, ]; - const { lastFrame } = renderWithProviders( - - - , + const { lastFrame, waitUntilReady } = renderWithProviders( + , { uiState: { streamingState: StreamingState.Idle, @@ -51,12 +48,13 @@ describe('ToolResultDisplay Overflow', () => { }, ); - // ResizeObserver might take a tick - await waitFor(() => - expect(lastFrame()?.toLowerCase()).toContain( - 'press ctrl+o to show more lines', - ), - ); + await waitUntilReady(); + + // ResizeObserver might take a tick, though ToolGroupMessage calculates overflow synchronously + await waitFor(() => { + const frame = lastFrame(); + expect(frame.toLowerCase()).toContain('press ctrl+o to show more lines'); + }); const frame = lastFrame(); expect(frame).toBeDefined(); diff --git a/packages/cli/src/ui/components/messages/__snapshots__/UserMessage.test.tsx.snap b/packages/cli/src/ui/components/messages/__snapshots__/UserMessage.test.tsx.snap index 9488a20ba3..679a5885d1 100644 --- a/packages/cli/src/ui/components/messages/__snapshots__/UserMessage.test.tsx.snap +++ b/packages/cli/src/ui/components/messages/__snapshots__/UserMessage.test.tsx.snap @@ -2,29 +2,29 @@ exports[`UserMessage > renders multiline user message 1`] = ` "▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ - > Line 1 - Line 2 + > Line 1 + Line 2 ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ " `; exports[`UserMessage > renders normal user message with correct prefix 1`] = ` "▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ - > Hello Gemini + > Hello Gemini ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ " `; exports[`UserMessage > renders slash command message 1`] = ` "▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ - > /help + > /help ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ " `; exports[`UserMessage > transforms image paths in user message 1`] = ` "▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ - > Check out this image: [Image my-image.png] + > Check out this image: [Image my-image.png] ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ " `; diff --git a/packages/cli/src/ui/components/shared/DialogFooter.tsx b/packages/cli/src/ui/components/shared/DialogFooter.tsx index af75074645..7411a91611 100644 --- a/packages/cli/src/ui/components/shared/DialogFooter.tsx +++ b/packages/cli/src/ui/components/shared/DialogFooter.tsx @@ -15,6 +15,8 @@ export interface DialogFooterProps { navigationActions?: string; /** Exit shortcut (defaults to "Esc to cancel") */ cancelAction?: string; + /** Custom keyboard shortcut hints (e.g., ["Ctrl+P to edit"]) */ + extraParts?: string[]; } /** @@ -25,11 +27,13 @@ export const DialogFooter: React.FC = ({ primaryAction, navigationActions, cancelAction = 'Esc to cancel', + extraParts = [], }) => { const parts = [primaryAction]; if (navigationActions) { parts.push(navigationActions); } + parts.push(...extraParts); parts.push(cancelAction); return ( diff --git a/packages/cli/src/ui/components/shared/ExpandableText.test.tsx b/packages/cli/src/ui/components/shared/ExpandableText.test.tsx index 3634aafa8d..00c82a009d 100644 --- a/packages/cli/src/ui/components/shared/ExpandableText.test.tsx +++ b/packages/cli/src/ui/components/shared/ExpandableText.test.tsx @@ -4,7 +4,6 @@ * SPDX-License-Identifier: Apache-2.0 */ -import chalk from 'chalk'; import { describe, it, expect } from 'vitest'; import { render } from '../../../test-utils/render.js'; import { ExpandableText, MAX_WIDTH } from './ExpandableText.js'; @@ -14,7 +13,7 @@ describe('ExpandableText', () => { const flat = (s: string | undefined) => (s ?? '').replace(/\n/g, ''); it('renders plain label when no match (short label)', async () => { - const { lastFrame, waitUntilReady, unmount } = render( + const renderResult = render( { isExpanded={false} />, ); + const { waitUntilReady, unmount } = renderResult; await waitUntilReady(); - expect(lastFrame()).toMatchSnapshot(); + await expect(renderResult).toMatchSvgSnapshot(); unmount(); }); it('truncates long label when collapsed and no match', async () => { const long = 'x'.repeat(MAX_WIDTH + 25); - const { lastFrame, waitUntilReady, unmount } = render( + const renderResult = render( { isExpanded={false} />, ); + const { lastFrame, waitUntilReady, unmount } = renderResult; await waitUntilReady(); const out = lastFrame(); const f = flat(out); expect(f.endsWith('...')).toBe(true); expect(f.length).toBe(MAX_WIDTH + 3); - expect(out).toMatchSnapshot(); + await expect(renderResult).toMatchSvgSnapshot(); unmount(); }); it('shows full long label when expanded and no match', async () => { const long = 'y'.repeat(MAX_WIDTH + 25); - const { lastFrame, waitUntilReady, unmount } = render( + const renderResult = render( { isExpanded={true} />, ); + const { lastFrame, waitUntilReady, unmount } = renderResult; await waitUntilReady(); const out = lastFrame(); const f = flat(out); expect(f.length).toBe(long.length); - expect(out).toMatchSnapshot(); + await expect(renderResult).toMatchSvgSnapshot(); unmount(); }); @@ -69,7 +71,7 @@ describe('ExpandableText', () => { const label = 'run: git commit -m "feat: add search"'; const userInput = 'commit'; const matchedIndex = label.indexOf(userInput); - const { lastFrame, waitUntilReady, unmount } = render( + const renderResult = render( { />, 100, ); + const { waitUntilReady, unmount } = renderResult; await waitUntilReady(); - expect(lastFrame()).toMatchSnapshot(); - expect(lastFrame()).toContain(chalk.inverse(userInput)); + await expect(renderResult).toMatchSvgSnapshot(); unmount(); }); @@ -91,7 +93,7 @@ describe('ExpandableText', () => { const suffix = '/and/then/some/more/components/'.repeat(3); const label = prefix + core + suffix; const matchedIndex = prefix.length; - const { lastFrame, waitUntilReady, unmount } = render( + const renderResult = render( { />, 100, ); + const { lastFrame, waitUntilReady, unmount } = renderResult; await waitUntilReady(); const out = lastFrame(); const f = flat(out); expect(f.includes(core)).toBe(true); expect(f.startsWith('...')).toBe(true); expect(f.endsWith('...')).toBe(true); - expect(out).toMatchSnapshot(); + await expect(renderResult).toMatchSvgSnapshot(); unmount(); }); @@ -117,7 +120,7 @@ describe('ExpandableText', () => { const suffix = ' in this text'; const label = prefix + core + suffix; const matchedIndex = prefix.length; - const { lastFrame, waitUntilReady, unmount } = render( + const renderResult = render( { isExpanded={false} />, ); + const { lastFrame, waitUntilReady, unmount } = renderResult; await waitUntilReady(); const out = lastFrame(); const f = flat(out); @@ -133,14 +137,14 @@ describe('ExpandableText', () => { expect(f.startsWith('...')).toBe(false); expect(f.endsWith('...')).toBe(true); expect(f.length).toBe(MAX_WIDTH + 2); - expect(out).toMatchSnapshot(); + await expect(renderResult).toMatchSvgSnapshot(); unmount(); }); it('respects custom maxWidth', async () => { const customWidth = 50; const long = 'z'.repeat(100); - const { lastFrame, waitUntilReady, unmount } = render( + const renderResult = render( { maxWidth={customWidth} />, ); + const { lastFrame, waitUntilReady, unmount } = renderResult; await waitUntilReady(); const out = lastFrame(); const f = flat(out); expect(f.endsWith('...')).toBe(true); expect(f.length).toBe(customWidth + 3); - expect(out).toMatchSnapshot(); + await expect(renderResult).toMatchSvgSnapshot(); unmount(); }); }); diff --git a/packages/cli/src/ui/components/shared/__snapshots__/EnumSelector.test.tsx.snap b/packages/cli/src/ui/components/shared/__snapshots__/EnumSelector.test.tsx.snap index 8fd19b3868..203ceb61d6 100644 --- a/packages/cli/src/ui/components/shared/__snapshots__/EnumSelector.test.tsx.snap +++ b/packages/cli/src/ui/components/shared/__snapshots__/EnumSelector.test.tsx.snap @@ -11,7 +11,7 @@ exports[` > renders with numeric options and matches snapshot 1` `; exports[` > renders with single option and matches snapshot 1`] = ` -" Only Option +" Only Option " `; diff --git a/packages/cli/src/ui/components/shared/__snapshots__/ExpandableText-ExpandableText-creates-centered-window-around-match-when-collapsed.snap.svg b/packages/cli/src/ui/components/shared/__snapshots__/ExpandableText-ExpandableText-creates-centered-window-around-match-when-collapsed.snap.svg new file mode 100644 index 0000000000..1f6239e48c --- /dev/null +++ b/packages/cli/src/ui/components/shared/__snapshots__/ExpandableText-ExpandableText-creates-centered-window-around-match-when-collapsed.snap.svg @@ -0,0 +1,13 @@ + + + + + ...ry/long/path/that/keeps/going/cd_/very/long/path/that/keeps/going/ + + search-here + /and/then/some/more/ + components//and/then/some/more/components//and/... + + \ No newline at end of file diff --git a/packages/cli/src/ui/components/shared/__snapshots__/ExpandableText-ExpandableText-highlights-matched-substring-when-expanded-text-only-visible-.snap.svg b/packages/cli/src/ui/components/shared/__snapshots__/ExpandableText-ExpandableText-highlights-matched-substring-when-expanded-text-only-visible-.snap.svg new file mode 100644 index 0000000000..67899017a3 --- /dev/null +++ b/packages/cli/src/ui/components/shared/__snapshots__/ExpandableText-ExpandableText-highlights-matched-substring-when-expanded-text-only-visible-.snap.svg @@ -0,0 +1,12 @@ + + + + + run: git + + commit + -m "feat: add search" + + \ No newline at end of file diff --git a/packages/cli/src/ui/components/shared/__snapshots__/ExpandableText-ExpandableText-renders-plain-label-when-no-match-short-label-.snap.svg b/packages/cli/src/ui/components/shared/__snapshots__/ExpandableText-ExpandableText-renders-plain-label-when-no-match-short-label-.snap.svg new file mode 100644 index 0000000000..3d858a18af --- /dev/null +++ b/packages/cli/src/ui/components/shared/__snapshots__/ExpandableText-ExpandableText-renders-plain-label-when-no-match-short-label-.snap.svg @@ -0,0 +1,9 @@ + + + + + simple command + + \ No newline at end of file diff --git a/packages/cli/src/ui/components/shared/__snapshots__/ExpandableText-ExpandableText-respects-custom-maxWidth.snap.svg b/packages/cli/src/ui/components/shared/__snapshots__/ExpandableText-ExpandableText-respects-custom-maxWidth.snap.svg new file mode 100644 index 0000000000..3bca3c74e9 --- /dev/null +++ b/packages/cli/src/ui/components/shared/__snapshots__/ExpandableText-ExpandableText-respects-custom-maxWidth.snap.svg @@ -0,0 +1,9 @@ + + + + + zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz... + + \ No newline at end of file diff --git a/packages/cli/src/ui/components/shared/__snapshots__/ExpandableText-ExpandableText-shows-full-long-label-when-expanded-and-no-match.snap.svg b/packages/cli/src/ui/components/shared/__snapshots__/ExpandableText-ExpandableText-shows-full-long-label-when-expanded-and-no-match.snap.svg new file mode 100644 index 0000000000..283466b773 --- /dev/null +++ b/packages/cli/src/ui/components/shared/__snapshots__/ExpandableText-ExpandableText-shows-full-long-label-when-expanded-and-no-match.snap.svg @@ -0,0 +1,10 @@ + + + + + yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy + yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy + + \ No newline at end of file diff --git a/packages/cli/src/ui/components/shared/__snapshots__/ExpandableText-ExpandableText-truncates-long-label-when-collapsed-and-no-match.snap.svg b/packages/cli/src/ui/components/shared/__snapshots__/ExpandableText-ExpandableText-truncates-long-label-when-collapsed-and-no-match.snap.svg new file mode 100644 index 0000000000..79e13d7486 --- /dev/null +++ b/packages/cli/src/ui/components/shared/__snapshots__/ExpandableText-ExpandableText-truncates-long-label-when-collapsed-and-no-match.snap.svg @@ -0,0 +1,10 @@ + + + + + xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx + xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx... + + \ No newline at end of file diff --git a/packages/cli/src/ui/components/shared/__snapshots__/ExpandableText-ExpandableText-truncates-match-itself-when-match-is-very-long.snap.svg b/packages/cli/src/ui/components/shared/__snapshots__/ExpandableText-ExpandableText-truncates-match-itself-when-match-is-very-long.snap.svg new file mode 100644 index 0000000000..3eeb5c3250 --- /dev/null +++ b/packages/cli/src/ui/components/shared/__snapshots__/ExpandableText-ExpandableText-truncates-match-itself-when-match-is-very-long.snap.svg @@ -0,0 +1,12 @@ + + + + + + xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx + + xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx... + + \ No newline at end of file diff --git a/packages/cli/src/ui/components/shared/__snapshots__/ExpandableText.test.tsx.snap b/packages/cli/src/ui/components/shared/__snapshots__/ExpandableText.test.tsx.snap index 7baf47e628..8716c962ea 100644 --- a/packages/cli/src/ui/components/shared/__snapshots__/ExpandableText.test.tsx.snap +++ b/packages/cli/src/ui/components/shared/__snapshots__/ExpandableText.test.tsx.snap @@ -2,39 +2,26 @@ exports[`ExpandableText > creates centered window around match when collapsed 1`] = ` "...ry/long/path/that/keeps/going/cd_/very/long/path/that/keeps/going/search-here/and/then/some/more/ -components//and/then/some/more/components//and/... -" +components//and/then/some/more/components//and/..." `; -exports[`ExpandableText > highlights matched substring when expanded (text only visible) 1`] = ` -"run: git commit -m "feat: add search" -" -`; +exports[`ExpandableText > highlights matched substring when expanded (text only visible) 1`] = `"run: git commit -m "feat: add search""`; -exports[`ExpandableText > renders plain label when no match (short label) 1`] = ` -"simple command -" -`; +exports[`ExpandableText > renders plain label when no match (short label) 1`] = `"simple command"`; -exports[`ExpandableText > respects custom maxWidth 1`] = ` -"zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz... -" -`; +exports[`ExpandableText > respects custom maxWidth 1`] = `"zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz..."`; exports[`ExpandableText > shows full long label when expanded and no match 1`] = ` "yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy -yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy -" +yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy" `; exports[`ExpandableText > truncates long label when collapsed and no match 1`] = ` "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx -xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx... -" +xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx..." `; exports[`ExpandableText > truncates match itself when match is very long 1`] = ` "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx -xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx... -" +xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx..." `; diff --git a/packages/cli/src/ui/components/shared/__snapshots__/HalfLinePaddedBox.test.tsx.snap b/packages/cli/src/ui/components/shared/__snapshots__/HalfLinePaddedBox.test.tsx.snap index 5dcbfda73d..dbb9af2991 100644 --- a/packages/cli/src/ui/components/shared/__snapshots__/HalfLinePaddedBox.test.tsx.snap +++ b/packages/cli/src/ui/components/shared/__snapshots__/HalfLinePaddedBox.test.tsx.snap @@ -2,7 +2,7 @@ exports[` > renders iTerm2-specific blocks when iTerm2 is detected 1`] = ` "▄▄▄▄▄▄▄▄▄▄ -Content +Content ▀▀▀▀▀▀▀▀▀▀ " `; @@ -19,7 +19,7 @@ exports[` > renders nothing when useBackgroundColor is fals exports[` > renders standard background and blocks when not iTerm2 1`] = ` "▀▀▀▀▀▀▀▀▀▀ -Content +Content ▄▄▄▄▄▄▄▄▄▄ " `; diff --git a/packages/cli/src/ui/components/shared/__snapshots__/SearchableList.test.tsx.snap b/packages/cli/src/ui/components/shared/__snapshots__/SearchableList.test.tsx.snap index 35f21daee3..803ec8dd98 100644 --- a/packages/cli/src/ui/components/shared/__snapshots__/SearchableList.test.tsx.snap +++ b/packages/cli/src/ui/components/shared/__snapshots__/SearchableList.test.tsx.snap @@ -7,7 +7,7 @@ exports[`SearchableList > should match snapshot 1`] = ` │ Search... │ ╰────────────────────────────────────────────────────────────────────────────────────────────────╯ - ● Item One + ● Item One Description for item one Item Two @@ -28,7 +28,7 @@ exports[`SearchableList > should reset selection to top when items change if res Item One Description for item one - ● Item Two + ● Item Two Description for item two Item Three @@ -43,7 +43,7 @@ exports[`SearchableList > should reset selection to top when items change if res │ One │ ╰────────────────────────────────────────────────────────────────────────────────────────────────╯ - ● Item One + ● Item One Description for item one " `; @@ -55,7 +55,7 @@ exports[`SearchableList > should reset selection to top when items change if res │ Search... │ ╰────────────────────────────────────────────────────────────────────────────────────────────────╯ - ● Item One + ● Item One Description for item one Item Two diff --git a/packages/cli/src/ui/components/shared/text-buffer.ts b/packages/cli/src/ui/components/shared/text-buffer.ts index e641633e97..71ee40b642 100644 --- a/packages/cli/src/ui/components/shared/text-buffer.ts +++ b/packages/cli/src/ui/components/shared/text-buffer.ts @@ -4,7 +4,6 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { spawnSync } from 'node:child_process'; import fs from 'node:fs'; import os from 'node:os'; import pathMod from 'node:path'; @@ -13,12 +12,9 @@ import { useState, useCallback, useEffect, useMemo, useReducer } from 'react'; import { LRUCache } from 'mnemonist'; import { coreEvents, - CoreEvent, debugLogger, unescapePath, type EditorType, - getEditorCommand, - isGuiEditor, } from '@google/gemini-cli-core'; import { toCodePoints, @@ -33,6 +29,7 @@ import { keyMatchers, Command } from '../../keyMatchers.js'; import type { VimAction } from './vim-buffer-actions.js'; import { handleVimAction } from './vim-buffer-actions.js'; import { LRU_BUFFER_PERF_CACHE_LIMIT } from '../../constants.js'; +import { openFileInEditor } from '../../utils/editorUtils.js'; export const LARGE_PASTE_LINE_THRESHOLD = 5; export const LARGE_PASTE_CHAR_THRESHOLD = 500; @@ -3095,36 +3092,15 @@ export function useTextBuffer({ ); fs.writeFileSync(filePath, expandedText, 'utf8'); - let command: string | undefined = undefined; - const args = [filePath]; - - const preferredEditorType = getPreferredEditor?.(); - if (!command && preferredEditorType) { - command = getEditorCommand(preferredEditorType); - if (isGuiEditor(preferredEditorType)) { - args.unshift('--wait'); - } - } - - if (!command) { - command = - process.env['VISUAL'] ?? - process.env['EDITOR'] ?? - (process.platform === 'win32' ? 'notepad' : 'vi'); - } - dispatch({ type: 'create_undo_snapshot' }); - const wasRaw = stdin?.isRaw ?? false; try { - setRawMode?.(false); - const { status, error } = spawnSync(command, args, { - stdio: 'inherit', - shell: process.platform === 'win32', - }); - if (error) throw error; - if (typeof status === 'number' && status !== 0) - throw new Error(`External editor exited with status ${status}`); + await openFileInEditor( + filePath, + stdin, + setRawMode, + getPreferredEditor?.(), + ); let newText = fs.readFileSync(filePath, 'utf8'); newText = newText.replace(/\r\n?/g, '\n'); @@ -3147,8 +3123,6 @@ export function useTextBuffer({ err, ); } finally { - coreEvents.emit(CoreEvent.ExternalEditorClosed); - if (wasRaw) setRawMode?.(true); try { fs.unlinkSync(filePath); } catch { diff --git a/packages/cli/src/ui/contexts/UIActionsContext.tsx b/packages/cli/src/ui/contexts/UIActionsContext.tsx index 03780c5068..23c5e995db 100644 --- a/packages/cli/src/ui/contexts/UIActionsContext.tsx +++ b/packages/cli/src/ui/contexts/UIActionsContext.tsx @@ -87,6 +87,7 @@ export interface UIActions { onHintSubmit: (hint: string) => void; handleRestart: () => void; handleNewAgentsSelect: (choice: NewAgentsChoice) => Promise; + getPreferredEditor: () => EditorType | undefined; } export const UIActionsContext = createContext(null); diff --git a/packages/cli/src/ui/contexts/UIStateContext.tsx b/packages/cli/src/ui/contexts/UIStateContext.tsx index 9fb2852361..79464271b8 100644 --- a/packages/cli/src/ui/contexts/UIStateContext.tsx +++ b/packages/cli/src/ui/contexts/UIStateContext.tsx @@ -24,6 +24,7 @@ import type { ApprovalMode, UserTierId, IdeInfo, + AuthType, FallbackIntent, ValidationIntent, AgentDefinition, @@ -42,6 +43,7 @@ export interface ProQuotaDialogRequest { message: string; isTerminalQuotaError: boolean; isModelNotFoundError?: boolean; + authType?: AuthType; resolve: (intent: FallbackIntent) => void; } diff --git a/packages/cli/src/ui/hooks/shell-completions/gitProvider.test.ts b/packages/cli/src/ui/hooks/shell-completions/gitProvider.test.ts new file mode 100644 index 0000000000..f0d7cb4573 --- /dev/null +++ b/packages/cli/src/ui/hooks/shell-completions/gitProvider.test.ts @@ -0,0 +1,131 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { gitProvider } from './gitProvider.js'; +import * as childProcess from 'node:child_process'; + +vi.mock('node:child_process', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + execFile: vi.fn(), + }; +}); + +describe('gitProvider', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('suggests git subcommands for cursorIndex 1', async () => { + const result = await gitProvider.getCompletions(['git', 'ch'], 1, '/tmp'); + + expect(result.exclusive).toBe(true); + expect(result.suggestions).toEqual( + expect.arrayContaining([expect.objectContaining({ value: 'checkout' })]), + ); + expect( + result.suggestions.find((s) => s.value === 'commit'), + ).toBeUndefined(); + }); + + it('suggests branch names for checkout at cursorIndex 2', async () => { + vi.mocked(childProcess.execFile).mockImplementation( + (_cmd, _args, _opts, cb: unknown) => { + const callback = (typeof _opts === 'function' ? _opts : cb) as ( + error: Error | null, + result: { stdout: string }, + ) => void; + callback(null, { + stdout: 'main\nfeature-branch\nfix/bug\nbranch(with)special\n', + }); + return {} as ReturnType; + }, + ); + + const result = await gitProvider.getCompletions( + ['git', 'checkout', 'feat'], + 2, + '/tmp', + ); + + expect(result.exclusive).toBe(true); + expect(result.suggestions).toHaveLength(1); + expect(result.suggestions[0].label).toBe('feature-branch'); + expect(result.suggestions[0].value).toBe('feature-branch'); + expect(childProcess.execFile).toHaveBeenCalledWith( + 'git', + ['branch', '--format=%(refname:short)'], + expect.any(Object), + expect.any(Function), + ); + }); + + it('escapes branch names with shell metacharacters', async () => { + vi.mocked(childProcess.execFile).mockImplementation( + (_cmd, _args, _opts, cb: unknown) => { + const callback = (typeof _opts === 'function' ? _opts : cb) as ( + error: Error | null, + result: { stdout: string }, + ) => void; + callback(null, { stdout: 'main\nbranch(with)special\n' }); + return {} as ReturnType; + }, + ); + + const result = await gitProvider.getCompletions( + ['git', 'checkout', 'branch('], + 2, + '/tmp', + ); + + expect(result.exclusive).toBe(true); + expect(result.suggestions).toHaveLength(1); + expect(result.suggestions[0].label).toBe('branch(with)special'); + + // On Windows, space escape is not done. But since UNIX_SHELL_SPECIAL_CHARS is mostly tested, + // we can use a matcher that checks if escaping was applied (it differs per platform but that's handled by escapeShellPath). + // Let's match the value against either unescaped (win) or escaped (unix). + const isWin = process.platform === 'win32'; + expect(result.suggestions[0].value).toBe( + isWin ? 'branch(with)special' : 'branch\\(with\\)special', + ); + }); + + it('returns empty results if git branch fails', async () => { + vi.mocked(childProcess.execFile).mockImplementation( + (_cmd, _args, _opts, cb: unknown) => { + const callback = (typeof _opts === 'function' ? _opts : cb) as ( + error: Error, + stdout?: string, + ) => void; + callback(new Error('Not a git repository')); + return {} as ReturnType; + }, + ); + + const result = await gitProvider.getCompletions( + ['git', 'checkout', ''], + 2, + '/tmp', + ); + + expect(result.exclusive).toBe(true); + expect(result.suggestions).toHaveLength(0); + }); + + it('returns non-exclusive for unrecognized position', async () => { + const result = await gitProvider.getCompletions( + ['git', 'commit', '-m', 'some message'], + 3, + '/tmp', + ); + + expect(result.exclusive).toBe(false); + expect(result.suggestions).toHaveLength(0); + }); +}); diff --git a/packages/cli/src/ui/hooks/shell-completions/gitProvider.ts b/packages/cli/src/ui/hooks/shell-completions/gitProvider.ts new file mode 100644 index 0000000000..7115718487 --- /dev/null +++ b/packages/cli/src/ui/hooks/shell-completions/gitProvider.ts @@ -0,0 +1,93 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; +import type { ShellCompletionProvider, CompletionResult } from './types.js'; +import { escapeShellPath } from '../useShellCompletion.js'; + +const execFileAsync = promisify(execFile); + +const GIT_SUBCOMMANDS = [ + 'add', + 'branch', + 'checkout', + 'commit', + 'diff', + 'merge', + 'pull', + 'push', + 'rebase', + 'status', + 'switch', +]; + +export const gitProvider: ShellCompletionProvider = { + command: 'git', + async getCompletions( + tokens: string[], + cursorIndex: number, + cwd: string, + signal?: AbortSignal, + ): Promise { + // We are completing the first argument (subcommand) + if (cursorIndex === 1) { + const partial = tokens[1] || ''; + return { + suggestions: GIT_SUBCOMMANDS.filter((cmd) => + cmd.startsWith(partial), + ).map((cmd) => ({ + label: cmd, + value: cmd, + description: 'git command', + })), + exclusive: true, + }; + } + + // We are completing the second argument (e.g. branch name) + if (cursorIndex === 2) { + const subcommand = tokens[1]; + if ( + subcommand === 'checkout' || + subcommand === 'switch' || + subcommand === 'merge' || + subcommand === 'branch' + ) { + const partial = tokens[2] || ''; + try { + const { stdout } = await execFileAsync( + 'git', + ['branch', '--format=%(refname:short)'], + { cwd, signal }, + ); + + const branches = stdout + .split('\n') + .map((b) => b.trim()) + .filter(Boolean); + + return { + suggestions: branches + .filter((b) => b.startsWith(partial)) + .map((b) => ({ + label: b, + value: escapeShellPath(b), + description: 'branch', + })), + exclusive: true, + }; + } catch { + // If git fails (e.g. not a git repo), return nothing + return { suggestions: [], exclusive: true }; + } + } + } + + // Unhandled git argument, fallback to default file completions + return { suggestions: [], exclusive: false }; + }, +}; diff --git a/packages/cli/src/ui/hooks/shell-completions/index.ts b/packages/cli/src/ui/hooks/shell-completions/index.ts new file mode 100644 index 0000000000..07b38abda6 --- /dev/null +++ b/packages/cli/src/ui/hooks/shell-completions/index.ts @@ -0,0 +1,25 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { ShellCompletionProvider, CompletionResult } from './types.js'; +import { gitProvider } from './gitProvider.js'; +import { npmProvider } from './npmProvider.js'; + +const providers: ShellCompletionProvider[] = [gitProvider, npmProvider]; + +export async function getArgumentCompletions( + commandToken: string, + tokens: string[], + cursorIndex: number, + cwd: string, + signal?: AbortSignal, +): Promise { + const provider = providers.find((p) => p.command === commandToken); + if (!provider) { + return null; + } + return provider.getCompletions(tokens, cursorIndex, cwd, signal); +} diff --git a/packages/cli/src/ui/hooks/shell-completions/npmProvider.test.ts b/packages/cli/src/ui/hooks/shell-completions/npmProvider.test.ts new file mode 100644 index 0000000000..95e61b7015 --- /dev/null +++ b/packages/cli/src/ui/hooks/shell-completions/npmProvider.test.ts @@ -0,0 +1,106 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { npmProvider } from './npmProvider.js'; +import * as fs from 'node:fs/promises'; + +vi.mock('node:fs/promises', () => ({ + readFile: vi.fn(), +})); + +describe('npmProvider', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('suggests npm subcommands for cursorIndex 1', async () => { + const result = await npmProvider.getCompletions(['npm', 'ru'], 1, '/tmp'); + + expect(result.exclusive).toBe(true); + expect(result.suggestions).toEqual([ + expect.objectContaining({ value: 'run' }), + ]); + }); + + it('suggests package.json scripts for npm run at cursorIndex 2', async () => { + const mockPackageJson = { + scripts: { + start: 'node index.js', + build: 'tsc', + 'build:dev': 'tsc --watch', + }, + }; + vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify(mockPackageJson)); + + const result = await npmProvider.getCompletions( + ['npm', 'run', 'bu'], + 2, + '/tmp', + ); + + expect(result.exclusive).toBe(true); + expect(result.suggestions).toHaveLength(2); + expect(result.suggestions[0].label).toBe('build'); + expect(result.suggestions[0].value).toBe('build'); + expect(result.suggestions[1].label).toBe('build:dev'); + expect(result.suggestions[1].value).toBe('build:dev'); + expect(fs.readFile).toHaveBeenCalledWith( + expect.stringContaining('package.json'), + 'utf8', + ); + }); + + it('escapes script names with shell metacharacters', async () => { + const mockPackageJson = { + scripts: { + 'build(prod)': 'tsc', + 'test:watch': 'vitest', + }, + }; + vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify(mockPackageJson)); + + const result = await npmProvider.getCompletions( + ['npm', 'run', 'bu'], + 2, + '/tmp', + ); + + expect(result.exclusive).toBe(true); + expect(result.suggestions).toHaveLength(1); + expect(result.suggestions[0].label).toBe('build(prod)'); + + // Windows does not escape spaces/parens in cmds by default in our function, but Unix does. + const isWin = process.platform === 'win32'; + expect(result.suggestions[0].value).toBe( + isWin ? 'build(prod)' : 'build\\(prod\\)', + ); + }); + + it('handles missing package.json gracefully', async () => { + vi.mocked(fs.readFile).mockRejectedValue(new Error('ENOENT')); + + const result = await npmProvider.getCompletions( + ['npm', 'run', ''], + 2, + '/tmp', + ); + + expect(result.exclusive).toBe(true); + expect(result.suggestions).toHaveLength(0); + }); + + it('returns non-exclusive for unrecognized position', async () => { + const result = await npmProvider.getCompletions( + ['npm', 'install', 'react'], + 2, + '/tmp', + ); + + expect(result.exclusive).toBe(false); + expect(result.suggestions).toHaveLength(0); + }); +}); diff --git a/packages/cli/src/ui/hooks/shell-completions/npmProvider.ts b/packages/cli/src/ui/hooks/shell-completions/npmProvider.ts new file mode 100644 index 0000000000..32b88ca5ca --- /dev/null +++ b/packages/cli/src/ui/hooks/shell-completions/npmProvider.ts @@ -0,0 +1,81 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; +import type { ShellCompletionProvider, CompletionResult } from './types.js'; +import { escapeShellPath } from '../useShellCompletion.js'; + +const NPM_SUBCOMMANDS = [ + 'build', + 'ci', + 'dev', + 'install', + 'publish', + 'run', + 'start', + 'test', +]; + +export const npmProvider: ShellCompletionProvider = { + command: 'npm', + async getCompletions( + tokens: string[], + cursorIndex: number, + cwd: string, + signal?: AbortSignal, + ): Promise { + if (cursorIndex === 1) { + const partial = tokens[1] || ''; + return { + suggestions: NPM_SUBCOMMANDS.filter((cmd) => + cmd.startsWith(partial), + ).map((cmd) => ({ + label: cmd, + value: cmd, + description: 'npm command', + })), + exclusive: true, + }; + } + + if (cursorIndex === 2 && tokens[1] === 'run') { + const partial = tokens[2] || ''; + try { + if (signal?.aborted) return { suggestions: [], exclusive: true }; + + const pkgJsonPath = path.join(cwd, 'package.json'); + const content = await fs.readFile(pkgJsonPath, 'utf8'); + const pkg = JSON.parse(content) as unknown; + + const scripts = + pkg && + typeof pkg === 'object' && + 'scripts' in pkg && + pkg.scripts && + typeof pkg.scripts === 'object' + ? Object.keys(pkg.scripts) + : []; + + return { + suggestions: scripts + .filter((s) => s.startsWith(partial)) + .map((s) => ({ + label: s, + value: escapeShellPath(s), + description: 'npm script', + })), + exclusive: true, + }; + } catch { + // No package.json or invalid JSON + return { suggestions: [], exclusive: true }; + } + } + + return { suggestions: [], exclusive: false }; + }, +}; diff --git a/packages/cli/src/ui/hooks/shell-completions/types.ts b/packages/cli/src/ui/hooks/shell-completions/types.ts new file mode 100644 index 0000000000..df3900cf8f --- /dev/null +++ b/packages/cli/src/ui/hooks/shell-completions/types.ts @@ -0,0 +1,24 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { Suggestion } from '../../components/SuggestionsDisplay.js'; + +export interface CompletionResult { + suggestions: Suggestion[]; + // If true, this prevents the shell from appending generic file/path completions + // to this list. Use this when the tool expects ONLY specific values (e.g. branches). + exclusive?: boolean; +} + +export interface ShellCompletionProvider { + command: string; // The command trigger, e.g., 'git' or 'npm' + getCompletions( + tokens: string[], // List of arguments parsed from the input + cursorIndex: number, // Which token index the cursor is currently on + cwd: string, + signal?: AbortSignal, + ): Promise; +} diff --git a/packages/cli/src/ui/hooks/useCommandCompletion.test.tsx b/packages/cli/src/ui/hooks/useCommandCompletion.test.tsx index 2b0bad2743..c7bb2afb50 100644 --- a/packages/cli/src/ui/hooks/useCommandCompletion.test.tsx +++ b/packages/cli/src/ui/hooks/useCommandCompletion.test.tsx @@ -40,6 +40,16 @@ vi.mock('./useSlashCompletion', () => ({ })), })); +vi.mock('./useShellCompletion', async () => { + const actual = await vi.importActual< + typeof import('./useShellCompletion.js') + >('./useShellCompletion'); + return { + ...actual, + useShellCompletion: vi.fn(), + }; +}); + // Helper to set up mocks in a consistent way for both child hooks const setupMocks = ({ atSuggestions = [], @@ -94,6 +104,7 @@ const setupMocks = ({ describe('useCommandCompletion', () => { const mockCommandContext = {} as CommandContext; const mockConfig = { + getEnablePromptCompletion: () => false, getGeminiClient: vi.fn(), } as unknown as Config; const testRootDir = '/'; @@ -498,6 +509,7 @@ describe('useCommandCompletion', () => { describe('prompt completion filtering', () => { it('should not trigger prompt completion for line comments', async () => { const mockConfig = { + getEnablePromptCompletion: () => true, getGeminiClient: vi.fn(), } as unknown as Config; @@ -530,6 +542,7 @@ describe('useCommandCompletion', () => { it('should not trigger prompt completion for block comments', async () => { const mockConfig = { + getEnablePromptCompletion: () => true, getGeminiClient: vi.fn(), } as unknown as Config; @@ -564,6 +577,7 @@ describe('useCommandCompletion', () => { it('should trigger prompt completion for regular text when enabled', async () => { const mockConfig = { + getEnablePromptCompletion: () => true, getGeminiClient: vi.fn(), } as unknown as Config; diff --git a/packages/cli/src/ui/hooks/useCommandCompletion.tsx b/packages/cli/src/ui/hooks/useCommandCompletion.tsx index b9fcb95626..097a1e63b3 100644 --- a/packages/cli/src/ui/hooks/useCommandCompletion.tsx +++ b/packages/cli/src/ui/hooks/useCommandCompletion.tsx @@ -13,6 +13,7 @@ import { isSlashCommand } from '../utils/commandUtils.js'; import { toCodePoints } from '../utils/textUtils.js'; import { useAtCompletion } from './useAtCompletion.js'; import { useSlashCompletion } from './useSlashCompletion.js'; +import { useShellCompletion, getTokenAtCursor } from './useShellCompletion.js'; import type { PromptCompletion } from './usePromptCompletion.js'; import { usePromptCompletion, @@ -26,6 +27,7 @@ export enum CompletionMode { AT = 'AT', SLASH = 'SLASH', PROMPT = 'PROMPT', + SHELL = 'SHELL', } export interface UseCommandCompletionReturn { @@ -99,85 +101,135 @@ export function useCommandCompletion({ const cursorRow = buffer.cursor[0]; const cursorCol = buffer.cursor[1]; - const { completionMode, query, completionStart, completionEnd } = - useMemo(() => { - const currentLine = buffer.lines[cursorRow] || ''; - const codePoints = toCodePoints(currentLine); + const { + completionMode, + query, + completionStart, + completionEnd, + shellTokenIsCommand, + shellTokens, + shellCursorIndex, + shellCommandToken, + } = useMemo(() => { + const currentLine = buffer.lines[cursorRow] || ''; + const codePoints = toCodePoints(currentLine); - // FIRST: Check for @ completion (scan backwards from cursor) - // This must happen before slash command check so that `/cmd @file` - // triggers file completion, not just slash command completion. - for (let i = cursorCol - 1; i >= 0; i--) { - const char = codePoints[i]; + if (shellModeActive) { + const tokenInfo = getTokenAtCursor(currentLine, cursorCol); + if (tokenInfo) { + return { + completionMode: CompletionMode.SHELL, + query: tokenInfo.token, + completionStart: tokenInfo.start, + completionEnd: tokenInfo.end, + shellTokenIsCommand: tokenInfo.isFirstToken, + shellTokens: tokenInfo.tokens, + shellCursorIndex: tokenInfo.cursorIndex, + shellCommandToken: tokenInfo.commandToken, + }; + } + return { + completionMode: CompletionMode.SHELL, + query: '', + completionStart: cursorCol, + completionEnd: cursorCol, + shellTokenIsCommand: currentLine.trim().length === 0, + shellTokens: [''], + shellCursorIndex: 0, + shellCommandToken: '', + }; + } - if (char === ' ') { - let backslashCount = 0; - for (let j = i - 1; j >= 0 && codePoints[j] === '\\'; j--) { - backslashCount++; - } - if (backslashCount % 2 === 0) { - break; - } - } else if (char === '@') { - let end = codePoints.length; - for (let i = cursorCol; i < codePoints.length; i++) { - if (codePoints[i] === ' ') { - let backslashCount = 0; - for (let j = i - 1; j >= 0 && codePoints[j] === '\\'; j--) { - backslashCount++; - } + // FIRST: Check for @ completion (scan backwards from cursor) + // This must happen before slash command check so that `/cmd @file` + // triggers file completion, not just slash command completion. + for (let i = cursorCol - 1; i >= 0; i--) { + const char = codePoints[i]; - if (backslashCount % 2 === 0) { - end = i; - break; - } + if (char === ' ') { + let backslashCount = 0; + for (let j = i - 1; j >= 0 && codePoints[j] === '\\'; j--) { + backslashCount++; + } + if (backslashCount % 2 === 0) { + break; + } + } else if (char === '@') { + let end = codePoints.length; + for (let i = cursorCol; i < codePoints.length; i++) { + if (codePoints[i] === ' ') { + let backslashCount = 0; + for (let j = i - 1; j >= 0 && codePoints[j] === '\\'; j--) { + backslashCount++; + } + + if (backslashCount % 2 === 0) { + end = i; + break; } } - const pathStart = i + 1; - const partialPath = currentLine.substring(pathStart, end); - return { - completionMode: CompletionMode.AT, - query: partialPath, - completionStart: pathStart, - completionEnd: end, - }; } - } - - // THEN: Check for slash command (only if no @ completion is active) - if (cursorRow === 0 && isSlashCommand(currentLine.trim())) { + const pathStart = i + 1; + const partialPath = currentLine.substring(pathStart, end); return { - completionMode: CompletionMode.SLASH, - query: currentLine, - completionStart: 0, - completionEnd: currentLine.length, - }; - } - - // Check for prompt completion - only if enabled - const trimmedText = buffer.text.trim(); - const isPromptCompletionEnabled = false; - if ( - isPromptCompletionEnabled && - trimmedText.length >= PROMPT_COMPLETION_MIN_LENGTH && - !isSlashCommand(trimmedText) && - !trimmedText.includes('@') - ) { - return { - completionMode: CompletionMode.PROMPT, - query: trimmedText, - completionStart: 0, - completionEnd: trimmedText.length, + completionMode: CompletionMode.AT, + query: partialPath, + completionStart: pathStart, + completionEnd: end, + shellTokenIsCommand: false, + shellTokens: [], + shellCursorIndex: -1, + shellCommandToken: '', }; } + } + // THEN: Check for slash command (only if no @ completion is active) + if (cursorRow === 0 && isSlashCommand(currentLine.trim())) { return { - completionMode: CompletionMode.IDLE, - query: null, - completionStart: -1, - completionEnd: -1, + completionMode: CompletionMode.SLASH, + query: currentLine, + completionStart: 0, + completionEnd: currentLine.length, + shellTokenIsCommand: false, + shellTokens: [], + shellCursorIndex: -1, + shellCommandToken: '', }; - }, [cursorRow, cursorCol, buffer.lines, buffer.text]); + } + + // Check for prompt completion - only if enabled + const trimmedText = buffer.text.trim(); + const isPromptCompletionEnabled = false; + if ( + isPromptCompletionEnabled && + trimmedText.length >= PROMPT_COMPLETION_MIN_LENGTH && + !isSlashCommand(trimmedText) && + !trimmedText.includes('@') + ) { + return { + completionMode: CompletionMode.PROMPT, + query: trimmedText, + completionStart: 0, + completionEnd: trimmedText.length, + shellTokenIsCommand: false, + shellTokens: [], + shellCursorIndex: -1, + shellCommandToken: '', + }; + } + + return { + completionMode: CompletionMode.IDLE, + query: null, + completionStart: -1, + completionEnd: -1, + shellTokenIsCommand: false, + shellTokens: [], + shellCursorIndex: -1, + shellCommandToken: '', + }; + }, [cursorRow, cursorCol, buffer.lines, buffer.text, shellModeActive]); useAtCompletion({ enabled: active && completionMode === CompletionMode.AT, @@ -199,9 +251,20 @@ export function useCommandCompletion({ setIsPerfectMatch, }); + useShellCompletion({ + enabled: active && completionMode === CompletionMode.SHELL, + query: query || '', + isCommandPosition: shellTokenIsCommand, + tokens: shellTokens, + cursorIndex: shellCursorIndex, + commandToken: shellCommandToken, + cwd, + setSuggestions, + setIsLoadingSuggestions, + }); + const promptCompletion = usePromptCompletion({ buffer, - config, }); useEffect(() => { diff --git a/packages/cli/src/ui/hooks/usePhraseCycler.test.tsx b/packages/cli/src/ui/hooks/usePhraseCycler.test.tsx index 837d953c3c..ca89c623ac 100644 --- a/packages/cli/src/ui/hooks/usePhraseCycler.test.tsx +++ b/packages/cli/src/ui/hooks/usePhraseCycler.test.tsx @@ -337,7 +337,7 @@ describe('usePhraseCycler', () => { await act(async () => { setStateExternally?.({ isActive: true, - customPhrases: [], + customPhrases: [] as string[], }); }); await waitUntilReady(); diff --git a/packages/cli/src/ui/hooks/useQuotaAndFallback.test.ts b/packages/cli/src/ui/hooks/useQuotaAndFallback.test.ts index 5d6db5abfa..2272de5bf9 100644 --- a/packages/cli/src/ui/hooks/useQuotaAndFallback.test.ts +++ b/packages/cli/src/ui/hooks/useQuotaAndFallback.test.ts @@ -96,9 +96,13 @@ describe('useQuotaAndFallback', () => { }); describe('Fallback Handler Logic', () => { - // Helper function to render the hook and extract the registered handler - const getRegisteredHandler = (): FallbackModelHandler => { - renderHook(() => + it('should show fallback dialog but omit switch to API key message if authType is not LOGIN_WITH_GOOGLE', async () => { + // Override the default mock from beforeEach for this specific test + vi.spyOn(mockConfig, 'getContentGeneratorConfig').mockReturnValue({ + authType: AuthType.USE_GEMINI, + }); + + const { result } = renderHook(() => useQuotaAndFallback({ config: mockConfig, historyManager: mockHistoryManager, @@ -107,20 +111,24 @@ describe('useQuotaAndFallback', () => { onShowAuthSelection: mockOnShowAuthSelection, }), ); - return setFallbackHandlerSpy.mock.calls[0][0] as FallbackModelHandler; - }; - it('should return null and take no action if authType is not LOGIN_WITH_GOOGLE', async () => { - // Override the default mock from beforeEach for this specific test - vi.spyOn(mockConfig, 'getContentGeneratorConfig').mockReturnValue({ - authType: AuthType.USE_GEMINI, + const handler = setFallbackHandlerSpy.mock + .calls[0][0] as FallbackModelHandler; + + const error = new TerminalQuotaError( + 'pro quota', + mockGoogleApiError, + 1000 * 60 * 5, + ); + + act(() => { + void handler('gemini-pro', 'gemini-flash', error); }); - const handler = getRegisteredHandler(); - const result = await handler('gemini-pro', 'gemini-flash', new Error()); - - expect(result).toBeNull(); - expect(mockHistoryManager.addItem).not.toHaveBeenCalled(); + expect(result.current.proQuotaRequest).not.toBeNull(); + expect(result.current.proQuotaRequest?.message).not.toContain( + '/auth to switch to API key.', + ); }); describe('Interactive Fallback', () => { diff --git a/packages/cli/src/ui/hooks/useQuotaAndFallback.ts b/packages/cli/src/ui/hooks/useQuotaAndFallback.ts index 1ba03f2a47..a9e2b0c867 100644 --- a/packages/cli/src/ui/hooks/useQuotaAndFallback.ts +++ b/packages/cli/src/ui/hooks/useQuotaAndFallback.ts @@ -55,14 +55,7 @@ export function useQuotaAndFallback({ fallbackModel, error, ): Promise => { - // Fallbacks are currently only handled for OAuth users. const contentGeneratorConfig = config.getContentGeneratorConfig(); - if ( - !contentGeneratorConfig || - contentGeneratorConfig.authType !== AuthType.LOGIN_WITH_GOOGLE - ) { - return null; - } let message: string; let isTerminalQuotaError = false; @@ -78,7 +71,9 @@ export function useQuotaAndFallback({ error.retryDelayMs ? getResetTimeMessage(error.retryDelayMs) : null, `/stats model for usage details`, `/model to switch models.`, - `/auth to switch to API key.`, + contentGeneratorConfig?.authType === AuthType.LOGIN_WITH_GOOGLE + ? `/auth to switch to API key.` + : null, ].filter(Boolean); message = messageLines.join('\n'); } else if (error instanceof ModelNotFoundError) { @@ -122,6 +117,7 @@ export function useQuotaAndFallback({ message, isTerminalQuotaError, isModelNotFoundError, + authType: contentGeneratorConfig?.authType, }); }, ); diff --git a/packages/cli/src/ui/hooks/useShellCompletion.test.ts b/packages/cli/src/ui/hooks/useShellCompletion.test.ts new file mode 100644 index 0000000000..477db3b184 --- /dev/null +++ b/packages/cli/src/ui/hooks/useShellCompletion.test.ts @@ -0,0 +1,405 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, afterEach, vi } from 'vitest'; +import { + getTokenAtCursor, + escapeShellPath, + resolvePathCompletions, + scanPathExecutables, +} from './useShellCompletion.js'; +import type { FileSystemStructure } from '@google/gemini-cli-test-utils'; +import { createTmpDir, cleanupTmpDir } from '@google/gemini-cli-test-utils'; + +describe('useShellCompletion utilities', () => { + describe('getTokenAtCursor', () => { + it('should return empty token struct for empty line', () => { + expect(getTokenAtCursor('', 0)).toEqual({ + token: '', + start: 0, + end: 0, + isFirstToken: true, + tokens: [''], + cursorIndex: 0, + commandToken: '', + }); + }); + + it('should extract the first token at cursor position 0', () => { + const result = getTokenAtCursor('git status', 3); + expect(result).toEqual({ + token: 'git', + start: 0, + end: 3, + isFirstToken: true, + tokens: ['git', 'status'], + cursorIndex: 0, + commandToken: 'git', + }); + }); + + it('should extract the second token when cursor is on it', () => { + const result = getTokenAtCursor('git status', 7); + expect(result).toEqual({ + token: 'status', + start: 4, + end: 10, + isFirstToken: false, + tokens: ['git', 'status'], + cursorIndex: 1, + commandToken: 'git', + }); + }); + + it('should handle cursor at start of second token', () => { + const result = getTokenAtCursor('git status', 4); + expect(result).toEqual({ + token: 'status', + start: 4, + end: 10, + isFirstToken: false, + tokens: ['git', 'status'], + cursorIndex: 1, + commandToken: 'git', + }); + }); + + it('should handle escaped spaces', () => { + const result = getTokenAtCursor('cat my\\ file.txt', 16); + expect(result).toEqual({ + token: 'my file.txt', + start: 4, + end: 16, + isFirstToken: false, + tokens: ['cat', 'my file.txt'], + cursorIndex: 1, + commandToken: 'cat', + }); + }); + + it('should handle single-quoted strings', () => { + const result = getTokenAtCursor("cat 'my file.txt'", 17); + expect(result).toEqual({ + token: 'my file.txt', + start: 4, + end: 17, + isFirstToken: false, + tokens: ['cat', 'my file.txt'], + cursorIndex: 1, + commandToken: 'cat', + }); + }); + + it('should handle double-quoted strings', () => { + const result = getTokenAtCursor('cat "my file.txt"', 17); + expect(result).toEqual({ + token: 'my file.txt', + start: 4, + end: 17, + isFirstToken: false, + tokens: ['cat', 'my file.txt'], + cursorIndex: 1, + commandToken: 'cat', + }); + }); + + it('should handle cursor past all tokens (trailing space)', () => { + const result = getTokenAtCursor('git ', 4); + expect(result).toEqual({ + token: '', + start: 4, + end: 4, + isFirstToken: false, + tokens: ['git', ''], + cursorIndex: 1, + commandToken: 'git', + }); + }); + + it('should handle cursor in the middle of a word', () => { + const result = getTokenAtCursor('git checkout main', 7); + expect(result).toEqual({ + token: 'checkout', + start: 4, + end: 12, + isFirstToken: false, + tokens: ['git', 'checkout', 'main'], + cursorIndex: 1, + commandToken: 'git', + }); + }); + + it('should mark isFirstToken correctly for first word', () => { + const result = getTokenAtCursor('gi', 2); + expect(result?.isFirstToken).toBe(true); + }); + + it('should mark isFirstToken correctly for second word', () => { + const result = getTokenAtCursor('git sta', 7); + expect(result?.isFirstToken).toBe(false); + }); + + it('should handle cursor in whitespace between tokens', () => { + const result = getTokenAtCursor('git status', 4); + expect(result).toEqual({ + token: '', + start: 4, + end: 4, + isFirstToken: false, + tokens: ['git', '', 'status'], + cursorIndex: 1, + commandToken: 'git', + }); + }); + }); + + describe('escapeShellPath', () => { + const isWin = process.platform === 'win32'; + + it('should escape spaces', () => { + expect(escapeShellPath('my file.txt')).toBe( + isWin ? 'my file.txt' : 'my\\ file.txt', + ); + }); + + it('should escape parentheses', () => { + expect(escapeShellPath('file (copy).txt')).toBe( + isWin ? 'file (copy).txt' : 'file\\ \\(copy\\).txt', + ); + }); + + it('should not escape normal characters', () => { + expect(escapeShellPath('normal-file.txt')).toBe('normal-file.txt'); + }); + + it('should escape tabs, newlines, carriage returns, and backslashes', () => { + if (isWin) { + expect(escapeShellPath('a\tb')).toBe('a\tb'); + expect(escapeShellPath('a\nb')).toBe('a\nb'); + expect(escapeShellPath('a\rb')).toBe('a\rb'); + expect(escapeShellPath('a\\b')).toBe('a\\b'); + } else { + expect(escapeShellPath('a\tb')).toBe('a\\\tb'); + expect(escapeShellPath('a\nb')).toBe('a\\\nb'); + expect(escapeShellPath('a\rb')).toBe('a\\\rb'); + expect(escapeShellPath('a\\b')).toBe('a\\\\b'); + } + }); + + it('should handle empty string', () => { + expect(escapeShellPath('')).toBe(''); + }); + }); + + describe('resolvePathCompletions', () => { + let tmpDir: string; + + afterEach(async () => { + if (tmpDir) { + await cleanupTmpDir(tmpDir); + } + }); + + it('should list directory contents for empty partial', async () => { + const structure: FileSystemStructure = { + 'file.txt': '', + subdir: {}, + }; + tmpDir = await createTmpDir(structure); + + const results = await resolvePathCompletions('', tmpDir); + const values = results.map((s) => s.label); + expect(values).toContain('subdir/'); + expect(values).toContain('file.txt'); + }); + + it('should filter by prefix', async () => { + const structure: FileSystemStructure = { + 'abc.txt': '', + 'def.txt': '', + }; + tmpDir = await createTmpDir(structure); + + const results = await resolvePathCompletions('a', tmpDir); + expect(results).toHaveLength(1); + expect(results[0].label).toBe('abc.txt'); + }); + + it('should match case-insensitively', async () => { + const structure: FileSystemStructure = { + Desktop: {}, + }; + tmpDir = await createTmpDir(structure); + + const results = await resolvePathCompletions('desk', tmpDir); + expect(results).toHaveLength(1); + expect(results[0].label).toBe('Desktop/'); + }); + + it('should append trailing slash to directories', async () => { + const structure: FileSystemStructure = { + mydir: {}, + 'myfile.txt': '', + }; + tmpDir = await createTmpDir(structure); + + const results = await resolvePathCompletions('my', tmpDir); + const dirSuggestion = results.find((s) => s.label.startsWith('mydir')); + expect(dirSuggestion?.label).toBe('mydir/'); + expect(dirSuggestion?.description).toBe('directory'); + }); + + it('should hide dotfiles by default', async () => { + const structure: FileSystemStructure = { + '.hidden': '', + visible: '', + }; + tmpDir = await createTmpDir(structure); + + const results = await resolvePathCompletions('', tmpDir); + const labels = results.map((s) => s.label); + expect(labels).not.toContain('.hidden'); + expect(labels).toContain('visible'); + }); + + it('should show dotfiles when query starts with a dot', async () => { + const structure: FileSystemStructure = { + '.hidden': '', + '.bashrc': '', + visible: '', + }; + tmpDir = await createTmpDir(structure); + + const results = await resolvePathCompletions('.h', tmpDir); + const labels = results.map((s) => s.label); + expect(labels).toContain('.hidden'); + }); + + it('should show dotfiles in the current directory when query is exactly "."', async () => { + const structure: FileSystemStructure = { + '.hidden': '', + '.bashrc': '', + visible: '', + }; + tmpDir = await createTmpDir(structure); + + const results = await resolvePathCompletions('.', tmpDir); + const labels = results.map((s) => s.label); + expect(labels).toContain('.hidden'); + expect(labels).toContain('.bashrc'); + expect(labels).not.toContain('visible'); + }); + + it('should handle dotfile completions within a subdirectory', async () => { + const structure: FileSystemStructure = { + subdir: { + '.secret': '', + 'public.txt': '', + }, + }; + tmpDir = await createTmpDir(structure); + + const results = await resolvePathCompletions('subdir/.', tmpDir); + const labels = results.map((s) => s.label); + expect(labels).toContain('.secret'); + expect(labels).not.toContain('public.txt'); + }); + + it('should strip leading quotes to resolve inner directory contents', async () => { + const structure: FileSystemStructure = { + src: { + 'index.ts': '', + }, + }; + tmpDir = await createTmpDir(structure); + + const results = await resolvePathCompletions('"src/', tmpDir); + expect(results).toHaveLength(1); + expect(results[0].label).toBe('index.ts'); + + const resultsSingleQuote = await resolvePathCompletions("'src/", tmpDir); + expect(resultsSingleQuote).toHaveLength(1); + expect(resultsSingleQuote[0].label).toBe('index.ts'); + }); + + it('should properly escape resolutions with spaces inside stripped quote queries', async () => { + const structure: FileSystemStructure = { + 'Folder With Spaces': {}, + }; + tmpDir = await createTmpDir(structure); + + const results = await resolvePathCompletions('"Fo', tmpDir); + expect(results).toHaveLength(1); + expect(results[0].label).toBe('Folder With Spaces/'); + expect(results[0].value).toBe(escapeShellPath('Folder With Spaces/')); + }); + + it('should return empty array for non-existent directory', async () => { + const results = await resolvePathCompletions( + '/nonexistent/path/foo', + '/tmp', + ); + expect(results).toEqual([]); + }); + + it('should handle tilde expansion', async () => { + // Just ensure ~ doesn't throw + const results = await resolvePathCompletions('~/', '/tmp'); + // We can't assert specific files since it depends on the test runner's home + expect(Array.isArray(results)).toBe(true); + }); + + it('should escape special characters in results', async () => { + const isWin = process.platform === 'win32'; + const structure: FileSystemStructure = { + 'my file.txt': '', + }; + tmpDir = await createTmpDir(structure); + + const results = await resolvePathCompletions('my', tmpDir); + expect(results).toHaveLength(1); + expect(results[0].value).toBe(isWin ? 'my file.txt' : 'my\\ file.txt'); + }); + + it('should sort directories before files', async () => { + const structure: FileSystemStructure = { + 'b-file.txt': '', + 'a-dir': {}, + }; + tmpDir = await createTmpDir(structure); + + const results = await resolvePathCompletions('', tmpDir); + expect(results[0].description).toBe('directory'); + expect(results[1].description).toBe('file'); + }); + }); + + describe('scanPathExecutables', () => { + it('should return an array of executables', async () => { + const results = await scanPathExecutables(); + expect(Array.isArray(results)).toBe(true); + // Very basic sanity check: common commands should be found + if (process.platform !== 'win32') { + expect(results).toContain('ls'); + } + }); + + it('should support abort signal', async () => { + const controller = new AbortController(); + controller.abort(); + const results = await scanPathExecutables(controller.signal); + // May return empty or partial depending on timing + expect(Array.isArray(results)).toBe(true); + }); + + it('should handle empty PATH', async () => { + vi.stubEnv('PATH', ''); + const results = await scanPathExecutables(); + expect(results).toEqual([]); + vi.unstubAllEnvs(); + }); + }); +}); diff --git a/packages/cli/src/ui/hooks/useShellCompletion.ts b/packages/cli/src/ui/hooks/useShellCompletion.ts new file mode 100644 index 0000000000..8569ab5cfb --- /dev/null +++ b/packages/cli/src/ui/hooks/useShellCompletion.ts @@ -0,0 +1,548 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useEffect, useRef, useCallback } from 'react'; +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; +import * as os from 'node:os'; +import type { Suggestion } from '../components/SuggestionsDisplay.js'; +import { debugLogger } from '@google/gemini-cli-core'; +import { getArgumentCompletions } from './shell-completions/index.js'; + +/** + * Maximum number of suggestions to return to avoid freezing the React Ink UI. + */ +const MAX_SHELL_SUGGESTIONS = 100; + +/** + * Debounce interval (ms) for file system completions. + */ +const FS_COMPLETION_DEBOUNCE_MS = 50; + +// Backslash-quote shell metacharacters on non-Windows platforms. + +// On Unix, backslash-quote shell metacharacters (spaces, parens, etc.). +// On Windows, cmd.exe doesn't use backslash-quoting and `\` is the path +// separator, so we leave the path as-is. +const UNIX_SHELL_SPECIAL_CHARS = /[ \t\n\r'"()&|;<>!#$`{}[\]*?\\]/g; + +/** + * Escapes special shell characters in a path segment. + */ +export function escapeShellPath(segment: string): string { + if (process.platform === 'win32') { + return segment; + } + return segment.replace(UNIX_SHELL_SPECIAL_CHARS, '\\$&'); +} + +export interface TokenInfo { + /** The raw token text (without surrounding quotes but with internal escapes). */ + token: string; + /** Offset in the original line where this token begins. */ + start: number; + /** Offset in the original line where this token ends (exclusive). */ + end: number; + /** Whether this is the first token (command position). */ + isFirstToken: boolean; + /** The fully built list of tokens parsing the string. */ + tokens: string[]; + /** The index in the tokens list where the cursor lies. */ + cursorIndex: number; + /** The command token (always tokens[0] if length > 0, otherwise empty string) */ + commandToken: string; +} + +export function getTokenAtCursor( + line: string, + cursorCol: number, +): TokenInfo | null { + const tokensInfo: Array<{ token: string; start: number; end: number }> = []; + let i = 0; + + while (i < line.length) { + // Skip whitespace + if (line[i] === ' ' || line[i] === '\t') { + i++; + continue; + } + + const tokenStart = i; + let token = ''; + + while (i < line.length) { + const ch = line[i]; + + // Backslash escape: consume the next char literally + if (ch === '\\' && i + 1 < line.length) { + token += line[i + 1]; + i += 2; + continue; + } + + // Single-quoted string + if (ch === "'") { + i++; // skip opening quote + while (i < line.length && line[i] !== "'") { + token += line[i]; + i++; + } + if (i < line.length) i++; // skip closing quote + continue; + } + + // Double-quoted string + if (ch === '"') { + i++; // skip opening quote + while (i < line.length && line[i] !== '"') { + if (line[i] === '\\' && i + 1 < line.length) { + token += line[i + 1]; + i += 2; + } else { + token += line[i]; + i++; + } + } + if (i < line.length) i++; // skip closing quote + continue; + } + + // Unquoted whitespace ends the token + if (ch === ' ' || ch === '\t') { + break; + } + + token += ch; + i++; + } + + tokensInfo.push({ token, start: tokenStart, end: i }); + } + + const rawTokens = tokensInfo.map((t) => t.token); + const commandToken = rawTokens.length > 0 ? rawTokens[0] : ''; + + if (tokensInfo.length === 0) { + return { + token: '', + start: cursorCol, + end: cursorCol, + isFirstToken: true, + tokens: [''], + cursorIndex: 0, + commandToken: '', + }; + } + + // Find the token that contains or is immediately adjacent to the cursor + for (let idx = 0; idx < tokensInfo.length; idx++) { + const t = tokensInfo[idx]; + if (cursorCol >= t.start && cursorCol <= t.end) { + return { + token: t.token, + start: t.start, + end: t.end, + isFirstToken: idx === 0, + tokens: rawTokens, + cursorIndex: idx, + commandToken, + }; + } + } + + // Cursor is in whitespace between tokens, or at the start/end of the line. + // Find the appropriate insertion index for a new empty token. + let insertIndex = tokensInfo.length; + for (let idx = 0; idx < tokensInfo.length; idx++) { + if (cursorCol < tokensInfo[idx].start) { + insertIndex = idx; + break; + } + } + + const newTokens = [ + ...rawTokens.slice(0, insertIndex), + '', + ...rawTokens.slice(insertIndex), + ]; + + return { + token: '', + start: cursorCol, + end: cursorCol, + isFirstToken: insertIndex === 0, + tokens: newTokens, + cursorIndex: insertIndex, + commandToken: newTokens.length > 0 ? newTokens[0] : '', + }; +} + +export async function scanPathExecutables( + signal?: AbortSignal, +): Promise { + const pathEnv = process.env['PATH'] ?? ''; + const dirs = pathEnv.split(path.delimiter).filter(Boolean); + const isWindows = process.platform === 'win32'; + const pathExtList = isWindows + ? (process.env['PATHEXT'] ?? '.EXE;.CMD;.BAT;.COM') + .split(';') + .filter(Boolean) + .map((e) => e.toLowerCase()) + : []; + + const seen = new Set(); + const executables: string[] = []; + + const dirResults = await Promise.all( + dirs.map(async (dir) => { + if (signal?.aborted) return []; + try { + const entries = await fs.readdir(dir, { withFileTypes: true }); + const validEntries: string[] = []; + + // Check executability in parallel (batched per directory) + await Promise.all( + entries.map(async (entry) => { + if (signal?.aborted) return; + if (!entry.isFile() && !entry.isSymbolicLink()) return; + + const name = entry.name; + if (isWindows) { + const ext = path.extname(name).toLowerCase(); + if (pathExtList.length > 0 && !pathExtList.includes(ext)) return; + } + + try { + await fs.access( + path.join(dir, name), + fs.constants.R_OK | fs.constants.X_OK, + ); + validEntries.push(name); + } catch { + // Not executable — skip + } + }), + ); + + return validEntries; + } catch { + // EACCES, ENOENT, etc. — skip this directory + return []; + } + }), + ); + + for (const names of dirResults) { + for (const name of names) { + if (!seen.has(name)) { + seen.add(name); + executables.push(name); + } + } + } + + executables.sort(); + return executables; +} + +function expandTilde(inputPath: string): [string, boolean] { + if ( + inputPath === '~' || + inputPath.startsWith('~/') || + inputPath.startsWith('~' + path.sep) + ) { + return [path.join(os.homedir(), inputPath.slice(1)), true]; + } + return [inputPath, false]; +} + +export async function resolvePathCompletions( + partial: string, + cwd: string, + signal?: AbortSignal, +): Promise { + if (partial == null) return []; + + // Input Sanitization + let strippedPartial = partial; + if (strippedPartial.startsWith('"') || strippedPartial.startsWith("'")) { + strippedPartial = strippedPartial.slice(1); + } + if (strippedPartial.endsWith('"') || strippedPartial.endsWith("'")) { + strippedPartial = strippedPartial.slice(0, -1); + } + + // Normalize separators \ to / + const normalizedPartial = strippedPartial.replace(/\\/g, '/'); + + const [expandedPartial, didExpandTilde] = expandTilde(normalizedPartial); + + // Directory Detection + const endsWithSep = + normalizedPartial.endsWith('/') || normalizedPartial === ''; + const dirToRead = endsWithSep + ? path.resolve(cwd, expandedPartial) + : path.resolve(cwd, path.dirname(expandedPartial)); + + const prefix = endsWithSep ? '' : path.basename(expandedPartial); + const prefixLower = prefix.toLowerCase(); + + const showDotfiles = prefix.startsWith('.'); + + let entries: Array; + try { + if (signal?.aborted) return []; + entries = await fs.readdir(dirToRead, { withFileTypes: true }); + } catch { + // EACCES, ENOENT, etc. + return []; + } + + if (signal?.aborted) return []; + + const suggestions: Suggestion[] = []; + for (const entry of entries) { + if (signal?.aborted) break; + + const name = entry.name; + + // Hide dotfiles unless query starts with '.' + if (name.startsWith('.') && !showDotfiles) continue; + + // Case-insensitive matching + if (!name.toLowerCase().startsWith(prefixLower)) continue; + + const isDir = entry.isDirectory(); + const displayName = isDir ? name + '/' : name; + + // Build the completion value relative to what the user typed + let completionValue: string; + if (endsWithSep) { + completionValue = normalizedPartial + displayName; + } else { + const parentPart = normalizedPartial.slice( + 0, + normalizedPartial.length - path.basename(normalizedPartial).length, + ); + completionValue = parentPart + displayName; + } + + // Restore tilde if we expanded it + if (didExpandTilde) { + const homeDir = os.homedir().replace(/\\/g, '/'); + if (completionValue.startsWith(homeDir)) { + completionValue = '~' + completionValue.slice(homeDir.length); + } + } + + // Output formatting: Escape special characters in the completion value + // Since normalizedPartial stripped quotes, we escape the value directly. + const escapedValue = escapeShellPath(completionValue); + + suggestions.push({ + label: displayName, + value: escapedValue, + description: isDir ? 'directory' : 'file', + }); + + if (suggestions.length >= MAX_SHELL_SUGGESTIONS) break; + } + + // Sort: directories first, then alphabetically + suggestions.sort((a, b) => { + const aIsDir = a.description === 'directory'; + const bIsDir = b.description === 'directory'; + if (aIsDir !== bIsDir) return aIsDir ? -1 : 1; + return a.label.localeCompare(b.label); + }); + + return suggestions; +} + +export interface UseShellCompletionProps { + /** Whether shell completion is active. */ + enabled: boolean; + /** The partial query string (the token under the cursor). */ + query: string; + /** Whether the token is in command position (first word). */ + isCommandPosition: boolean; + /** The full list of parsed tokens */ + tokens: string[]; + /** The cursor index in the full list of parsed tokens */ + cursorIndex: number; + /** The root command token */ + commandToken: string; + /** The current working directory for path resolution. */ + cwd: string; + /** Callback to set suggestions on the parent state. */ + setSuggestions: (suggestions: Suggestion[]) => void; + /** Callback to set loading state on the parent. */ + setIsLoadingSuggestions: (isLoading: boolean) => void; +} + +export function useShellCompletion({ + enabled, + query, + isCommandPosition, + tokens, + cursorIndex, + commandToken, + cwd, + setSuggestions, + setIsLoadingSuggestions, +}: UseShellCompletionProps): void { + const pathCacheRef = useRef(null); + const pathEnvRef = useRef(process.env['PATH'] ?? ''); + const abortRef = useRef(null); + const debounceRef = useRef(null); + + // Invalidate PATH cache when $PATH changes + useEffect(() => { + const currentPath = process.env['PATH'] ?? ''; + if (currentPath !== pathEnvRef.current) { + pathCacheRef.current = null; + pathEnvRef.current = currentPath; + } + }); + + const performCompletion = useCallback(async () => { + if (!enabled) { + setSuggestions([]); + return; + } + + // Skip flags + if (query.startsWith('-')) { + setSuggestions([]); + return; + } + + // Cancel any in-flight request + if (abortRef.current) { + abortRef.current.abort(); + } + const controller = new AbortController(); + abortRef.current = controller; + const { signal } = controller; + + try { + let results: Suggestion[]; + + if (isCommandPosition) { + setIsLoadingSuggestions(true); + + if (!pathCacheRef.current) { + pathCacheRef.current = await scanPathExecutables(signal); + } + + if (signal.aborted) return; + + const queryLower = query.toLowerCase(); + results = pathCacheRef.current + .filter((cmd) => cmd.toLowerCase().startsWith(queryLower)) + .slice(0, MAX_SHELL_SUGGESTIONS) + .map((cmd) => ({ + label: cmd, + value: escapeShellPath(cmd), + description: 'command', + })); + } else { + const argumentCompletions = await getArgumentCompletions( + commandToken, + tokens, + cursorIndex, + cwd, + signal, + ); + + if (signal.aborted) return; + + if (argumentCompletions?.exclusive) { + results = argumentCompletions.suggestions; + } else { + const pathSuggestions = await resolvePathCompletions( + query, + cwd, + signal, + ); + if (signal.aborted) return; + + results = [ + ...(argumentCompletions?.suggestions ?? []), + ...pathSuggestions, + ].slice(0, MAX_SHELL_SUGGESTIONS); + } + } + + if (signal.aborted) return; + + setSuggestions(results); + } catch (error) { + if ( + !( + signal.aborted || + (error instanceof Error && error.name === 'AbortError') + ) + ) { + debugLogger.warn( + `[WARN] shell completion failed: ${error instanceof Error ? error.message : String(error)}`, + ); + } + if (!signal.aborted) { + setSuggestions([]); + } + } finally { + if (!signal.aborted) { + setIsLoadingSuggestions(false); + } + } + }, [ + enabled, + query, + isCommandPosition, + tokens, + cursorIndex, + commandToken, + cwd, + setSuggestions, + setIsLoadingSuggestions, + ]); + + // Debounced effect to trigger completion + useEffect(() => { + if (!enabled) { + setSuggestions([]); + setIsLoadingSuggestions(false); + return; + } + + if (debounceRef.current) { + clearTimeout(debounceRef.current); + } + + debounceRef.current = setTimeout(() => { + // eslint-disable-next-line @typescript-eslint/no-floating-promises + performCompletion(); + }, FS_COMPLETION_DEBOUNCE_MS); + + return () => { + if (debounceRef.current) { + clearTimeout(debounceRef.current); + } + }; + }, [enabled, performCompletion, setSuggestions, setIsLoadingSuggestions]); + + // Cleanup on unmount + useEffect( + () => () => { + abortRef.current?.abort(); + if (debounceRef.current) { + clearTimeout(debounceRef.current); + } + }, + [], + ); +} diff --git a/packages/cli/src/ui/utils/MarkdownDisplay.test.tsx b/packages/cli/src/ui/utils/MarkdownDisplay.test.tsx index 98a63b6838..8dddb69f82 100644 --- a/packages/cli/src/ui/utils/MarkdownDisplay.test.tsx +++ b/packages/cli/src/ui/utils/MarkdownDisplay.test.tsx @@ -235,7 +235,7 @@ Another paragraph. ); await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); - expect(lastFrame()).not.toContain(' 1 '); + expect(lastFrame()).not.toContain('1 const x = 1;'); unmount(); }); @@ -246,7 +246,7 @@ Another paragraph. ); await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); - expect(lastFrame()).toContain(' 1 '); + expect(lastFrame()).toContain('1 const x = 1;'); unmount(); }); }); diff --git a/packages/cli/src/ui/utils/TableRenderer.test.tsx b/packages/cli/src/ui/utils/TableRenderer.test.tsx index 9d22d5d301..e9d84e6649 100644 --- a/packages/cli/src/ui/utils/TableRenderer.test.tsx +++ b/packages/cli/src/ui/utils/TableRenderer.test.tsx @@ -17,20 +17,21 @@ describe('TableRenderer', () => { ]; const terminalWidth = 80; - const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + const renderResult = renderWithProviders( , ); + const { lastFrame, waitUntilReady, unmount } = renderResult; await waitUntilReady(); const output = lastFrame(); expect(output).toContain('Header 1'); expect(output).toContain('Row 1, Col 1'); expect(output).toContain('Row 3, Col 3'); - expect(output).toMatchSnapshot(); + await expect(renderResult).toMatchSvgSnapshot(); unmount(); }); @@ -48,13 +49,14 @@ describe('TableRenderer', () => { ]; const terminalWidth = 80; - const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + const renderResult = renderWithProviders( , ); + const { lastFrame, waitUntilReady, unmount } = renderResult; await waitUntilReady(); const output = lastFrame(); @@ -62,7 +64,7 @@ describe('TableRenderer', () => { // We just check for some of the content. expect(output).toContain('Data 1.1'); expect(output).toContain('Data 3.4'); - expect(output).toMatchSnapshot(); + await expect(renderResult).toMatchSvgSnapshot(); unmount(); }); @@ -77,19 +79,20 @@ describe('TableRenderer', () => { ]; const terminalWidth = 50; - const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + const renderResult = renderWithProviders( , ); + const { lastFrame, waitUntilReady, unmount } = renderResult; await waitUntilReady(); const output = lastFrame(); expect(output).toContain('This is a very'); expect(output).toContain('long cell'); - expect(output).toMatchSnapshot(); + await expect(renderResult).toMatchSvgSnapshot(); unmount(); }); @@ -104,18 +107,19 @@ describe('TableRenderer', () => { ]; const terminalWidth = 60; - const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + const renderResult = renderWithProviders( , ); + const { lastFrame, waitUntilReady, unmount } = renderResult; await waitUntilReady(); const output = lastFrame(); expect(output).toContain('wrapping in'); - expect(output).toMatchSnapshot(); + await expect(renderResult).toMatchSvgSnapshot(); unmount(); }); @@ -130,19 +134,20 @@ describe('TableRenderer', () => { ]; const terminalWidth = 50; - const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + const renderResult = renderWithProviders( , ); + const { lastFrame, waitUntilReady, unmount } = renderResult; await waitUntilReady(); const output = lastFrame(); expect(output).toContain('Tiny'); expect(output).toContain('definitely needs'); - expect(output).toMatchSnapshot(); + await expect(renderResult).toMatchSvgSnapshot(); unmount(); }); @@ -158,18 +163,19 @@ describe('TableRenderer', () => { ]; const terminalWidth = 60; - const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + const renderResult = renderWithProviders( , ); + const { lastFrame, waitUntilReady, unmount } = renderResult; await waitUntilReady(); const output = lastFrame(); expect(output).toContain('Start. Stop.'); - expect(output).toMatchSnapshot(); + await expect(renderResult).toMatchSvgSnapshot(); unmount(); }); @@ -178,20 +184,21 @@ describe('TableRenderer', () => { const rows = [['Data 1', 'Data 2', 'Data 3']]; const terminalWidth = 50; - const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + const renderResult = renderWithProviders( , ); + const { lastFrame, waitUntilReady, unmount } = renderResult; await waitUntilReady(); const output = lastFrame(); // The output should NOT contain the literal '**' expect(output).not.toContain('**Bold Header**'); expect(output).toContain('Bold Header'); - expect(output).toMatchSnapshot(); + await expect(renderResult).toMatchSvgSnapshot(); unmount(); }); @@ -204,20 +211,21 @@ describe('TableRenderer', () => { const rows = [['Data 1', 'Data 2', 'Data 3']]; const terminalWidth = 40; - const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + const renderResult = renderWithProviders( , ); + const { lastFrame, waitUntilReady, unmount } = renderResult; await waitUntilReady(); const output = lastFrame(); // Markers should be gone expect(output).not.toContain('**'); expect(output).toContain('Very Long'); - expect(output).toMatchSnapshot(); + await expect(renderResult).toMatchSvgSnapshot(); unmount(); }); @@ -247,7 +255,7 @@ describe('TableRenderer', () => { const terminalWidth = 160; - const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + const renderResult = renderWithProviders( { />, { width: terminalWidth }, ); + const { lastFrame, waitUntilReady, unmount } = renderResult; await waitUntilReady(); const output = lastFrame(); @@ -271,7 +280,7 @@ describe('TableRenderer', () => { expect(output).toContain('J.'); expect(output).toContain('Doe'); - expect(output).toMatchSnapshot(); + await expect(renderResult).toMatchSvgSnapshot(); unmount(); }); @@ -317,7 +326,7 @@ describe('TableRenderer', () => { expected: ['Mixed 😃 中文', '你好 😃', 'こんにちは 🚀'], }, ])('$name', async ({ headers, rows, terminalWidth, expected }) => { - const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + const renderResult = renderWithProviders( { />, { width: terminalWidth }, ); + const { lastFrame, waitUntilReady, unmount } = renderResult; await waitUntilReady(); const output = lastFrame(); expected.forEach((text) => { expect(output).toContain(text); }); - expect(output).toMatchSnapshot(); + await expect(renderResult).toMatchSvgSnapshot(); unmount(); }); @@ -351,19 +361,21 @@ describe('TableRenderer', () => { ])('$name', async ({ headers, rows, expected }) => { const terminalWidth = 50; - const { lastFrame, waitUntilReady } = renderWithProviders( + const renderResult = renderWithProviders( , ); + const { lastFrame, waitUntilReady, unmount } = renderResult; await waitUntilReady(); const output = lastFrame(); expected.forEach((text) => { expect(output).toContain(text); }); - expect(output).toMatchSnapshot(); + await expect(renderResult).toMatchSvgSnapshot(); + unmount(); }); }); diff --git a/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-handles-non-ASCII-characters-emojis-.snap.svg b/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-handles-non-ASCII-characters-emojis-.snap.svg new file mode 100644 index 0000000000..d9612cce33 --- /dev/null +++ b/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-handles-non-ASCII-characters-emojis-.snap.svg @@ -0,0 +1,32 @@ + + + + + ┌──────────────┬────────────┬───────────────┐ + + Emoji 😃 + + Asian 汉字 + + Mixed 🚀 Text + + ├──────────────┼────────────┼───────────────┤ + + Start 🌟 End + + 你好世界 + + Rocket 🚀 Man + + + Thumbs 👍 Up + + こんにちは + + Fire 🔥 + + └──────────────┴────────────┴───────────────┘ + + \ No newline at end of file diff --git a/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-handles-wrapped-bold-headers-without-showing-markers.snap.svg b/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-handles-wrapped-bold-headers-without-showing-markers.snap.svg new file mode 100644 index 0000000000..0118d133cf --- /dev/null +++ b/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-handles-wrapped-bold-headers-without-showing-markers.snap.svg @@ -0,0 +1,47 @@ + + + + + ┌─────────────┬───────┬─────────┐ + + Very Long + + Short + + Another + + + Bold Header + + + Long + + + That Will + + + Header + + + Wrap + + + + ├─────────────┼───────┼─────────┤ + + Data 1 + + Data + + Data 3 + + + + 2 + + + └─────────────┴───────┴─────────┘ + + \ No newline at end of file diff --git a/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-renders-a-3x3-table-correctly.snap.svg b/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-renders-a-3x3-table-correctly.snap.svg new file mode 100644 index 0000000000..84e4d856f6 --- /dev/null +++ b/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-renders-a-3x3-table-correctly.snap.svg @@ -0,0 +1,39 @@ + + + + + ┌──────────────┬──────────────┬──────────────┐ + + Header 1 + + Header 2 + + Header 3 + + ├──────────────┼──────────────┼──────────────┤ + + Row 1, Col 1 + + Row 1, Col 2 + + Row 1, Col 3 + + + Row 2, Col 1 + + Row 2, Col 2 + + Row 2, Col 3 + + + Row 3, Col 1 + + Row 3, Col 2 + + Row 3, Col 3 + + └──────────────┴──────────────┴──────────────┘ + + \ No newline at end of file diff --git a/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-renders-a-complex-table-with-mixed-content-lengths-correctly.snap.svg b/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-renders-a-complex-table-with-mixed-content-lengths-correctly.snap.svg new file mode 100644 index 0000000000..95654cb4d8 --- /dev/null +++ b/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-renders-a-complex-table-with-mixed-content-lengths-correctly.snap.svg @@ -0,0 +1,401 @@ + + + + + ┌─────────────────────────────┬──────────────────────────────┬─────────────────────────────┬──────────────────────────────┬─────┬────────┬─────────┬───────┐ + + Comprehensive Architectural + + Implementation Details for + + Longitudinal Performance + + Strategic Security Framework + + Key + + Status + + Version + + Owner + + + Specification for the + + the High-Throughput + + Analysis Across + + for Mitigating Sophisticated + + + + + + + Distributed Infrastructure + + Asynchronous Message + + Multi-Regional Cloud + + Cross-Site Scripting + + + + + + + Layer + + Processing Pipeline with + + Deployment Clusters + + Vulnerabilities + + + + + + + + Extended Scalability + + + + + + + + + + Features and Redundancy + + + + + + + + + + Protocols + + + + + + + + ├─────────────────────────────┼──────────────────────────────┼─────────────────────────────┼──────────────────────────────┼─────┼────────┼─────────┼───────┤ + + The primary architecture + + Each message is processed + + Historical data indicates a + + A multi-layered defense + + INF + + Active + + v2.4 + + J. + + + utilizes a decoupled + + through a series of + + significant reduction in + + strategy incorporates + + + + + Doe + + + microservices approach, + + specialized workers that + + tail latency when utilizing + + content security policies, + + + + + + + leveraging container + + handle data transformation, + + edge computing nodes closer + + input sanitization + + + + + + + orchestration for + + validation, and persistent + + to the geographic location + + libraries, and regular + + + + + + + scalability and fault + + storage using a persistent + + of the end-user base. + + automated penetration + + + + + + + tolerance in high-load + + queue. + + + testing routines. + + + + + + + scenarios. + + + Monitoring tools have + + + + + + + + + The pipeline features + + captured a steady increase + + Developers are required to + + + + + + + This layer provides the + + built-in retry mechanisms + + in throughput efficiency + + undergo mandatory security + + + + + + + fundamental building blocks + + with exponential backoff to + + since the introduction of + + training focusing on the + + + + + + + for service discovery, load + + ensure message delivery + + the vectorized query engine + + OWASP Top Ten to ensure that + + + + + + + balancing, and + + integrity even during + + in the primary data + + security is integrated into + + + + + + + inter-service communication + + transient network or service + + warehouse. + + the initial design phase. + + + + + + + via highly efficient + + failures. + + + + + + + + + protocol buffers. + + + Resource utilization + + The implementation of a + + + + + + + + Horizontal autoscaling is + + metrics demonstrate that + + robust Identity and Access + + + + + + + Advanced telemetry and + + triggered automatically + + the transition to + + Management system ensures + + + + + + + logging integrations allow + + based on the depth of the + + serverless compute for + + that the principle of least + + + + + + + for real-time monitoring of + + processing queue, ensuring + + intermittent tasks has + + privilege is strictly + + + + + + + system health and rapid + + consistent performance + + resulted in a thirty + + enforced across all + + + + + + + identification of + + during unexpected traffic + + percent cost optimization. + + environments. + + + + + + + bottlenecks within the + + spikes. + + + + + + + + + service mesh. + + + + + + + + + └─────────────────────────────┴──────────────────────────────┴─────────────────────────────┴──────────────────────────────┴─────┴────────┴─────────┴───────┘ + + \ No newline at end of file diff --git a/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-renders-a-table-with-long-headers-and-4-columns-correctly.snap.svg b/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-renders-a-table-with-long-headers-and-4-columns-correctly.snap.svg new file mode 100644 index 0000000000..b4d6353c3c --- /dev/null +++ b/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-renders-a-table-with-long-headers-and-4-columns-correctly.snap.svg @@ -0,0 +1,63 @@ + + + + + ┌───────────────┬───────────────┬──────────────────┬──────────────────┐ + + Very Long + + Very Long + + Very Long Column + + Very Long Column + + + Column Header + + Column Header + + Header Three + + Header Four + + + One + + Two + + + + ├───────────────┼───────────────┼──────────────────┼──────────────────┤ + + Data 1.1 + + Data 1.2 + + Data 1.3 + + Data 1.4 + + + Data 2.1 + + Data 2.2 + + Data 2.3 + + Data 2.4 + + + Data 3.1 + + Data 3.2 + + Data 3.3 + + Data 3.4 + + └───────────────┴───────────────┴──────────────────┴──────────────────┘ + + \ No newline at end of file diff --git a/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-renders-a-table-with-mixed-emojis-As-.snap.svg b/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-renders-a-table-with-mixed-emojis-As-.snap.svg new file mode 100644 index 0000000000..707bf53f43 --- /dev/null +++ b/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-renders-a-table-with-mixed-emojis-As-.snap.svg @@ -0,0 +1,32 @@ + + + + + ┌───────────────┬───────────────────┬────────────────┐ + + Mixed 😃 中文 + + Complex 🚀 日本語 + + Text 📝 한국어 + + ├───────────────┼───────────────────┼────────────────┤ + + 你好 😃 + + こんにちは 🚀 + + 안녕하세요 📝 + + + World 🌍 + + Code 💻 + + Pizza 🍕 + + └───────────────┴───────────────────┴────────────────┘ + + \ No newline at end of file diff --git a/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-renders-a-table-with-only-Asian-chara-.snap.svg b/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-renders-a-table-with-only-Asian-chara-.snap.svg new file mode 100644 index 0000000000..0f51eba244 --- /dev/null +++ b/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-renders-a-table-with-only-Asian-chara-.snap.svg @@ -0,0 +1,32 @@ + + + + + ┌──────────────┬─────────────────┬───────────────┐ + + Chinese 中文 + + Japanese 日本語 + + Korean 한국어 + + ├──────────────┼─────────────────┼───────────────┤ + + 你好 + + こんにちは + + 안녕하세요 + + + 世界 + + 世界 + + 세계 + + └──────────────┴─────────────────┴───────────────┘ + + \ No newline at end of file diff --git a/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-renders-a-table-with-only-emojis-and-.snap.svg b/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-renders-a-table-with-only-emojis-and-.snap.svg new file mode 100644 index 0000000000..1a849696dd --- /dev/null +++ b/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-renders-a-table-with-only-emojis-and-.snap.svg @@ -0,0 +1,32 @@ + + + + + ┌──────────┬───────────┬──────────┐ + + Happy 😀 + + Rocket 🚀 + + Heart ❤️ + + ├──────────┼───────────┼──────────┤ + + Smile 😃 + + Fire 🔥 + + Love 💖 + + + Cool 😎 + + Star ⭐ + + Blue 💙 + + └──────────┴───────────┴──────────┘ + + \ No newline at end of file diff --git a/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-renders-correctly-when-headers-are-em-.snap.svg b/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-renders-correctly-when-headers-are-em-.snap.svg new file mode 100644 index 0000000000..2cc7b1cadd --- /dev/null +++ b/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-renders-correctly-when-headers-are-em-.snap.svg @@ -0,0 +1,19 @@ + + + + + ┌────────┬────────┐ + + + + ├────────┼────────┤ + + Data 1 + + Data 2 + + └────────┴────────┘ + + \ No newline at end of file diff --git a/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-renders-correctly-when-there-are-more-.snap.svg b/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-renders-correctly-when-there-are-more-.snap.svg new file mode 100644 index 0000000000..452bb1fb12 --- /dev/null +++ b/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-renders-correctly-when-there-are-more-.snap.svg @@ -0,0 +1,24 @@ + + + + + ┌──────────┬──────────┬──────────┐ + + Header 1 + + Header 2 + + Header 3 + + ├──────────┼──────────┼──────────┤ + + Data 1 + + Data 2 + + + └──────────┴──────────┴──────────┘ + + \ No newline at end of file diff --git a/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-strips-bold-markers-from-headers-and-renders-them-correctly.snap.svg b/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-strips-bold-markers-from-headers-and-renders-them-correctly.snap.svg new file mode 100644 index 0000000000..6de776060b --- /dev/null +++ b/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-strips-bold-markers-from-headers-and-renders-them-correctly.snap.svg @@ -0,0 +1,25 @@ + + + + + ┌─────────────┬───────────────┬──────────────┐ + + Bold Header + + Normal Header + + Another Bold + + ├─────────────┼───────────────┼──────────────┤ + + Data 1 + + Data 2 + + Data 3 + + └─────────────┴───────────────┴──────────────┘ + + \ No newline at end of file diff --git a/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-wraps-all-long-columns-correctly.snap.svg b/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-wraps-all-long-columns-correctly.snap.svg new file mode 100644 index 0000000000..4b459cfea0 --- /dev/null +++ b/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-wraps-all-long-columns-correctly.snap.svg @@ -0,0 +1,52 @@ + + + + + ┌────────────────┬────────────────┬─────────────────┐ + + Col 1 + + Col 2 + + Col 3 + + ├────────────────┼────────────────┼─────────────────┤ + + This is a very + + This is also a + + And this is the + + + long text that + + very long text + + third long text + + + needs wrapping + + that needs + + that needs + + + in column 1 + + wrapping in + + wrapping in + + + + column 2 + + column 3 + + └────────────────┴────────────────┴─────────────────┘ + + \ No newline at end of file diff --git a/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-wraps-columns-with-punctuation-correctly.snap.svg b/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-wraps-columns-with-punctuation-correctly.snap.svg new file mode 100644 index 0000000000..7173ce475f --- /dev/null +++ b/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-wraps-columns-with-punctuation-correctly.snap.svg @@ -0,0 +1,51 @@ + + + + + ┌───────────────────┬───────────────┬─────────────────┐ + + Punctuation 1 + + Punctuation 2 + + Punctuation 3 + + ├───────────────────┼───────────────┼─────────────────┤ + + Start. Stop. + + Semi; colon: + + At@ Hash# + + + Comma, separated. + + Pipe| Slash/ + + Dollar$ + + + Exclamation! + + Backslash\ + + Percent% Caret^ + + + Question? + + + Ampersand& + + + hyphen-ated + + + Asterisk* + + └───────────────────┴───────────────┴─────────────────┘ + + \ No newline at end of file diff --git a/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-wraps-long-cell-content-correctly.snap.svg b/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-wraps-long-cell-content-correctly.snap.svg new file mode 100644 index 0000000000..7f7b67a7dd --- /dev/null +++ b/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-wraps-long-cell-content-correctly.snap.svg @@ -0,0 +1,35 @@ + + + + + ┌───────┬─────────────────────────────┬───────┐ + + Col 1 + + Col 2 + + Col 3 + + ├───────┼─────────────────────────────┼───────┤ + + Short + + This is a very long cell + + Short + + + + content that should wrap to + + + + + multiple lines + + + └───────┴─────────────────────────────┴───────┘ + + \ No newline at end of file diff --git a/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-wraps-mixed-long-and-short-columns-correctly.snap.svg b/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-wraps-mixed-long-and-short-columns-correctly.snap.svg new file mode 100644 index 0000000000..3ff0542a26 --- /dev/null +++ b/packages/cli/src/ui/utils/__snapshots__/TableRenderer-TableRenderer-wraps-mixed-long-and-short-columns-correctly.snap.svg @@ -0,0 +1,36 @@ + + + + + ┌───────┬──────────────────────────┬────────┐ + + Short + + Long + + Medium + + ├───────┼──────────────────────────┼────────┤ + + Tiny + + This is a very long text + + Not so + + + + that definitely needs to + + long + + + + wrap to the next line + + + └───────┴──────────────────────────┴────────┘ + + \ No newline at end of file diff --git a/packages/cli/src/ui/utils/editorUtils.ts b/packages/cli/src/ui/utils/editorUtils.ts new file mode 100644 index 0000000000..7b9efd5a81 --- /dev/null +++ b/packages/cli/src/ui/utils/editorUtils.ts @@ -0,0 +1,151 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { spawn, spawnSync } from 'node:child_process'; +import type { ReadStream } from 'node:tty'; +import { + coreEvents, + CoreEvent, + type EditorType, + getEditorCommand, + isGuiEditor, + isTerminalEditor, +} from '@google/gemini-cli-core'; + +/** + * Opens a file in an external editor and waits for it to close. + * Handles raw mode switching to ensure the editor can interact with the terminal. + * + * @param filePath Path to the file to open + * @param stdin The stdin stream from Ink/Node + * @param setRawMode Function to toggle raw mode + * @param preferredEditorType The user's preferred editor from config + */ +export async function openFileInEditor( + filePath: string, + stdin: ReadStream | null | undefined, + setRawMode: ((mode: boolean) => void) | undefined, + preferredEditorType?: EditorType, +): Promise { + let command: string | undefined = undefined; + const args = [filePath]; + + if (preferredEditorType) { + command = getEditorCommand(preferredEditorType); + if (isGuiEditor(preferredEditorType)) { + args.unshift('--wait'); + } + } + + if (!command) { + command = process.env['VISUAL'] ?? process.env['EDITOR']; + if (command) { + const lowerCommand = command.toLowerCase(); + const isGui = ['code', 'cursor', 'subl', 'zed', 'atom'].some((gui) => + lowerCommand.includes(gui), + ); + if ( + isGui && + !lowerCommand.includes('--wait') && + !lowerCommand.includes('-w') + ) { + args.unshift(lowerCommand.includes('subl') ? '-w' : '--wait'); + } + } + } + + if (!command) { + command = process.platform === 'win32' ? 'notepad' : 'vi'; + } + + const [executable = '', ...initialArgs] = command.split(' '); + + // Determine if we should use sync or async based on the command/editor type. + // If we have a preferredEditorType, we can check if it's a terminal editor. + // Otherwise, we guess based on the command name. + const terminalEditors = ['vi', 'vim', 'nvim', 'emacs', 'hx', 'nano']; + const isTerminal = preferredEditorType + ? isTerminalEditor(preferredEditorType) + : terminalEditors.some((te) => executable.toLowerCase().includes(te)); + + if ( + isTerminal && + (executable.includes('vi') || + executable.includes('vim') || + executable.includes('nvim')) + ) { + // Pass -i NONE to prevent E138 'Can't write viminfo file' errors in restricted environments. + args.unshift('-i', 'NONE'); + } + + const wasRaw = stdin?.isRaw ?? false; + setRawMode?.(false); + + try { + if (isTerminal) { + const result = spawnSync(executable, [...initialArgs, ...args], { + stdio: 'inherit', + shell: process.platform === 'win32', + }); + if (result.error) { + coreEvents.emitFeedback( + 'error', + '[editorUtils] external terminal editor error', + result.error, + ); + throw result.error; + } + if (typeof result.status === 'number' && result.status !== 0) { + const err = new Error( + `External editor exited with status ${result.status}`, + ); + coreEvents.emitFeedback( + 'error', + '[editorUtils] external editor error', + err, + ); + throw err; + } + } else { + await new Promise((resolve, reject) => { + const child = spawn(executable, [...initialArgs, ...args], { + stdio: 'inherit', + shell: process.platform === 'win32', + }); + + child.on('error', (err) => { + coreEvents.emitFeedback( + 'error', + '[editorUtils] external editor spawn error', + err, + ); + reject(err); + }); + + child.on('close', (status) => { + if (typeof status === 'number' && status !== 0) { + const err = new Error( + `External editor exited with status ${status}`, + ); + coreEvents.emitFeedback( + 'error', + '[editorUtils] external editor error', + err, + ); + reject(err); + } else { + resolve(); + } + }); + }); + } + } finally { + if (wasRaw) { + setRawMode?.(true); + } + coreEvents.emit(CoreEvent.ExternalEditorClosed); + } +} diff --git a/packages/cli/src/utils/cleanup.test.ts b/packages/cli/src/utils/cleanup.test.ts index 5dbeb4d548..e9a2b0ea76 100644 --- a/packages/cli/src/utils/cleanup.test.ts +++ b/packages/cli/src/utils/cleanup.test.ts @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { vi, describe, it, expect, beforeEach } from 'vitest'; +import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; import { promises as fs } from 'node:fs'; import * as path from 'node:path'; @@ -15,6 +15,7 @@ vi.mock('@google/gemini-cli-core', () => ({ })), shutdownTelemetry: vi.fn(), isTelemetrySdkInitialized: vi.fn().mockReturnValue(false), + ExitCodes: { SUCCESS: 0 }, })); vi.mock('node:fs', () => ({ @@ -30,6 +31,8 @@ import { runSyncCleanup, cleanupCheckpoints, resetCleanupForTesting, + setupSignalHandlers, + setupTtyCheck, } from './cleanup.js'; describe('cleanup', () => { @@ -123,3 +126,160 @@ describe('cleanup', () => { }); }); }); + +describe('signal and TTY handling', () => { + let processOnHandlers: Map< + string, + Array<(...args: unknown[]) => void | Promise> + >; + + beforeEach(() => { + processOnHandlers = new Map(); + resetCleanupForTesting(); + + vi.spyOn(process, 'on').mockImplementation( + (event: string | symbol, handler: (...args: unknown[]) => void) => { + if (typeof event === 'string') { + const handlers = processOnHandlers.get(event) || []; + handlers.push(handler); + processOnHandlers.set(event, handlers); + } + return process; + }, + ); + + vi.spyOn(process, 'exit').mockImplementation((() => { + // Don't actually exit + }) as typeof process.exit); + }); + + afterEach(() => { + vi.restoreAllMocks(); + processOnHandlers.clear(); + }); + + describe('setupSignalHandlers', () => { + it('should register handlers for SIGHUP, SIGTERM, and SIGINT', () => { + setupSignalHandlers(); + + expect(processOnHandlers.has('SIGHUP')).toBe(true); + expect(processOnHandlers.has('SIGTERM')).toBe(true); + expect(processOnHandlers.has('SIGINT')).toBe(true); + }); + + it('should gracefully shutdown when SIGHUP is received', async () => { + setupSignalHandlers(); + + const sighupHandlers = processOnHandlers.get('SIGHUP') || []; + expect(sighupHandlers.length).toBeGreaterThan(0); + + await sighupHandlers[0]?.(); + + expect(process.exit).toHaveBeenCalledWith(0); + }); + + it('should register SIGTERM handler that can trigger shutdown', () => { + setupSignalHandlers(); + + const sigtermHandlers = processOnHandlers.get('SIGTERM') || []; + expect(sigtermHandlers.length).toBeGreaterThan(0); + expect(typeof sigtermHandlers[0]).toBe('function'); + }); + }); + + describe('setupTtyCheck', () => { + let originalStdinIsTTY: boolean | undefined; + let originalStdoutIsTTY: boolean | undefined; + + beforeEach(() => { + originalStdinIsTTY = process.stdin.isTTY; + originalStdoutIsTTY = process.stdout.isTTY; + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + Object.defineProperty(process.stdin, 'isTTY', { + value: originalStdinIsTTY, + writable: true, + configurable: true, + }); + Object.defineProperty(process.stdout, 'isTTY', { + value: originalStdoutIsTTY, + writable: true, + configurable: true, + }); + }); + + it('should return a cleanup function', () => { + const cleanup = setupTtyCheck(); + expect(typeof cleanup).toBe('function'); + cleanup(); + }); + + it('should not exit when both stdin and stdout are TTY', async () => { + Object.defineProperty(process.stdin, 'isTTY', { + value: true, + writable: true, + configurable: true, + }); + Object.defineProperty(process.stdout, 'isTTY', { + value: true, + writable: true, + configurable: true, + }); + + const cleanup = setupTtyCheck(); + await vi.advanceTimersByTimeAsync(5000); + expect(process.exit).not.toHaveBeenCalled(); + cleanup(); + }); + + it('should exit when both stdin and stdout are not TTY', async () => { + Object.defineProperty(process.stdin, 'isTTY', { + value: false, + writable: true, + configurable: true, + }); + Object.defineProperty(process.stdout, 'isTTY', { + value: false, + writable: true, + configurable: true, + }); + + const cleanup = setupTtyCheck(); + await vi.advanceTimersByTimeAsync(5000); + expect(process.exit).toHaveBeenCalledWith(0); + cleanup(); + }); + + it('should not check when SANDBOX env is set', async () => { + const originalSandbox = process.env['SANDBOX']; + process.env['SANDBOX'] = 'true'; + + Object.defineProperty(process.stdin, 'isTTY', { + value: false, + writable: true, + configurable: true, + }); + Object.defineProperty(process.stdout, 'isTTY', { + value: false, + writable: true, + configurable: true, + }); + + const cleanup = setupTtyCheck(); + await vi.advanceTimersByTimeAsync(5000); + expect(process.exit).not.toHaveBeenCalled(); + cleanup(); + process.env['SANDBOX'] = originalSandbox; + }); + + it('cleanup function should stop the interval', () => { + const cleanup = setupTtyCheck(); + cleanup(); + vi.advanceTimersByTime(10000); + expect(process.exit).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/cli/src/utils/cleanup.ts b/packages/cli/src/utils/cleanup.ts index 3fce73dd44..6185b34fe5 100644 --- a/packages/cli/src/utils/cleanup.ts +++ b/packages/cli/src/utils/cleanup.ts @@ -10,12 +10,14 @@ import { Storage, shutdownTelemetry, isTelemetrySdkInitialized, + ExitCodes, } from '@google/gemini-cli-core'; import type { Config } from '@google/gemini-cli-core'; const cleanupFunctions: Array<(() => void) | (() => Promise)> = []; const syncCleanupFunctions: Array<() => void> = []; let configForTelemetry: Config | null = null; +let isShuttingDown = false; export function registerCleanup(fn: (() => void) | (() => Promise)) { cleanupFunctions.push(fn); @@ -33,6 +35,7 @@ export function resetCleanupForTesting() { cleanupFunctions.length = 0; syncCleanupFunctions.length = 0; configForTelemetry = null; + isShuttingDown = false; } export function runSyncCleanup() { @@ -100,6 +103,65 @@ async function drainStdin() { await new Promise((resolve) => setTimeout(resolve, 50)); } +/** + * Gracefully shuts down the process, ensuring cleanup runs exactly once. + * Guards against concurrent shutdown from signals (SIGHUP, SIGTERM, SIGINT) + * and TTY loss detection racing each other. + * + * @see https://github.com/google-gemini/gemini-cli/issues/15874 + */ +async function gracefulShutdown(_reason: string) { + if (isShuttingDown) { + return; + } + isShuttingDown = true; + + await runExitCleanup(); + process.exit(ExitCodes.SUCCESS); +} + +export function setupSignalHandlers() { + process.on('SIGHUP', () => gracefulShutdown('SIGHUP')); + process.on('SIGTERM', () => gracefulShutdown('SIGTERM')); + process.on('SIGINT', () => gracefulShutdown('SIGINT')); +} + +export function setupTtyCheck(): () => void { + let intervalId: ReturnType | null = null; + let isCheckingTty = false; + + intervalId = setInterval(async () => { + if (isCheckingTty || isShuttingDown) { + return; + } + + if (process.env['SANDBOX']) { + return; + } + + if (!process.stdin.isTTY && !process.stdout.isTTY) { + isCheckingTty = true; + + if (intervalId) { + clearInterval(intervalId); + intervalId = null; + } + + await gracefulShutdown('TTY loss'); + } + }, 5000); + + // Don't keep the process alive just for this interval + intervalId.unref(); + + return () => { + if (intervalId) { + clearInterval(intervalId); + intervalId = null; + } + }; +} + export async function cleanupCheckpoints() { const storage = new Storage(process.cwd()); await storage.initialize(); diff --git a/packages/cli/src/zed-integration/zedIntegration.ts b/packages/cli/src/zed-integration/zedIntegration.ts index d4f1b27b92..e89a884ab5 100644 --- a/packages/cli/src/zed-integration/zedIntegration.ts +++ b/packages/cli/src/zed-integration/zedIntegration.ts @@ -1335,6 +1335,8 @@ function toAcpToolKind(kind: Kind): acp.ToolKind { case Kind.SwitchMode: case Kind.Other: return kind as acp.ToolKind; + case Kind.Agent: + return 'think'; case Kind.Plan: case Kind.Communicate: default: diff --git a/packages/cli/test-setup.ts b/packages/cli/test-setup.ts index 541f7a6a72..dc75dd217b 100644 --- a/packages/cli/test-setup.ts +++ b/packages/cli/test-setup.ts @@ -23,6 +23,9 @@ if (process.env.NO_COLOR !== undefined) { delete process.env.NO_COLOR; } +// Force true color output for ink so that snapshots always include color information. +process.env.FORCE_COLOR = '3'; + import './src/test-utils/customMatchers.js'; let consoleErrorSpy: vi.SpyInstance; diff --git a/packages/core/src/agents/a2aUtils.test.ts b/packages/core/src/agents/a2aUtils.test.ts index 711650ea80..f0ea746025 100644 --- a/packages/core/src/agents/a2aUtils.test.ts +++ b/packages/core/src/agents/a2aUtils.test.ts @@ -284,5 +284,68 @@ describe('a2aUtils', () => { 'Analyzing...\n\nProcessing...\n\nArtifact (Code):\nprint("Done")', ); }); + + it('should fallback to history in a task chunk if no message or artifacts exist and task is terminal', () => { + const reassembler = new A2AResultReassembler(); + + reassembler.update({ + kind: 'task', + status: { state: 'completed' }, + history: [ + { + kind: 'message', + role: 'agent', + parts: [{ kind: 'text', text: 'Answer from history' }], + } as Message, + ], + } as unknown as SendMessageResult); + + expect(reassembler.toString()).toBe('Answer from history'); + }); + + it('should NOT fallback to history in a task chunk if task is not terminal', () => { + const reassembler = new A2AResultReassembler(); + + reassembler.update({ + kind: 'task', + status: { state: 'working' }, + history: [ + { + kind: 'message', + role: 'agent', + parts: [{ kind: 'text', text: 'Answer from history' }], + } as Message, + ], + } as unknown as SendMessageResult); + + expect(reassembler.toString()).toBe(''); + }); + + it('should not fallback to history if artifacts exist', () => { + const reassembler = new A2AResultReassembler(); + + reassembler.update({ + kind: 'task', + status: { state: 'completed' }, + artifacts: [ + { + artifactId: 'art-1', + name: 'Data', + parts: [{ kind: 'text', text: 'Artifact Content' }], + }, + ], + history: [ + { + kind: 'message', + role: 'agent', + parts: [{ kind: 'text', text: 'Answer from history' }], + } as Message, + ], + } as unknown as SendMessageResult); + + const output = reassembler.toString(); + expect(output).toContain('Artifact (Data):'); + expect(output).not.toContain('Answer from history'); + }); }); }); diff --git a/packages/core/src/agents/a2aUtils.ts b/packages/core/src/agents/a2aUtils.ts index e753d047d0..52817f4971 100644 --- a/packages/core/src/agents/a2aUtils.ts +++ b/packages/core/src/agents/a2aUtils.ts @@ -74,6 +74,26 @@ export class A2AResultReassembler { ]); } } + // History Fallback: Some agent implementations do not populate the + // status.message in their final terminal response, instead archiving + // the final answer in the task's history array. To ensure we don't + // present an empty result, we fallback to the most recent agent message + // in the history only when the task is terminal and no other content + // (message log or artifacts) has been reassembled. + if ( + isTerminalState(chunk.status?.state) && + this.messageLog.length === 0 && + this.artifacts.size === 0 && + chunk.history && + chunk.history.length > 0 + ) { + const lastAgentMsg = [...chunk.history] + .reverse() + .find((m) => m.role?.toLowerCase().includes('agent')); + if (lastAgentMsg) { + this.pushMessage(lastAgentMsg); + } + } break; case 'message': { @@ -126,7 +146,7 @@ export class A2AResultReassembler { * Handles Text, Data (JSON), and File parts. */ export function extractMessageText(message: Message | undefined): string { - if (!message) { + if (!message || !message.parts || !Array.isArray(message.parts)) { return ''; } @@ -158,7 +178,6 @@ function extractPartText(part: Part): string { } if (isDataPart(part)) { - // Attempt to format known data types if metadata exists, otherwise JSON stringify return `Data: ${JSON.stringify(part.data)}`; } diff --git a/packages/core/src/agents/generalist-agent.ts b/packages/core/src/agents/generalist-agent.ts index 4f9040a7b0..412880b089 100644 --- a/packages/core/src/agents/generalist-agent.ts +++ b/packages/core/src/agents/generalist-agent.ts @@ -24,8 +24,7 @@ export const GeneralistAgent = ( name: 'generalist', displayName: 'Generalist Agent', description: - "A general-purpose AI agent with access to all tools. Use it for complex tasks that don't fit into other specialized agents.", - experimental: true, + 'A general-purpose AI agent with access to all tools. Highly recommended for tasks that are turn-intensive or involve processing large amounts of data. Use this to keep the main session history lean and efficient. Excellent for: batch refactoring/error fixing across multiple files, running commands with high-volume output, and speculative investigations.', inputConfig: { inputSchema: { type: 'object', diff --git a/packages/core/src/agents/registry.test.ts b/packages/core/src/agents/registry.test.ts index 8cc45a9a5a..c5f2faa06f 100644 --- a/packages/core/src/agents/registry.test.ts +++ b/packages/core/src/agents/registry.test.ts @@ -50,6 +50,7 @@ function makeMockedConfig(params?: Partial): Config { } as unknown as ToolRegistry); vi.spyOn(config, 'getAgentRegistry').mockReturnValue({ getDirectoryContext: () => 'mock directory context', + getAllDefinitions: () => [], } as unknown as AgentRegistry); return config; } @@ -262,6 +263,7 @@ describe('AgentRegistry', () => { overrides: { codebase_investigator: { enabled: false }, cli_help: { enabled: false }, + generalist: { enabled: false }, }, }, }); @@ -299,13 +301,13 @@ describe('AgentRegistry', () => { expect(registry.getDefinition('cli_help')).toBeUndefined(); }); - it('should NOT register generalist agent by default (because it is experimental)', async () => { + it('should register generalist agent by default', async () => { const config = makeMockedConfig(); const registry = new TestableAgentRegistry(config); await registry.initialize(); - expect(registry.getDefinition('generalist')).toBeUndefined(); + expect(registry.getDefinition('generalist')).toBeDefined(); }); it('should register generalist agent if explicitly enabled via override', async () => { diff --git a/packages/core/src/agents/subagent-tool-wrapper.test.ts b/packages/core/src/agents/subagent-tool-wrapper.test.ts index c4f3d178c9..e433e6f7d3 100644 --- a/packages/core/src/agents/subagent-tool-wrapper.test.ts +++ b/packages/core/src/agents/subagent-tool-wrapper.test.ts @@ -70,7 +70,7 @@ describe('SubagentToolWrapper', () => { expect(wrapper.name).toBe(mockDefinition.name); expect(wrapper.displayName).toBe(mockDefinition.displayName); expect(wrapper.description).toBe(mockDefinition.description); - expect(wrapper.kind).toBe(Kind.Think); + expect(wrapper.kind).toBe(Kind.Agent); expect(wrapper.isOutputMarkdown).toBe(true); expect(wrapper.canUpdateOutput).toBe(true); }); diff --git a/packages/core/src/agents/subagent-tool-wrapper.ts b/packages/core/src/agents/subagent-tool-wrapper.ts index 57ee929205..d0e94f1b4b 100644 --- a/packages/core/src/agents/subagent-tool-wrapper.ts +++ b/packages/core/src/agents/subagent-tool-wrapper.ts @@ -45,7 +45,7 @@ export class SubagentToolWrapper extends BaseDeclarativeTool< definition.name, definition.displayName ?? definition.name, definition.description, - Kind.Think, + Kind.Agent, definition.inputConfig.inputSchema, messageBus, /* isOutputMarkdown */ true, diff --git a/packages/core/src/agents/subagent-tool.test.ts b/packages/core/src/agents/subagent-tool.test.ts index d6d6bdfd89..40db4822a2 100644 --- a/packages/core/src/agents/subagent-tool.test.ts +++ b/packages/core/src/agents/subagent-tool.test.ts @@ -7,6 +7,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { SubagentTool } from './subagent-tool.js'; import { SubagentToolWrapper } from './subagent-tool-wrapper.js'; +import { Kind } from '../tools/tools.js'; import type { LocalAgentDefinition, RemoteAgentDefinition, @@ -70,6 +71,11 @@ describe('SubAgentInvocation', () => { .mockReturnValue(mockInnerInvocation); }); + it('should have Kind.Agent', () => { + const tool = new SubagentTool(testDefinition, mockConfig, mockMessageBus); + expect(tool.kind).toBe(Kind.Agent); + }); + it('should delegate shouldConfirmExecute to the inner sub-invocation (local)', async () => { const tool = new SubagentTool(testDefinition, mockConfig, mockMessageBus); const params = {}; diff --git a/packages/core/src/agents/subagent-tool.ts b/packages/core/src/agents/subagent-tool.ts index f47b506634..8584ae97f1 100644 --- a/packages/core/src/agents/subagent-tool.ts +++ b/packages/core/src/agents/subagent-tool.ts @@ -41,7 +41,7 @@ export class SubagentTool extends BaseDeclarativeTool { definition.name, definition.displayName ?? definition.name, definition.description, - Kind.Think, + Kind.Agent, inputSchema, messageBus, /* isOutputMarkdown */ true, diff --git a/packages/core/src/code_assist/server.test.ts b/packages/core/src/code_assist/server.test.ts index 8ec8cb8dad..d79526d1c3 100644 --- a/packages/core/src/code_assist/server.test.ts +++ b/packages/core/src/code_assist/server.test.ts @@ -73,17 +73,19 @@ describe('CodeAssistServer', () => { LlmRole.MAIN, ); - expect(mockRequest).toHaveBeenCalledWith({ - url: expect.stringContaining(':generateContent'), - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'x-custom-header': 'test-value', - }, - responseType: 'json', - body: expect.any(String), - signal: undefined, - }); + expect(mockRequest).toHaveBeenCalledWith( + expect.objectContaining({ + url: expect.stringContaining(':generateContent'), + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-custom-header': 'test-value', + }, + responseType: 'json', + body: expect.any(String), + signal: undefined, + }), + ); const requestBody = JSON.parse(mockRequest.mock.calls[0][0].body); expect(requestBody.user_prompt_id).toBe('user-prompt-id'); @@ -391,17 +393,19 @@ describe('CodeAssistServer', () => { results.push(res); } - expect(mockRequest).toHaveBeenCalledWith({ - url: expect.stringContaining(':streamGenerateContent'), - method: 'POST', - params: { alt: 'sse' }, - responseType: 'stream', - body: expect.any(String), - headers: { - 'Content-Type': 'application/json', - }, - signal: undefined, - }); + expect(mockRequest).toHaveBeenCalledWith( + expect.objectContaining({ + url: expect.stringContaining(':streamGenerateContent'), + method: 'POST', + params: { alt: 'sse' }, + responseType: 'stream', + body: expect.any(String), + headers: { + 'Content-Type': 'application/json', + }, + signal: undefined, + }), + ); expect(results).toHaveLength(2); expect(results[0].candidates?.[0].content?.parts?.[0].text).toBe('Hello'); diff --git a/packages/core/src/config/config.test.ts b/packages/core/src/config/config.test.ts index e92f464fa2..1034246e9c 100644 --- a/packages/core/src/config/config.test.ts +++ b/packages/core/src/config/config.test.ts @@ -225,8 +225,10 @@ import type { } from '../services/modelConfigService.js'; import { ExitPlanModeTool } from '../tools/exit-plan-mode.js'; import { EnterPlanModeTool } from '../tools/enter-plan-mode.js'; +import { LocalLiteRtLmClient } from '../core/localLiteRtLmClient.js'; vi.mock('../core/baseLlmClient.js'); +vi.mock('../core/localLiteRtLmClient.js'); vi.mock('../core/tokenLimits.js', () => ({ tokenLimit: vi.fn(), })); @@ -1418,6 +1420,79 @@ describe('Server Config (config.ts)', () => { }); }); +describe('GemmaModelRouterSettings', () => { + const MODEL = DEFAULT_GEMINI_MODEL; + const SANDBOX: SandboxConfig = { + command: 'docker', + image: 'gemini-cli-sandbox', + }; + const TARGET_DIR = '/path/to/target'; + const DEBUG_MODE = false; + const QUESTION = 'test question'; + const USER_MEMORY = 'Test User Memory'; + const TELEMETRY_SETTINGS = { enabled: false }; + const EMBEDDING_MODEL = 'gemini-embedding'; + const SESSION_ID = 'test-session-id'; + const baseParams: ConfigParameters = { + cwd: '/tmp', + embeddingModel: EMBEDDING_MODEL, + sandbox: SANDBOX, + targetDir: TARGET_DIR, + debugMode: DEBUG_MODE, + question: QUESTION, + userMemory: USER_MEMORY, + telemetry: TELEMETRY_SETTINGS, + sessionId: SESSION_ID, + model: MODEL, + usageStatisticsEnabled: false, + }; + + it('should default gemmaModelRouter.enabled to false', () => { + const config = new Config(baseParams); + expect(config.getGemmaModelRouterEnabled()).toBe(false); + }); + + it('should return default gemma model router settings when not provided', () => { + const config = new Config(baseParams); + const settings = config.getGemmaModelRouterSettings(); + expect(settings.enabled).toBe(false); + expect(settings.classifier?.host).toBe('http://localhost:9379'); + expect(settings.classifier?.model).toBe('gemma3-1b-gpu-custom'); + }); + + it('should override default gemma model router settings when provided', () => { + const params: ConfigParameters = { + ...baseParams, + gemmaModelRouter: { + enabled: true, + classifier: { + host: 'http://custom:1234', + model: 'custom-gemma', + }, + }, + }; + const config = new Config(params); + const settings = config.getGemmaModelRouterSettings(); + expect(settings.enabled).toBe(true); + expect(settings.classifier?.host).toBe('http://custom:1234'); + expect(settings.classifier?.model).toBe('custom-gemma'); + }); + + it('should merge partial gemma model router settings with defaults', () => { + const params: ConfigParameters = { + ...baseParams, + gemmaModelRouter: { + enabled: true, + }, + }; + const config = new Config(params); + const settings = config.getGemmaModelRouterSettings(); + expect(settings.enabled).toBe(true); + expect(settings.classifier?.host).toBe('http://localhost:9379'); + expect(settings.classifier?.model).toBe('gemma3-1b-gpu-custom'); + }); +}); + describe('setApprovalMode with folder trust', () => { const baseParams: ConfigParameters = { sessionId: 'test', @@ -2069,6 +2144,71 @@ describe('Config getHooks', () => { }); }); +describe('LocalLiteRtLmClient Lifecycle', () => { + const MODEL = 'gemini-pro'; + const SANDBOX: SandboxConfig = { + command: 'docker', + image: 'gemini-cli-sandbox', + }; + const TARGET_DIR = '/path/to/target'; + const DEBUG_MODE = false; + const QUESTION = 'test question'; + const USER_MEMORY = 'Test User Memory'; + const TELEMETRY_SETTINGS = { enabled: false }; + const EMBEDDING_MODEL = 'gemini-embedding'; + const SESSION_ID = 'test-session-id'; + const baseParams: ConfigParameters = { + cwd: '/tmp', + embeddingModel: EMBEDDING_MODEL, + sandbox: SANDBOX, + targetDir: TARGET_DIR, + debugMode: DEBUG_MODE, + question: QUESTION, + userMemory: USER_MEMORY, + telemetry: TELEMETRY_SETTINGS, + sessionId: SESSION_ID, + model: MODEL, + usageStatisticsEnabled: false, + }; + + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(getExperiments).mockResolvedValue({ + experimentIds: [], + flags: {}, + }); + }); + + it('should successfully initialize LocalLiteRtLmClient on first call and reuse it', () => { + const config = new Config(baseParams); + const client1 = config.getLocalLiteRtLmClient(); + const client2 = config.getLocalLiteRtLmClient(); + + expect(client1).toBeDefined(); + expect(client1).toBe(client2); // Should return the same instance + }); + + it('should configure LocalLiteRtLmClient with settings from getGemmaModelRouterSettings', () => { + const customHost = 'http://my-custom-host:9999'; + const customModel = 'my-custom-gemma-model'; + const params: ConfigParameters = { + ...baseParams, + gemmaModelRouter: { + enabled: true, + classifier: { + host: customHost, + model: customModel, + }, + }, + }; + + const config = new Config(params); + config.getLocalLiteRtLmClient(); + + expect(LocalLiteRtLmClient).toHaveBeenCalledWith(config); + }); +}); + describe('Config getExperiments', () => { const baseParams: ConfigParameters = { cwd: '/tmp', diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 7297693b8e..2f5d452446 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -38,6 +38,7 @@ import { ExitPlanModeTool } from '../tools/exit-plan-mode.js'; import { EnterPlanModeTool } from '../tools/enter-plan-mode.js'; import { GeminiClient } from '../core/client.js'; import { BaseLlmClient } from '../core/baseLlmClient.js'; +import { LocalLiteRtLmClient } from '../core/localLiteRtLmClient.js'; import type { HookDefinition, HookEventName } from '../hooks/types.js'; import { FileDiscoveryService } from '../services/fileDiscoveryService.js'; import { GitService } from '../services/gitService.js'; @@ -178,6 +179,14 @@ export interface ToolOutputMaskingConfig { protectLatestTurn: boolean; } +export interface GemmaModelRouterSettings { + enabled?: boolean; + classifier?: { + host?: string; + model?: string; + }; +} + export interface ExtensionSetting { name: string; description: string; @@ -509,6 +518,7 @@ export interface ConfigParameters { directWebFetch?: boolean; policyUpdateConfirmationRequest?: PolicyUpdateConfirmationRequest; output?: OutputSettings; + gemmaModelRouter?: GemmaModelRouterSettings; disableModelRouterForAuth?: AuthType[]; continueOnFailedApiCall?: boolean; retryFetchErrors?: boolean; @@ -599,6 +609,7 @@ export class Config { private readonly usageStatisticsEnabled: boolean; private geminiClient!: GeminiClient; private baseLlmClient!: BaseLlmClient; + private localLiteRtLmClient?: LocalLiteRtLmClient; private modelRouterService: ModelRouterService; private readonly modelAvailabilityService: ModelAvailabilityService; private readonly fileFiltering: { @@ -694,6 +705,9 @@ export class Config { | PolicyUpdateConfirmationRequest | undefined; private readonly outputSettings: OutputSettings; + + private readonly gemmaModelRouter: GemmaModelRouterSettings; + private readonly continueOnFailedApiCall: boolean; private readonly retryFetchErrors: boolean; private readonly maxAttempts: number; @@ -942,6 +956,15 @@ export class Config { this.outputSettings = { format: params.output?.format ?? OutputFormat.TEXT, }; + this.gemmaModelRouter = { + enabled: params.gemmaModelRouter?.enabled ?? false, + classifier: { + host: + params.gemmaModelRouter?.classifier?.host ?? 'http://localhost:9379', + model: + params.gemmaModelRouter?.classifier?.model ?? 'gemma3-1b-gpu-custom', + }, + }; this.retryFetchErrors = params.retryFetchErrors ?? false; this.maxAttempts = Math.min( params.maxAttempts ?? DEFAULT_MAX_ATTEMPTS, @@ -1245,6 +1268,13 @@ export class Config { return this.baseLlmClient; } + getLocalLiteRtLmClient(): LocalLiteRtLmClient { + if (!this.localLiteRtLmClient) { + this.localLiteRtLmClient = new LocalLiteRtLmClient(this); + } + return this.localLiteRtLmClient; + } + getSessionId(): string { return this.sessionId; } @@ -2578,6 +2608,14 @@ export class Config { return this.enableHooksUI; } + getGemmaModelRouterEnabled(): boolean { + return this.gemmaModelRouter.enabled ?? false; + } + + getGemmaModelRouterSettings(): GemmaModelRouterSettings { + return this.gemmaModelRouter; + } + /** * Get override settings for a specific agent. * Reads from agents.overrides.. diff --git a/packages/core/src/config/storage.ts b/packages/core/src/config/storage.ts index f66d60ef8b..e8530887b3 100644 --- a/packages/core/src/config/storage.ts +++ b/packages/core/src/config/storage.ts @@ -289,6 +289,10 @@ export class Storage { return path.join(this.getProjectTempDir(), 'plans'); } + getProjectTempTrackerDir(): string { + return path.join(this.getProjectTempDir(), 'tracker'); + } + getPlansDir(): string { if (this.customPlansDir) { const resolvedPath = path.resolve( diff --git a/packages/core/src/core/__snapshots__/prompts.test.ts.snap b/packages/core/src/core/__snapshots__/prompts.test.ts.snap index 94351e69e1..48afa13515 100644 --- a/packages/core/src/core/__snapshots__/prompts.test.ts.snap +++ b/packages/core/src/core/__snapshots__/prompts.test.ts.snap @@ -32,7 +32,7 @@ Use the following guidelines to optimize your search and read patterns. -- **Searching:** utilize search tools like grep_search and glob with a conservative result count (\`total_max_matches\`) and a narrow scope (\`include\` and \`exclude\` parameters). +- **Searching:** utilize search tools like grep_search and glob with a conservative result count (\`total_max_matches\`) and a narrow scope (\`include_pattern\` and \`exclude_pattern\` parameters). - **Searching and editing:** utilize search tools like grep_search with a conservative result count and a narrow scope. Use \`context\`, \`before\`, and/or \`after\` to request enough context to avoid the need to read the file before editing matches. - **Understanding:** minimize turns needed to understand a file. It's most efficient to read small files in their entirety. - **Large files:** utilize search tools like grep_search and/or read_file called in parallel with 'start_line' and 'end_line' to reduce the impact on context. Minimize extra turns, unless unavoidable due to the file being too large. @@ -57,6 +57,18 @@ Use the following guidelines to optimize your search and read patterns. Sub-agents are specialized expert agents. Each sub-agent is available as a tool of the same name. You MUST delegate tasks to the sub-agent with the most relevant expertise. +### Strategic Orchestration & Delegation +Operate as a **strategic orchestrator**. Your own context window is your most precious resource. Every turn you take adds to the permanent session history. To keep the session fast and efficient, use sub-agents to "compress" complex or repetitive work. + +When you delegate, the sub-agent's entire execution is consolidated into a single summary in your history, keeping your main loop lean. + +**High-Impact Delegation Candidates:** +- **Repetitive Batch Tasks:** Tasks involving more than 3 files or repeated steps (e.g., "Add license headers to all files in src/", "Fix all lint errors in the project"). +- **High-Volume Output:** Commands or tools expected to return large amounts of data (e.g., verbose builds, exhaustive file searches). +- **Speculative Research:** Investigations that require many "trial and error" steps before a clear path is found. + +**Assertive Action:** Continue to handle "surgical" tasks directly—simple reads, single-file edits, or direct questions that can be resolved in 1-2 turns. Delegation is an efficiency tool, not a way to avoid direct action when it is the fastest path. + mock-agent @@ -79,7 +91,7 @@ For example: # Active Approval Mode: Plan -You are operating in **Plan Mode**. Your goal is to produce a detailed implementation plan in \`/tmp/plans/\` and get user approval before editing source code. +You are operating in **Plan Mode**. Your goal is to produce an implementation plan in \`/tmp/plans/\` and get user approval before editing source code. ## Available Tools The following tools are available in Plan Mode: @@ -95,31 +107,35 @@ The following tools are available in Plan Mode: ## Rules -1. **Read-Only:** You cannot modify source code. You may ONLY use read-only tools to explore, and you can only write to \`/tmp/plans/\`. If the user asks you to modify source code directly, you MUST explain that you are in Plan Mode and must first create a detailed plan in the plans directory and get approval before any source code changes can be made. +1. **Read-Only:** You cannot modify source code. You may ONLY use read-only tools to explore, and you can only write to \`/tmp/plans/\`. If the user asks you to modify source code directly, you MUST explain that you are in Plan Mode and must first create a plan and get approval. 2. **Write Constraint:** \`write_file\` and \`replace\` may ONLY be used to write .md plan files to \`/tmp/plans/\`. They cannot modify source code. -3. **Efficiency:** Autonomously combine discovery and drafting phases to minimize conversational turns. If the request is ambiguous, use \`ask_user\` to clarify. Otherwise, explore the codebase and write the draft in one fluid motion. +3. **Efficiency:** Autonomously combine discovery and drafting phases to minimize conversational turns. If the request is ambiguous, use \`ask_user\` to clarify. 4. **Inquiries and Directives:** Distinguish between Inquiries and Directives to minimize unnecessary planning. - - **Inquiries:** If the request is an **Inquiry** (e.g., "How does X work?"), use read-only tools to explore and answer directly in your chat response. DO NOT create a plan or call \`exit_plan_mode\`. - - **Directives:** If the request is a **Directive** (e.g., "Fix bug Y"), follow the workflow below to create and approve a plan. -5. **Plan Storage:** Save plans as Markdown (.md) using descriptive filenames (e.g., \`feature-x.md\`). -6. **Direct Modification:** If asked to modify code outside the plans directory, or if the user requests implementation of an existing plan, explain that you are in Plan Mode and use the \`exit_plan_mode\` tool to request approval and exit Plan Mode to enable edits. + - **Inquiries:** If the request is an **Inquiry** (e.g., "How does X work?"), answer directly. DO NOT create a plan. + - **Directives:** If the request is a **Directive** (e.g., "Fix bug Y"), follow the workflow below. +5. **Plan Storage:** Save plans as Markdown (.md) using descriptive filenames. +6. **Direct Modification:** If asked to modify code, explain you are in Plan Mode and use \`exit_plan_mode\` to request approval. -## Required Plan Structure -When writing the plan file, you MUST include the following structure: - # Objective - (A concise summary of what needs to be built or fixed) - # Key Files & Context - (List the specific files that will be modified, including helpful context like function signatures or code snippets) - # Implementation Steps - (Iterative development steps, e.g., "1. Implement X in [File]", "2. Verify with test Y") - # Verification & Testing - (Specific unit tests, manual checks, or build commands to verify success) +## Planning Workflow +Plan Mode uses an adaptive planning workflow where the research depth, plan structure, and consultation level are proportional to the task's complexity. -## Workflow -1. **Explore & Analyze:** Analyze requirements and use search/read tools to explore the codebase. For complex tasks, identify at least two viable implementation approaches. -2. **Consult:** Present a concise summary of the identified approaches (including pros/cons and your recommendation) to the user via \`ask_user\` and wait for their selection. For simple or canonical tasks, you may skip this and proceed to drafting. -3. **Draft:** Write the detailed implementation plan for the selected approach to the plans directory using \`write_file\`. -4. **Review & Approval:** Present a brief summary of the drafted plan in your chat response and concurrently call the \`exit_plan_mode\` tool to formally request approval. If rejected, iterate. +### 1. Explore & Analyze +Analyze requirements and use search/read tools to explore the codebase. Systematically map affected modules, trace data flow, and identify dependencies. + +### 2. Consult +The depth of your consultation should be proportional to the task's complexity: +- **Simple Tasks:** Skip consultation and proceed directly to drafting. +- **Standard Tasks:** If multiple viable approaches exist, present a concise summary (including pros/cons and your recommendation) via \`ask_user\` and wait for a decision. +- **Complex Tasks:** You MUST present at least two viable approaches with detailed trade-offs via \`ask_user\` and obtain approval before drafting the plan. + +### 3. Draft +Write the implementation plan to \`/tmp/plans/\`. The plan's structure adapts to the task: +- **Simple Tasks:** Include a bulleted list of specific **Changes** and **Verification** steps. +- **Standard Tasks:** Include an **Objective**, **Key Files & Context**, **Implementation Steps**, and **Verification & Testing**. +- **Complex Tasks:** Include **Background & Motivation**, **Scope & Impact**, **Proposed Solution**, **Alternatives Considered**, a phased **Implementation Plan**, **Verification**, and **Migration & Rollback** strategies. + +### 4. Review & Approval +Use the \`exit_plan_mode\` tool to present the plan and formally request approval. # Operational Guidelines @@ -184,7 +200,7 @@ Use the following guidelines to optimize your search and read patterns. -- **Searching:** utilize search tools like grep_search and glob with a conservative result count (\`total_max_matches\`) and a narrow scope (\`include\` and \`exclude\` parameters). +- **Searching:** utilize search tools like grep_search and glob with a conservative result count (\`total_max_matches\`) and a narrow scope (\`include_pattern\` and \`exclude_pattern\` parameters). - **Searching and editing:** utilize search tools like grep_search with a conservative result count and a narrow scope. Use \`context\`, \`before\`, and/or \`after\` to request enough context to avoid the need to read the file before editing matches. - **Understanding:** minimize turns needed to understand a file. It's most efficient to read small files in their entirety. - **Large files:** utilize search tools like grep_search and/or read_file called in parallel with 'start_line' and 'end_line' to reduce the impact on context. Minimize extra turns, unless unavoidable due to the file being too large. @@ -209,6 +225,18 @@ Use the following guidelines to optimize your search and read patterns. Sub-agents are specialized expert agents. Each sub-agent is available as a tool of the same name. You MUST delegate tasks to the sub-agent with the most relevant expertise. +### Strategic Orchestration & Delegation +Operate as a **strategic orchestrator**. Your own context window is your most precious resource. Every turn you take adds to the permanent session history. To keep the session fast and efficient, use sub-agents to "compress" complex or repetitive work. + +When you delegate, the sub-agent's entire execution is consolidated into a single summary in your history, keeping your main loop lean. + +**High-Impact Delegation Candidates:** +- **Repetitive Batch Tasks:** Tasks involving more than 3 files or repeated steps (e.g., "Add license headers to all files in src/", "Fix all lint errors in the project"). +- **High-Volume Output:** Commands or tools expected to return large amounts of data (e.g., verbose builds, exhaustive file searches). +- **Speculative Research:** Investigations that require many "trial and error" steps before a clear path is found. + +**Assertive Action:** Continue to handle "surgical" tasks directly—simple reads, single-file edits, or direct questions that can be resolved in 1-2 turns. Delegation is an efficiency tool, not a way to avoid direct action when it is the fastest path. + mock-agent @@ -231,7 +259,7 @@ For example: # Active Approval Mode: Plan -You are operating in **Plan Mode**. Your goal is to produce a detailed implementation plan in \`/tmp/plans/\` and get user approval before editing source code. +You are operating in **Plan Mode**. Your goal is to produce an implementation plan in \`/tmp/plans/\` and get user approval before editing source code. ## Available Tools The following tools are available in Plan Mode: @@ -247,31 +275,35 @@ The following tools are available in Plan Mode: ## Rules -1. **Read-Only:** You cannot modify source code. You may ONLY use read-only tools to explore, and you can only write to \`/tmp/plans/\`. If the user asks you to modify source code directly, you MUST explain that you are in Plan Mode and must first create a detailed plan in the plans directory and get approval before any source code changes can be made. +1. **Read-Only:** You cannot modify source code. You may ONLY use read-only tools to explore, and you can only write to \`/tmp/plans/\`. If the user asks you to modify source code directly, you MUST explain that you are in Plan Mode and must first create a plan and get approval. 2. **Write Constraint:** \`write_file\` and \`replace\` may ONLY be used to write .md plan files to \`/tmp/plans/\`. They cannot modify source code. -3. **Efficiency:** Autonomously combine discovery and drafting phases to minimize conversational turns. If the request is ambiguous, use \`ask_user\` to clarify. Otherwise, explore the codebase and write the draft in one fluid motion. +3. **Efficiency:** Autonomously combine discovery and drafting phases to minimize conversational turns. If the request is ambiguous, use \`ask_user\` to clarify. 4. **Inquiries and Directives:** Distinguish between Inquiries and Directives to minimize unnecessary planning. - - **Inquiries:** If the request is an **Inquiry** (e.g., "How does X work?"), use read-only tools to explore and answer directly in your chat response. DO NOT create a plan or call \`exit_plan_mode\`. - - **Directives:** If the request is a **Directive** (e.g., "Fix bug Y"), follow the workflow below to create and approve a plan. -5. **Plan Storage:** Save plans as Markdown (.md) using descriptive filenames (e.g., \`feature-x.md\`). -6. **Direct Modification:** If asked to modify code outside the plans directory, or if the user requests implementation of an existing plan, explain that you are in Plan Mode and use the \`exit_plan_mode\` tool to request approval and exit Plan Mode to enable edits. + - **Inquiries:** If the request is an **Inquiry** (e.g., "How does X work?"), answer directly. DO NOT create a plan. + - **Directives:** If the request is a **Directive** (e.g., "Fix bug Y"), follow the workflow below. +5. **Plan Storage:** Save plans as Markdown (.md) using descriptive filenames. +6. **Direct Modification:** If asked to modify code, explain you are in Plan Mode and use \`exit_plan_mode\` to request approval. -## Required Plan Structure -When writing the plan file, you MUST include the following structure: - # Objective - (A concise summary of what needs to be built or fixed) - # Key Files & Context - (List the specific files that will be modified, including helpful context like function signatures or code snippets) - # Implementation Steps - (Iterative development steps, e.g., "1. Implement X in [File]", "2. Verify with test Y") - # Verification & Testing - (Specific unit tests, manual checks, or build commands to verify success) +## Planning Workflow +Plan Mode uses an adaptive planning workflow where the research depth, plan structure, and consultation level are proportional to the task's complexity. -## Workflow -1. **Explore & Analyze:** Analyze requirements and use search/read tools to explore the codebase. For complex tasks, identify at least two viable implementation approaches. -2. **Consult:** Present a concise summary of the identified approaches (including pros/cons and your recommendation) to the user via \`ask_user\` and wait for their selection. For simple or canonical tasks, you may skip this and proceed to drafting. -3. **Draft:** Write the detailed implementation plan for the selected approach to the plans directory using \`write_file\`. -4. **Review & Approval:** Present a brief summary of the drafted plan in your chat response and concurrently call the \`exit_plan_mode\` tool to formally request approval. If rejected, iterate. +### 1. Explore & Analyze +Analyze requirements and use search/read tools to explore the codebase. Systematically map affected modules, trace data flow, and identify dependencies. + +### 2. Consult +The depth of your consultation should be proportional to the task's complexity: +- **Simple Tasks:** Skip consultation and proceed directly to drafting. +- **Standard Tasks:** If multiple viable approaches exist, present a concise summary (including pros/cons and your recommendation) via \`ask_user\` and wait for a decision. +- **Complex Tasks:** You MUST present at least two viable approaches with detailed trade-offs via \`ask_user\` and obtain approval before drafting the plan. + +### 3. Draft +Write the implementation plan to \`/tmp/plans/\`. The plan's structure adapts to the task: +- **Simple Tasks:** Include a bulleted list of specific **Changes** and **Verification** steps. +- **Standard Tasks:** Include an **Objective**, **Key Files & Context**, **Implementation Steps**, and **Verification & Testing**. +- **Complex Tasks:** Include **Background & Motivation**, **Scope & Impact**, **Proposed Solution**, **Alternatives Considered**, a phased **Implementation Plan**, **Verification**, and **Migration & Rollback** strategies. + +### 4. Review & Approval +Use the \`exit_plan_mode\` tool to present the plan and formally request approval. ## Approved Plan An approved plan is available for this task at \`/tmp/plans/feature-x.md\`. @@ -455,7 +487,7 @@ Use the following guidelines to optimize your search and read patterns. -- **Searching:** utilize search tools like grep_search and glob with a conservative result count (\`total_max_matches\`) and a narrow scope (\`include\` and \`exclude\` parameters). +- **Searching:** utilize search tools like grep_search and glob with a conservative result count (\`total_max_matches\`) and a narrow scope (\`include_pattern\` and \`exclude_pattern\` parameters). - **Searching and editing:** utilize search tools like grep_search with a conservative result count and a narrow scope. Use \`context\`, \`before\`, and/or \`after\` to request enough context to avoid the need to read the file before editing matches. - **Understanding:** minimize turns needed to understand a file. It's most efficient to read small files in their entirety. - **Large files:** utilize search tools like grep_search and/or read_file called in parallel with 'start_line' and 'end_line' to reduce the impact on context. Minimize extra turns, unless unavoidable due to the file being too large. @@ -480,6 +512,18 @@ Use the following guidelines to optimize your search and read patterns. Sub-agents are specialized expert agents. Each sub-agent is available as a tool of the same name. You MUST delegate tasks to the sub-agent with the most relevant expertise. +### Strategic Orchestration & Delegation +Operate as a **strategic orchestrator**. Your own context window is your most precious resource. Every turn you take adds to the permanent session history. To keep the session fast and efficient, use sub-agents to "compress" complex or repetitive work. + +When you delegate, the sub-agent's entire execution is consolidated into a single summary in your history, keeping your main loop lean. + +**High-Impact Delegation Candidates:** +- **Repetitive Batch Tasks:** Tasks involving more than 3 files or repeated steps (e.g., "Add license headers to all files in src/", "Fix all lint errors in the project"). +- **High-Volume Output:** Commands or tools expected to return large amounts of data (e.g., verbose builds, exhaustive file searches). +- **Speculative Research:** Investigations that require many "trial and error" steps before a clear path is found. + +**Assertive Action:** Continue to handle "surgical" tasks directly—simple reads, single-file edits, or direct questions that can be resolved in 1-2 turns. Delegation is an efficiency tool, not a way to avoid direct action when it is the fastest path. + mock-agent @@ -502,7 +546,7 @@ For example: # Active Approval Mode: Plan -You are operating in **Plan Mode**. Your goal is to produce a detailed implementation plan in \`/tmp/project-temp/plans/\` and get user approval before editing source code. +You are operating in **Plan Mode**. Your goal is to produce an implementation plan in \`/tmp/project-temp/plans/\` and get user approval before editing source code. ## Available Tools The following tools are available in Plan Mode: @@ -518,31 +562,35 @@ The following tools are available in Plan Mode: ## Rules -1. **Read-Only:** You cannot modify source code. You may ONLY use read-only tools to explore, and you can only write to \`/tmp/project-temp/plans/\`. If the user asks you to modify source code directly, you MUST explain that you are in Plan Mode and must first create a detailed plan in the plans directory and get approval before any source code changes can be made. +1. **Read-Only:** You cannot modify source code. You may ONLY use read-only tools to explore, and you can only write to \`/tmp/project-temp/plans/\`. If the user asks you to modify source code directly, you MUST explain that you are in Plan Mode and must first create a plan and get approval. 2. **Write Constraint:** \`write_file\` and \`replace\` may ONLY be used to write .md plan files to \`/tmp/project-temp/plans/\`. They cannot modify source code. -3. **Efficiency:** Autonomously combine discovery and drafting phases to minimize conversational turns. If the request is ambiguous, use \`ask_user\` to clarify. Otherwise, explore the codebase and write the draft in one fluid motion. +3. **Efficiency:** Autonomously combine discovery and drafting phases to minimize conversational turns. If the request is ambiguous, use \`ask_user\` to clarify. 4. **Inquiries and Directives:** Distinguish between Inquiries and Directives to minimize unnecessary planning. - - **Inquiries:** If the request is an **Inquiry** (e.g., "How does X work?"), use read-only tools to explore and answer directly in your chat response. DO NOT create a plan or call \`exit_plan_mode\`. - - **Directives:** If the request is a **Directive** (e.g., "Fix bug Y"), follow the workflow below to create and approve a plan. -5. **Plan Storage:** Save plans as Markdown (.md) using descriptive filenames (e.g., \`feature-x.md\`). -6. **Direct Modification:** If asked to modify code outside the plans directory, or if the user requests implementation of an existing plan, explain that you are in Plan Mode and use the \`exit_plan_mode\` tool to request approval and exit Plan Mode to enable edits. + - **Inquiries:** If the request is an **Inquiry** (e.g., "How does X work?"), answer directly. DO NOT create a plan. + - **Directives:** If the request is a **Directive** (e.g., "Fix bug Y"), follow the workflow below. +5. **Plan Storage:** Save plans as Markdown (.md) using descriptive filenames. +6. **Direct Modification:** If asked to modify code, explain you are in Plan Mode and use \`exit_plan_mode\` to request approval. -## Required Plan Structure -When writing the plan file, you MUST include the following structure: - # Objective - (A concise summary of what needs to be built or fixed) - # Key Files & Context - (List the specific files that will be modified, including helpful context like function signatures or code snippets) - # Implementation Steps - (Iterative development steps, e.g., "1. Implement X in [File]", "2. Verify with test Y") - # Verification & Testing - (Specific unit tests, manual checks, or build commands to verify success) +## Planning Workflow +Plan Mode uses an adaptive planning workflow where the research depth, plan structure, and consultation level are proportional to the task's complexity. -## Workflow -1. **Explore & Analyze:** Analyze requirements and use search/read tools to explore the codebase. For complex tasks, identify at least two viable implementation approaches. -2. **Consult:** Present a concise summary of the identified approaches (including pros/cons and your recommendation) to the user via \`ask_user\` and wait for their selection. For simple or canonical tasks, you may skip this and proceed to drafting. -3. **Draft:** Write the detailed implementation plan for the selected approach to the plans directory using \`write_file\`. -4. **Review & Approval:** Present a brief summary of the drafted plan in your chat response and concurrently call the \`exit_plan_mode\` tool to formally request approval. If rejected, iterate. +### 1. Explore & Analyze +Analyze requirements and use search/read tools to explore the codebase. Systematically map affected modules, trace data flow, and identify dependencies. + +### 2. Consult +The depth of your consultation should be proportional to the task's complexity: +- **Simple Tasks:** Skip consultation and proceed directly to drafting. +- **Standard Tasks:** If multiple viable approaches exist, present a concise summary (including pros/cons and your recommendation) via \`ask_user\` and wait for a decision. +- **Complex Tasks:** You MUST present at least two viable approaches with detailed trade-offs via \`ask_user\` and obtain approval before drafting the plan. + +### 3. Draft +Write the implementation plan to \`/tmp/project-temp/plans/\`. The plan's structure adapts to the task: +- **Simple Tasks:** Include a bulleted list of specific **Changes** and **Verification** steps. +- **Standard Tasks:** Include an **Objective**, **Key Files & Context**, **Implementation Steps**, and **Verification & Testing**. +- **Complex Tasks:** Include **Background & Motivation**, **Scope & Impact**, **Proposed Solution**, **Alternatives Considered**, a phased **Implementation Plan**, **Verification**, and **Migration & Rollback** strategies. + +### 4. Review & Approval +Use the \`exit_plan_mode\` tool to present the plan and formally request approval. # Operational Guidelines @@ -607,7 +655,7 @@ Use the following guidelines to optimize your search and read patterns. -- **Searching:** utilize search tools like grep_search and glob with a conservative result count (\`total_max_matches\`) and a narrow scope (\`include\` and \`exclude\` parameters). +- **Searching:** utilize search tools like grep_search and glob with a conservative result count (\`total_max_matches\`) and a narrow scope (\`include_pattern\` and \`exclude_pattern\` parameters). - **Searching and editing:** utilize search tools like grep_search with a conservative result count and a narrow scope. Use \`context\`, \`before\`, and/or \`after\` to request enough context to avoid the need to read the file before editing matches. - **Understanding:** minimize turns needed to understand a file. It's most efficient to read small files in their entirety. - **Large files:** utilize search tools like grep_search and/or read_file called in parallel with 'start_line' and 'end_line' to reduce the impact on context. Minimize extra turns, unless unavoidable due to the file being too large. @@ -632,6 +680,18 @@ Use the following guidelines to optimize your search and read patterns. Sub-agents are specialized expert agents. Each sub-agent is available as a tool of the same name. You MUST delegate tasks to the sub-agent with the most relevant expertise. +### Strategic Orchestration & Delegation +Operate as a **strategic orchestrator**. Your own context window is your most precious resource. Every turn you take adds to the permanent session history. To keep the session fast and efficient, use sub-agents to "compress" complex or repetitive work. + +When you delegate, the sub-agent's entire execution is consolidated into a single summary in your history, keeping your main loop lean. + +**High-Impact Delegation Candidates:** +- **Repetitive Batch Tasks:** Tasks involving more than 3 files or repeated steps (e.g., "Add license headers to all files in src/", "Fix all lint errors in the project"). +- **High-Volume Output:** Commands or tools expected to return large amounts of data (e.g., verbose builds, exhaustive file searches). +- **Speculative Research:** Investigations that require many "trial and error" steps before a clear path is found. + +**Assertive Action:** Continue to handle "surgical" tasks directly—simple reads, single-file edits, or direct questions that can be resolved in 1-2 turns. Delegation is an efficiency tool, not a way to avoid direct action when it is the fastest path. + mock-agent @@ -763,7 +823,7 @@ Use the following guidelines to optimize your search and read patterns. -- **Searching:** utilize search tools like grep_search and glob with a conservative result count (\`total_max_matches\`) and a narrow scope (\`include\` and \`exclude\` parameters). +- **Searching:** utilize search tools like grep_search and glob with a conservative result count (\`total_max_matches\`) and a narrow scope (\`include_pattern\` and \`exclude_pattern\` parameters). - **Searching and editing:** utilize search tools like grep_search with a conservative result count and a narrow scope. Use \`context\`, \`before\`, and/or \`after\` to request enough context to avoid the need to read the file before editing matches. - **Understanding:** minimize turns needed to understand a file. It's most efficient to read small files in their entirety. - **Large files:** utilize search tools like grep_search and/or read_file called in parallel with 'start_line' and 'end_line' to reduce the impact on context. Minimize extra turns, unless unavoidable due to the file being too large. @@ -885,7 +945,7 @@ Use the following guidelines to optimize your search and read patterns. -- **Searching:** utilize search tools like grep_search and glob with a conservative result count (\`total_max_matches\`) and a narrow scope (\`include\` and \`exclude\` parameters). +- **Searching:** utilize search tools like grep_search and glob with a conservative result count (\`total_max_matches\`) and a narrow scope (\`include_pattern\` and \`exclude_pattern\` parameters). - **Searching and editing:** utilize search tools like grep_search with a conservative result count and a narrow scope. Use \`context\`, \`before\`, and/or \`after\` to request enough context to avoid the need to read the file before editing matches. - **Understanding:** minimize turns needed to understand a file. It's most efficient to read small files in their entirety. - **Large files:** utilize search tools like grep_search and/or read_file called in parallel with 'start_line' and 'end_line' to reduce the impact on context. Minimize extra turns, unless unavoidable due to the file being too large. @@ -1480,7 +1540,7 @@ Use the following guidelines to optimize your search and read patterns. -- **Searching:** utilize search tools like grep_search and glob with a conservative result count (\`total_max_matches\`) and a narrow scope (\`include\` and \`exclude\` parameters). +- **Searching:** utilize search tools like grep_search and glob with a conservative result count (\`total_max_matches\`) and a narrow scope (\`include_pattern\` and \`exclude_pattern\` parameters). - **Searching and editing:** utilize search tools like grep_search with a conservative result count and a narrow scope. Use \`context\`, \`before\`, and/or \`after\` to request enough context to avoid the need to read the file before editing matches. - **Understanding:** minimize turns needed to understand a file. It's most efficient to read small files in their entirety. - **Large files:** utilize search tools like grep_search and/or read_file called in parallel with 'start_line' and 'end_line' to reduce the impact on context. Minimize extra turns, unless unavoidable due to the file being too large. @@ -1506,6 +1566,18 @@ Use the following guidelines to optimize your search and read patterns. Sub-agents are specialized expert agents. Each sub-agent is available as a tool of the same name. You MUST delegate tasks to the sub-agent with the most relevant expertise. +### Strategic Orchestration & Delegation +Operate as a **strategic orchestrator**. Your own context window is your most precious resource. Every turn you take adds to the permanent session history. To keep the session fast and efficient, use sub-agents to "compress" complex or repetitive work. + +When you delegate, the sub-agent's entire execution is consolidated into a single summary in your history, keeping your main loop lean. + +**High-Impact Delegation Candidates:** +- **Repetitive Batch Tasks:** Tasks involving more than 3 files or repeated steps (e.g., "Add license headers to all files in src/", "Fix all lint errors in the project"). +- **High-Volume Output:** Commands or tools expected to return large amounts of data (e.g., verbose builds, exhaustive file searches). +- **Speculative Research:** Investigations that require many "trial and error" steps before a clear path is found. + +**Assertive Action:** Continue to handle "surgical" tasks directly—simple reads, single-file edits, or direct questions that can be resolved in 1-2 turns. Delegation is an efficiency tool, not a way to avoid direct action when it is the fastest path. + mock-agent @@ -1632,7 +1704,7 @@ Use the following guidelines to optimize your search and read patterns. -- **Searching:** utilize search tools like grep_search and glob with a conservative result count (\`total_max_matches\`) and a narrow scope (\`include\` and \`exclude\` parameters). +- **Searching:** utilize search tools like grep_search and glob with a conservative result count (\`total_max_matches\`) and a narrow scope (\`include_pattern\` and \`exclude_pattern\` parameters). - **Searching and editing:** utilize search tools like grep_search with a conservative result count and a narrow scope. Use \`context\`, \`before\`, and/or \`after\` to request enough context to avoid the need to read the file before editing matches. - **Understanding:** minimize turns needed to understand a file. It's most efficient to read small files in their entirety. - **Large files:** utilize search tools like grep_search and/or read_file called in parallel with 'start_line' and 'end_line' to reduce the impact on context. Minimize extra turns, unless unavoidable due to the file being too large. @@ -1657,6 +1729,18 @@ Use the following guidelines to optimize your search and read patterns. Sub-agents are specialized expert agents. Each sub-agent is available as a tool of the same name. You MUST delegate tasks to the sub-agent with the most relevant expertise. +### Strategic Orchestration & Delegation +Operate as a **strategic orchestrator**. Your own context window is your most precious resource. Every turn you take adds to the permanent session history. To keep the session fast and efficient, use sub-agents to "compress" complex or repetitive work. + +When you delegate, the sub-agent's entire execution is consolidated into a single summary in your history, keeping your main loop lean. + +**High-Impact Delegation Candidates:** +- **Repetitive Batch Tasks:** Tasks involving more than 3 files or repeated steps (e.g., "Add license headers to all files in src/", "Fix all lint errors in the project"). +- **High-Volume Output:** Commands or tools expected to return large amounts of data (e.g., verbose builds, exhaustive file searches). +- **Speculative Research:** Investigations that require many "trial and error" steps before a clear path is found. + +**Assertive Action:** Continue to handle "surgical" tasks directly—simple reads, single-file edits, or direct questions that can be resolved in 1-2 turns. Delegation is an efficiency tool, not a way to avoid direct action when it is the fastest path. + mock-agent @@ -1775,7 +1859,7 @@ Use the following guidelines to optimize your search and read patterns. -- **Searching:** utilize search tools like grep_search and glob with a conservative result count (\`total_max_matches\`) and a narrow scope (\`include\` and \`exclude\` parameters). +- **Searching:** utilize search tools like grep_search and glob with a conservative result count (\`total_max_matches\`) and a narrow scope (\`include_pattern\` and \`exclude_pattern\` parameters). - **Searching and editing:** utilize search tools like grep_search with a conservative result count and a narrow scope. Use \`context\`, \`before\`, and/or \`after\` to request enough context to avoid the need to read the file before editing matches. - **Understanding:** minimize turns needed to understand a file. It's most efficient to read small files in their entirety. - **Large files:** utilize search tools like grep_search and/or read_file called in parallel with 'start_line' and 'end_line' to reduce the impact on context. Minimize extra turns, unless unavoidable due to the file being too large. @@ -1800,6 +1884,18 @@ Use the following guidelines to optimize your search and read patterns. Sub-agents are specialized expert agents. Each sub-agent is available as a tool of the same name. You MUST delegate tasks to the sub-agent with the most relevant expertise. +### Strategic Orchestration & Delegation +Operate as a **strategic orchestrator**. Your own context window is your most precious resource. Every turn you take adds to the permanent session history. To keep the session fast and efficient, use sub-agents to "compress" complex or repetitive work. + +When you delegate, the sub-agent's entire execution is consolidated into a single summary in your history, keeping your main loop lean. + +**High-Impact Delegation Candidates:** +- **Repetitive Batch Tasks:** Tasks involving more than 3 files or repeated steps (e.g., "Add license headers to all files in src/", "Fix all lint errors in the project"). +- **High-Volume Output:** Commands or tools expected to return large amounts of data (e.g., verbose builds, exhaustive file searches). +- **Speculative Research:** Investigations that require many "trial and error" steps before a clear path is found. + +**Assertive Action:** Continue to handle "surgical" tasks directly—simple reads, single-file edits, or direct questions that can be resolved in 1-2 turns. Delegation is an efficiency tool, not a way to avoid direct action when it is the fastest path. + mock-agent @@ -1918,7 +2014,7 @@ Use the following guidelines to optimize your search and read patterns. -- **Searching:** utilize search tools like grep_search and glob with a conservative result count (\`total_max_matches\`) and a narrow scope (\`include\` and \`exclude\` parameters). +- **Searching:** utilize search tools like grep_search and glob with a conservative result count (\`total_max_matches\`) and a narrow scope (\`include_pattern\` and \`exclude_pattern\` parameters). - **Searching and editing:** utilize search tools like grep_search with a conservative result count and a narrow scope. Use \`context\`, \`before\`, and/or \`after\` to request enough context to avoid the need to read the file before editing matches. - **Understanding:** minimize turns needed to understand a file. It's most efficient to read small files in their entirety. - **Large files:** utilize search tools like grep_search and/or read_file called in parallel with 'start_line' and 'end_line' to reduce the impact on context. Minimize extra turns, unless unavoidable due to the file being too large. @@ -1943,6 +2039,18 @@ Use the following guidelines to optimize your search and read patterns. Sub-agents are specialized expert agents. Each sub-agent is available as a tool of the same name. You MUST delegate tasks to the sub-agent with the most relevant expertise. +### Strategic Orchestration & Delegation +Operate as a **strategic orchestrator**. Your own context window is your most precious resource. Every turn you take adds to the permanent session history. To keep the session fast and efficient, use sub-agents to "compress" complex or repetitive work. + +When you delegate, the sub-agent's entire execution is consolidated into a single summary in your history, keeping your main loop lean. + +**High-Impact Delegation Candidates:** +- **Repetitive Batch Tasks:** Tasks involving more than 3 files or repeated steps (e.g., "Add license headers to all files in src/", "Fix all lint errors in the project"). +- **High-Volume Output:** Commands or tools expected to return large amounts of data (e.g., verbose builds, exhaustive file searches). +- **Speculative Research:** Investigations that require many "trial and error" steps before a clear path is found. + +**Assertive Action:** Continue to handle "surgical" tasks directly—simple reads, single-file edits, or direct questions that can be resolved in 1-2 turns. Delegation is an efficiency tool, not a way to avoid direct action when it is the fastest path. + mock-agent @@ -2057,7 +2165,7 @@ Use the following guidelines to optimize your search and read patterns. -- **Searching:** utilize search tools like grep_search and glob with a conservative result count (\`total_max_matches\`) and a narrow scope (\`include\` and \`exclude\` parameters). +- **Searching:** utilize search tools like grep_search and glob with a conservative result count (\`total_max_matches\`) and a narrow scope (\`include_pattern\` and \`exclude_pattern\` parameters). - **Searching and editing:** utilize search tools like grep_search with a conservative result count and a narrow scope. Use \`context\`, \`before\`, and/or \`after\` to request enough context to avoid the need to read the file before editing matches. - **Understanding:** minimize turns needed to understand a file. It's most efficient to read small files in their entirety. - **Large files:** utilize search tools like grep_search and/or read_file called in parallel with 'start_line' and 'end_line' to reduce the impact on context. Minimize extra turns, unless unavoidable due to the file being too large. @@ -2082,6 +2190,18 @@ Use the following guidelines to optimize your search and read patterns. Sub-agents are specialized expert agents. Each sub-agent is available as a tool of the same name. You MUST delegate tasks to the sub-agent with the most relevant expertise. +### Strategic Orchestration & Delegation +Operate as a **strategic orchestrator**. Your own context window is your most precious resource. Every turn you take adds to the permanent session history. To keep the session fast and efficient, use sub-agents to "compress" complex or repetitive work. + +When you delegate, the sub-agent's entire execution is consolidated into a single summary in your history, keeping your main loop lean. + +**High-Impact Delegation Candidates:** +- **Repetitive Batch Tasks:** Tasks involving more than 3 files or repeated steps (e.g., "Add license headers to all files in src/", "Fix all lint errors in the project"). +- **High-Volume Output:** Commands or tools expected to return large amounts of data (e.g., verbose builds, exhaustive file searches). +- **Speculative Research:** Investigations that require many "trial and error" steps before a clear path is found. + +**Assertive Action:** Continue to handle "surgical" tasks directly—simple reads, single-file edits, or direct questions that can be resolved in 1-2 turns. Delegation is an efficiency tool, not a way to avoid direct action when it is the fastest path. + mock-agent @@ -2196,7 +2316,7 @@ Use the following guidelines to optimize your search and read patterns. -- **Searching:** utilize search tools like grep_search and glob with a conservative result count (\`total_max_matches\`) and a narrow scope (\`include\` and \`exclude\` parameters). +- **Searching:** utilize search tools like grep_search and glob with a conservative result count (\`total_max_matches\`) and a narrow scope (\`include_pattern\` and \`exclude_pattern\` parameters). - **Searching and editing:** utilize search tools like grep_search with a conservative result count and a narrow scope. Use \`context\`, \`before\`, and/or \`after\` to request enough context to avoid the need to read the file before editing matches. - **Understanding:** minimize turns needed to understand a file. It's most efficient to read small files in their entirety. - **Large files:** utilize search tools like grep_search and/or read_file called in parallel with 'start_line' and 'end_line' to reduce the impact on context. Minimize extra turns, unless unavoidable due to the file being too large. @@ -2221,6 +2341,18 @@ Use the following guidelines to optimize your search and read patterns. Sub-agents are specialized expert agents. Each sub-agent is available as a tool of the same name. You MUST delegate tasks to the sub-agent with the most relevant expertise. +### Strategic Orchestration & Delegation +Operate as a **strategic orchestrator**. Your own context window is your most precious resource. Every turn you take adds to the permanent session history. To keep the session fast and efficient, use sub-agents to "compress" complex or repetitive work. + +When you delegate, the sub-agent's entire execution is consolidated into a single summary in your history, keeping your main loop lean. + +**High-Impact Delegation Candidates:** +- **Repetitive Batch Tasks:** Tasks involving more than 3 files or repeated steps (e.g., "Add license headers to all files in src/", "Fix all lint errors in the project"). +- **High-Volume Output:** Commands or tools expected to return large amounts of data (e.g., verbose builds, exhaustive file searches). +- **Speculative Research:** Investigations that require many "trial and error" steps before a clear path is found. + +**Assertive Action:** Continue to handle "surgical" tasks directly—simple reads, single-file edits, or direct questions that can be resolved in 1-2 turns. Delegation is an efficiency tool, not a way to avoid direct action when it is the fastest path. + mock-agent @@ -2327,7 +2459,7 @@ Use the following guidelines to optimize your search and read patterns. -- **Searching:** utilize search tools like grep_search and glob with a conservative result count (\`total_max_matches\`) and a narrow scope (\`include\` and \`exclude\` parameters). +- **Searching:** utilize search tools like grep_search and glob with a conservative result count (\`total_max_matches\`) and a narrow scope (\`include_pattern\` and \`exclude_pattern\` parameters). - **Searching and editing:** utilize search tools like grep_search with a conservative result count and a narrow scope. Use \`context\`, \`before\`, and/or \`after\` to request enough context to avoid the need to read the file before editing matches. - **Understanding:** minimize turns needed to understand a file. It's most efficient to read small files in their entirety. - **Large files:** utilize search tools like grep_search and/or read_file called in parallel with 'start_line' and 'end_line' to reduce the impact on context. Minimize extra turns, unless unavoidable due to the file being too large. @@ -2352,6 +2484,18 @@ Use the following guidelines to optimize your search and read patterns. Sub-agents are specialized expert agents. Each sub-agent is available as a tool of the same name. You MUST delegate tasks to the sub-agent with the most relevant expertise. +### Strategic Orchestration & Delegation +Operate as a **strategic orchestrator**. Your own context window is your most precious resource. Every turn you take adds to the permanent session history. To keep the session fast and efficient, use sub-agents to "compress" complex or repetitive work. + +When you delegate, the sub-agent's entire execution is consolidated into a single summary in your history, keeping your main loop lean. + +**High-Impact Delegation Candidates:** +- **Repetitive Batch Tasks:** Tasks involving more than 3 files or repeated steps (e.g., "Add license headers to all files in src/", "Fix all lint errors in the project"). +- **High-Volume Output:** Commands or tools expected to return large amounts of data (e.g., verbose builds, exhaustive file searches). +- **Speculative Research:** Investigations that require many "trial and error" steps before a clear path is found. + +**Assertive Action:** Continue to handle "surgical" tasks directly—simple reads, single-file edits, or direct questions that can be resolved in 1-2 turns. Delegation is an efficiency tool, not a way to avoid direct action when it is the fastest path. + mock-agent @@ -2377,7 +2521,7 @@ For example: ## Development Lifecycle Operate using a **Research -> Strategy -> Execution** lifecycle. For the Execution phase, resolve each sub-task through an iterative **Plan -> Act -> Validate** cycle. -1. **Research:** Systematically map the codebase and validate assumptions. Use search tools extensively to understand file structures, existing code patterns, and conventions. Use \`read_file\` to validate all assumptions. **Prioritize empirical reproduction of reported issues to confirm the failure state.** If the request is ambiguous, broad in scope, or involves creating a new feature/application, you MUST use the \`enter_plan_mode\` tool to design your approach before making changes. Do NOT use Plan Mode for straightforward bug fixes, answering questions, or simple inquiries. +1. **Research:** Systematically map the codebase and validate assumptions. Use search tools extensively to understand file structures, existing code patterns, and conventions. Use \`read_file\` to validate all assumptions. **Prioritize empirical reproduction of reported issues to confirm the failure state.** If the request is ambiguous, broad in scope, or involves architectural decisions or cross-cutting changes, use the \`enter_plan_mode\` tool to safely research and design your strategy. Do NOT use Plan Mode for straightforward bug fixes, answering questions, or simple inquiries. 2. **Strategy:** Formulate a grounded plan based on your research. Share a concise summary of your strategy. 3. **Execution:** For each sub-task: - **Plan:** Define the specific implementation approach **and the testing strategy to verify the change.** @@ -2465,7 +2609,7 @@ Use the following guidelines to optimize your search and read patterns. -- **Searching:** utilize search tools like grep_search and glob with a conservative result count (\`total_max_matches\`) and a narrow scope (\`include\` and \`exclude\` parameters). +- **Searching:** utilize search tools like grep_search and glob with a conservative result count (\`total_max_matches\`) and a narrow scope (\`include_pattern\` and \`exclude_pattern\` parameters). - **Searching and editing:** utilize search tools like grep_search with a conservative result count and a narrow scope. Use \`context\`, \`before\`, and/or \`after\` to request enough context to avoid the need to read the file before editing matches. - **Understanding:** minimize turns needed to understand a file. It's most efficient to read small files in their entirety. - **Large files:** utilize search tools like grep_search and/or read_file called in parallel with 'start_line' and 'end_line' to reduce the impact on context. Minimize extra turns, unless unavoidable due to the file being too large. @@ -2490,6 +2634,18 @@ Use the following guidelines to optimize your search and read patterns. Sub-agents are specialized expert agents. Each sub-agent is available as a tool of the same name. You MUST delegate tasks to the sub-agent with the most relevant expertise. +### Strategic Orchestration & Delegation +Operate as a **strategic orchestrator**. Your own context window is your most precious resource. Every turn you take adds to the permanent session history. To keep the session fast and efficient, use sub-agents to "compress" complex or repetitive work. + +When you delegate, the sub-agent's entire execution is consolidated into a single summary in your history, keeping your main loop lean. + +**High-Impact Delegation Candidates:** +- **Repetitive Batch Tasks:** Tasks involving more than 3 files or repeated steps (e.g., "Add license headers to all files in src/", "Fix all lint errors in the project"). +- **High-Volume Output:** Commands or tools expected to return large amounts of data (e.g., verbose builds, exhaustive file searches). +- **Speculative Research:** Investigations that require many "trial and error" steps before a clear path is found. + +**Assertive Action:** Continue to handle "surgical" tasks directly—simple reads, single-file edits, or direct questions that can be resolved in 1-2 turns. Delegation is an efficiency tool, not a way to avoid direct action when it is the fastest path. + test-agent @@ -2845,7 +3001,7 @@ Use the following guidelines to optimize your search and read patterns. -- **Searching:** utilize search tools like grep_search and glob with a conservative result count (\`total_max_matches\`) and a narrow scope (\`include\` and \`exclude\` parameters). +- **Searching:** utilize search tools like grep_search and glob with a conservative result count (\`total_max_matches\`) and a narrow scope (\`include_pattern\` and \`exclude_pattern\` parameters). - **Searching and editing:** utilize search tools like grep_search with a conservative result count and a narrow scope. Use \`context\`, \`before\`, and/or \`after\` to request enough context to avoid the need to read the file before editing matches. - **Understanding:** minimize turns needed to understand a file. It's most efficient to read small files in their entirety. - **Large files:** utilize search tools like grep_search and/or read_file called in parallel with 'start_line' and 'end_line' to reduce the impact on context. Minimize extra turns, unless unavoidable due to the file being too large. @@ -2870,6 +3026,18 @@ Use the following guidelines to optimize your search and read patterns. Sub-agents are specialized expert agents. Each sub-agent is available as a tool of the same name. You MUST delegate tasks to the sub-agent with the most relevant expertise. +### Strategic Orchestration & Delegation +Operate as a **strategic orchestrator**. Your own context window is your most precious resource. Every turn you take adds to the permanent session history. To keep the session fast and efficient, use sub-agents to "compress" complex or repetitive work. + +When you delegate, the sub-agent's entire execution is consolidated into a single summary in your history, keeping your main loop lean. + +**High-Impact Delegation Candidates:** +- **Repetitive Batch Tasks:** Tasks involving more than 3 files or repeated steps (e.g., "Add license headers to all files in src/", "Fix all lint errors in the project"). +- **High-Volume Output:** Commands or tools expected to return large amounts of data (e.g., verbose builds, exhaustive file searches). +- **Speculative Research:** Investigations that require many "trial and error" steps before a clear path is found. + +**Assertive Action:** Continue to handle "surgical" tasks directly—simple reads, single-file edits, or direct questions that can be resolved in 1-2 turns. Delegation is an efficiency tool, not a way to avoid direct action when it is the fastest path. + mock-agent @@ -2984,7 +3152,7 @@ Use the following guidelines to optimize your search and read patterns. -- **Searching:** utilize search tools like grep_search and glob with a conservative result count (\`total_max_matches\`) and a narrow scope (\`include\` and \`exclude\` parameters). +- **Searching:** utilize search tools like grep_search and glob with a conservative result count (\`total_max_matches\`) and a narrow scope (\`include_pattern\` and \`exclude_pattern\` parameters). - **Searching and editing:** utilize search tools like grep_search with a conservative result count and a narrow scope. Use \`context\`, \`before\`, and/or \`after\` to request enough context to avoid the need to read the file before editing matches. - **Understanding:** minimize turns needed to understand a file. It's most efficient to read small files in their entirety. - **Large files:** utilize search tools like grep_search and/or read_file called in parallel with 'start_line' and 'end_line' to reduce the impact on context. Minimize extra turns, unless unavoidable due to the file being too large. @@ -3009,6 +3177,18 @@ Use the following guidelines to optimize your search and read patterns. Sub-agents are specialized expert agents. Each sub-agent is available as a tool of the same name. You MUST delegate tasks to the sub-agent with the most relevant expertise. +### Strategic Orchestration & Delegation +Operate as a **strategic orchestrator**. Your own context window is your most precious resource. Every turn you take adds to the permanent session history. To keep the session fast and efficient, use sub-agents to "compress" complex or repetitive work. + +When you delegate, the sub-agent's entire execution is consolidated into a single summary in your history, keeping your main loop lean. + +**High-Impact Delegation Candidates:** +- **Repetitive Batch Tasks:** Tasks involving more than 3 files or repeated steps (e.g., "Add license headers to all files in src/", "Fix all lint errors in the project"). +- **High-Volume Output:** Commands or tools expected to return large amounts of data (e.g., verbose builds, exhaustive file searches). +- **Speculative Research:** Investigations that require many "trial and error" steps before a clear path is found. + +**Assertive Action:** Continue to handle "surgical" tasks directly—simple reads, single-file edits, or direct questions that can be resolved in 1-2 turns. Delegation is an efficiency tool, not a way to avoid direct action when it is the fastest path. + mock-agent @@ -3235,7 +3415,7 @@ Use the following guidelines to optimize your search and read patterns. -- **Searching:** utilize search tools like grep_search and glob with a conservative result count (\`total_max_matches\`) and a narrow scope (\`include\` and \`exclude\` parameters). +- **Searching:** utilize search tools like grep_search and glob with a conservative result count (\`total_max_matches\`) and a narrow scope (\`include_pattern\` and \`exclude_pattern\` parameters). - **Searching and editing:** utilize search tools like grep_search with a conservative result count and a narrow scope. Use \`context\`, \`before\`, and/or \`after\` to request enough context to avoid the need to read the file before editing matches. - **Understanding:** minimize turns needed to understand a file. It's most efficient to read small files in their entirety. - **Large files:** utilize search tools like grep_search and/or read_file called in parallel with 'start_line' and 'end_line' to reduce the impact on context. Minimize extra turns, unless unavoidable due to the file being too large. @@ -3260,6 +3440,18 @@ Use the following guidelines to optimize your search and read patterns. Sub-agents are specialized expert agents. Each sub-agent is available as a tool of the same name. You MUST delegate tasks to the sub-agent with the most relevant expertise. +### Strategic Orchestration & Delegation +Operate as a **strategic orchestrator**. Your own context window is your most precious resource. Every turn you take adds to the permanent session history. To keep the session fast and efficient, use sub-agents to "compress" complex or repetitive work. + +When you delegate, the sub-agent's entire execution is consolidated into a single summary in your history, keeping your main loop lean. + +**High-Impact Delegation Candidates:** +- **Repetitive Batch Tasks:** Tasks involving more than 3 files or repeated steps (e.g., "Add license headers to all files in src/", "Fix all lint errors in the project"). +- **High-Volume Output:** Commands or tools expected to return large amounts of data (e.g., verbose builds, exhaustive file searches). +- **Speculative Research:** Investigations that require many "trial and error" steps before a clear path is found. + +**Assertive Action:** Continue to handle "surgical" tasks directly—simple reads, single-file edits, or direct questions that can be resolved in 1-2 turns. Delegation is an efficiency tool, not a way to avoid direct action when it is the fastest path. + mock-agent @@ -3374,7 +3566,7 @@ Use the following guidelines to optimize your search and read patterns. -- **Searching:** utilize search tools like grep_search and glob with a conservative result count (\`total_max_matches\`) and a narrow scope (\`include\` and \`exclude\` parameters). +- **Searching:** utilize search tools like grep_search and glob with a conservative result count (\`total_max_matches\`) and a narrow scope (\`include_pattern\` and \`exclude_pattern\` parameters). - **Searching and editing:** utilize search tools like grep_search with a conservative result count and a narrow scope. Use \`context\`, \`before\`, and/or \`after\` to request enough context to avoid the need to read the file before editing matches. - **Understanding:** minimize turns needed to understand a file. It's most efficient to read small files in their entirety. - **Large files:** utilize search tools like grep_search and/or read_file called in parallel with 'start_line' and 'end_line' to reduce the impact on context. Minimize extra turns, unless unavoidable due to the file being too large. @@ -3399,6 +3591,18 @@ Use the following guidelines to optimize your search and read patterns. Sub-agents are specialized expert agents. Each sub-agent is available as a tool of the same name. You MUST delegate tasks to the sub-agent with the most relevant expertise. +### Strategic Orchestration & Delegation +Operate as a **strategic orchestrator**. Your own context window is your most precious resource. Every turn you take adds to the permanent session history. To keep the session fast and efficient, use sub-agents to "compress" complex or repetitive work. + +When you delegate, the sub-agent's entire execution is consolidated into a single summary in your history, keeping your main loop lean. + +**High-Impact Delegation Candidates:** +- **Repetitive Batch Tasks:** Tasks involving more than 3 files or repeated steps (e.g., "Add license headers to all files in src/", "Fix all lint errors in the project"). +- **High-Volume Output:** Commands or tools expected to return large amounts of data (e.g., verbose builds, exhaustive file searches). +- **Speculative Research:** Investigations that require many "trial and error" steps before a clear path is found. + +**Assertive Action:** Continue to handle "surgical" tasks directly—simple reads, single-file edits, or direct questions that can be resolved in 1-2 turns. Delegation is an efficiency tool, not a way to avoid direct action when it is the fastest path. + mock-agent diff --git a/packages/core/src/core/contentGenerator.ts b/packages/core/src/core/contentGenerator.ts index 98d8d50020..29f2ff03df 100644 --- a/packages/core/src/core/contentGenerator.ts +++ b/packages/core/src/core/contentGenerator.ts @@ -77,6 +77,12 @@ export function getAuthTypeFromEnv(): AuthType | undefined { if (process.env['GEMINI_API_KEY']) { return AuthType.USE_GEMINI; } + if ( + process.env['CLOUD_SHELL'] === 'true' || + process.env['GEMINI_CLI_USE_COMPUTE_ADC'] === 'true' + ) { + return AuthType.COMPUTE_ADC; + } return undefined; } diff --git a/packages/core/src/core/geminiChat.test.ts b/packages/core/src/core/geminiChat.test.ts index bfcb803a95..770a594bda 100644 --- a/packages/core/src/core/geminiChat.test.ts +++ b/packages/core/src/core/geminiChat.test.ts @@ -1032,6 +1032,59 @@ describe('GeminiChat', () => { LlmRole.MAIN, ); }); + + it('should flush transcript before tool dispatch for pure tool call with no text or thoughts', async () => { + const pureToolCallStream = (async function* () { + yield { + candidates: [ + { + content: { + role: 'model', + parts: [ + { + functionCall: { + name: 'read_file', + args: { path: 'test.py' }, + }, + }, + ], + }, + }, + ], + } as unknown as GenerateContentResponse; + })(); + + vi.mocked(mockContentGenerator.generateContentStream).mockResolvedValue( + pureToolCallStream, + ); + + const { default: fs } = await import('node:fs'); + const writeFileSync = vi.mocked(fs.writeFileSync); + const writeCountBefore = writeFileSync.mock.calls.length; + + const stream = await chat.sendMessageStream( + { model: 'test-model' }, + 'analyze test.py', + 'prompt-id-pure-tool-flush', + new AbortController().signal, + LlmRole.MAIN, + ); + for await (const _ of stream) { + // consume + } + + const newWrites = writeFileSync.mock.calls.slice(writeCountBefore); + expect(newWrites.length).toBeGreaterThan(0); + + const lastWriteData = JSON.parse( + newWrites[newWrites.length - 1][1] as string, + ) as { messages: Array<{ type: string }> }; + + const geminiMessages = lastWriteData.messages.filter( + (m) => m.type === 'gemini', + ); + expect(geminiMessages.length).toBeGreaterThan(0); + }); }); describe('addHistory', () => { diff --git a/packages/core/src/core/geminiChat.ts b/packages/core/src/core/geminiChat.ts index b7319c8afd..6814f31402 100644 --- a/packages/core/src/core/geminiChat.ts +++ b/packages/core/src/core/geminiChat.ts @@ -818,6 +818,7 @@ export class GeminiChat { const modelResponseParts: Part[] = []; let hasToolCall = false; + let hasThoughts = false; let finishReason: FinishReason | undefined; for await (const chunk of streamResponse) { @@ -834,6 +835,7 @@ export class GeminiChat { if (content?.parts) { if (content.parts.some((part) => part.thought)) { // Record thoughts + hasThoughts = true; this.recordThoughtFromContent(content); } if (content.parts.some((part) => part.functionCall)) { @@ -901,8 +903,10 @@ export class GeminiChat { .join('') .trim(); - // Record model response text from the collected parts - if (responseText) { + // Record model response text from the collected parts. + // Also flush when there are thoughts or a tool call (even with no text) + // so that BeforeTool hooks always see the latest transcript state. + if (responseText || hasThoughts || hasToolCall) { this.chatRecordingService.recordMessage({ model, type: 'gemini', diff --git a/packages/core/src/core/localLiteRtLmClient.test.ts b/packages/core/src/core/localLiteRtLmClient.test.ts new file mode 100644 index 0000000000..c4398b5b9c --- /dev/null +++ b/packages/core/src/core/localLiteRtLmClient.test.ts @@ -0,0 +1,125 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { LocalLiteRtLmClient } from './localLiteRtLmClient.js'; +import type { Config } from '../config/config.js'; +const mockGenerateContent = vi.fn(); + +vi.mock('@google/genai', () => { + const GoogleGenAI = vi.fn().mockImplementation(() => ({ + models: { + generateContent: mockGenerateContent, + }, + })); + return { GoogleGenAI }; +}); + +describe('LocalLiteRtLmClient', () => { + let mockConfig: Config; + + beforeEach(() => { + vi.clearAllMocks(); + mockGenerateContent.mockClear(); + + mockConfig = { + getGemmaModelRouterSettings: vi.fn().mockReturnValue({ + classifier: { + host: 'http://test-host:1234', + model: 'gemma:latest', + }, + }), + } as unknown as Config; + }); + + it('should successfully call generateJson and return parsed JSON', async () => { + mockGenerateContent.mockResolvedValue({ + text: '{"key": "value"}', + }); + + const client = new LocalLiteRtLmClient(mockConfig); + const result = await client.generateJson([], 'test-instruction'); + + expect(result).toEqual({ key: 'value' }); + expect(mockGenerateContent).toHaveBeenCalledWith( + expect.objectContaining({ + model: 'gemma:latest', + config: expect.objectContaining({ + responseMimeType: 'application/json', + temperature: 0, + }), + }), + ); + }); + + it('should throw an error if the API response has no text', async () => { + mockGenerateContent.mockResolvedValue({ + text: null, + }); + + const client = new LocalLiteRtLmClient(mockConfig); + await expect(client.generateJson([], 'test-instruction')).rejects.toThrow( + 'Invalid response from Local Gemini API: No text found', + ); + }); + + it('should throw if the JSON is malformed', async () => { + mockGenerateContent.mockResolvedValue({ + text: `{ + “key”: ‘value’, +}`, // Smart quotes, trailing comma + }); + + const client = new LocalLiteRtLmClient(mockConfig); + await expect(client.generateJson([], 'test-instruction')).rejects.toThrow( + SyntaxError, + ); + }); + + it('should add reminder to the last user message', async () => { + mockGenerateContent.mockResolvedValue({ + text: '{"key": "value"}', + }); + + const client = new LocalLiteRtLmClient(mockConfig); + await client.generateJson( + [{ role: 'user', parts: [{ text: 'initial prompt' }] }], + 'test-instruction', + 'test-reminder', + ); + + const calledContents = + vi.mocked(mockGenerateContent).mock.calls[0][0].contents; + expect(calledContents.at(-1)?.parts[0].text).toBe( + `initial prompt + +test-reminder`, + ); + }); + + it('should pass abortSignal to generateContent', async () => { + mockGenerateContent.mockResolvedValue({ + text: '{"key": "value"}', + }); + + const client = new LocalLiteRtLmClient(mockConfig); + const controller = new AbortController(); + await client.generateJson( + [], + 'test-instruction', + undefined, + controller.signal, + ); + + expect(mockGenerateContent).toHaveBeenCalledWith( + expect.objectContaining({ + config: expect.objectContaining({ + abortSignal: controller.signal, + }), + }), + ); + }); +}); diff --git a/packages/core/src/core/localLiteRtLmClient.ts b/packages/core/src/core/localLiteRtLmClient.ts new file mode 100644 index 0000000000..8f4a020a50 --- /dev/null +++ b/packages/core/src/core/localLiteRtLmClient.ts @@ -0,0 +1,96 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { GoogleGenAI } from '@google/genai'; +import type { Config } from '../config/config.js'; +import { debugLogger } from '../utils/debugLogger.js'; +import type { Content } from '@google/genai'; + +/** + * A client for making single, non-streaming calls to a local Gemini-compatible API + * and expecting a JSON response. + */ +export class LocalLiteRtLmClient { + private readonly host: string; + private readonly model: string; + private readonly client: GoogleGenAI; + + constructor(config: Config) { + const gemmaModelRouterSettings = config.getGemmaModelRouterSettings(); + this.host = gemmaModelRouterSettings.classifier!.host!; + this.model = gemmaModelRouterSettings.classifier!.model!; + + this.client = new GoogleGenAI({ + // The LiteRT-LM server does not require an API key, but the SDK requires one to be set even for local endpoints. This is a dummy value and is not used for authentication. + apiKey: 'no-api-key-needed', + httpOptions: { + baseUrl: this.host, + // If the LiteRT-LM server is started but the wrong port is set, there will be a lengthy TCP timeout (here fixed to be 10 seconds). + // If the LiteRT-LM server is not started, there will be an immediate connection refusal. + // If the LiteRT-LM server is started and the model is unsupported or not downloaded, the server will return an error immediately. + // If the model's context window is exceeded, the server will return an error immediately. + timeout: 10000, + }, + }); + } + + /** + * Sends a prompt to the local Gemini model and expects a JSON object in response. + * @param contents The history and current prompt. + * @param systemInstruction The system prompt. + * @returns A promise that resolves to the parsed JSON object. + */ + async generateJson( + contents: Content[], + systemInstruction: string, + reminder?: string, + abortSignal?: AbortSignal, + ): Promise { + const geminiContents = contents.map((c) => ({ + role: c.role, + parts: c.parts ? c.parts.map((p) => ({ text: p.text })) : [], + })); + + if (reminder) { + const lastContent = geminiContents.at(-1); + if (lastContent?.role === 'user' && lastContent.parts?.[0]?.text) { + lastContent.parts[0].text += `\n\n${reminder}`; + } + } + + try { + const result = await this.client.models.generateContent({ + model: this.model, + contents: geminiContents, + config: { + responseMimeType: 'application/json', + systemInstruction: systemInstruction + ? { parts: [{ text: systemInstruction }] } + : undefined, + temperature: 0, + maxOutputTokens: 256, + abortSignal, + }, + }); + + const text = result.text; + if (!text) { + throw new Error( + 'Invalid response from Local Gemini API: No text found', + ); + } + + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return JSON.parse(result.text); + } catch (error) { + debugLogger.error( + `[LocalLiteRtLmClient] Failed to generate content:`, + error, + ); + throw error; + } + } +} diff --git a/packages/core/src/core/prompts.test.ts b/packages/core/src/core/prompts.test.ts index 0ed072b64f..6d65596ce4 100644 --- a/packages/core/src/core/prompts.test.ts +++ b/packages/core/src/core/prompts.test.ts @@ -652,7 +652,7 @@ describe('Core System Prompt (prompts.ts)', () => { const prompt = getCoreSystemPrompt(mockConfig); expect(prompt).toContain( - 'If the request is ambiguous, broad in scope, or involves creating a new feature/application, you MUST use the `enter_plan_mode` tool to design your approach before making changes. Do NOT use Plan Mode for straightforward bug fixes, answering questions, or simple inquiries.', + 'If the request is ambiguous, broad in scope, or involves architectural decisions or cross-cutting changes, use the `enter_plan_mode` tool to safely research and design your strategy. Do NOT use Plan Mode for straightforward bug fixes, answering questions, or simple inquiries.', ); expect(prompt).toMatchSnapshot(); }); diff --git a/packages/core/src/policy/policies/plan.toml b/packages/core/src/policy/policies/plan.toml index 3a26fab679..a490e589b0 100644 --- a/packages/core/src/policy/policies/plan.toml +++ b/packages/core/src/policy/policies/plan.toml @@ -50,7 +50,7 @@ priority = 70 modes = ["plan"] [[rule]] -toolName = ["ask_user", "exit_plan_mode"] +toolName = ["ask_user", "exit_plan_mode", "save_memory"] decision = "ask_user" priority = 70 modes = ["plan"] diff --git a/packages/core/src/policy/policy-engine.test.ts b/packages/core/src/policy/policy-engine.test.ts index 0d110f8b2d..7accf5c7e5 100644 --- a/packages/core/src/policy/policy-engine.test.ts +++ b/packages/core/src/policy/policy-engine.test.ts @@ -2601,6 +2601,12 @@ describe('PolicyEngine', () => { priority: 70, modes: [ApprovalMode.PLAN], }, + { + toolName: 'save_memory', + decision: PolicyDecision.ASK_USER, + priority: 70, + modes: [ApprovalMode.PLAN], + }, { toolName: 'exit_plan_mode', decision: PolicyDecision.ASK_USER, @@ -2638,6 +2644,7 @@ describe('PolicyEngine', () => { 'web_fetch', 'write_todos', 'memory', + 'save_memory', 'read_tool', 'write_tool', ]); @@ -2667,6 +2674,7 @@ describe('PolicyEngine', () => { expect(excluded.has('activate_skill')).toBe(false); expect(excluded.has('ask_user')).toBe(false); expect(excluded.has('exit_plan_mode')).toBe(false); + expect(excluded.has('save_memory')).toBe(false); // Read-only MCP tool allowed by annotation rule (matched via _serverName) expect(excluded.has('read_tool')).toBe(false); }); diff --git a/packages/core/src/prompts/snippets.ts b/packages/core/src/prompts/snippets.ts index 6f1cb43985..7f6c5c633e 100644 --- a/packages/core/src/prompts/snippets.ts +++ b/packages/core/src/prompts/snippets.ts @@ -189,7 +189,7 @@ Use the following guidelines to optimize your search and read patterns. -- **Searching:** utilize search tools like ${GREP_TOOL_NAME} and ${GLOB_TOOL_NAME} with a conservative result count (\`total_max_matches\`) and a narrow scope (\`include\` and \`exclude\` parameters). +- **Searching:** utilize search tools like ${GREP_TOOL_NAME} and ${GLOB_TOOL_NAME} with a conservative result count (\`total_max_matches\`) and a narrow scope (\`include_pattern\` and \`exclude_pattern\` parameters). - **Searching and editing:** utilize search tools like ${GREP_TOOL_NAME} with a conservative result count and a narrow scope. Use \`context\`, \`before\`, and/or \`after\` to request enough context to avoid the need to read the file before editing matches. - **Understanding:** minimize turns needed to understand a file. It's most efficient to read small files in their entirety. - **Large files:** utilize search tools like ${GREP_TOOL_NAME} and/or ${READ_FILE_TOOL_NAME} called in parallel with 'start_line' and 'end_line' to reduce the impact on context. Minimize extra turns, unless unavoidable due to the file being too large. @@ -228,6 +228,18 @@ export function renderSubAgents(subAgents?: SubAgentOptions[]): string { Sub-agents are specialized expert agents. Each sub-agent is available as a tool of the same name. You MUST delegate tasks to the sub-agent with the most relevant expertise. +### Strategic Orchestration & Delegation +Operate as a **strategic orchestrator**. Your own context window is your most precious resource. Every turn you take adds to the permanent session history. To keep the session fast and efficient, use sub-agents to "compress" complex or repetitive work. + +When you delegate, the sub-agent's entire execution is consolidated into a single summary in your history, keeping your main loop lean. + +**High-Impact Delegation Candidates:** +- **Repetitive Batch Tasks:** Tasks involving more than 3 files or repeated steps (e.g., "Add license headers to all files in src/", "Fix all lint errors in the project"). +- **High-Volume Output:** Commands or tools expected to return large amounts of data (e.g., verbose builds, exhaustive file searches). +- **Speculative Research:** Investigations that require many "trial and error" steps before a clear path is found. + +**Assertive Action:** Continue to handle "surgical" tasks directly—simple reads, single-file edits, or direct questions that can be resolved in 1-2 turns. Delegation is an efficiency tool, not a way to avoid direct action when it is the fastest path. + ${subAgentsXml} @@ -449,7 +461,7 @@ export function renderPlanningWorkflow( return ` # Active Approval Mode: Plan -You are operating in **Plan Mode**. Your goal is to produce a detailed implementation plan in \`${options.plansDir}/\` and get user approval before editing source code. +You are operating in **Plan Mode**. Your goal is to produce an implementation plan in \`${options.plansDir}/\` and get user approval before editing source code. ## Available Tools The following tools are available in Plan Mode: @@ -458,35 +470,35 @@ ${options.planModeToolsList} ## Rules -1. **Read-Only:** You cannot modify source code. You may ONLY use read-only tools to explore, and you can only write to \`${options.plansDir}/\`. If the user asks you to modify source code directly, you MUST explain that you are in Plan Mode and must first create a detailed plan in the plans directory and get approval before any source code changes can be made. +1. **Read-Only:** You cannot modify source code. You may ONLY use read-only tools to explore, and you can only write to \`${options.plansDir}/\`. If the user asks you to modify source code directly, you MUST explain that you are in Plan Mode and must first create a plan and get approval. 2. **Write Constraint:** ${formatToolName(WRITE_FILE_TOOL_NAME)} and ${formatToolName(EDIT_TOOL_NAME)} may ONLY be used to write .md plan files to \`${options.plansDir}/\`. They cannot modify source code. -3. **Efficiency:** Autonomously combine discovery and drafting phases to minimize conversational turns. If the request is ambiguous, use ${formatToolName(ASK_USER_TOOL_NAME)} to clarify. Otherwise, explore the codebase and write the draft in one fluid motion. +3. **Efficiency:** Autonomously combine discovery and drafting phases to minimize conversational turns. If the request is ambiguous, use ${formatToolName(ASK_USER_TOOL_NAME)} to clarify. 4. **Inquiries and Directives:** Distinguish between Inquiries and Directives to minimize unnecessary planning. - - **Inquiries:** If the request is an **Inquiry** (e.g., "How does X work?"), use read-only tools to explore and answer directly in your chat response. DO NOT create a plan or call ${formatToolName( - EXIT_PLAN_MODE_TOOL_NAME, - )}. - - **Directives:** If the request is a **Directive** (e.g., "Fix bug Y"), follow the workflow below to create and approve a plan. -5. **Plan Storage:** Save plans as Markdown (.md) using descriptive filenames (e.g., \`feature-x.md\`). -6. **Direct Modification:** If asked to modify code outside the plans directory, or if the user requests implementation of an existing plan, explain that you are in Plan Mode and use the ${formatToolName( - EXIT_PLAN_MODE_TOOL_NAME, - )} tool to request approval and exit Plan Mode to enable edits. + - **Inquiries:** If the request is an **Inquiry** (e.g., "How does X work?"), answer directly. DO NOT create a plan. + - **Directives:** If the request is a **Directive** (e.g., "Fix bug Y"), follow the workflow below. +5. **Plan Storage:** Save plans as Markdown (.md) using descriptive filenames. +6. **Direct Modification:** If asked to modify code, explain you are in Plan Mode and use ${formatToolName(EXIT_PLAN_MODE_TOOL_NAME)} to request approval. -## Required Plan Structure -When writing the plan file, you MUST include the following structure: - # Objective - (A concise summary of what needs to be built or fixed) - # Key Files & Context - (List the specific files that will be modified, including helpful context like function signatures or code snippets) - # Implementation Steps - (Iterative development steps, e.g., "1. Implement X in [File]", "2. Verify with test Y") - # Verification & Testing - (Specific unit tests, manual checks, or build commands to verify success) +## Planning Workflow +Plan Mode uses an adaptive planning workflow where the research depth, plan structure, and consultation level are proportional to the task's complexity. -## Workflow -1. **Explore & Analyze:** Analyze requirements and use search/read tools to explore the codebase. For complex tasks, identify at least two viable implementation approaches. -2. **Consult:** Present a concise summary of the identified approaches (including pros/cons and your recommendation) to the user via ${formatToolName(ASK_USER_TOOL_NAME)} and wait for their selection. For simple or canonical tasks, you may skip this and proceed to drafting. -3. **Draft:** Write the detailed implementation plan for the selected approach to the plans directory using ${formatToolName(WRITE_FILE_TOOL_NAME)}. -4. **Review & Approval:** Present a brief summary of the drafted plan in your chat response and concurrently call the ${formatToolName(EXIT_PLAN_MODE_TOOL_NAME)} tool to formally request approval. If rejected, iterate. +### 1. Explore & Analyze +Analyze requirements and use search/read tools to explore the codebase. Systematically map affected modules, trace data flow, and identify dependencies. + +### 2. Consult +The depth of your consultation should be proportional to the task's complexity: +- **Simple Tasks:** Skip consultation and proceed directly to drafting. +- **Standard Tasks:** If multiple viable approaches exist, present a concise summary (including pros/cons and your recommendation) via ${formatToolName(ASK_USER_TOOL_NAME)} and wait for a decision. +- **Complex Tasks:** You MUST present at least two viable approaches with detailed trade-offs via ${formatToolName(ASK_USER_TOOL_NAME)} and obtain approval before drafting the plan. + +### 3. Draft +Write the implementation plan to \`${options.plansDir}/\`. The plan's structure adapts to the task: +- **Simple Tasks:** Include a bulleted list of specific **Changes** and **Verification** steps. +- **Standard Tasks:** Include an **Objective**, **Key Files & Context**, **Implementation Steps**, and **Verification & Testing**. +- **Complex Tasks:** Include **Background & Motivation**, **Scope & Impact**, **Proposed Solution**, **Alternatives Considered**, a phased **Implementation Plan**, **Verification**, and **Migration & Rollback** strategies. + +### 4. Review & Approval +Use the ${formatToolName(EXIT_PLAN_MODE_TOOL_NAME)} tool to present the plan and formally request approval. ${renderApprovedPlanSection(options.approvedPlanPath)}`.trim(); } @@ -529,7 +541,7 @@ function mandateContinueWork(interactive: boolean): string { function workflowStepResearch(options: PrimaryWorkflowsOptions): string { let suggestion = ''; if (options.enableEnterPlanModeTool) { - suggestion = ` If the request is ambiguous, broad in scope, or involves creating a new feature/application, you MUST use the ${formatToolName(ENTER_PLAN_MODE_TOOL_NAME)} tool to design your approach before making changes. Do NOT use Plan Mode for straightforward bug fixes, answering questions, or simple inquiries.`; + suggestion = ` If the request is ambiguous, broad in scope, or involves architectural decisions or cross-cutting changes, use the ${formatToolName(ENTER_PLAN_MODE_TOOL_NAME)} tool to safely research and design your strategy. Do NOT use Plan Mode for straightforward bug fixes, answering questions, or simple inquiries.`; } const searchTools: string[] = []; diff --git a/packages/core/src/routing/modelRouterService.test.ts b/packages/core/src/routing/modelRouterService.test.ts index 144d8d3232..ad0e3c890e 100644 --- a/packages/core/src/routing/modelRouterService.test.ts +++ b/packages/core/src/routing/modelRouterService.test.ts @@ -9,6 +9,7 @@ import { ModelRouterService } from './modelRouterService.js'; import { Config } from '../config/config.js'; import type { BaseLlmClient } from '../core/baseLlmClient.js'; +import type { LocalLiteRtLmClient } from '../core/localLiteRtLmClient.js'; import type { RoutingContext, RoutingDecision } from './routingStrategy.js'; import { DefaultStrategy } from './strategies/defaultStrategy.js'; import { CompositeStrategy } from './strategies/compositeStrategy.js'; @@ -19,6 +20,7 @@ import { ClassifierStrategy } from './strategies/classifierStrategy.js'; import { NumericalClassifierStrategy } from './strategies/numericalClassifierStrategy.js'; import { logModelRouting } from '../telemetry/loggers.js'; import { ModelRoutingEvent } from '../telemetry/types.js'; +import { GemmaClassifierStrategy } from './strategies/gemmaClassifierStrategy.js'; import { ApprovalMode } from '../policy/types.js'; vi.mock('../config/config.js'); @@ -30,6 +32,7 @@ vi.mock('./strategies/overrideStrategy.js'); vi.mock('./strategies/approvalModeStrategy.js'); vi.mock('./strategies/classifierStrategy.js'); vi.mock('./strategies/numericalClassifierStrategy.js'); +vi.mock('./strategies/gemmaClassifierStrategy.js'); vi.mock('../telemetry/loggers.js'); vi.mock('../telemetry/types.js'); @@ -37,6 +40,7 @@ describe('ModelRouterService', () => { let service: ModelRouterService; let mockConfig: Config; let mockBaseLlmClient: BaseLlmClient; + let mockLocalLiteRtLmClient: LocalLiteRtLmClient; let mockContext: RoutingContext; let mockCompositeStrategy: CompositeStrategy; @@ -45,9 +49,20 @@ describe('ModelRouterService', () => { mockConfig = new Config({} as never); mockBaseLlmClient = {} as BaseLlmClient; + mockLocalLiteRtLmClient = {} as LocalLiteRtLmClient; vi.spyOn(mockConfig, 'getBaseLlmClient').mockReturnValue(mockBaseLlmClient); + vi.spyOn(mockConfig, 'getLocalLiteRtLmClient').mockReturnValue( + mockLocalLiteRtLmClient, + ); vi.spyOn(mockConfig, 'getNumericalRoutingEnabled').mockResolvedValue(false); vi.spyOn(mockConfig, 'getClassifierThreshold').mockResolvedValue(undefined); + vi.spyOn(mockConfig, 'getGemmaModelRouterSettings').mockReturnValue({ + enabled: false, + classifier: { + host: 'http://localhost:1234', + model: 'gemma3-1b-gpu-custom', + }, + }); vi.spyOn(mockConfig, 'getApprovalMode').mockReturnValue( ApprovalMode.DEFAULT, ); @@ -96,6 +111,36 @@ describe('ModelRouterService', () => { expect(compositeStrategyArgs[1]).toBe('agent-router'); }); + it('should include GemmaClassifierStrategy when enabled', () => { + // Override the default mock for this specific test + vi.spyOn(mockConfig, 'getGemmaModelRouterSettings').mockReturnValue({ + enabled: true, + classifier: { + host: 'http://localhost:1234', + model: 'gemma3-1b-gpu-custom', + }, + }); + + // Clear previous mock calls from beforeEach + vi.mocked(CompositeStrategy).mockClear(); + + // Re-initialize the service to pick up the new config + service = new ModelRouterService(mockConfig); + + const compositeStrategyArgs = vi.mocked(CompositeStrategy).mock.calls[0]; + const childStrategies = compositeStrategyArgs[0]; + + expect(childStrategies.length).toBe(7); + expect(childStrategies[0]).toBeInstanceOf(FallbackStrategy); + expect(childStrategies[1]).toBeInstanceOf(OverrideStrategy); + expect(childStrategies[2]).toBeInstanceOf(ApprovalModeStrategy); + expect(childStrategies[3]).toBeInstanceOf(GemmaClassifierStrategy); + expect(childStrategies[4]).toBeInstanceOf(ClassifierStrategy); + expect(childStrategies[5]).toBeInstanceOf(NumericalClassifierStrategy); + expect(childStrategies[6]).toBeInstanceOf(DefaultStrategy); + expect(compositeStrategyArgs[1]).toBe('agent-router'); + }); + describe('route()', () => { const strategyDecision: RoutingDecision = { model: 'strategy-chosen-model', @@ -117,6 +162,7 @@ describe('ModelRouterService', () => { mockContext, mockConfig, mockBaseLlmClient, + mockLocalLiteRtLmClient, ); expect(decision).toEqual(strategyDecision); }); diff --git a/packages/core/src/routing/modelRouterService.ts b/packages/core/src/routing/modelRouterService.ts index 54cfa72259..1bd19f3622 100644 --- a/packages/core/src/routing/modelRouterService.ts +++ b/packages/core/src/routing/modelRouterService.ts @@ -4,10 +4,12 @@ * SPDX-License-Identifier: Apache-2.0 */ +import { GemmaClassifierStrategy } from './strategies/gemmaClassifierStrategy.js'; import type { Config } from '../config/config.js'; import type { RoutingContext, RoutingDecision, + RoutingStrategy, TerminalStrategy, } from './routingStrategy.js'; import { DefaultStrategy } from './strategies/defaultStrategy.js'; @@ -35,17 +37,31 @@ export class ModelRouterService { } private initializeDefaultStrategy(): TerminalStrategy { - // Initialize the composite strategy with the desired priority order. - // The strategies are ordered in order of highest priority. + const strategies: RoutingStrategy[] = []; + + // Order matters here. Fallback and override are checked first. + strategies.push(new FallbackStrategy()); + strategies.push(new OverrideStrategy()); + + // Approval mode is next. + strategies.push(new ApprovalModeStrategy()); + + // Then, if enabled, the Gemma classifier is used. + if (this.config.getGemmaModelRouterSettings()?.enabled) { + strategies.push(new GemmaClassifierStrategy()); + } + + // The generic classifier is next. + strategies.push(new ClassifierStrategy()); + + // The numerical classifier is next. + strategies.push(new NumericalClassifierStrategy()); + + // The default strategy is the terminal strategy. + const terminalStrategy = new DefaultStrategy(); + return new CompositeStrategy( - [ - new FallbackStrategy(), - new OverrideStrategy(), - new ApprovalModeStrategy(), - new ClassifierStrategy(), - new NumericalClassifierStrategy(), - new DefaultStrategy(), - ], + [...strategies, terminalStrategy], 'agent-router', ); } @@ -75,6 +91,7 @@ export class ModelRouterService { context, this.config, this.config.getBaseLlmClient(), + this.config.getLocalLiteRtLmClient(), ); debugLogger.debug( diff --git a/packages/core/src/routing/routingStrategy.ts b/packages/core/src/routing/routingStrategy.ts index de8bcf04f1..a2f9448989 100644 --- a/packages/core/src/routing/routingStrategy.ts +++ b/packages/core/src/routing/routingStrategy.ts @@ -7,6 +7,7 @@ import type { Content, PartListUnion } from '@google/genai'; import type { BaseLlmClient } from '../core/baseLlmClient.js'; import type { Config } from '../config/config.js'; +import type { LocalLiteRtLmClient } from '../core/localLiteRtLmClient.js'; /** * The output of a routing decision. It specifies which model to use and why. @@ -58,6 +59,7 @@ export interface RoutingStrategy { context: RoutingContext, config: Config, baseLlmClient: BaseLlmClient, + localLiteRtLmClient: LocalLiteRtLmClient, ): Promise; } @@ -74,5 +76,6 @@ export interface TerminalStrategy extends RoutingStrategy { context: RoutingContext, config: Config, baseLlmClient: BaseLlmClient, + localLiteRtLmClient: LocalLiteRtLmClient, ): Promise; } diff --git a/packages/core/src/routing/strategies/classifierStrategy.test.ts b/packages/core/src/routing/strategies/classifierStrategy.test.ts index 7e024b790a..701e7de932 100644 --- a/packages/core/src/routing/strategies/classifierStrategy.test.ts +++ b/packages/core/src/routing/strategies/classifierStrategy.test.ts @@ -9,6 +9,7 @@ import { ClassifierStrategy } from './classifierStrategy.js'; import type { RoutingContext } from '../routingStrategy.js'; import type { Config } from '../../config/config.js'; import type { BaseLlmClient } from '../../core/baseLlmClient.js'; +import type { LocalLiteRtLmClient } from '../../core/localLiteRtLmClient.js'; import { isFunctionCall, isFunctionResponse, @@ -34,6 +35,7 @@ describe('ClassifierStrategy', () => { let mockContext: RoutingContext; let mockConfig: Config; let mockBaseLlmClient: BaseLlmClient; + let mockLocalLiteRtLmClient: LocalLiteRtLmClient; let mockResolvedConfig: ResolvedModelConfig; beforeEach(() => { @@ -64,6 +66,7 @@ describe('ClassifierStrategy', () => { mockBaseLlmClient = { generateJson: vi.fn(), } as unknown as BaseLlmClient; + mockLocalLiteRtLmClient = {} as LocalLiteRtLmClient; vi.spyOn(promptIdContext, 'getStore').mockReturnValue('test-prompt-id'); }); @@ -76,6 +79,7 @@ describe('ClassifierStrategy', () => { mockContext, mockConfig, mockBaseLlmClient, + mockLocalLiteRtLmClient, ); expect(decision).toBeNull(); @@ -94,6 +98,7 @@ describe('ClassifierStrategy', () => { mockContext, mockConfig, mockBaseLlmClient, + mockLocalLiteRtLmClient, ); expect(decision).not.toBeNull(); @@ -109,7 +114,12 @@ describe('ClassifierStrategy', () => { mockApiResponse, ); - await strategy.route(mockContext, mockConfig, mockBaseLlmClient); + await strategy.route( + mockContext, + mockConfig, + mockBaseLlmClient, + mockLocalLiteRtLmClient, + ); expect(mockBaseLlmClient.generateJson).toHaveBeenCalledWith( expect.objectContaining({ @@ -132,6 +142,7 @@ describe('ClassifierStrategy', () => { mockContext, mockConfig, mockBaseLlmClient, + mockLocalLiteRtLmClient, ); expect(mockBaseLlmClient.generateJson).toHaveBeenCalledOnce(); @@ -159,6 +170,7 @@ describe('ClassifierStrategy', () => { mockContext, mockConfig, mockBaseLlmClient, + mockLocalLiteRtLmClient, ); expect(mockBaseLlmClient.generateJson).toHaveBeenCalledOnce(); @@ -183,6 +195,7 @@ describe('ClassifierStrategy', () => { mockContext, mockConfig, mockBaseLlmClient, + mockLocalLiteRtLmClient, ); expect(decision).toBeNull(); @@ -206,6 +219,7 @@ describe('ClassifierStrategy', () => { mockContext, mockConfig, mockBaseLlmClient, + mockLocalLiteRtLmClient, ); expect(decision).toBeNull(); @@ -233,7 +247,12 @@ describe('ClassifierStrategy', () => { mockApiResponse, ); - await strategy.route(mockContext, mockConfig, mockBaseLlmClient); + await strategy.route( + mockContext, + mockConfig, + mockBaseLlmClient, + mockLocalLiteRtLmClient, + ); const generateJsonCall = vi.mocked(mockBaseLlmClient.generateJson).mock .calls[0][0]; @@ -269,7 +288,12 @@ describe('ClassifierStrategy', () => { mockApiResponse, ); - await strategy.route(mockContext, mockConfig, mockBaseLlmClient); + await strategy.route( + mockContext, + mockConfig, + mockBaseLlmClient, + mockLocalLiteRtLmClient, + ); const generateJsonCall = vi.mocked(mockBaseLlmClient.generateJson).mock .calls[0][0]; @@ -305,7 +329,12 @@ describe('ClassifierStrategy', () => { mockApiResponse, ); - await strategy.route(mockContext, mockConfig, mockBaseLlmClient); + await strategy.route( + mockContext, + mockConfig, + mockBaseLlmClient, + mockLocalLiteRtLmClient, + ); const generateJsonCall = vi.mocked(mockBaseLlmClient.generateJson).mock .calls[0][0]; @@ -340,6 +369,7 @@ describe('ClassifierStrategy', () => { contextWithRequestedModel, mockConfig, mockBaseLlmClient, + mockLocalLiteRtLmClient, ); expect(decision).not.toBeNull(); @@ -363,6 +393,7 @@ describe('ClassifierStrategy', () => { mockContext, mockConfig, mockBaseLlmClient, + mockLocalLiteRtLmClient, ); expect(decision?.model).toBe(PREVIEW_GEMINI_3_1_MODEL); @@ -386,6 +417,7 @@ describe('ClassifierStrategy', () => { mockContext, mockConfig, mockBaseLlmClient, + mockLocalLiteRtLmClient, ); expect(decision?.model).toBe(PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL); diff --git a/packages/core/src/routing/strategies/classifierStrategy.ts b/packages/core/src/routing/strategies/classifierStrategy.ts index 7e54d161de..5fd6208b15 100644 --- a/packages/core/src/routing/strategies/classifierStrategy.ts +++ b/packages/core/src/routing/strategies/classifierStrategy.ts @@ -20,6 +20,7 @@ import { isFunctionResponse, } from '../../utils/messageInspectors.js'; import { debugLogger } from '../../utils/debugLogger.js'; +import type { LocalLiteRtLmClient } from '../../core/localLiteRtLmClient.js'; import { LlmRole } from '../../telemetry/types.js'; import { AuthType } from '../../core/contentGenerator.js'; @@ -132,6 +133,7 @@ export class ClassifierStrategy implements RoutingStrategy { context: RoutingContext, config: Config, baseLlmClient: BaseLlmClient, + _localLiteRtLmClient: LocalLiteRtLmClient, ): Promise { const startTime = Date.now(); try { diff --git a/packages/core/src/routing/strategies/compositeStrategy.test.ts b/packages/core/src/routing/strategies/compositeStrategy.test.ts index 1be0b8a8e3..5b627a1692 100644 --- a/packages/core/src/routing/strategies/compositeStrategy.test.ts +++ b/packages/core/src/routing/strategies/compositeStrategy.test.ts @@ -16,6 +16,7 @@ import type { Config } from '../../config/config.js'; import type { BaseLlmClient } from '../../core/baseLlmClient.js'; import { debugLogger } from '../../utils/debugLogger.js'; import { coreEvents } from '../../utils/events.js'; +import type { LocalLiteRtLmClient } from '../../core/localLiteRtLmClient.js'; vi.mock('../../utils/debugLogger.js', () => ({ debugLogger: { @@ -27,6 +28,7 @@ describe('CompositeStrategy', () => { let mockContext: RoutingContext; let mockConfig: Config; let mockBaseLlmClient: BaseLlmClient; + let mockLocalLiteRtLmClient: LocalLiteRtLmClient; let mockStrategy1: RoutingStrategy; let mockStrategy2: RoutingStrategy; let mockTerminalStrategy: TerminalStrategy; @@ -38,6 +40,7 @@ describe('CompositeStrategy', () => { mockContext = {} as RoutingContext; mockConfig = {} as Config; mockBaseLlmClient = {} as BaseLlmClient; + mockLocalLiteRtLmClient = {} as LocalLiteRtLmClient; emitFeedbackSpy = vi.spyOn(coreEvents, 'emitFeedback'); @@ -84,17 +87,20 @@ describe('CompositeStrategy', () => { mockContext, mockConfig, mockBaseLlmClient, + mockLocalLiteRtLmClient, ); expect(mockStrategy1.route).toHaveBeenCalledWith( mockContext, mockConfig, mockBaseLlmClient, + mockLocalLiteRtLmClient, ); expect(mockStrategy2.route).toHaveBeenCalledWith( mockContext, mockConfig, mockBaseLlmClient, + mockLocalLiteRtLmClient, ); expect(mockTerminalStrategy.route).not.toHaveBeenCalled(); @@ -112,6 +118,7 @@ describe('CompositeStrategy', () => { mockContext, mockConfig, mockBaseLlmClient, + mockLocalLiteRtLmClient, ); expect(mockStrategy1.route).toHaveBeenCalledTimes(1); @@ -136,6 +143,7 @@ describe('CompositeStrategy', () => { mockContext, mockConfig, mockBaseLlmClient, + mockLocalLiteRtLmClient, ); expect(debugLogger.warn).toHaveBeenCalledWith( @@ -152,7 +160,12 @@ describe('CompositeStrategy', () => { const composite = new CompositeStrategy([mockTerminalStrategy]); await expect( - composite.route(mockContext, mockConfig, mockBaseLlmClient), + composite.route( + mockContext, + mockConfig, + mockBaseLlmClient, + mockLocalLiteRtLmClient, + ), ).rejects.toThrow(terminalError); expect(emitFeedbackSpy).toHaveBeenCalledWith( @@ -182,6 +195,7 @@ describe('CompositeStrategy', () => { mockContext, mockConfig, mockBaseLlmClient, + mockLocalLiteRtLmClient, ); expect(result.model).toBe('some-model'); @@ -212,6 +226,7 @@ describe('CompositeStrategy', () => { mockContext, mockConfig, mockBaseLlmClient, + mockLocalLiteRtLmClient, ); expect(result.metadata.latencyMs).toBeGreaterThanOrEqual(0); diff --git a/packages/core/src/routing/strategies/compositeStrategy.ts b/packages/core/src/routing/strategies/compositeStrategy.ts index 29e6b96355..1706282864 100644 --- a/packages/core/src/routing/strategies/compositeStrategy.ts +++ b/packages/core/src/routing/strategies/compositeStrategy.ts @@ -14,6 +14,7 @@ import type { RoutingStrategy, TerminalStrategy, } from '../routingStrategy.js'; +import type { LocalLiteRtLmClient } from '../../core/localLiteRtLmClient.js'; /** * A strategy that attempts a list of child strategies in order (Chain of Responsibility). @@ -40,6 +41,7 @@ export class CompositeStrategy implements TerminalStrategy { context: RoutingContext, config: Config, baseLlmClient: BaseLlmClient, + localLiteRtLmClient: LocalLiteRtLmClient, ): Promise { const startTime = performance.now(); @@ -57,7 +59,12 @@ export class CompositeStrategy implements TerminalStrategy { // Try non-terminal strategies, allowing them to fail gracefully. for (const strategy of nonTerminalStrategies) { try { - const decision = await strategy.route(context, config, baseLlmClient); + const decision = await strategy.route( + context, + config, + baseLlmClient, + localLiteRtLmClient, + ); if (decision) { return this.finalizeDecision(decision, startTime); } @@ -75,6 +82,7 @@ export class CompositeStrategy implements TerminalStrategy { context, config, baseLlmClient, + localLiteRtLmClient, ); return this.finalizeDecision(decision, startTime); diff --git a/packages/core/src/routing/strategies/defaultStrategy.test.ts b/packages/core/src/routing/strategies/defaultStrategy.test.ts index ceec72d171..de27a84e19 100644 --- a/packages/core/src/routing/strategies/defaultStrategy.test.ts +++ b/packages/core/src/routing/strategies/defaultStrategy.test.ts @@ -8,6 +8,7 @@ import { describe, it, expect, vi } from 'vitest'; import { DefaultStrategy } from './defaultStrategy.js'; import type { RoutingContext } from '../routingStrategy.js'; import type { BaseLlmClient } from '../../core/baseLlmClient.js'; +import type { LocalLiteRtLmClient } from '../../core/localLiteRtLmClient.js'; import { DEFAULT_GEMINI_MODEL, PREVIEW_GEMINI_MODEL, @@ -26,8 +27,14 @@ describe('DefaultStrategy', () => { getModel: vi.fn().mockReturnValue(DEFAULT_GEMINI_MODEL_AUTO), } as unknown as Config; const mockClient = {} as BaseLlmClient; + const mockLocalLiteRtLmClient = {} as LocalLiteRtLmClient; - const decision = await strategy.route(mockContext, mockConfig, mockClient); + const decision = await strategy.route( + mockContext, + mockConfig, + mockClient, + mockLocalLiteRtLmClient, + ); expect(decision).toEqual({ model: DEFAULT_GEMINI_MODEL, @@ -46,8 +53,14 @@ describe('DefaultStrategy', () => { getModel: vi.fn().mockReturnValue(PREVIEW_GEMINI_MODEL_AUTO), } as unknown as Config; const mockClient = {} as BaseLlmClient; + const mockLocalLiteRtLmClient = {} as LocalLiteRtLmClient; - const decision = await strategy.route(mockContext, mockConfig, mockClient); + const decision = await strategy.route( + mockContext, + mockConfig, + mockClient, + mockLocalLiteRtLmClient, + ); expect(decision).toEqual({ model: PREVIEW_GEMINI_MODEL, @@ -66,8 +79,14 @@ describe('DefaultStrategy', () => { getModel: vi.fn().mockReturnValue(GEMINI_MODEL_ALIAS_AUTO), } as unknown as Config; const mockClient = {} as BaseLlmClient; + const mockLocalLiteRtLmClient = {} as LocalLiteRtLmClient; - const decision = await strategy.route(mockContext, mockConfig, mockClient); + const decision = await strategy.route( + mockContext, + mockConfig, + mockClient, + mockLocalLiteRtLmClient, + ); expect(decision).toEqual({ model: PREVIEW_GEMINI_MODEL, @@ -87,8 +106,14 @@ describe('DefaultStrategy', () => { getModel: vi.fn().mockReturnValue(PREVIEW_GEMINI_FLASH_MODEL), } as unknown as Config; const mockClient = {} as BaseLlmClient; + const mockLocalLiteRtLmClient = {} as LocalLiteRtLmClient; - const decision = await strategy.route(mockContext, mockConfig, mockClient); + const decision = await strategy.route( + mockContext, + mockConfig, + mockClient, + mockLocalLiteRtLmClient, + ); expect(decision).toEqual({ model: PREVIEW_GEMINI_FLASH_MODEL, diff --git a/packages/core/src/routing/strategies/defaultStrategy.ts b/packages/core/src/routing/strategies/defaultStrategy.ts index 1f5b7e54c2..d380ba7ad2 100644 --- a/packages/core/src/routing/strategies/defaultStrategy.ts +++ b/packages/core/src/routing/strategies/defaultStrategy.ts @@ -12,6 +12,7 @@ import type { TerminalStrategy, } from '../routingStrategy.js'; import { resolveModel } from '../../config/models.js'; +import type { LocalLiteRtLmClient } from '../../core/localLiteRtLmClient.js'; export class DefaultStrategy implements TerminalStrategy { readonly name = 'default'; @@ -20,6 +21,7 @@ export class DefaultStrategy implements TerminalStrategy { _context: RoutingContext, config: Config, _baseLlmClient: BaseLlmClient, + _localLiteRtLmClient: LocalLiteRtLmClient, ): Promise { const defaultModel = resolveModel( config.getModel(), diff --git a/packages/core/src/routing/strategies/fallbackStrategy.test.ts b/packages/core/src/routing/strategies/fallbackStrategy.test.ts index d0be7938c4..ffe2ed6446 100644 --- a/packages/core/src/routing/strategies/fallbackStrategy.test.ts +++ b/packages/core/src/routing/strategies/fallbackStrategy.test.ts @@ -10,6 +10,7 @@ import type { RoutingContext } from '../routingStrategy.js'; import type { BaseLlmClient } from '../../core/baseLlmClient.js'; import type { Config } from '../../config/config.js'; import type { ModelAvailabilityService } from '../../availability/modelAvailabilityService.js'; +import type { LocalLiteRtLmClient } from '../../core/localLiteRtLmClient.js'; import { DEFAULT_GEMINI_MODEL, DEFAULT_GEMINI_FLASH_MODEL, @@ -32,6 +33,7 @@ describe('FallbackStrategy', () => { const strategy = new FallbackStrategy(); const mockContext = {} as RoutingContext; const mockClient = {} as BaseLlmClient; + const mockLocalLiteRtLmClient = {} as LocalLiteRtLmClient; let mockService: ModelAvailabilityService; let mockConfig: Config; @@ -51,7 +53,12 @@ describe('FallbackStrategy', () => { // Mock snapshot to return available vi.mocked(mockService.snapshot).mockReturnValue({ available: true }); - const decision = await strategy.route(mockContext, mockConfig, mockClient); + const decision = await strategy.route( + mockContext, + mockConfig, + mockClient, + mockLocalLiteRtLmClient, + ); expect(decision).toBeNull(); // Should check availability of the resolved model (DEFAULT_GEMINI_MODEL) expect(mockService.snapshot).toHaveBeenCalledWith(DEFAULT_GEMINI_MODEL); @@ -69,7 +76,12 @@ describe('FallbackStrategy', () => { skipped: [], }); - const decision = await strategy.route(mockContext, mockConfig, mockClient); + const decision = await strategy.route( + mockContext, + mockConfig, + mockClient, + mockLocalLiteRtLmClient, + ); expect(decision).toBeNull(); }); @@ -86,7 +98,12 @@ describe('FallbackStrategy', () => { skipped: [{ model: DEFAULT_GEMINI_MODEL, reason: 'quota' }], }); - const decision = await strategy.route(mockContext, mockConfig, mockClient); + const decision = await strategy.route( + mockContext, + mockConfig, + mockClient, + mockLocalLiteRtLmClient, + ); expect(decision).not.toBeNull(); expect(decision?.model).toBe(DEFAULT_GEMINI_FLASH_MODEL); @@ -101,7 +118,12 @@ describe('FallbackStrategy', () => { vi.mocked(mockService.snapshot).mockReturnValue({ available: true }); vi.mocked(mockConfig.getModel).mockReturnValue(DEFAULT_GEMINI_MODEL_AUTO); - const decision = await strategy.route(mockContext, mockConfig, mockClient); + const decision = await strategy.route( + mockContext, + mockConfig, + mockClient, + mockLocalLiteRtLmClient, + ); expect(decision).toBeNull(); // Important: check that it queried snapshot with the RESOLVED model, not 'auto' @@ -122,6 +144,7 @@ describe('FallbackStrategy', () => { contextWithRequestedModel, mockConfig, mockClient, + mockLocalLiteRtLmClient, ); expect(decision).toBeNull(); diff --git a/packages/core/src/routing/strategies/fallbackStrategy.ts b/packages/core/src/routing/strategies/fallbackStrategy.ts index a18e4fc4dd..21a080e9da 100644 --- a/packages/core/src/routing/strategies/fallbackStrategy.ts +++ b/packages/core/src/routing/strategies/fallbackStrategy.ts @@ -13,6 +13,7 @@ import type { RoutingDecision, RoutingStrategy, } from '../routingStrategy.js'; +import type { LocalLiteRtLmClient } from '../../core/localLiteRtLmClient.js'; export class FallbackStrategy implements RoutingStrategy { readonly name = 'fallback'; @@ -21,6 +22,7 @@ export class FallbackStrategy implements RoutingStrategy { context: RoutingContext, config: Config, _baseLlmClient: BaseLlmClient, + _localLiteRtLmClient: LocalLiteRtLmClient, ): Promise { const requestedModel = context.requestedModel ?? config.getModel(); const resolvedModel = resolveModel( diff --git a/packages/core/src/routing/strategies/gemmaClassifierStrategy.test.ts b/packages/core/src/routing/strategies/gemmaClassifierStrategy.test.ts new file mode 100644 index 0000000000..9425208fd7 --- /dev/null +++ b/packages/core/src/routing/strategies/gemmaClassifierStrategy.test.ts @@ -0,0 +1,324 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { Mock } from 'vitest'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { GemmaClassifierStrategy } from './gemmaClassifierStrategy.js'; +import type { RoutingContext } from '../routingStrategy.js'; +import type { Config } from '../../config/config.js'; +import type { BaseLlmClient } from '../../core/baseLlmClient.js'; +import { + DEFAULT_GEMINI_FLASH_MODEL, + DEFAULT_GEMINI_MODEL, +} from '../../config/models.js'; +import type { Content } from '@google/genai'; +import { debugLogger } from '../../utils/debugLogger.js'; +import type { LocalLiteRtLmClient } from '../../core/localLiteRtLmClient.js'; + +vi.mock('../../core/localLiteRtLmClient.js'); + +describe('GemmaClassifierStrategy', () => { + let strategy: GemmaClassifierStrategy; + let mockContext: RoutingContext; + let mockConfig: Config; + let mockBaseLlmClient: BaseLlmClient; + let mockLocalLiteRtLmClient: LocalLiteRtLmClient; + let mockGenerateJson: Mock; + + beforeEach(() => { + vi.clearAllMocks(); + mockGenerateJson = vi.fn(); + + mockConfig = { + getGemmaModelRouterSettings: vi.fn().mockReturnValue({ + enabled: true, + classifier: { model: 'gemma3-1b-gpu-custom' }, + }), + getModel: () => DEFAULT_GEMINI_MODEL, + getPreviewFeatures: () => false, + } as unknown as Config; + + strategy = new GemmaClassifierStrategy(); + mockContext = { + history: [], + request: 'simple task', + signal: new AbortController().signal, + }; + + mockBaseLlmClient = {} as BaseLlmClient; + mockLocalLiteRtLmClient = { + generateJson: mockGenerateJson, + } as unknown as LocalLiteRtLmClient; + }); + + it('should return null if gemma model router is disabled', async () => { + vi.mocked(mockConfig.getGemmaModelRouterSettings).mockReturnValue({ + enabled: false, + }); + + const decision = await strategy.route( + mockContext, + mockConfig, + mockBaseLlmClient, + mockLocalLiteRtLmClient, + ); + expect(decision).toBeNull(); + }); + + it('should throw an error if the model is not gemma3-1b-gpu-custom', async () => { + vi.mocked(mockConfig.getGemmaModelRouterSettings).mockReturnValue({ + enabled: true, + classifier: { model: 'other-model' }, + }); + + await expect( + strategy.route( + mockContext, + mockConfig, + mockBaseLlmClient, + mockLocalLiteRtLmClient, + ), + ).rejects.toThrow('Only gemma3-1b-gpu-custom has been tested'); + }); + + it('should call generateJson with the correct parameters', async () => { + const mockApiResponse = { + reasoning: 'Simple task', + model_choice: 'flash', + }; + mockGenerateJson.mockResolvedValue(mockApiResponse); + + await strategy.route( + mockContext, + mockConfig, + mockBaseLlmClient, + mockLocalLiteRtLmClient, + ); + + expect(mockGenerateJson).toHaveBeenCalledWith( + expect.any(Array), + expect.any(String), + expect.any(String), + expect.any(AbortSignal), + ); + }); + + it('should route to FLASH model for a simple task', async () => { + const mockApiResponse = { + reasoning: 'This is a simple task.', + model_choice: 'flash', + }; + mockGenerateJson.mockResolvedValue(mockApiResponse); + + const decision = await strategy.route( + mockContext, + mockConfig, + mockBaseLlmClient, + mockLocalLiteRtLmClient, + ); + + expect(mockGenerateJson).toHaveBeenCalledOnce(); + expect(decision).toEqual({ + model: DEFAULT_GEMINI_FLASH_MODEL, + metadata: { + source: 'GemmaClassifier', + latencyMs: expect.any(Number), + reasoning: mockApiResponse.reasoning, + }, + }); + }); + + it('should route to PRO model for a complex task', async () => { + const mockApiResponse = { + reasoning: 'This is a complex task.', + model_choice: 'pro', + }; + mockGenerateJson.mockResolvedValue(mockApiResponse); + mockContext.request = 'how do I build a spaceship?'; + + const decision = await strategy.route( + mockContext, + mockConfig, + mockBaseLlmClient, + mockLocalLiteRtLmClient, + ); + + expect(mockGenerateJson).toHaveBeenCalledOnce(); + expect(decision).toEqual({ + model: DEFAULT_GEMINI_MODEL, + metadata: { + source: 'GemmaClassifier', + latencyMs: expect.any(Number), + reasoning: mockApiResponse.reasoning, + }, + }); + }); + + it('should return null if the classifier API call fails', async () => { + const consoleWarnSpy = vi + .spyOn(debugLogger, 'warn') + .mockImplementation(() => {}); + const testError = new Error('API Failure'); + mockGenerateJson.mockRejectedValue(testError); + + const decision = await strategy.route( + mockContext, + mockConfig, + mockBaseLlmClient, + mockLocalLiteRtLmClient, + ); + + expect(decision).toBeNull(); + expect(consoleWarnSpy).toHaveBeenCalled(); + consoleWarnSpy.mockRestore(); + }); + + it('should return null if the classifier returns a malformed JSON object', async () => { + const consoleWarnSpy = vi + .spyOn(debugLogger, 'warn') + .mockImplementation(() => {}); + const malformedApiResponse = { + reasoning: 'This is a simple task.', + // model_choice is missing, which will cause a Zod parsing error. + }; + mockGenerateJson.mockResolvedValue(malformedApiResponse); + + const decision = await strategy.route( + mockContext, + mockConfig, + mockBaseLlmClient, + mockLocalLiteRtLmClient, + ); + + expect(decision).toBeNull(); + expect(consoleWarnSpy).toHaveBeenCalled(); + consoleWarnSpy.mockRestore(); + }); + + it('should filter out tool-related history before sending to classifier', async () => { + mockContext.history = [ + { role: 'user', parts: [{ text: 'call a tool' }] }, + { + role: 'model', + parts: [{ functionCall: { name: 'test_tool', args: {} } }], + }, + { + role: 'user', + parts: [ + { functionResponse: { name: 'test_tool', response: { ok: true } } }, + ], + }, + { role: 'user', parts: [{ text: 'another user turn' }] }, + ]; + const mockApiResponse = { + reasoning: 'Simple.', + model_choice: 'flash', + }; + mockGenerateJson.mockResolvedValue(mockApiResponse); + + await strategy.route( + mockContext, + mockConfig, + mockBaseLlmClient, + mockLocalLiteRtLmClient, + ); + + // Define a type for the arguments passed to the mock `generateJson` + type GenerateJsonCall = [Content[], string, string | undefined]; + const calls = mockGenerateJson.mock.calls as GenerateJsonCall[]; + const contents = calls[0][0]; + const lastTurn = contents.at(-1); + expect(lastTurn).toBeDefined(); + if (!lastTurn?.parts) { + // Fail test if parts is not defined. + expect(lastTurn?.parts).toBeDefined(); + return; + } + const expectedLastTurn = `You are provided with a **Chat History** and the user's **Current Request** below. + +#### Chat History: +call a tool + +another user turn + +#### Current Request: +"simple task" +`; + expect(lastTurn.parts.at(0)?.text).toEqual(expectedLastTurn); + }); + + it('should respect HISTORY_SEARCH_WINDOW and HISTORY_TURNS_FOR_CONTEXT', async () => { + const longHistory: Content[] = []; + for (let i = 0; i < 30; i++) { + longHistory.push({ role: 'user', parts: [{ text: `Message ${i}` }] }); + // Add noise that should be filtered + if (i % 2 === 0) { + longHistory.push({ + role: 'model', + parts: [{ functionCall: { name: 'noise', args: {} } }], + }); + } + } + mockContext.history = longHistory; + const mockApiResponse = { + reasoning: 'Simple.', + model_choice: 'flash', + }; + mockGenerateJson.mockResolvedValue(mockApiResponse); + + await strategy.route( + mockContext, + mockConfig, + mockBaseLlmClient, + mockLocalLiteRtLmClient, + ); + + const generateJsonCall = mockGenerateJson.mock.calls[0][0]; + + // There should be 1 item which is the flattened history. + expect(generateJsonCall).toHaveLength(1); + }); + + it('should filter out non-text parts from history', async () => { + mockContext.history = [ + { role: 'user', parts: [{ text: 'first message' }] }, + // This part has no `text` property and should be filtered out. + { role: 'user', parts: [{}] } as Content, + { role: 'user', parts: [{ text: 'second message' }] }, + ]; + const mockApiResponse = { + reasoning: 'Simple.', + model_choice: 'flash', + }; + mockGenerateJson.mockResolvedValue(mockApiResponse); + + await strategy.route( + mockContext, + mockConfig, + mockBaseLlmClient, + mockLocalLiteRtLmClient, + ); + + type GenerateJsonCall = [Content[], string, string | undefined]; + const calls = mockGenerateJson.mock.calls as GenerateJsonCall[]; + const contents = calls[0][0]; + const lastTurn = contents.at(-1); + expect(lastTurn).toBeDefined(); + + const expectedLastTurn = `You are provided with a **Chat History** and the user's **Current Request** below. + +#### Chat History: +first message + +second message + +#### Current Request: +"simple task" +`; + + expect(lastTurn!.parts!.at(0)!.text).toEqual(expectedLastTurn); + }); +}); diff --git a/packages/core/src/routing/strategies/gemmaClassifierStrategy.ts b/packages/core/src/routing/strategies/gemmaClassifierStrategy.ts new file mode 100644 index 0000000000..f1175cc101 --- /dev/null +++ b/packages/core/src/routing/strategies/gemmaClassifierStrategy.ts @@ -0,0 +1,232 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { z } from 'zod'; + +import type { BaseLlmClient } from '../../core/baseLlmClient.js'; +import type { + RoutingContext, + RoutingDecision, + RoutingStrategy, +} from '../routingStrategy.js'; +import { resolveClassifierModel } from '../../config/models.js'; +import { createUserContent, type Content, type Part } from '@google/genai'; +import type { Config } from '../../config/config.js'; +import { + isFunctionCall, + isFunctionResponse, +} from '../../utils/messageInspectors.js'; +import { debugLogger } from '../../utils/debugLogger.js'; +import type { LocalLiteRtLmClient } from '../../core/localLiteRtLmClient.js'; + +// The number of recent history turns to provide to the router for context. +const HISTORY_TURNS_FOR_CONTEXT = 4; +const HISTORY_SEARCH_WINDOW = 20; + +const FLASH_MODEL = 'flash'; +const PRO_MODEL = 'pro'; + +const COMPLEXITY_RUBRIC = `### Complexity Rubric +A task is COMPLEX (Choose \`${PRO_MODEL}\`) if it meets ONE OR MORE of the following criteria: +1. **High Operational Complexity (Est. 4+ Steps/Tool Calls):** Requires dependent actions, significant planning, or multiple coordinated changes. +2. **Strategic Planning & Conceptual Design:** Asking "how" or "why." Requires advice, architecture, or high-level strategy. +3. **High Ambiguity or Large Scope (Extensive Investigation):** Broadly defined requests requiring extensive investigation. +4. **Deep Debugging & Root Cause Analysis:** Diagnosing unknown or complex problems from symptoms. +A task is SIMPLE (Choose \`${FLASH_MODEL}\`) if it is highly specific, bounded, and has Low Operational Complexity (Est. 1-3 tool calls). Operational simplicity overrides strategic phrasing.`; + +const OUTPUT_FORMAT = `### Output Format +Respond *only* in JSON format like this: +{ + "reasoning": Your reasoning... + "model_choice": Either ${FLASH_MODEL} or ${PRO_MODEL} +} +And you must follow the following JSON schema: +{ + "type": "object", + "properties": { + "reasoning": { + "type": "string", + "description": "A brief summary of the user objective, followed by a step-by-step explanation for the model choice, referencing the rubric." + }, + "model_choice": { + "type": "string", + "enum": ["${FLASH_MODEL}", "${PRO_MODEL}"] + } + }, + "required": ["reasoning", "model_choice"] +} +You must ensure that your reasoning is no more than 2 sentences long and directly references the rubric criteria. +When making your decision, the user's request should be weighted much more heavily than the surrounding context when making your determination.`; + +const LITERT_GEMMA_CLASSIFIER_SYSTEM_PROMPT = `### Role +You are the **Lead Orchestrator** for an AI system. You do not talk to users. Your sole responsibility is to analyze the **Chat History** and delegate the **Current Request** to the most appropriate **Model** based on the request's complexity. + +### Models +Choose between \`${FLASH_MODEL}\` (SIMPLE) or \`${PRO_MODEL}\` (COMPLEX). +1. \`${FLASH_MODEL}\`: A fast, efficient model for simple, well-defined tasks. +2. \`${PRO_MODEL}\`: A powerful, advanced model for complex, open-ended, or multi-step tasks. + +${COMPLEXITY_RUBRIC} + +${OUTPUT_FORMAT} + +### Examples +**Example 1 (Strategic Planning):** +*User Prompt:* "How should I architect the data pipeline for this new analytics service?" +*Your JSON Output:* +{ + "reasoning": "The user is asking for high-level architectural design and strategy. This falls under 'Strategic Planning & Conceptual Design'.", + "model_choice": "${PRO_MODEL}" +} +**Example 2 (Simple Tool Use):** +*User Prompt:* "list the files in the current directory" +*Your JSON Output:* +{ + "reasoning": "This is a direct command requiring a single tool call (ls). It has Low Operational Complexity (1 step).", + "model_choice": "${FLASH_MODEL}" +} +**Example 3 (High Operational Complexity):** +*User Prompt:* "I need to add a new 'email' field to the User schema in 'src/models/user.ts', migrate the database, and update the registration endpoint." +*Your JSON Output:* +{ + "reasoning": "This request involves multiple coordinated steps across different files and systems. This meets the criteria for High Operational Complexity (4+ steps).", + "model_choice": "${PRO_MODEL}" +} +**Example 4 (Simple Read):** +*User Prompt:* "Read the contents of 'package.json'." +*Your JSON Output:* +{ + "reasoning": "This is a direct command requiring a single read. It has Low Operational Complexity (1 step).", + "model_choice": "${FLASH_MODEL}" +} +**Example 5 (Deep Debugging):** +*User Prompt:* "I'm getting an error 'Cannot read property 'map' of undefined' when I click the save button. Can you fix it?" +*Your JSON Output:* +{ + "reasoning": "The user is reporting an error symptom without a known cause. This requires investigation and falls under 'Deep Debugging'.", + "model_choice": "${PRO_MODEL}" +} +**Example 6 (Simple Edit despite Phrasing):** +*User Prompt:* "What is the best way to rename the variable 'data' to 'userData' in 'src/utils.js'?" +*Your JSON Output:* +{ + "reasoning": "Although the user uses strategic language ('best way'), the underlying task is a localized edit. The operational complexity is low (1-2 steps).", + "model_choice": "${FLASH_MODEL}" +} +`; + +const LITERT_GEMMA_CLASSIFIER_REMINDER = `### Reminder +You are a Task Routing AI. Your sole task is to analyze the preceding **Chat History** and **Current Request** and classify its complexity. + +${COMPLEXITY_RUBRIC} + +${OUTPUT_FORMAT} +`; + +const ClassifierResponseSchema = z.object({ + reasoning: z.string(), + model_choice: z.enum([FLASH_MODEL, PRO_MODEL]), +}); + +export class GemmaClassifierStrategy implements RoutingStrategy { + readonly name = 'gemma-classifier'; + + private flattenChatHistory(turns: Content[]): Content[] { + const formattedHistory = turns + .slice(0, -1) + .map((turn) => + turn.parts + ? turn.parts + .map((part) => part.text) + .filter(Boolean) + .join('\n') + : '', + ) + .filter(Boolean) + .join('\n\n'); + + const lastTurn = turns.at(-1); + const userRequest = + lastTurn?.parts + ?.map((part: Part) => part.text) + .filter(Boolean) + .join('\n\n') ?? ''; + + const finalPrompt = `You are provided with a **Chat History** and the user's **Current Request** below. + +#### Chat History: +${formattedHistory} + +#### Current Request: +"${userRequest}" +`; + return [createUserContent(finalPrompt)]; + } + + async route( + context: RoutingContext, + config: Config, + _baseLlmClient: BaseLlmClient, + client: LocalLiteRtLmClient, + ): Promise { + const startTime = Date.now(); + const gemmaRouterSettings = config.getGemmaModelRouterSettings(); + if (!gemmaRouterSettings?.enabled) { + return null; + } + + // Only the gemma3-1b-gpu-custom model has been tested and verified. + if (gemmaRouterSettings.classifier?.model !== 'gemma3-1b-gpu-custom') { + throw new Error('Only gemma3-1b-gpu-custom has been tested'); + } + + try { + const historySlice = context.history.slice(-HISTORY_SEARCH_WINDOW); + + // Filter out tool-related turns. + // TODO - Consider using function req/res if they help accuracy. + const cleanHistory = historySlice.filter( + (content) => !isFunctionCall(content) && !isFunctionResponse(content), + ); + + // Take the last N turns from the *cleaned* history. + const finalHistory = cleanHistory.slice(-HISTORY_TURNS_FOR_CONTEXT); + + const history = [...finalHistory, createUserContent(context.request)]; + const singleMessageHistory = this.flattenChatHistory(history); + + const jsonResponse = await client.generateJson( + singleMessageHistory, + LITERT_GEMMA_CLASSIFIER_SYSTEM_PROMPT, + LITERT_GEMMA_CLASSIFIER_REMINDER, + context.signal, + ); + + const routerResponse = ClassifierResponseSchema.parse(jsonResponse); + + const reasoning = routerResponse.reasoning; + const latencyMs = Date.now() - startTime; + const selectedModel = resolveClassifierModel( + context.requestedModel ?? config.getModel(), + routerResponse.model_choice, + ); + + return { + model: selectedModel, + metadata: { + source: 'GemmaClassifier', + latencyMs, + reasoning, + }, + }; + } catch (error) { + // If the classifier fails for any reason (API error, parsing error, etc.), + // we log it and return null to allow the composite strategy to proceed. + debugLogger.warn(`[Routing] GemmaClassifierStrategy failed:`, error); + return null; + } + } +} diff --git a/packages/core/src/routing/strategies/numericalClassifierStrategy.test.ts b/packages/core/src/routing/strategies/numericalClassifierStrategy.test.ts index b8f6c50282..77fc69a218 100644 --- a/packages/core/src/routing/strategies/numericalClassifierStrategy.test.ts +++ b/packages/core/src/routing/strategies/numericalClassifierStrategy.test.ts @@ -22,6 +22,7 @@ import { promptIdContext } from '../../utils/promptIdContext.js'; import type { Content } from '@google/genai'; import type { ResolvedModelConfig } from '../../services/modelConfigService.js'; import { debugLogger } from '../../utils/debugLogger.js'; +import type { LocalLiteRtLmClient } from '../../core/localLiteRtLmClient.js'; import { AuthType } from '../../core/contentGenerator.js'; vi.mock('../../core/baseLlmClient.js'); @@ -31,6 +32,7 @@ describe('NumericalClassifierStrategy', () => { let mockContext: RoutingContext; let mockConfig: Config; let mockBaseLlmClient: BaseLlmClient; + let mockLocalLiteRtLmClient: LocalLiteRtLmClient; let mockResolvedConfig: ResolvedModelConfig; beforeEach(() => { @@ -63,6 +65,7 @@ describe('NumericalClassifierStrategy', () => { mockBaseLlmClient = { generateJson: vi.fn(), } as unknown as BaseLlmClient; + mockLocalLiteRtLmClient = {} as LocalLiteRtLmClient; vi.spyOn(promptIdContext, 'getStore').mockReturnValue('test-prompt-id'); }); @@ -78,6 +81,7 @@ describe('NumericalClassifierStrategy', () => { mockContext, mockConfig, mockBaseLlmClient, + mockLocalLiteRtLmClient, ); expect(decision).toBeNull(); @@ -91,6 +95,7 @@ describe('NumericalClassifierStrategy', () => { mockContext, mockConfig, mockBaseLlmClient, + mockLocalLiteRtLmClient, ); expect(decision).toBeNull(); @@ -104,6 +109,7 @@ describe('NumericalClassifierStrategy', () => { mockContext, mockConfig, mockBaseLlmClient, + mockLocalLiteRtLmClient, ); expect(decision).toBeNull(); @@ -119,7 +125,12 @@ describe('NumericalClassifierStrategy', () => { mockApiResponse, ); - await strategy.route(mockContext, mockConfig, mockBaseLlmClient); + await strategy.route( + mockContext, + mockConfig, + mockBaseLlmClient, + mockLocalLiteRtLmClient, + ); const generateJsonCall = vi.mocked(mockBaseLlmClient.generateJson).mock .calls[0][0]; @@ -151,6 +162,7 @@ describe('NumericalClassifierStrategy', () => { mockContext, mockConfig, mockBaseLlmClient, + mockLocalLiteRtLmClient, ); expect(decision).toEqual({ @@ -177,6 +189,7 @@ describe('NumericalClassifierStrategy', () => { mockContext, mockConfig, mockBaseLlmClient, + mockLocalLiteRtLmClient, ); expect(decision).toEqual({ @@ -203,6 +216,7 @@ describe('NumericalClassifierStrategy', () => { mockContext, mockConfig, mockBaseLlmClient, + mockLocalLiteRtLmClient, ); expect(decision).toEqual({ @@ -229,6 +243,7 @@ describe('NumericalClassifierStrategy', () => { mockContext, mockConfig, mockBaseLlmClient, + mockLocalLiteRtLmClient, ); expect(decision).toEqual({ @@ -257,6 +272,7 @@ describe('NumericalClassifierStrategy', () => { mockContext, mockConfig, mockBaseLlmClient, + mockLocalLiteRtLmClient, ); expect(decision).toEqual({ @@ -283,6 +299,7 @@ describe('NumericalClassifierStrategy', () => { mockContext, mockConfig, mockBaseLlmClient, + mockLocalLiteRtLmClient, ); expect(decision).toEqual({ @@ -309,6 +326,7 @@ describe('NumericalClassifierStrategy', () => { mockContext, mockConfig, mockBaseLlmClient, + mockLocalLiteRtLmClient, ); expect(decision).toEqual({ @@ -337,6 +355,7 @@ describe('NumericalClassifierStrategy', () => { mockContext, mockConfig, mockBaseLlmClient, + mockLocalLiteRtLmClient, ); expect(decision).toEqual({ @@ -364,6 +383,7 @@ describe('NumericalClassifierStrategy', () => { mockContext, mockConfig, mockBaseLlmClient, + mockLocalLiteRtLmClient, ); expect(decision).toEqual({ @@ -391,6 +411,7 @@ describe('NumericalClassifierStrategy', () => { mockContext, mockConfig, mockBaseLlmClient, + mockLocalLiteRtLmClient, ); expect(decision).toEqual({ @@ -415,6 +436,7 @@ describe('NumericalClassifierStrategy', () => { mockContext, mockConfig, mockBaseLlmClient, + mockLocalLiteRtLmClient, ); expect(decision).toBeNull(); @@ -437,6 +459,7 @@ describe('NumericalClassifierStrategy', () => { mockContext, mockConfig, mockBaseLlmClient, + mockLocalLiteRtLmClient, ); expect(decision).toBeNull(); @@ -463,7 +486,12 @@ describe('NumericalClassifierStrategy', () => { mockApiResponse, ); - await strategy.route(mockContext, mockConfig, mockBaseLlmClient); + await strategy.route( + mockContext, + mockConfig, + mockBaseLlmClient, + mockLocalLiteRtLmClient, + ); const generateJsonCall = vi.mocked(mockBaseLlmClient.generateJson).mock .calls[0][0]; @@ -495,7 +523,12 @@ describe('NumericalClassifierStrategy', () => { mockApiResponse, ); - await strategy.route(mockContext, mockConfig, mockBaseLlmClient); + await strategy.route( + mockContext, + mockConfig, + mockBaseLlmClient, + mockLocalLiteRtLmClient, + ); const generateJsonCall = vi.mocked(mockBaseLlmClient.generateJson).mock .calls[0][0]; @@ -528,7 +561,12 @@ describe('NumericalClassifierStrategy', () => { mockApiResponse, ); - await strategy.route(mockContext, mockConfig, mockBaseLlmClient); + await strategy.route( + mockContext, + mockConfig, + mockBaseLlmClient, + mockLocalLiteRtLmClient, + ); const generateJsonCall = vi.mocked(mockBaseLlmClient.generateJson).mock .calls[0][0]; @@ -558,6 +596,7 @@ describe('NumericalClassifierStrategy', () => { mockContext, mockConfig, mockBaseLlmClient, + mockLocalLiteRtLmClient, ); expect(decision?.model).toBe(PREVIEW_GEMINI_3_1_MODEL); @@ -579,6 +618,7 @@ describe('NumericalClassifierStrategy', () => { mockContext, mockConfig, mockBaseLlmClient, + mockLocalLiteRtLmClient, ); expect(decision?.model).toBe(PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL); @@ -601,6 +641,7 @@ describe('NumericalClassifierStrategy', () => { mockContext, mockConfig, mockBaseLlmClient, + mockLocalLiteRtLmClient, ); expect(decision?.model).toBe(PREVIEW_GEMINI_3_1_MODEL); diff --git a/packages/core/src/routing/strategies/numericalClassifierStrategy.ts b/packages/core/src/routing/strategies/numericalClassifierStrategy.ts index 32cc6ccbb7..39805fb43c 100644 --- a/packages/core/src/routing/strategies/numericalClassifierStrategy.ts +++ b/packages/core/src/routing/strategies/numericalClassifierStrategy.ts @@ -16,6 +16,7 @@ import { resolveClassifierModel, isGemini3Model } from '../../config/models.js'; import { createUserContent, Type } from '@google/genai'; import type { Config } from '../../config/config.js'; import { debugLogger } from '../../utils/debugLogger.js'; +import type { LocalLiteRtLmClient } from '../../core/localLiteRtLmClient.js'; import { LlmRole } from '../../telemetry/types.js'; import { AuthType } from '../../core/contentGenerator.js'; @@ -133,6 +134,7 @@ export class NumericalClassifierStrategy implements RoutingStrategy { context: RoutingContext, config: Config, baseLlmClient: BaseLlmClient, + _localLiteRtLmClient: LocalLiteRtLmClient, ): Promise { const startTime = Date.now(); try { diff --git a/packages/core/src/routing/strategies/overrideStrategy.test.ts b/packages/core/src/routing/strategies/overrideStrategy.test.ts index 73c1aeec62..804ee8f962 100644 --- a/packages/core/src/routing/strategies/overrideStrategy.test.ts +++ b/packages/core/src/routing/strategies/overrideStrategy.test.ts @@ -10,18 +10,25 @@ import type { RoutingContext } from '../routingStrategy.js'; import type { BaseLlmClient } from '../../core/baseLlmClient.js'; import type { Config } from '../../config/config.js'; import { DEFAULT_GEMINI_MODEL_AUTO } from '../../config/models.js'; +import type { LocalLiteRtLmClient } from '../../core/localLiteRtLmClient.js'; describe('OverrideStrategy', () => { const strategy = new OverrideStrategy(); const mockContext = {} as RoutingContext; const mockClient = {} as BaseLlmClient; + const mockLocalLiteRtLmClient = {} as LocalLiteRtLmClient; it('should return null when the override model is auto', async () => { const mockConfig = { getModel: () => DEFAULT_GEMINI_MODEL_AUTO, } as Config; - const decision = await strategy.route(mockContext, mockConfig, mockClient); + const decision = await strategy.route( + mockContext, + mockConfig, + mockClient, + mockLocalLiteRtLmClient, + ); expect(decision).toBeNull(); }); @@ -31,7 +38,12 @@ describe('OverrideStrategy', () => { getModel: () => overrideModel, } as Config; - const decision = await strategy.route(mockContext, mockConfig, mockClient); + const decision = await strategy.route( + mockContext, + mockConfig, + mockClient, + mockLocalLiteRtLmClient, + ); expect(decision).not.toBeNull(); expect(decision?.model).toBe(overrideModel); @@ -48,7 +60,12 @@ describe('OverrideStrategy', () => { getModel: () => overrideModel, } as Config; - const decision = await strategy.route(mockContext, mockConfig, mockClient); + const decision = await strategy.route( + mockContext, + mockConfig, + mockClient, + mockLocalLiteRtLmClient, + ); expect(decision).not.toBeNull(); expect(decision?.model).toBe(overrideModel); @@ -68,6 +85,7 @@ describe('OverrideStrategy', () => { contextWithRequestedModel, mockConfig, mockClient, + mockLocalLiteRtLmClient, ); expect(decision).not.toBeNull(); diff --git a/packages/core/src/routing/strategies/overrideStrategy.ts b/packages/core/src/routing/strategies/overrideStrategy.ts index 5101ba9fe7..9a89d2af70 100644 --- a/packages/core/src/routing/strategies/overrideStrategy.ts +++ b/packages/core/src/routing/strategies/overrideStrategy.ts @@ -12,6 +12,7 @@ import type { RoutingDecision, RoutingStrategy, } from '../routingStrategy.js'; +import type { LocalLiteRtLmClient } from '../../core/localLiteRtLmClient.js'; /** * Handles cases where the user explicitly specifies a model (override). @@ -23,6 +24,7 @@ export class OverrideStrategy implements RoutingStrategy { context: RoutingContext, config: Config, _baseLlmClient: BaseLlmClient, + _localLiteRtLmClient: LocalLiteRtLmClient, ): Promise { const overrideModel = context.requestedModel ?? config.getModel(); diff --git a/packages/core/src/services/trackerService.test.ts b/packages/core/src/services/trackerService.test.ts new file mode 100644 index 0000000000..70a29d25af --- /dev/null +++ b/packages/core/src/services/trackerService.test.ts @@ -0,0 +1,142 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as fs from 'node:fs/promises'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { TrackerService } from './trackerService.js'; +import { TaskStatus, TaskType, type TrackerTask } from './trackerTypes.js'; + +describe('TrackerService', () => { + let testTrackerDir: string; + let service: TrackerService; + + beforeEach(async () => { + testTrackerDir = await fs.mkdtemp( + path.join(os.tmpdir(), 'tracker-service-test-'), + ); + service = new TrackerService(testTrackerDir); + }); + + afterEach(async () => { + await fs.rm(testTrackerDir, { recursive: true, force: true }); + }); + + it('should create a task with a generated 6-char hex ID', async () => { + const taskData: Omit = { + title: 'Test Task', + description: 'Test Description', + type: TaskType.TASK, + status: TaskStatus.OPEN, + dependencies: [], + }; + + const task = await service.createTask(taskData); + expect(task.id).toMatch(/^[0-9a-f]{6}$/); + expect(task.title).toBe(taskData.title); + + const savedTask = await service.getTask(task.id); + expect(savedTask).toEqual(task); + }); + + it('should list all tasks', async () => { + await service.createTask({ + title: 'Task 1', + description: 'Desc 1', + type: TaskType.TASK, + status: TaskStatus.OPEN, + dependencies: [], + }); + await service.createTask({ + title: 'Task 2', + description: 'Desc 2', + type: TaskType.TASK, + status: TaskStatus.OPEN, + dependencies: [], + }); + + const tasks = await service.listTasks(); + expect(tasks.length).toBe(2); + expect(tasks.map((t) => t.title)).toContain('Task 1'); + expect(tasks.map((t) => t.title)).toContain('Task 2'); + }); + + it('should update a task', async () => { + const task = await service.createTask({ + title: 'Original Title', + description: 'Original Desc', + type: TaskType.TASK, + status: TaskStatus.OPEN, + dependencies: [], + }); + + const updated = await service.updateTask(task.id, { + title: 'New Title', + status: TaskStatus.IN_PROGRESS, + }); + expect(updated.title).toBe('New Title'); + expect(updated.status).toBe('in_progress'); + expect(updated.description).toBe('Original Desc'); + + const retrieved = await service.getTask(task.id); + expect(retrieved).toEqual(updated); + }); + + it('should prevent closing a task if dependencies are not closed', async () => { + const dep = await service.createTask({ + title: 'Dependency', + description: 'Must be closed first', + type: TaskType.TASK, + status: TaskStatus.OPEN, + dependencies: [], + }); + + const task = await service.createTask({ + title: 'Main Task', + description: 'Depends on dep', + type: TaskType.TASK, + status: TaskStatus.OPEN, + dependencies: [dep.id], + }); + + await expect( + service.updateTask(task.id, { status: TaskStatus.CLOSED }), + ).rejects.toThrow(/Cannot close task/); + + // Close dependency + await service.updateTask(dep.id, { status: TaskStatus.CLOSED }); + + // Now it should work + const updated = await service.updateTask(task.id, { + status: TaskStatus.CLOSED, + }); + expect(updated.status).toBe('closed'); + }); + + it('should detect circular dependencies', async () => { + const taskA = await service.createTask({ + title: 'Task A', + description: 'A', + type: TaskType.TASK, + status: TaskStatus.OPEN, + dependencies: [], + }); + + const taskB = await service.createTask({ + title: 'Task B', + description: 'B', + type: TaskType.TASK, + status: TaskStatus.OPEN, + dependencies: [taskA.id], + }); + + // Try to make A depend on B + await expect( + service.updateTask(taskA.id, { dependencies: [taskB.id] }), + ).rejects.toThrow(/Circular dependency detected/); + }); +}); diff --git a/packages/core/src/services/trackerService.ts b/packages/core/src/services/trackerService.ts new file mode 100644 index 0000000000..3203b759e1 --- /dev/null +++ b/packages/core/src/services/trackerService.ts @@ -0,0 +1,223 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { randomBytes } from 'node:crypto'; +import { debugLogger } from '../utils/debugLogger.js'; +import { coreEvents } from '../utils/events.js'; +import { + TrackerTaskSchema, + TaskStatus, + type TrackerTask, +} from './trackerTypes.js'; +import { type z } from 'zod'; + +export class TrackerService { + private readonly tasksDir: string; + + private initialized = false; + + constructor(readonly trackerDir: string) { + this.tasksDir = trackerDir; + } + + private async ensureInitialized(): Promise { + if (!this.initialized) { + await fs.mkdir(this.tasksDir, { recursive: true }); + this.initialized = true; + } + } + + /** + * Generates a 6-character hex ID. + */ + private generateId(): string { + return randomBytes(3).toString('hex'); + } + + /** + * Creates a new task and saves it to disk. + */ + async createTask(taskData: Omit): Promise { + await this.ensureInitialized(); + const id = this.generateId(); + const task: TrackerTask = { + ...taskData, + id, + }; + + await this.saveTask(task); + return task; + } + + /** + * Helper to read and validate a JSON file. + */ + private async readJsonFile( + filePath: string, + schema: z.ZodSchema, + ): Promise { + try { + const content = await fs.readFile(filePath, 'utf8'); + const data: unknown = JSON.parse(content); + return schema.parse(data); + } catch (error) { + if ( + error && + typeof error === 'object' && + 'code' in error && + error.code === 'ENOENT' + ) { + return null; + } + + const fileName = path.basename(filePath); + debugLogger.warn(`Failed to read or parse task file ${fileName}:`, error); + coreEvents.emitFeedback( + 'warning', + `Task tracker encountered an issue reading ${fileName}. The data might be corrupted.`, + error, + ); + throw error; + } + } + + /** + * Reads a task by ID. + */ + async getTask(id: string): Promise { + await this.ensureInitialized(); + const taskPath = path.join(this.tasksDir, `${id}.json`); + return this.readJsonFile(taskPath, TrackerTaskSchema); + } + + /** + * Lists all tasks in the tracker. + */ + async listTasks(): Promise { + await this.ensureInitialized(); + try { + const files = await fs.readdir(this.tasksDir); + const jsonFiles = files.filter((f: string) => f.endsWith('.json')); + const tasks = await Promise.all( + jsonFiles.map(async (f: string) => { + const taskPath = path.join(this.tasksDir, f); + return this.readJsonFile(taskPath, TrackerTaskSchema); + }), + ); + return tasks.filter((t): t is TrackerTask => t !== null); + } catch (error) { + if ( + error && + typeof error === 'object' && + 'code' in error && + error.code === 'ENOENT' + ) { + return []; + } + throw error; + } + } + + /** + * Updates an existing task and saves it to disk. + */ + async updateTask( + id: string, + updates: Partial, + ): Promise { + const task = await this.getTask(id); + if (!task) { + throw new Error(`Task with ID ${id} not found.`); + } + + const updatedTask = { ...task, ...updates }; + + // Validate status transition if closing + if ( + updatedTask.status === TaskStatus.CLOSED && + task.status !== TaskStatus.CLOSED + ) { + await this.validateCanClose(updatedTask); + } + + // Validate circular dependencies if dependencies changed + if (updates.dependencies) { + await this.validateNoCircularDependencies(updatedTask); + } + + await this.saveTask(updatedTask); + return updatedTask; + } + + /** + * Saves a task to disk. + */ + private async saveTask(task: TrackerTask): Promise { + const taskPath = path.join(this.tasksDir, `${task.id}.json`); + await fs.writeFile(taskPath, JSON.stringify(task, null, 2), 'utf8'); + } + + /** + * Validates that a task can be closed (all dependencies must be closed). + */ + private async validateCanClose(task: TrackerTask): Promise { + for (const depId of task.dependencies) { + const dep = await this.getTask(depId); + if (!dep) { + throw new Error(`Dependency ${depId} not found for task ${task.id}.`); + } + if (dep.status !== TaskStatus.CLOSED) { + throw new Error( + `Cannot close task ${task.id} because dependency ${depId} is still ${dep.status}.`, + ); + } + } + } + + /** + * Validates that there are no circular dependencies. + */ + private async validateNoCircularDependencies( + task: TrackerTask, + ): Promise { + const allTasks = await this.listTasks(); + const taskMap = new Map( + allTasks.map((t) => [t.id, t]), + ); + // Ensure the current (possibly unsaved) task state is used + taskMap.set(task.id, task); + + const visited = new Set(); + const stack = new Set(); + + const check = (currentId: string) => { + if (stack.has(currentId)) { + throw new Error( + `Circular dependency detected involving task ${currentId}.`, + ); + } + if (visited.has(currentId)) { + return; + } + + visited.add(currentId); + stack.add(currentId); + + const currentTask = taskMap.get(currentId); + if (currentTask) { + for (const depId of currentTask.dependencies) { + check(depId); + } + } + + stack.delete(currentId); + }; + + check(task.id); + } +} diff --git a/packages/core/src/services/trackerTypes.ts b/packages/core/src/services/trackerTypes.ts new file mode 100644 index 0000000000..7c48f5bcd4 --- /dev/null +++ b/packages/core/src/services/trackerTypes.ts @@ -0,0 +1,36 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { z } from 'zod'; + +export enum TaskType { + EPIC = 'epic', + TASK = 'task', + BUG = 'bug', +} +export const TaskTypeSchema = z.nativeEnum(TaskType); + +export enum TaskStatus { + OPEN = 'open', + IN_PROGRESS = 'in_progress', + BLOCKED = 'blocked', + CLOSED = 'closed', +} +export const TaskStatusSchema = z.nativeEnum(TaskStatus); + +export const TrackerTaskSchema = z.object({ + id: z.string().length(6), + title: z.string(), + description: z.string(), + type: TaskTypeSchema, + status: TaskStatusSchema, + parentId: z.string().optional(), + dependencies: z.array(z.string()), + subagentSessionId: z.string().optional(), + metadata: z.record(z.unknown()).optional(), +}); + +export type TrackerTask = z.infer; diff --git a/packages/core/src/tools/definitions/__snapshots__/coreToolsModelSnapshots.test.ts.snap b/packages/core/src/tools/definitions/__snapshots__/coreToolsModelSnapshots.test.ts.snap index effab9144d..2d5cfe8d52 100644 --- a/packages/core/src/tools/definitions/__snapshots__/coreToolsModelSnapshots.test.ts.snap +++ b/packages/core/src/tools/definitions/__snapshots__/coreToolsModelSnapshots.test.ts.snap @@ -266,7 +266,7 @@ exports[`coreTools snapshots for specific models > Model: gemini-2.5-pro > snaps "description": "Optional: A regular expression pattern to exclude from the search results. If a line matches both the pattern and the exclude_pattern, it will be omitted.", "type": "string", }, - "include": { + "include_pattern": { "description": "Optional: A glob pattern to filter which files are searched (e.g., '*.js', '*.{ts,tsx}', 'src/**'). If omitted, searches all files (respecting potential global ignores).", "type": "string", }, @@ -333,7 +333,7 @@ exports[`coreTools snapshots for specific models > Model: gemini-2.5-pro > snaps "description": "If true, treats the \`pattern\` as a literal string instead of a regular expression. Defaults to false (basic regex) if omitted.", "type": "boolean", }, - "include": { + "include_pattern": { "description": "Glob pattern to filter files (e.g., '*.ts', 'src/**'). Recommended for large repositories to reduce noise. Defaults to all files if omitted.", "type": "string", }, @@ -1053,7 +1053,7 @@ exports[`coreTools snapshots for specific models > Model: gemini-3-pro-preview > "description": "Optional: A regular expression pattern to exclude from the search results. If a line matches both the pattern and the exclude_pattern, it will be omitted.", "type": "string", }, - "include": { + "include_pattern": { "description": "Optional: A glob pattern to filter which files are searched (e.g., '*.js', '*.{ts,tsx}', 'src/**'). If omitted, searches all files (respecting potential global ignores).", "type": "string", }, @@ -1120,7 +1120,7 @@ exports[`coreTools snapshots for specific models > Model: gemini-3-pro-preview > "description": "If true, treats the \`pattern\` as a literal string instead of a regular expression. Defaults to false (basic regex) if omitted.", "type": "boolean", }, - "include": { + "include_pattern": { "description": "Glob pattern to filter files (e.g., '*.ts', 'src/**'). Recommended for large repositories to reduce noise. Defaults to all files if omitted.", "type": "string", }, diff --git a/packages/core/src/tools/definitions/model-family-sets/default-legacy.ts b/packages/core/src/tools/definitions/model-family-sets/default-legacy.ts index 569f379cd0..23f36fbb24 100644 --- a/packages/core/src/tools/definitions/model-family-sets/default-legacy.ts +++ b/packages/core/src/tools/definitions/model-family-sets/default-legacy.ts @@ -96,7 +96,7 @@ export const DEFAULT_LEGACY_SET: CoreToolSet = { 'Optional: The absolute path to the directory to search within. If omitted, searches the current working directory.', type: 'string', }, - include: { + include_pattern: { description: `Optional: A glob pattern to filter which files are searched (e.g., '*.js', '*.{ts,tsx}', 'src/**'). If omitted, searches all files (respecting potential global ignores).`, type: 'string', }, @@ -143,7 +143,7 @@ export const DEFAULT_LEGACY_SET: CoreToolSet = { "Directory or file to search. Directories are searched recursively. Relative paths are resolved against current working directory. Defaults to current working directory ('.') if omitted.", type: 'string', }, - include: { + include_pattern: { description: "Glob pattern to filter files (e.g., '*.ts', 'src/**'). Recommended for large repositories to reduce noise. Defaults to all files if omitted.", type: 'string', diff --git a/packages/core/src/tools/definitions/model-family-sets/gemini-3.ts b/packages/core/src/tools/definitions/model-family-sets/gemini-3.ts index 0cfe8ffbc2..1d50eae7e8 100644 --- a/packages/core/src/tools/definitions/model-family-sets/gemini-3.ts +++ b/packages/core/src/tools/definitions/model-family-sets/gemini-3.ts @@ -97,7 +97,7 @@ export const GEMINI_3_SET: CoreToolSet = { 'Optional: The absolute path to the directory to search within. If omitted, searches the current working directory.', type: 'string', }, - include: { + include_pattern: { description: `Optional: A glob pattern to filter which files are searched (e.g., '*.js', '*.{ts,tsx}', 'src/**'). If omitted, searches all files (respecting potential global ignores).`, type: 'string', }, @@ -144,7 +144,7 @@ export const GEMINI_3_SET: CoreToolSet = { "Directory or file to search. Directories are searched recursively. Relative paths are resolved against current working directory. Defaults to current working directory ('.') if omitted.", type: 'string', }, - include: { + include_pattern: { description: "Glob pattern to filter files (e.g., '*.ts', 'src/**'). Recommended for large repositories to reduce noise. Defaults to all files if omitted.", type: 'string', diff --git a/packages/core/src/tools/grep-utils.ts b/packages/core/src/tools/grep-utils.ts index 27c744f60c..6dd2cdc83e 100644 --- a/packages/core/src/tools/grep-utils.ts +++ b/packages/core/src/tools/grep-utils.ts @@ -139,7 +139,7 @@ export async function formatGrepResults( params: { pattern: string; names_only?: boolean; - include?: string; + include_pattern?: string; // Context params to determine if auto-context should be skipped context?: number; before?: number; @@ -148,10 +148,10 @@ export async function formatGrepResults( searchLocationDescription: string, totalMaxMatches: number, ): Promise<{ llmContent: string; returnDisplay: string }> { - const { pattern, names_only, include } = params; + const { pattern, names_only, include_pattern } = params; if (allMatches.length === 0) { - const noMatchMsg = `No matches found for pattern "${pattern}" ${searchLocationDescription}${include ? ` (filter: "${include}")` : ''}.`; + const noMatchMsg = `No matches found for pattern "${pattern}" ${searchLocationDescription}${include_pattern ? ` (filter: "${include_pattern}")` : ''}.`; return { llmContent: noMatchMsg, returnDisplay: `No matches found` }; } @@ -171,7 +171,7 @@ export async function formatGrepResults( if (names_only) { const filePaths = Object.keys(matchesByFile).sort(); let llmContent = `Found ${filePaths.length} files with matches for pattern "${pattern}" ${searchLocationDescription}${ - include ? ` (filter: "${include}")` : '' + include_pattern ? ` (filter: "${include_pattern}")` : '' }${ wasTruncated ? ` (results limited to ${totalMaxMatches} matches for performance)` @@ -184,7 +184,7 @@ export async function formatGrepResults( }; } - let llmContent = `Found ${matchCount} ${matchTerm} for pattern "${pattern}" ${searchLocationDescription}${include ? ` (filter: "${include}")` : ''}`; + let llmContent = `Found ${matchCount} ${matchTerm} for pattern "${pattern}" ${searchLocationDescription}${include_pattern ? ` (filter: "${include_pattern}")` : ''}`; if (wasTruncated) { llmContent += ` (results limited to ${totalMaxMatches} matches for performance)`; diff --git a/packages/core/src/tools/grep.test.ts b/packages/core/src/tools/grep.test.ts index f696495253..6f98b0f2fc 100644 --- a/packages/core/src/tools/grep.test.ts +++ b/packages/core/src/tools/grep.test.ts @@ -118,7 +118,7 @@ describe('GrepTool', () => { const params: GrepToolParams = { pattern: 'hello', dir_path: '.', - include: '*.txt', + include_pattern: '*.txt', }; expect(grepTool.validateToolParams(params)).toBeNull(); }); @@ -226,7 +226,10 @@ describe('GrepTool', () => { }, 30000); it('should find matches with an include glob', async () => { - const params: GrepToolParams = { pattern: 'hello', include: '*.js' }; + const params: GrepToolParams = { + pattern: 'hello', + include_pattern: '*.js', + }; const invocation = grepTool.build(params); const result = await invocation.execute(abortSignal); expect(result.llmContent).toContain( @@ -247,7 +250,7 @@ describe('GrepTool', () => { const params: GrepToolParams = { pattern: 'hello', dir_path: 'sub', - include: '*.js', + include_pattern: '*.js', }; const invocation = grepTool.build(params); const result = await invocation.execute(abortSignal); @@ -572,7 +575,7 @@ describe('GrepTool', () => { it('should generate correct description with pattern and include', () => { const params: GrepToolParams = { pattern: 'testPattern', - include: '*.ts', + include_pattern: '*.ts', }; const invocation = grepTool.build(params); expect(invocation.getDescription()).toBe("'testPattern' in *.ts"); @@ -618,7 +621,7 @@ describe('GrepTool', () => { await fs.mkdir(dirPath, { recursive: true }); const params: GrepToolParams = { pattern: 'testPattern', - include: '*.ts', + include_pattern: '*.ts', dir_path: path.join('src', 'app'), }; const invocation = grepTool.build(params); diff --git a/packages/core/src/tools/grep.ts b/packages/core/src/tools/grep.ts index 92fe58288d..3d74521513 100644 --- a/packages/core/src/tools/grep.ts +++ b/packages/core/src/tools/grep.ts @@ -48,7 +48,7 @@ export interface GrepToolParams { /** * File pattern to include in the search (e.g. "*.js", "*.{ts,tsx}") */ - include?: string; + include_pattern?: string; /** * Optional: A regular expression pattern to exclude from the search results. @@ -227,7 +227,7 @@ class GrepToolInvocation extends BaseToolInvocation< const matches = await this.performGrepSearch({ pattern: this.params.pattern, path: searchDir, - include: this.params.include, + include_pattern: this.params.include_pattern, exclude_pattern: this.params.exclude_pattern, maxMatches: remainingLimit, max_matches_per_file: this.params.max_matches_per_file, @@ -317,7 +317,7 @@ class GrepToolInvocation extends BaseToolInvocation< private async performGrepSearch(options: { pattern: string; path: string; // Expects absolute path - include?: string; + include_pattern?: string; exclude_pattern?: string; maxMatches: number; max_matches_per_file?: number; @@ -326,7 +326,7 @@ class GrepToolInvocation extends BaseToolInvocation< const { pattern, path: absolutePath, - include, + include_pattern, exclude_pattern, maxMatches, max_matches_per_file, @@ -356,8 +356,8 @@ class GrepToolInvocation extends BaseToolInvocation< if (max_matches_per_file) { gitArgs.push('--max-count', max_matches_per_file.toString()); } - if (include) { - gitArgs.push('--', include); + if (include_pattern) { + gitArgs.push('--', include_pattern); } try { @@ -424,8 +424,8 @@ class GrepToolInvocation extends BaseToolInvocation< if (max_matches_per_file) { grepArgs.push('--max-count', max_matches_per_file.toString()); } - if (include) { - grepArgs.push(`--include=${include}`); + if (include_pattern) { + grepArgs.push(`--include=${include_pattern}`); } grepArgs.push(pattern); grepArgs.push('.'); @@ -471,7 +471,7 @@ class GrepToolInvocation extends BaseToolInvocation< 'GrepLogic: Falling back to JavaScript grep implementation.', ); strategyUsed = 'javascript fallback'; - const globPattern = include ? include : '**/*'; + const globPattern = include_pattern ? include_pattern : '**/*'; const ignorePatterns = this.fileExclusions.getGlobExcludes(); const filesStream = globStream(globPattern, { @@ -551,8 +551,8 @@ class GrepToolInvocation extends BaseToolInvocation< getDescription(): string { let description = `'${this.params.pattern}'`; - if (this.params.include) { - description += ` in ${this.params.include}`; + if (this.params.include_pattern) { + description += ` in ${this.params.include_pattern}`; } if (this.params.dir_path) { const resolvedPath = path.resolve( diff --git a/packages/core/src/tools/ripGrep.test.ts b/packages/core/src/tools/ripGrep.test.ts index 58842e9b22..0eaf5c0b68 100644 --- a/packages/core/src/tools/ripGrep.test.ts +++ b/packages/core/src/tools/ripGrep.test.ts @@ -350,7 +350,7 @@ describe('RipGrepTool', () => { }, { name: 'pattern, path, and include', - params: { pattern: 'hello', dir_path: '.', include: '*.txt' }, + params: { pattern: 'hello', dir_path: '.', include_pattern: '*.txt' }, expected: null, }, ])( @@ -526,7 +526,10 @@ describe('RipGrepTool', () => { }), ); - const params: RipGrepToolParams = { pattern: 'hello', include: '*.js' }; + const params: RipGrepToolParams = { + pattern: 'hello', + include_pattern: '*.js', + }; const invocation = grepTool.build(params); const result = await invocation.execute(abortSignal); expect(result.llmContent).toContain( @@ -564,7 +567,7 @@ describe('RipGrepTool', () => { const params: RipGrepToolParams = { pattern: 'hello', dir_path: 'sub', - include: '*.js', + include_pattern: '*.js', }; const invocation = grepTool.build(params); const result = await invocation.execute(abortSignal); @@ -1314,7 +1317,7 @@ describe('RipGrepTool', () => { const params: RipGrepToolParams = { pattern: 'content', - include: '*.{ts,tsx}', + include_pattern: '*.{ts,tsx}', }; const invocation = grepTool.build(params); const result = await invocation.execute(abortSignal); @@ -1350,7 +1353,7 @@ describe('RipGrepTool', () => { const params: RipGrepToolParams = { pattern: 'code', - include: 'src/**', + include_pattern: 'src/**', }; const invocation = grepTool.build(params); const result = await invocation.execute(abortSignal); @@ -1774,7 +1777,7 @@ describe('RipGrepTool', () => { }, { name: 'pattern and include', - params: { pattern: 'testPattern', include: '*.ts' }, + params: { pattern: 'testPattern', include_pattern: '*.ts' }, expected: "'testPattern' in *.ts within ./", }, { @@ -1849,7 +1852,7 @@ describe('RipGrepTool', () => { await fs.mkdir(dirPath, { recursive: true }); const params: RipGrepToolParams = { pattern: 'testPattern', - include: '*.ts', + include_pattern: '*.ts', dir_path: path.join('src', 'app'), }; const invocation = grepTool.build(params); diff --git a/packages/core/src/tools/ripGrep.ts b/packages/core/src/tools/ripGrep.ts index 9ad929f256..ac65cf6362 100644 --- a/packages/core/src/tools/ripGrep.ts +++ b/packages/core/src/tools/ripGrep.ts @@ -103,7 +103,7 @@ export interface RipGrepToolParams { /** * File pattern to include in the search (e.g. "*.js", "*.{ts,tsx}") */ - include?: string; + include_pattern?: string; /** * Optional: A regular expression pattern to exclude from the search results. @@ -246,7 +246,7 @@ class GrepToolInvocation extends BaseToolInvocation< allMatches = await this.performRipgrepSearch({ pattern: this.params.pattern, path: searchDirAbs, - include: this.params.include, + include_pattern: this.params.include_pattern, exclude_pattern: this.params.exclude_pattern, case_sensitive: this.params.case_sensitive, fixed_strings: this.params.fixed_strings, @@ -329,7 +329,7 @@ class GrepToolInvocation extends BaseToolInvocation< pattern: this.params.pattern, path: uniqueFiles, basePath: searchDirAbs, - include: this.params.include, + include_pattern: this.params.include_pattern, exclude_pattern: this.params.exclude_pattern, case_sensitive: this.params.case_sensitive, fixed_strings: this.params.fixed_strings, @@ -360,7 +360,7 @@ class GrepToolInvocation extends BaseToolInvocation< pattern: string; path: string | string[]; basePath?: string; - include?: string; + include_pattern?: string; exclude_pattern?: string; case_sensitive?: boolean; fixed_strings?: boolean; @@ -376,7 +376,7 @@ class GrepToolInvocation extends BaseToolInvocation< pattern, path, basePath, - include, + include_pattern, exclude_pattern, case_sensitive, fixed_strings, @@ -419,8 +419,8 @@ class GrepToolInvocation extends BaseToolInvocation< rgArgs.push('--max-count', max_matches_per_file.toString()); } - if (include) { - rgArgs.push('--glob', include); + if (include_pattern) { + rgArgs.push('--glob', include_pattern); } if (!no_ignore) { @@ -543,8 +543,8 @@ class GrepToolInvocation extends BaseToolInvocation< */ getDescription(): string { let description = `'${this.params.pattern}'`; - if (this.params.include) { - description += ` in ${this.params.include}`; + if (this.params.include_pattern) { + description += ` in ${this.params.include_pattern}`; } const pathParam = this.params.dir_path || '.'; const resolvedPath = path.resolve(this.config.getTargetDir(), pathParam); diff --git a/packages/core/src/tools/tools.ts b/packages/core/src/tools/tools.ts index d847b596e0..3c024168d4 100644 --- a/packages/core/src/tools/tools.ts +++ b/packages/core/src/tools/tools.ts @@ -832,6 +832,7 @@ export enum Kind { Search = 'search', Execute = 'execute', Think = 'think', + Agent = 'agent', Fetch = 'fetch', Communicate = 'communicate', Plan = 'plan', diff --git a/packages/core/src/utils/errors.ts b/packages/core/src/utils/errors.ts index 5465977ff2..62db5dcbf4 100644 --- a/packages/core/src/utils/errors.ts +++ b/packages/core/src/utils/errors.ts @@ -29,6 +29,15 @@ export function getErrorMessage(error: unknown): string { if (friendlyError instanceof Error) { return friendlyError.message; } + if ( + typeof friendlyError === 'object' && + friendlyError !== null && + 'message' in friendlyError && + typeof (friendlyError as { message: unknown }).message === 'string' + ) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + return (friendlyError as { message: string }).message; + } try { return String(friendlyError); } catch { diff --git a/packages/core/src/utils/errors_timeout.test.ts b/packages/core/src/utils/errors_timeout.test.ts new file mode 100644 index 0000000000..54eda960a0 --- /dev/null +++ b/packages/core/src/utils/errors_timeout.test.ts @@ -0,0 +1,46 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect } from 'vitest'; +import { getErrorMessage } from './errors.js'; +import { type HttpError } from './httpErrors.js'; + +describe('getErrorMessage with timeout errors', () => { + it('should handle undici HeadersTimeoutError correctly', () => { + // Simulate what undici might throw if it's not a proper Error instance + // or has a specific code. + const timeoutError = { + name: 'HeadersTimeoutError', + code: 'UND_ERR_HEADERS_TIMEOUT', + message: 'Headers timeout error', + }; + + // If it's a plain object, getErrorMessage might struggle if it expects an Error + const message = getErrorMessage(timeoutError); + // Based on existing implementation: + // friendlyError = toFriendlyError(timeoutError) -> returns timeoutError + // if (friendlyError instanceof Error) -> false + // return String(friendlyError) -> "[object Object]" + + expect(message).toBe('Headers timeout error'); + }); + + it('should handle undici HeadersTimeoutError as an Error instance', () => { + const error = new Error('Headers timeout error'); + (error as HttpError).name = 'HeadersTimeoutError'; + (error as HttpError).status = 504; // simulate status for test + (error as HttpError & { code?: string }).code = 'UND_ERR_HEADERS_TIMEOUT'; + + const message = getErrorMessage(error); + expect(message).toBe('Headers timeout error'); + }); + + it('should return String representation for objects without a message property', () => { + const error = { some: 'other', object: 123 }; + const message = getErrorMessage(error); + expect(message).toBe('[object Object]'); + }); +}); diff --git a/packages/core/src/utils/fetch.ts b/packages/core/src/utils/fetch.ts index 30d583e99f..e0bb1f3378 100644 --- a/packages/core/src/utils/fetch.ts +++ b/packages/core/src/utils/fetch.ts @@ -6,7 +6,18 @@ import { getErrorMessage, isNodeError } from './errors.js'; import { URL } from 'node:url'; -import { ProxyAgent, setGlobalDispatcher } from 'undici'; +import { Agent, ProxyAgent, setGlobalDispatcher } from 'undici'; + +const DEFAULT_HEADERS_TIMEOUT = 60000; // 60 seconds +const DEFAULT_BODY_TIMEOUT = 300000; // 5 minutes + +// Configure default global dispatcher with higher timeouts +setGlobalDispatcher( + new Agent({ + headersTimeout: DEFAULT_HEADERS_TIMEOUT, + bodyTimeout: DEFAULT_BODY_TIMEOUT, + }), +); const PRIVATE_IP_RANGES = [ /^10\./, @@ -73,5 +84,11 @@ export async function fetchWithTimeout( } export function setGlobalProxy(proxy: string) { - setGlobalDispatcher(new ProxyAgent(proxy)); + setGlobalDispatcher( + new ProxyAgent({ + uri: proxy, + headersTimeout: DEFAULT_HEADERS_TIMEOUT, + bodyTimeout: DEFAULT_BODY_TIMEOUT, + }), + ); } diff --git a/packages/core/src/utils/fileUtils.test.ts b/packages/core/src/utils/fileUtils.test.ts index c2f413a27e..de668db3ad 100644 --- a/packages/core/src/utils/fileUtils.test.ts +++ b/packages/core/src/utils/fileUtils.test.ts @@ -20,7 +20,7 @@ import fsPromises from 'node:fs/promises'; import path from 'node:path'; import os from 'node:os'; import { fileURLToPath } from 'node:url'; -// eslint-disable-next-line import/no-internal-modules + import mime from 'mime/lite'; import { diff --git a/packages/core/src/utils/fileUtils.ts b/packages/core/src/utils/fileUtils.ts index a5b32a3cb4..42119c3f18 100644 --- a/packages/core/src/utils/fileUtils.ts +++ b/packages/core/src/utils/fileUtils.ts @@ -8,7 +8,7 @@ import fs from 'node:fs'; import fsPromises from 'node:fs/promises'; import path from 'node:path'; import type { PartUnion } from '@google/genai'; -// eslint-disable-next-line import/no-internal-modules + import mime from 'mime/lite'; import type { FileSystemService } from '../services/fileSystemService.js'; import { ToolErrorType } from '../tools/tool-error.js'; diff --git a/packages/core/src/utils/flashFallback.test.ts b/packages/core/src/utils/flashFallback.test.ts index ec95de94ef..af4a73c213 100644 --- a/packages/core/src/utils/flashFallback.test.ts +++ b/packages/core/src/utils/flashFallback.test.ts @@ -19,6 +19,7 @@ import { AuthType } from '../core/contentGenerator.js'; // Import the new types (Assuming this test file is in packages/core/src/utils/) import type { FallbackModelHandler } from '../fallback/types.js'; import type { GoogleApiError } from './googleErrors.js'; +import { type HttpError } from './httpErrors.js'; import { TerminalQuotaError } from './googleQuotaErrors.js'; vi.mock('node:fs'); @@ -106,6 +107,34 @@ describe('Retry Utility Fallback Integration', () => { expect(mockApiCall).toHaveBeenCalledTimes(3); }); + it('should trigger onPersistent429 when HTTP 499 persists through all retry attempts', async () => { + let fallbackCalled = false; + const mockError: HttpError = new Error('Simulated 499 error'); + mockError.status = 499; + + const mockApiCall = vi.fn().mockRejectedValue(mockError); // Always fail with 499 + + const mockPersistent429Callback = vi.fn(async (_authType?: string) => { + fallbackCalled = true; + // In a real scenario, this would change the model being called by mockApiCall + // or similar, but for the test we just need to see if it's called. + // We return null to stop retrying after the fallback attempt in this test. + return null; + }); + + const promise = retryWithBackoff(mockApiCall, { + maxAttempts: 2, + initialDelayMs: 1, + maxDelayMs: 10, + onPersistent429: mockPersistent429Callback, + authType: AuthType.LOGIN_WITH_GOOGLE, + }); + + await expect(promise).rejects.toThrow('Simulated 499 error'); + expect(fallbackCalled).toBe(true); + expect(mockPersistent429Callback).toHaveBeenCalledTimes(1); + }); + it('should not trigger onPersistent429 for API key users', async () => { const fallbackCallback = vi.fn(); diff --git a/packages/core/src/utils/googleQuotaErrors.test.ts b/packages/core/src/utils/googleQuotaErrors.test.ts index 06bde6444b..185f48e92a 100644 --- a/packages/core/src/utils/googleQuotaErrors.test.ts +++ b/packages/core/src/utils/googleQuotaErrors.test.ts @@ -81,7 +81,7 @@ describe('classifyGoogleError', () => { } }); - it('should return original error if code is not 429 or 503', () => { + it('should return original error if code is not 429, 499 or 503', () => { const apiError: GoogleApiError = { code: 500, message: 'Server error', @@ -95,6 +95,22 @@ describe('classifyGoogleError', () => { expect(result).not.toBeInstanceOf(RetryableQuotaError); }); + it('should return RetryableQuotaError for 499 Client Closed Request', () => { + const apiError: GoogleApiError = { + code: 499, + message: 'Client Closed Request', + details: [], + }; + vi.spyOn(errorParser, 'parseGoogleApiError').mockReturnValue(apiError); + const originalError = new Error('Client Closed Request'); + const result = classifyGoogleError(originalError); + expect(result).toBeInstanceOf(RetryableQuotaError); + if (result instanceof RetryableQuotaError) { + expect(result.cause).toBe(apiError); + expect(result.message).toBe('Client Closed Request'); + } + }); + it('should return TerminalQuotaError for daily quota violations in QuotaFailure', () => { const apiError: GoogleApiError = { code: 429, diff --git a/packages/core/src/utils/googleQuotaErrors.ts b/packages/core/src/utils/googleQuotaErrors.ts index 40c1c34361..a075b79b89 100644 --- a/packages/core/src/utils/googleQuotaErrors.ts +++ b/packages/core/src/utils/googleQuotaErrors.ts @@ -219,7 +219,7 @@ export function classifyGoogleError(error: unknown): unknown { if ( !googleApiError || - googleApiError.code !== 429 || + (googleApiError.code !== 429 && googleApiError.code !== 499) || googleApiError.details.length === 0 ) { // Fallback: try to parse the error message for a retry delay @@ -233,27 +233,27 @@ export function classifyGoogleError(error: unknown): unknown { return new RetryableQuotaError( errorMessage, googleApiError ?? { - code: 429, + code: status ?? 429, message: errorMessage, details: [], }, retryDelaySeconds, ); } - } else if (status === 429) { - // Fallback: If it is a 429 but doesn't have a specific "retry in" message, + } else if (status === 429 || status === 499) { + // Fallback: If it is a 429 or 499 but doesn't have a specific "retry in" message, // assume it is a temporary rate limit and retry after 5 sec (same as DEFAULT_RETRY_OPTIONS). return new RetryableQuotaError( errorMessage, googleApiError ?? { - code: 429, + code: status, message: errorMessage, details: [], }, ); } - return error; // Not a 429 error we can handle with structured details or a parsable retry message. + return error; // Not a retryable error we can handle with structured details or a parsable retry message. } const quotaFailure = googleApiError.details.find( @@ -353,15 +353,15 @@ export function classifyGoogleError(error: unknown): unknown { } } - // If we reached this point and the status is still 429, we return retryable. - if (status === 429) { + // If we reached this point and the status is still 429 or 499, we return retryable. + if (status === 429 || status === 499) { const errorMessage = googleApiError?.message || (error instanceof Error ? error.message : String(error)); return new RetryableQuotaError( errorMessage, googleApiError ?? { - code: 429, + code: status, message: errorMessage, details: [], }, diff --git a/packages/core/src/utils/retry.test.ts b/packages/core/src/utils/retry.test.ts index 43f038cfaa..f63a5ed723 100644 --- a/packages/core/src/utils/retry.test.ts +++ b/packages/core/src/utils/retry.test.ts @@ -158,6 +158,30 @@ describe('retryWithBackoff', () => { expect(mockFn).not.toHaveBeenCalled(); }); + it('should retry on HTTP 499 (Client Closed Request) error', async () => { + let attempts = 0; + const mockFn = vi.fn(async () => { + attempts++; + if (attempts === 1) { + const error: HttpError = new Error('Simulated 499 error'); + error.status = 499; + throw error; + } + return 'success'; + }); + + const promise = retryWithBackoff(mockFn, { + maxAttempts: 2, + initialDelayMs: 10, + }); + + await vi.runAllTimersAsync(); + + const result = await promise; + expect(result).toBe('success'); + expect(mockFn).toHaveBeenCalledTimes(2); + }); + it('should use default shouldRetry if not provided, retrying on ApiError 429', async () => { const mockFn = vi.fn(async () => { throw new ApiError({ message: 'Too Many Requests', status: 429 }); diff --git a/packages/core/src/utils/retry.ts b/packages/core/src/utils/retry.ts index 17c4a656ed..50c992d6de 100644 --- a/packages/core/src/utils/retry.ts +++ b/packages/core/src/utils/retry.ts @@ -130,13 +130,17 @@ export function isRetryableError( if (error instanceof ApiError) { // Explicitly do not retry 400 (Bad Request) if (error.status === 400) return false; - return error.status === 429 || (error.status >= 500 && error.status < 600); + return ( + error.status === 429 || + error.status === 499 || + (error.status >= 500 && error.status < 600) + ); } // Check for status using helper (handles other error shapes) const status = getErrorStatus(error); if (status !== undefined) { - return status === 429 || (status >= 500 && status < 600); + return status === 429 || status === 499 || (status >= 500 && status < 600); } return false; diff --git a/packages/core/src/utils/schemaValidator.ts b/packages/core/src/utils/schemaValidator.ts index e58b7b8d9b..db5dee11ba 100644 --- a/packages/core/src/utils/schemaValidator.ts +++ b/packages/core/src/utils/schemaValidator.ts @@ -6,7 +6,7 @@ import AjvPkg, { type AnySchema, type Ajv } from 'ajv'; // Ajv2020 is the documented way to use draft-2020-12: https://ajv.js.org/json-schema.html#draft-2020-12 -// eslint-disable-next-line import/no-internal-modules + import Ajv2020Pkg from 'ajv/dist/2020.js'; import * as addFormats from 'ajv-formats'; import { debugLogger } from './debugLogger.js'; diff --git a/packages/sdk/src/agent.integration.test.ts b/packages/sdk/src/agent.integration.test.ts index 1de8e52ac7..78229a81cc 100644 --- a/packages/sdk/src/agent.integration.test.ts +++ b/packages/sdk/src/agent.integration.test.ts @@ -144,14 +144,14 @@ describe('GeminiCliAgent Integration', () => { }); it('propagates errors from dynamic instructions', async () => { + const goldenFile = getGoldenPath('agent-static-instructions'); const agent = new GeminiCliAgent({ instructions: () => { throw new Error('Dynamic instruction failure'); }, model: 'gemini-2.0-flash', - fakeResponses: RECORD_MODE - ? undefined - : getGoldenPath('agent-dynamic-instructions'), + recordResponses: RECORD_MODE ? goldenFile : undefined, + fakeResponses: RECORD_MODE ? undefined : goldenFile, }); const session = agent.session(); diff --git a/schemas/settings.schema.json b/schemas/settings.schema.json index 059584a73f..51bf9c84e2 100644 --- a/schemas/settings.schema.json +++ b/schemas/settings.schema.json @@ -1694,6 +1694,47 @@ "markdownDescription": "Enable web fetch behavior that bypasses LLM summarization.\n\n- Category: `Experimental`\n- Requires restart: `yes`\n- Default: `false`", "default": false, "type": "boolean" + }, + "gemmaModelRouter": { + "title": "Gemma Model Router", + "description": "Enable Gemma model router (experimental).", + "markdownDescription": "Enable Gemma model router (experimental).\n\n- Category: `Experimental`\n- Requires restart: `yes`\n- Default: `{}`", + "default": {}, + "type": "object", + "properties": { + "enabled": { + "title": "Enable Gemma Model Router", + "description": "Enable the Gemma Model Router. Requires a local endpoint serving Gemma via the Gemini API using LiteRT-LM shim.", + "markdownDescription": "Enable the Gemma Model Router. Requires a local endpoint serving Gemma via the Gemini API using LiteRT-LM shim.\n\n- Category: `Experimental`\n- Requires restart: `yes`\n- Default: `false`", + "default": false, + "type": "boolean" + }, + "classifier": { + "title": "Classifier", + "description": "Classifier configuration.", + "markdownDescription": "Classifier configuration.\n\n- Category: `Experimental`\n- Requires restart: `yes`\n- Default: `{}`", + "default": {}, + "type": "object", + "properties": { + "host": { + "title": "Host", + "description": "The host of the classifier.", + "markdownDescription": "The host of the classifier.\n\n- Category: `Experimental`\n- Requires restart: `yes`\n- Default: `http://localhost:9379`", + "default": "http://localhost:9379", + "type": "string" + }, + "model": { + "title": "Model", + "description": "The model to use for the classifier. Only tested on `gemma3-1b-gpu-custom`.", + "markdownDescription": "The model to use for the classifier. Only tested on `gemma3-1b-gpu-custom`.\n\n- Category: `Experimental`\n- Requires restart: `yes`\n- Default: `gemma3-1b-gpu-custom`", + "default": "gemma3-1b-gpu-custom", + "type": "string" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false } }, "additionalProperties": false