Compare commits

...

34 Commits

Author SHA1 Message Date
Jack Wotherspoon 78b22bed61 docs: remove GitHub pages site 2026-02-27 13:41:03 -05:00
Dev Randalpura ec39aa17c2 Moved markdown parsing logic to a separate util file (#20526) 2026-02-27 17:43:18 +00:00
DeWitt Clinton 7a1f2f3288 Disable expensive and scheduled workflows on personal forks (#20449) 2026-02-27 17:40:09 +00:00
Abhijit Balaji 59c0e73718 feat(cli): add temporary flag to disable workspace policies (#20523) 2026-02-27 17:25:16 +00:00
Dmitry Lyalin 3b2632fe40 fix(cli): keep thought summary when loading phrases are off (#20497)
Co-authored-by: Jacob Richman <jacob314@gmail.com>
2026-02-27 17:11:13 +00:00
Sehoon Shon e709789067 fix(core): handle optional response fields from code assist API (#20345) 2026-02-27 16:52:37 +00:00
Christian Gunderman 514d431049 Demote unreliable test. (#20571) 2026-02-27 16:48:46 +00:00
Abhijit Balaji 32e777f838 fix(core): revert auto-save of policies to user space (#20531) 2026-02-27 16:03:36 +00:00
Jacob Richman 14dd07be00 fix(cli): ensure dialogs stay scrolled to bottom in alternate buffer mode (#20527) 2026-02-27 16:00:07 +00:00
Pyush Sinha d7320f5425 refactor(core,cli): useAlternateBuffer read from config (#20346)
Co-authored-by: Jacob Richman <jacob314@gmail.com>
2026-02-27 15:55:02 +00:00
Adib234 25ade7bcb7 feat(plan): update planning workflow to encourage multi-select with descriptions of options (#20491) 2026-02-27 15:42:37 +00:00
Jacob Richman ac4d0c20d8 fix(cli): hide shortcuts hint while model is thinking or the user has typed a prompt + add debounce to avoid flicker (#19389) 2026-02-27 15:34:49 +00:00
Jacob Richman 4d9cc36146 Fix flicker showing message to press ctrl-O again to collapse. (#20414)
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-02-27 15:07:14 +00:00
Jerop Kipruto 66b8922d66 feat(ui): add 'ctrl+o' hint to truncated content message (#20529) 2026-02-27 15:02:46 +00:00
christine betts 58df1c6237 Fix extension MCP server env var loading (#20374) 2026-02-27 14:49:10 +00:00
Bryan Morgan 522e95439c fix(core): apply retry logic to CodeAssistServer for all users (#20507) 2026-02-27 09:26:53 -05:00
Jacob Richman 82336de7a4 Fix bottom border rendering for search and add a regression test. (#20517) 2026-02-27 04:55:21 +00:00
Jenna Inouye 83a3851dfd Docs: Update to Terms of Service & FAQ (#20488)
Co-authored-by: Jerop Kipruto <jerop@google.com>
2026-02-27 04:14:57 +00:00
christine betts e17f927a69 Add support for policy engine in extensions (#20049)
Co-authored-by: Jerop Kipruto <jerop@google.com>
2026-02-27 03:29:33 +00:00
heaventourist b1befee8fb feat(telemetry) Instrument traces with more attributes and make them available to OTEL users (#20237)
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
Co-authored-by: Jerop Kipruto <jerop@google.com>
Co-authored-by: MD. MOHIBUR RAHMAN <35300157+mrpmohiburrahman@users.noreply.github.com>
Co-authored-by: Jeffrey Ying <jeffrey.ying86@live.com>
Co-authored-by: Bryan Morgan <bryanmorgan@google.com>
Co-authored-by: joshualitt <joshualitt@google.com>
Co-authored-by: Dev Randalpura <devrandalpura@google.com>
Co-authored-by: Google Admin <github-admin@google.com>
Co-authored-by: Ben Knutson <benknutson@google.com>
2026-02-27 02:26:16 +00:00
Tommaso Sciortino 4b7ce1fe67 Avoid overaggressive unescaping (#20520) 2026-02-27 01:50:21 +00:00
Dev Randalpura ecfa4e0437 fix(ui): correct styled table width calculations (#20042) 2026-02-27 01:31:21 +00:00
Jarrod Whelan 085441352b ui(cli): reduce length of Ctrl+O hint (#20490) 2026-02-27 00:57:38 +00:00
Jarrod Whelan 049d48ef60 test(cli): fix flaky ToolResultDisplay overflow test (#20518) 2026-02-27 00:30:43 +00:00
Adib234 bc622a443e docs(plan): update documentation regarding supporting editing of plan files during plan approval (#20452)
Co-authored-by: Jerop Kipruto <jerop@google.com>
2026-02-27 00:30:23 +00:00
Siddharth Diwan 9b7852f11c [Gemma x Gemini CLI] Add an Experimental Gemma Router that uses a LiteRT-LM shim into the Composite Model Classifier Strategy (#17231)
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
Co-authored-by: Allen Hutchison <adh@google.com>
2026-02-26 23:43:43 +00:00
Bryan Morgan 6dc9d5ff11 feat(core): increase fetch timeout and fix [object Object] error stringification (#20441)
Co-authored-by: Gal Zahavi <38544478+galz10@users.noreply.github.com>
2026-02-26 23:41:09 +00:00
Yuvraj Angad Singh b8d6041d42 fix: prevent orphaned processes from consuming 100% CPU when terminal closes (#16965)
Co-authored-by: Tommaso Sciortino <sciortino@gmail.com>
2026-02-26 23:17:09 +00:00
Jerop Kipruto aa98cafca7 feat(plan): adapt planning workflow based on complexity of task (#20465)
Co-authored-by: Gal Zahavi <38544478+galz10@users.noreply.github.com>
2026-02-26 22:58:19 +00:00
krishdef7 f700c923d9 fix(core): flush transcript for pure tool-call responses to ensure BeforeTool hooks see complete state (#20419)
Co-authored-by: Bryan Morgan <bryanmorgan@google.com>
2026-02-26 22:39:36 +00:00
Sehoon Shon edb1fdea30 fix(cli): support quota error fallbacks for all authentication types (#20475)
Co-authored-by: Gal Zahavi <38544478+galz10@users.noreply.github.com>
2026-02-26 22:39:25 +00:00
Adam Weidman 10c5bd8ce9 feat(core): improve A2A content extraction (#20487)
Co-authored-by: Gal Zahavi <38544478+galz10@users.noreply.github.com>
2026-02-26 22:38:30 +00:00
Gal Zahavi 9de8349cf0 fix: action var usage (#20492) 2026-02-26 22:06:09 +00:00
Jerop Kipruto 020da58327 fix(github): resolve actionlint and yamllint regressions from #19443 (#20467) 2026-02-26 11:31:31 -08:00
212 changed files with 7146 additions and 3140 deletions
@@ -39,10 +39,10 @@ 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 }}
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: |
+5 -5
View File
@@ -44,8 +44,8 @@ runs:
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 }}
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 }}'
+16 -16
View File
@@ -97,7 +97,7 @@ runs:
git switch -c "${BRANCH_NAME}"
echo "BRANCH_NAME=${BRANCH_NAME}" >> "${GITHUB_OUTPUT}"
env:
INPUTS_RELEASE_TAG: ${{ inputs.release-tag }}
INPUTS_RELEASE_TAG: '${{ inputs.release-tag }}'
- name: '⬆️ Update package versions'
working-directory: '${{ inputs.working-directory }}'
@@ -105,7 +105,7 @@ runs:
run: |
npm run release:version "${INPUTS_RELEASE_VERSION}"
env:
INPUTS_RELEASE_VERSION: ${{ inputs.release-version }}
INPUTS_RELEASE_VERSION: '${{ inputs.release-version }}'
- name: '💾 Commit and Conditionally Push package versions'
working-directory: '${{ inputs.working-directory }}'
@@ -167,8 +167,8 @@ 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 }}
INPUTS_DRY_RUN: '${{ inputs.dry-run }}'
INPUTS_CORE_PACKAGE_NAME: '${{ inputs.core-package-name }}'
shell: 'bash'
run: |
npm publish \
@@ -187,10 +187,10 @@ runs:
--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 }}
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'
@@ -206,8 +206,8 @@ 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 }}
INPUTS_DRY_RUN: '${{ inputs.dry-run }}'
INPUTS_CLI_PACKAGE_NAME: '${{ inputs.cli-package-name }}'
shell: 'bash'
run: |
npm publish \
@@ -230,8 +230,8 @@ 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 }}
INPUTS_DRY_RUN: '${{ inputs.dry-run }}'
INPUTS_A2A_PACKAGE_NAME: '${{ inputs.a2a-package-name }}'
shell: 'bash'
# Tag staging for initial release
run: |
@@ -273,9 +273,9 @@ 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 }}
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}" \
@@ -296,4 +296,4 @@ runs:
git push origin --delete "${STEPS_RELEASE_BRANCH_OUTPUTS_BRANCH_NAME}"
env:
STEPS_RELEASE_BRANCH_OUTPUTS_BRANCH_NAME: ${{ steps.release_branch.outputs.BRANCH_NAME }}
STEPS_RELEASE_BRANCH_OUTPUTS_BRANCH_NAME: '${{ steps.release_branch.outputs.BRANCH_NAME }}'
+1 -1
View File
@@ -55,7 +55,7 @@ runs:
REF_NAME="${INPUTS_REF_NAME}"
echo "name=${REF_NAME%/merge}" >> $GITHUB_OUTPUT
env:
INPUTS_REF_NAME: ${{ inputs.ref-name }}
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:
+4 -4
View File
@@ -67,15 +67,15 @@ runs:
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 }}
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 }}
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} \
@@ -95,7 +95,7 @@ runs:
run: |-
docker push "${STEPS_DOCKER_BUILD_OUTPUTS_URI}"
env:
STEPS_DOCKER_BUILD_OUTPUTS_URI: ${{ steps.docker_build.outputs.uri }}
STEPS_DOCKER_BUILD_OUTPUTS_URI: '${{ steps.docker_build.outputs.uri }}'
- name: 'Create issue on failure'
if: |-
${{ failure() }}
+1 -1
View File
@@ -21,4 +21,4 @@ runs:
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 }}
INPUTS_GITHUB_TOKEN: '${{ inputs.github-token }}'
+14 -14
View File
@@ -71,9 +71,9 @@ 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 }}
INPUTS_CORE_PACKAGE_NAME: '${{ inputs.core-package-name }}'
INPUTS_VERSION: '${{ inputs.version }}'
INPUTS_CHANNEL: '${{ inputs.channel }}'
shell: 'bash'
working-directory: '${{ inputs.working-directory }}'
run: |
@@ -94,9 +94,9 @@ 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 }}
INPUTS_CLI_PACKAGE_NAME: '${{ inputs.cli-package-name }}'
INPUTS_VERSION: '${{ inputs.version }}'
INPUTS_CHANNEL: '${{ inputs.channel }}'
shell: 'bash'
working-directory: '${{ inputs.working-directory }}'
run: |
@@ -117,9 +117,9 @@ 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 }}
INPUTS_A2A_PACKAGE_NAME: '${{ inputs.a2a-package-name }}'
INPUTS_VERSION: '${{ inputs.version }}'
INPUTS_CHANNEL: '${{ inputs.channel }}'
shell: 'bash'
working-directory: '${{ inputs.working-directory }}'
run: |
@@ -134,12 +134,12 @@ runs:
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_CHANNEL: '${{ inputs.channel }}'
INPUTS_VERSION: ${{ inputs.version }}
INPUTS_VERSION: '${{ inputs.version }}'
INPUTS_CLI_PACKAGE_NAME: ${{ inputs.cli-package-name }}
INPUTS_CLI_PACKAGE_NAME: '${{ inputs.cli-package-name }}'
INPUTS_CORE_PACKAGE_NAME: ${{ inputs.core-package-name }}
INPUTS_CORE_PACKAGE_NAME: '${{ inputs.core-package-name }}'
INPUTS_A2A_PACKAGE_NAME: ${{ inputs.a2a-package-name }}
INPUTS_A2A_PACKAGE_NAME: '${{ inputs.a2a-package-name }}'
+4 -4
View File
@@ -69,8 +69,8 @@ runs:
exit 1
fi
env:
INPUTS_EXPECTED_VERSION: ${{ inputs.expected-version }}
INPUTS_NPM_PACKAGE: ${{ inputs.npm-package }}
INPUTS_EXPECTED_VERSION: '${{ inputs.expected-version }}'
INPUTS_NPM_PACKAGE: '${{ inputs.npm-package }}'
- name: 'Clear npm cache'
shell: 'bash'
@@ -86,8 +86,8 @@ runs:
exit 1
fi
env:
INPUTS_NPM_PACKAGE: ${{ inputs.npm-package }}
INPUTS_EXPECTED_VERSION: ${{ inputs.expected-version }}
INPUTS_NPM_PACKAGE: '${{ inputs.npm-package }}'
INPUTS_EXPECTED_VERSION: '${{ inputs.expected-version }}'
- name: 'Install dependencies for integration tests'
shell: 'bash'
+17 -16
View File
@@ -31,6 +31,7 @@ jobs:
name: 'Merge Queue Skipper'
permissions: 'read-all'
runs-on: 'gemini-cli-ubuntu-16-core'
if: "github.repository == 'google-gemini/gemini-cli'"
outputs:
skip: '${{ steps.merge-queue-e2e-skipper.outputs.skip-check }}'
steps:
@@ -42,7 +43,7 @@ jobs:
download_repo_name:
runs-on: 'gemini-cli-ubuntu-16-core'
if: "${{github.event_name == 'workflow_dispatch' || github.event_name == 'workflow_run'}}"
if: "github.repository == 'google-gemini/gemini-cli' && (github.event_name == 'workflow_dispatch' || github.event_name == 'workflow_run')"
outputs:
repo_name: '${{ steps.output-repo-name.outputs.repo_name }}'
head_sha: '${{ steps.output-repo-name.outputs.head_sha }}'
@@ -53,7 +54,7 @@ jobs:
REPO_NAME: '${{ github.event.inputs.repo_name }}'
run: |
mkdir -p ./pr
echo '${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'
@@ -91,7 +92,7 @@ jobs:
name: 'Parse run context'
runs-on: 'gemini-cli-ubuntu-16-core'
needs: 'download_repo_name'
if: 'always()'
if: "github.repository == 'google-gemini/gemini-cli' && always()"
outputs:
repository: '${{ steps.set_context.outputs.REPO }}'
sha: '${{ steps.set_context.outputs.SHA }}'
@@ -111,11 +112,11 @@ jobs:
permissions: 'write-all'
needs:
- 'parse_run_context'
if: 'always()'
if: "github.repository == 'google-gemini/gemini-cli' && always()"
steps:
- name: 'Set pending status'
uses: 'myrotvorets/set-commit-status-action@16037e056d73b2d3c88e37e393ff369047f70886' # ratchet:myrotvorets/set-commit-status-action@master
if: 'always()'
if: "github.repository == 'google-gemini/gemini-cli' && always()"
with:
allowForks: 'true'
repo: '${{ github.repository }}'
@@ -131,7 +132,7 @@ jobs:
- 'parse_run_context'
runs-on: 'gemini-cli-ubuntu-16-core'
if: |
always() && (needs.merge_queue_skipper.result !='success' || needs.merge_queue_skipper.outputs.skip != 'true')
github.repository == 'google-gemini/gemini-cli' && always() && (needs.merge_queue_skipper.result !='success' || needs.merge_queue_skipper.outputs.skip != 'true')
strategy:
fail-fast: false
matrix:
@@ -184,7 +185,7 @@ jobs:
- 'parse_run_context'
runs-on: 'macos-latest'
if: |
always() && (needs.merge_queue_skipper.result !='success' || needs.merge_queue_skipper.outputs.skip != 'true')
github.repository == 'google-gemini/gemini-cli' && always() && (needs.merge_queue_skipper.result !='success' || needs.merge_queue_skipper.outputs.skip != 'true')
steps:
- name: 'Checkout'
uses: 'actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955' # ratchet:actions/checkout@v5
@@ -222,7 +223,7 @@ jobs:
- 'merge_queue_skipper'
- 'parse_run_context'
if: |
always() && (needs.merge_queue_skipper.result !='success' || needs.merge_queue_skipper.outputs.skip != 'true')
github.repository == 'google-gemini/gemini-cli' && always() && (needs.merge_queue_skipper.result !='success' || needs.merge_queue_skipper.outputs.skip != 'true')
runs-on: 'gemini-cli-windows-16-core'
steps:
- name: 'Checkout'
@@ -282,7 +283,7 @@ jobs:
- 'parse_run_context'
runs-on: 'gemini-cli-ubuntu-16-core'
if: |
always() && (needs.merge_queue_skipper.result !='success' || needs.merge_queue_skipper.outputs.skip != 'true')
github.repository == 'google-gemini/gemini-cli' && always() && (needs.merge_queue_skipper.result !='success' || needs.merge_queue_skipper.outputs.skip != 'true')
steps:
- name: 'Checkout'
uses: 'actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955' # ratchet:actions/checkout@v5
@@ -309,7 +310,7 @@ jobs:
e2e:
name: 'E2E'
if: |
always() && (needs.merge_queue_skipper.result !='success' || needs.merge_queue_skipper.outputs.skip != 'true')
github.repository == 'google-gemini/gemini-cli' && always() && (needs.merge_queue_skipper.result !='success' || needs.merge_queue_skipper.outputs.skip != 'true')
needs:
- 'e2e_linux'
- 'e2e_mac'
@@ -329,22 +330,22 @@ jobs:
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 }}
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'
permissions: 'write-all'
if: 'always()'
if: "github.repository == 'google-gemini/gemini-cli' && always()"
needs:
- 'parse_run_context'
- 'e2e'
steps:
- name: 'Set workflow status'
uses: 'myrotvorets/set-commit-status-action@16037e056d73b2d3c88e37e393ff369047f70886' # ratchet:myrotvorets/set-commit-status-action@master
if: 'always()'
if: "github.repository == 'google-gemini/gemini-cli' && always()"
with:
allowForks: 'true'
repo: '${{ github.repository }}'
+16 -14
View File
@@ -37,6 +37,7 @@ jobs:
permissions: 'read-all'
name: 'Merge Queue Skipper'
runs-on: 'gemini-cli-ubuntu-16-core'
if: "github.repository == 'google-gemini/gemini-cli'"
outputs:
skip: '${{ steps.merge-queue-ci-skipper.outputs.skip-check }}'
steps:
@@ -49,7 +50,7 @@ jobs:
name: 'Lint'
runs-on: 'gemini-cli-ubuntu-16-core'
needs: 'merge_queue_skipper'
if: "${{needs.merge_queue_skipper.outputs.skip == 'false'}}"
if: "github.repository == 'google-gemini/gemini-cli' && needs.merge_queue_skipper.outputs.skip == 'false'"
env:
GEMINI_LINT_TEMP_DIR: '${{ github.workspace }}/.gemini-linters'
steps:
@@ -116,6 +117,7 @@ jobs:
link_checker:
name: 'Link Checker'
runs-on: 'ubuntu-latest'
if: "github.repository == 'google-gemini/gemini-cli'"
steps:
- name: 'Checkout'
uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' # ratchet:actions/checkout@v5
@@ -129,7 +131,7 @@ jobs:
runs-on: 'gemini-cli-ubuntu-16-core'
needs:
- 'merge_queue_skipper'
if: "${{needs.merge_queue_skipper.outputs.skip == 'false'}}"
if: "github.repository == 'google-gemini/gemini-cli' && needs.merge_queue_skipper.outputs.skip == 'false'"
permissions:
contents: 'read'
checks: 'write'
@@ -216,7 +218,7 @@ jobs:
runs-on: 'macos-latest'
needs:
- 'merge_queue_skipper'
if: "${{needs.merge_queue_skipper.outputs.skip == 'false'}}"
if: "github.repository == 'google-gemini/gemini-cli' && needs.merge_queue_skipper.outputs.skip == 'false'"
permissions:
contents: 'read'
checks: 'write'
@@ -311,7 +313,7 @@ jobs:
name: 'CodeQL'
runs-on: 'gemini-cli-ubuntu-16-core'
needs: 'merge_queue_skipper'
if: "${{needs.merge_queue_skipper.outputs.skip == 'false'}}"
if: "github.repository == 'google-gemini/gemini-cli' && needs.merge_queue_skipper.outputs.skip == 'false'"
permissions:
actions: 'read'
contents: 'read'
@@ -334,7 +336,7 @@ jobs:
bundle_size:
name: 'Check Bundle Size'
needs: 'merge_queue_skipper'
if: "${{github.event_name == 'pull_request' && needs.merge_queue_skipper.outputs.skip == 'false'}}"
if: "github.repository == 'google-gemini/gemini-cli' && github.event_name == 'pull_request' && needs.merge_queue_skipper.outputs.skip == 'false'"
runs-on: 'gemini-cli-ubuntu-16-core'
permissions:
contents: 'read' # For checkout
@@ -359,7 +361,7 @@ jobs:
name: 'Slow Test - Win - ${{ matrix.shard }}'
runs-on: 'gemini-cli-windows-16-core'
needs: 'merge_queue_skipper'
if: "${{needs.merge_queue_skipper.outputs.skip == 'false'}}"
if: "github.repository == 'google-gemini/gemini-cli' && needs.merge_queue_skipper.outputs.skip == 'false'"
timeout-minutes: 60
strategy:
matrix:
@@ -451,7 +453,7 @@ jobs:
ci:
name: 'CI'
if: 'always()'
if: "github.repository == 'google-gemini/gemini-cli' && always()"
needs:
- 'lint'
- 'link_checker'
@@ -476,10 +478,10 @@ jobs:
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 }}
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 }}'
+3
View File
@@ -27,6 +27,7 @@ jobs:
deflake_e2e_linux:
name: 'E2E Test (Linux) - ${{ matrix.sandbox }}'
runs-on: 'gemini-cli-ubuntu-16-core'
if: "github.repository == 'google-gemini/gemini-cli'"
strategy:
fail-fast: false
matrix:
@@ -77,6 +78,7 @@ jobs:
deflake_e2e_mac:
name: 'E2E Test (macOS)'
runs-on: 'macos-latest'
if: "github.repository == 'google-gemini/gemini-cli'"
steps:
- name: 'Checkout'
uses: 'actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955' # ratchet:actions/checkout@v5
@@ -114,6 +116,7 @@ jobs:
deflake_e2e_windows:
name: 'Slow E2E - Win'
runs-on: 'gemini-cli-windows-16-core'
if: "github.repository == 'google-gemini/gemini-cli'"
steps:
- name: 'Checkout'
-50
View File
@@ -1,50 +0,0 @@
name: 'Deploy GitHub Pages'
on:
push:
tags: 'v*'
workflow_dispatch:
permissions:
contents: 'read'
pages: 'write'
id-token: 'write'
# Allow only one concurrent deployment, skipping runs queued between the run
# in-progress and latest queued. However, do NOT cancel in-progress runs as we
# want to allow these production deployments to complete.
concurrency:
group: '${{ github.workflow }}'
cancel-in-progress: false
jobs:
build:
if: |-
${{ !contains(github.ref_name, 'nightly') }}
runs-on: 'ubuntu-latest'
steps:
- name: 'Checkout'
uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' # ratchet:actions/checkout@v5
- name: 'Setup Pages'
uses: 'actions/configure-pages@983d7736d9b0ae728b81ab479565c72886d7745b' # ratchet:actions/configure-pages@v5
- name: 'Build with Jekyll'
uses: 'actions/jekyll-build-pages@44a6e6beabd48582f863aeeb6cb2151cc1716697' # ratchet:actions/jekyll-build-pages@v1
with:
source: './'
destination: './_site'
- name: 'Upload artifact'
uses: 'actions/upload-pages-artifact@56afc609e74202658d3ffba0e8f6dda462b719fa' # ratchet:actions/upload-pages-artifact@v3
deploy:
environment:
name: 'github-pages'
url: '${{ steps.deployment.outputs.page_url }}'
runs-on: 'ubuntu-latest'
needs: 'build'
steps:
- name: 'Deploy to GitHub Pages'
id: 'deployment'
uses: 'actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e' # ratchet:actions/deploy-pages@v4
+1
View File
@@ -7,6 +7,7 @@ on:
- 'docs/**'
jobs:
trigger-rebuild:
if: "github.repository == 'google-gemini/gemini-cli'"
runs-on: 'ubuntu-latest'
steps:
- name: 'Trigger rebuild'
+1 -1
View File
@@ -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
+2 -1
View File
@@ -23,6 +23,7 @@ jobs:
evals:
name: 'Evals (USUALLY_PASSING) nightly run'
runs-on: 'gemini-cli-ubuntu-16-core'
if: "github.repository == 'google-gemini/gemini-cli'"
strategy:
fail-fast: false
matrix:
@@ -85,7 +86,7 @@ jobs:
aggregate-results:
name: 'Aggregate Results'
needs: ['evals']
if: 'always()'
if: "github.repository == 'google-gemini/gemini-cli' && always()"
runs-on: 'gemini-cli-ubuntu-16-core'
steps:
- name: 'Checkout'
@@ -21,6 +21,7 @@ defaults:
jobs:
close-stale-issues:
if: "github.repository == 'google-gemini/gemini-cli'"
runs-on: 'ubuntu-latest'
permissions:
issues: 'write'
@@ -14,7 +14,7 @@ permissions:
jobs:
# Event-based: Quick reaction to new/edited issues in THIS repo
labeler:
if: "github.event_name == 'issues'"
if: "github.repository == 'google-gemini/gemini-cli' && github.event_name == 'issues'"
runs-on: 'ubuntu-latest'
steps:
- name: 'Checkout'
@@ -36,7 +36,7 @@ jobs:
# Scheduled/Manual: Recursive sync across multiple repos
sync-maintainer-labels:
if: "github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'"
if: "github.repository == 'google-gemini/gemini-cli' && (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch')"
runs-on: 'ubuntu-latest'
steps:
- name: 'Checkout'
@@ -9,6 +9,7 @@ on:
jobs:
labeler:
if: "github.repository == 'google-gemini/gemini-cli'"
runs-on: 'ubuntu-latest'
permissions:
issues: 'write'
@@ -32,6 +32,7 @@ on:
jobs:
change-tags:
if: "github.repository == 'google-gemini/gemini-cli'"
runs-on: 'ubuntu-latest'
environment: "${{ github.event.inputs.environment || 'prod' }}"
permissions:
+1
View File
@@ -47,6 +47,7 @@ on:
jobs:
release:
if: "github.repository == 'google-gemini/gemini-cli'"
runs-on: 'ubuntu-latest'
environment: "${{ github.event.inputs.environment || 'prod' }}"
permissions:
+1
View File
@@ -22,6 +22,7 @@ on:
jobs:
generate-release-notes:
if: "github.repository == 'google-gemini/gemini-cli'"
runs-on: 'ubuntu-latest'
permissions:
contents: 'write'
@@ -118,7 +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}}
VARS_CLI_PACKAGE_NAME: '${{ vars.CLI_PACKAGE_NAME }}'
run: |
echo "🔍 Verifying no concurrent patch releases have occurred..."
@@ -168,10 +168,10 @@ jobs:
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 }}
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'}}"
+3 -3
View File
@@ -366,18 +366,18 @@ jobs:
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 }}
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}"'
env:
NEEDS_CALCULATE_VERSIONS_OUTPUTS_NEXT_NIGHTLY_VERSION: ${{ needs.calculate-versions.outputs.NEXT_NIGHTLY_VERSION }}
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 }}
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
+2 -1
View File
@@ -42,6 +42,7 @@ on:
jobs:
change-tags:
if: "github.repository == 'google-gemini/gemini-cli'"
environment: "${{ github.event.inputs.environment || 'prod' }}"
runs-on: 'ubuntu-latest'
permissions:
@@ -203,7 +204,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
+1
View File
@@ -16,6 +16,7 @@ on:
jobs:
build:
if: "github.repository == 'google-gemini/gemini-cli'"
runs-on: 'ubuntu-latest'
permissions:
contents: 'read'
+1
View File
@@ -20,6 +20,7 @@ on:
jobs:
smoke-test:
if: "github.repository == 'google-gemini/gemini-cli'"
runs-on: 'ubuntu-latest'
permissions:
contents: 'write'
+4 -2
View File
@@ -15,6 +15,7 @@ on:
jobs:
save_repo_name:
if: "github.repository == 'google-gemini/gemini-cli'"
runs-on: 'gemini-cli-ubuntu-16-core'
steps:
- name: 'Save Repo name'
@@ -23,14 +24,15 @@ jobs:
HEAD_SHA: '${{ github.event.inputs.head_sha || github.event.pull_request.head.sha }}'
run: |
mkdir -p ./pr
echo '${REPO_NAME}' > ./pr/repo_name
echo '${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'
path: 'pr/'
trigger_e2e:
name: 'Trigger e2e'
if: "github.repository == 'google-gemini/gemini-cli'"
runs-on: 'gemini-cli-ubuntu-16-core'
steps:
- id: 'trigger-e2e'
+1
View File
@@ -28,6 +28,7 @@ on:
jobs:
verify-release:
if: "github.repository == 'google-gemini/gemini-cli'"
environment: "${{ github.event.inputs.environment || 'prod' }}"
strategy:
fail-fast: false
+27 -7
View File
@@ -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).
@@ -290,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
+1
View File
@@ -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
+39 -11
View File
@@ -176,11 +176,12 @@ Sends telemetry directly to Google Cloud services. No collector needed.
}
```
2. Run Gemini CLI and send prompts.
3. View logs and metrics:
3. View logs, metrics, and traces:
- Open the Google Cloud Console in your browser after sending prompts:
- Logs: https://console.cloud.google.com/logs/
- Metrics: https://console.cloud.google.com/monitoring/metrics-explorer
- Traces: https://console.cloud.google.com/traces/list
- Logs (Logs Explorer): https://console.cloud.google.com/logs/
- Metrics (Metrics Explorer):
https://console.cloud.google.com/monitoring/metrics-explorer
- Traces (Trace Explorer): https://console.cloud.google.com/traces/list
### Collector-based export (advanced)
@@ -208,11 +209,12 @@ forward data to Google Cloud.
- Save collector logs to `~/.gemini/tmp/<projectHash>/otel/collector-gcp.log`
- Stop collector on exit (e.g. `Ctrl+C`)
3. Run Gemini CLI and send prompts.
4. View logs and metrics:
4. View logs, metrics, and traces:
- Open the Google Cloud Console in your browser after sending prompts:
- Logs: https://console.cloud.google.com/logs/
- Metrics: https://console.cloud.google.com/monitoring/metrics-explorer
- Traces: https://console.cloud.google.com/traces/list
- Logs (Logs Explorer): https://console.cloud.google.com/logs/
- Metrics (Metrics Explorer):
https://console.cloud.google.com/monitoring/metrics-explorer
- Traces (Trace Explorer): https://console.cloud.google.com/traces/list
- Open `~/.gemini/tmp/<projectHash>/otel/collector-gcp.log` to view local
collector logs.
@@ -270,10 +272,10 @@ For local development and debugging, you can capture telemetry data locally:
3. View traces at http://localhost:16686 and logs/metrics in the collector log
file.
## Logs and metrics
## Logs, metrics, and traces
The following section describes the structure of logs and metrics generated for
Gemini CLI.
The following section describes the structure of logs, metrics, and traces
generated for Gemini CLI.
The `session.id`, `installation.id`, `active_approval_mode`, and `user.email`
(available only when authenticated with a Google account) are included as common
@@ -824,6 +826,32 @@ Optional performance monitoring for startup, CPU/memory, and phase timing.
- `current_value` (number)
- `baseline_value` (number)
### Traces
Traces offer a granular, "under-the-hood" view of every agent and backend
operation. By providing a high-fidelity execution map, they enable precise
debugging of complex tool interactions and deep performance optimization. Each
trace captures rich, consistent metadata via custom span attributes:
- `gen_ai.operation.name` (string): The high-level operation kind (e.g.
"tool_call", "llm_call").
- `gen_ai.agent.name` (string): The service agent identifier ("gemini-cli").
- `gen_ai.agent.description` (string): The service agent description.
- `gen_ai.input.messages` (string): Input messages or metadata specific to the
operation.
- `gen_ai.output.messages` (string): Output messages or metadata generated from
the operation.
- `gen_ai.request.model` (string): The request model name.
- `gen_ai.response.model` (string): The response model name.
- `gen_ai.system_instructions` (json string): The system instructions.
- `gen_ai.prompt.name` (string): The prompt name.
- `gen_ai.tool.name` (string): The executed tool's name.
- `gen_ai.tool.call_id` (string): The generated specific ID of the tool call.
- `gen_ai.tool.description` (string): The executed tool's description.
- `gen_ai.tool.definitions` (json string): The executed tool's description.
- `gen_ai.conversation.id` (string): The current CLI session ID.
- Additional user-defined Custom Attributes passed via the span's configuration.
#### GenAI semantic convention
The following metrics comply with [OpenTelemetry GenAI semantic conventions] for
+36
View File
@@ -227,6 +227,42 @@ skill definitions in a `skills/` directory. For example,
Provide [sub-agents](../core/subagents.md) that users can delegate tasks to. Add
agent definition files (`.md`) to an `agents/` directory in your extension root.
### <a id="policy-engine"></a>Policy Engine
Extensions can contribute policy rules and safety checkers to the Gemini CLI
[Policy Engine](../reference/policy-engine.md). These rules are defined in
`.toml` files and take effect when the extension is activated.
To add policies, create a `policies/` directory in your extension's root and
place your `.toml` policy files inside it. Gemini CLI automatically loads all
`.toml` files from this directory.
Rules contributed by extensions run in their own tier (tier 2), alongside
workspace-defined policies. This tier has higher priority than the default rules
but lower priority than user or admin policies.
> **Warning:** For security, Gemini CLI ignores any `allow` decisions or `yolo`
> mode configurations in extension policies. This ensures that an extension
> cannot automatically approve tool calls or bypass security measures without
> your confirmation.
**Example `policies.toml`**
```toml
[[rule]]
toolName = "my_server__dangerous_tool"
decision = "ask_user"
priority = 100
[[safety_checker]]
toolName = "my_server__write_data"
priority = 200
[safety_checker.checker]
type = "in-process"
name = "allowed-path"
required_context = ["environment"]
```
### Themes
Extensions can provide custom themes to personalize the CLI UI. Themes are
+47 -41
View File
@@ -1,23 +1,21 @@
# Local development guide
This guide provides instructions for setting up and using local development
features, such as development tracing.
features, such as tracing.
## Development tracing
## Tracing
Development traces (dev traces) are OpenTelemetry (OTel) traces that help you
debug your code by instrumenting interesting events like model calls, tool
scheduler, tool calls, etc.
Traces are OpenTelemetry (OTel) records that help you debug your code by
instrumenting key events like model calls, tool scheduler operations, and tool
calls.
Dev traces are verbose and are specifically meant for understanding agent
behavior and debugging issues. They are disabled by default.
Traces provide deep visibility into agent behavior and are invaluable for
debugging complex issues. They are captured automatically when telemetry is
enabled.
To enable dev traces, set the `GEMINI_DEV_TRACING=true` environment variable
when running Gemini CLI.
### Viewing traces
### Viewing dev traces
You can view dev traces using either Jaeger or the Genkit Developer UI.
You can view traces using either Jaeger or the Genkit Developer UI.
#### Using Genkit
@@ -37,13 +35,12 @@ Genkit provides a web-based UI for viewing traces and other telemetry data.
Genkit Developer UI: http://localhost:4000
```
2. **Run Gemini CLI with dev tracing:**
2. **Run Gemini CLI:**
In a separate terminal, run your Gemini CLI command with the
`GEMINI_DEV_TRACING` environment variable:
In a separate terminal, run your Gemini CLI command:
```bash
GEMINI_DEV_TRACING=true gemini
gemini
```
3. **View the traces:**
@@ -53,7 +50,7 @@ Genkit provides a web-based UI for viewing traces and other telemetry data.
#### Using Jaeger
You can view dev traces in the Jaeger UI. To get started, follow these steps:
You can view traces in the Jaeger UI. To get started, follow these steps:
1. **Start the telemetry collector:**
@@ -67,13 +64,12 @@ You can view dev traces in the Jaeger UI. To get started, follow these steps:
This command also configures your workspace for local telemetry and provides
a link to the Jaeger UI (usually `http://localhost:16686`).
2. **Run Gemini CLI with dev tracing:**
2. **Run Gemini CLI:**
In a separate terminal, run your Gemini CLI command with the
`GEMINI_DEV_TRACING` environment variable:
In a separate terminal, run your Gemini CLI command:
```bash
GEMINI_DEV_TRACING=true gemini
gemini
```
3. **View the traces:**
@@ -84,10 +80,10 @@ You can view dev traces in the Jaeger UI. To get started, follow these steps:
For more detailed information on telemetry, see the
[telemetry documentation](./cli/telemetry.md).
### Instrumenting code with dev traces
### Instrumenting code with traces
You can add dev traces to your own code for more detailed instrumentation. This
is useful for debugging and understanding the flow of execution.
You can add traces to your own code for more detailed instrumentation. This is
useful for debugging and understanding the flow of execution.
Use the `runInDevTraceSpan` function to wrap any section of code in a trace
span.
@@ -96,29 +92,39 @@ Here is a basic example:
```typescript
import { runInDevTraceSpan } from '@google/gemini-cli-core';
import { GeminiCliOperation } from '@google/gemini-cli-core/lib/telemetry/constants.js';
await runInDevTraceSpan({ name: 'my-custom-span' }, async ({ metadata }) => {
// The `metadata` object allows you to record the input and output of the
// operation as well as other attributes.
metadata.input = { key: 'value' };
// Set custom attributes.
metadata.attributes['gen_ai.request.model'] = 'gemini-4.0-mega';
await runInDevTraceSpan(
{
operation: GeminiCliOperation.ToolCall,
attributes: {
[GEN_AI_AGENT_NAME]: 'gemini-cli',
},
},
async ({ metadata }) => {
// The `metadata` object allows you to record the input and output of the
// operation as well as other attributes.
metadata.input = { key: 'value' };
// Set custom attributes.
metadata.attributes['custom.attribute'] = 'custom.value';
// Your code to be traced goes here
try {
const output = await somethingRisky();
metadata.output = output;
return output;
} catch (e) {
metadata.error = e;
throw e;
}
});
// Your code to be traced goes here
try {
const output = await somethingRisky();
metadata.output = output;
return output;
} catch (e) {
metadata.error = e;
throw e;
}
},
);
```
In this example:
- `name`: The name of the span, which will be displayed in the trace.
- `operation`: The operation type of the span, represented by the
`GeminiCliOperation` enum.
- `metadata.input`: (Optional) An object containing the input data for the
traced operation.
- `metadata.output`: (Optional) An object containing the output data from the
+17
View File
@@ -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):
+6 -6
View File
@@ -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`<br />`Cmd + Enter`<br />`Alt + Enter`<br />`Shift + Enter`<br />`Ctrl + J` |
| Open the current prompt in an external editor. | `Ctrl + X` |
| Paste from the clipboard. | `Ctrl + V`<br />`Cmd + V`<br />`Alt + V` |
| Action | Keys |
| ---------------------------------------------------------- | ----------------------------------------------------------------------------------------- |
| Submit the current prompt. | `Enter (no Shift, Alt, Ctrl, Cmd)` |
| Insert a newline without submitting. | `Ctrl + Enter`<br />`Cmd + Enter`<br />`Alt + Enter`<br />`Shift + Enter`<br />`Ctrl + J` |
| Open the current prompt or the plan in an external editor. | `Ctrl + X` |
| Paste from the clipboard. | `Ctrl + V`<br />`Cmd + V`<br />`Alt + V` |
#### App Controls
+4 -3
View File
@@ -97,9 +97,10 @@ has a designated number that forms the base of the final priority calculation.
| Tier | Base | Description |
| :-------- | :--- | :------------------------------------------------------------------------- |
| Default | 1 | Built-in policies that ship with the Gemini CLI. |
| Workspace | 2 | Policies defined in the current workspace's configuration directory. |
| User | 3 | Custom policies defined by the user. |
| Admin | 4 | Policies managed by an administrator (e.g., in an enterprise environment). |
| Extension | 2 | Policies defined in extensions. |
| Workspace | 3 | Policies defined in the current workspace's configuration directory. |
| User | 4 | Custom policies defined by the user. |
| Admin | 5 | Policies managed by an administrator (e.g., in an enterprise environment). |
Within a TOML policy file, you assign a priority value from **0 to 999**. The
engine transforms this into a final priority using the following formula:
+9
View File
@@ -5,6 +5,15 @@ problems encountered while using Gemini CLI.
## General issues
### Why can't I use third-party software (e.g. Claude Code, OpenClaw, OpenCode) with Gemini CLI?
Using third-party software, tools, or services to access Gemini CLI is a
violation of our [applicable terms and policies](tos-privacy.md), and severely
degrades the experience for legitimate product users. Such actions may be
grounds for suspension or termination of your account. If you would like to use
a third-party coding agent with Gemini, we recommend using a Vertex or AI Studio
API key.
### Why am I getting an `API error: 429 - Resource exhausted`?
This error indicates that you have exceeded your API request limit. The Gemini
+6
View File
@@ -7,6 +7,12 @@ is licensed under the
When you use Gemini CLI to access or use Googles services, the Terms of Service
and Privacy Notices applicable to those services apply to such access and use.
Directly accessing the services powering Gemini CLI (e.g., the Gemini Code
Assist service) using third-party software, tools, or services (for example,
using OpenClaw with Gemini CLI OAuth) is a violation of applicable terms and
policies. Such actions may be grounds for suspension or termination of your
account.
Your Gemini CLI Usage Statistics are handled in accordance with Google's Privacy
Policy.
+1 -1
View File
@@ -8,7 +8,7 @@ import { describe, expect } from 'vitest';
import { evalTest } from './test-helper.js';
describe('validation_fidelity', () => {
evalTest('ALWAYS_PASSES', {
evalTest('USUALLY_PASSES', {
name: 'should perform exhaustive validation autonomously when guided by system instructions',
files: {
'src/types.ts': `
-1
View File
@@ -72,7 +72,6 @@ describe('ACP telemetry', () => {
GEMINI_TELEMETRY_ENABLED: 'true',
GEMINI_TELEMETRY_TARGET: 'local',
GEMINI_TELEMETRY_OUTFILE: telemetryPath,
// GEMINI_DEV_TRACING not set: fake responses aren't instrumented for spans
},
},
);
+1 -25
View File
@@ -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"
},
+7
View File
@@ -15,4 +15,11 @@
- **Utilities**: Use `renderWithProviders` and `waitFor` from
`packages/cli/src/test-utils/`.
- **Snapshots**: Use `toMatchSnapshot()` to verify Ink output.
- **SVG Snapshots**: Use `await expect(renderResult).toMatchSvgSnapshot()` for
UI components whenever colors or detailed visual layout matter. SVG snapshots
capture styling accurately. Make sure to await the `waitUntilReady()` of the
render result before asserting. After updating SVG snapshots, always examine
the resulting `.svg` files (e.g. by reading their content or visually
inspecting them) to ensure the render and colors actually look as expected and
don't just contain an error message.
- **Mocks**: Use mocks as sparingly as possible.
@@ -0,0 +1,41 @@
# Policy engine example extension
This extension demonstrates how to contribute security rules and safety checkers
to the Gemini CLI Policy Engine.
## Description
The extension uses a `policies/` directory containing `.toml` files to define:
- A rule that requires user confirmation for `rm -rf` commands.
- A rule that denies searching for sensitive files (like `.env`) using `grep`.
- A safety checker that validates file paths for all write operations.
## Structure
- `gemini-extension.json`: The manifest file.
- `policies/`: Contains the `.toml` policy files.
## How to use
1. Link this extension to your local Gemini CLI installation:
```bash
gemini extensions link packages/cli/src/commands/extensions/examples/policies
```
2. Restart your Gemini CLI session.
3. **Observe the policies:**
- Try asking the model to delete a directory: The policy engine will prompt
you for confirmation due to the `rm -rf` rule.
- Try asking the model to search for secrets: The `grep` rule will deny the
request and display the custom deny message.
- Any file write operation will now be processed through the `allowed-path`
safety checker.
## Security note
For security, Gemini CLI ignores any `allow` decisions or `yolo` mode
configurations contributed by extensions. This ensures that extensions can
strengthen security but cannot bypass user confirmation.
@@ -0,0 +1,5 @@
{
"name": "policy-example",
"version": "1.0.0",
"description": "An example extension demonstrating Policy Engine support."
}
@@ -0,0 +1,28 @@
# Example Policy Rules for Gemini CLI Extension
#
# Extensions run in Tier 2 (Extension Tier).
# Security Note: 'allow' decisions and 'yolo' mode configurations are ignored.
# Rule: Always ask the user before running a specific dangerous shell command.
[[rule]]
toolName = "run_shell_command"
commandPrefix = "rm -rf"
decision = "ask_user"
priority = 100
# Rule: Deny access to sensitive files using the grep tool.
[[rule]]
toolName = "grep_search"
argsPattern = "(\.env|id_rsa|passwd)"
decision = "deny"
priority = 200
deny_message = "Access to sensitive credentials or system files is restricted by the policy-example extension."
# Safety Checker: Apply path validation to all write operations.
[[safety_checker]]
toolName = ["write_file", "replace"]
priority = 300
[safety_checker.checker]
type = "in-process"
name = "allowed-path"
required_context = ["environment"]
+60
View File
@@ -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;
+2
View File
@@ -843,6 +843,7 @@ export async function loadCliConfig(
interactive,
trustedFolder,
useBackgroundColor: settings.ui?.useBackgroundColor,
useAlternateBuffer: settings.ui?.useAlternateBuffer,
useRipgrep: settings.tools?.useRipgrep,
enableInteractiveShell: settings.tools?.shell?.enableInteractiveShell,
shellToolInactivityTimeout: settings.tools?.shell?.inactivityTimeout,
@@ -856,6 +857,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,
+36 -3
View File
@@ -52,6 +52,10 @@ import {
applyAdminAllowlist,
getAdminBlockedMcpServersMessage,
CoreToolCallStatus,
loadExtensionPolicies,
isSubpath,
type PolicyRule,
type SafetyCheckerRule,
HookType,
} from '@google/gemini-cli-core';
import { maybeRequestConsentOrFail } from './extensions/consent.js';
@@ -764,9 +768,18 @@ Would you like to attempt to install via "git clone" instead?`,
}
const contextFiles = getContextFileNames(config)
.map((contextFileName) =>
path.join(effectiveExtensionPath, contextFileName),
)
.map((contextFileName) => {
const contextFilePath = path.join(
effectiveExtensionPath,
contextFileName,
);
if (!isSubpath(effectiveExtensionPath, contextFilePath)) {
throw new Error(
`Invalid context file path: "${contextFileName}". Context files must be within the extension directory.`,
);
}
return contextFilePath;
})
.filter((contextFilePath) => fs.existsSync(contextFilePath));
const hydrationContext: VariableContext = {
@@ -820,6 +833,24 @@ Would you like to attempt to install via "git clone" instead?`,
recursivelyHydrateStrings(skill, hydrationContext),
);
let rules: PolicyRule[] | undefined;
let checkers: SafetyCheckerRule[] | undefined;
const policyDir = path.join(effectiveExtensionPath, 'policies');
if (fs.existsSync(policyDir)) {
const result = await loadExtensionPolicies(config.name, policyDir);
rules = result.rules;
checkers = result.checkers;
if (result.errors.length > 0) {
for (const error of result.errors) {
debugLogger.warn(
`[ExtensionManager] Error loading policies from ${config.name}: ${error.message}${error.details ? `\nDetails: ${error.details}` : ''}`,
);
}
}
}
const agentLoadResult = await loadAgentsFromDirectory(
path.join(effectiveExtensionPath, 'agents'),
);
@@ -853,6 +884,8 @@ Would you like to attempt to install via "git clone" instead?`,
skills,
agents: agentLoadResult.agents,
themes: config.themes,
rules,
checkers,
};
} catch (e) {
debugLogger.error(
+130 -4
View File
@@ -239,6 +239,27 @@ describe('extension tests', () => {
expect(extensions[0].name).toBe('test-extension');
});
it('should throw an error if a context file path is outside the extension directory', async () => {
const consoleSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
createExtension({
extensionsDir: userExtensionsDir,
name: 'traversal-extension',
version: '1.0.0',
contextFileName: '../secret.txt',
});
const extensions = await extensionManager.loadExtensions();
expect(extensions).toHaveLength(0);
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining(
'traversal-extension: Invalid context file path: "../secret.txt"',
),
);
consoleSpy.mockRestore();
});
it('should load context file path when GEMINI.md is present', async () => {
createExtension({
extensionsDir: userExtensionsDir,
@@ -363,6 +384,111 @@ describe('extension tests', () => {
]);
});
it('should load extension policies from the policies directory', async () => {
const extDir = createExtension({
extensionsDir: userExtensionsDir,
name: 'policy-extension',
version: '1.0.0',
});
const policiesDir = path.join(extDir, 'policies');
fs.mkdirSync(policiesDir);
const policiesContent = `
[[rule]]
toolName = "deny_tool"
decision = "deny"
priority = 500
[[rule]]
toolName = "ask_tool"
decision = "ask_user"
priority = 100
`;
fs.writeFileSync(
path.join(policiesDir, 'policies.toml'),
policiesContent,
);
const extensions = await extensionManager.loadExtensions();
expect(extensions).toHaveLength(1);
const extension = extensions[0];
expect(extension.rules).toBeDefined();
expect(extension.rules).toHaveLength(2);
expect(
extension.rules!.find((r) => r.toolName === 'deny_tool')?.decision,
).toBe('deny');
expect(
extension.rules!.find((r) => r.toolName === 'ask_tool')?.decision,
).toBe('ask_user');
// Verify source is prefixed
expect(extension.rules![0].source).toContain(
'Extension (policy-extension):',
);
});
it('should ignore ALLOW rules and YOLO mode from extension policies for security', async () => {
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
const extDir = createExtension({
extensionsDir: userExtensionsDir,
name: 'security-test-extension',
version: '1.0.0',
});
const policiesDir = path.join(extDir, 'policies');
fs.mkdirSync(policiesDir);
const policiesContent = `
[[rule]]
toolName = "allow_tool"
decision = "allow"
priority = 100
[[rule]]
toolName = "yolo_tool"
decision = "ask_user"
priority = 100
modes = ["yolo"]
[[safety_checker]]
toolName = "yolo_check"
priority = 100
modes = ["yolo"]
[safety_checker.checker]
type = "external"
name = "yolo-checker"
`;
fs.writeFileSync(
path.join(policiesDir, 'policies.toml'),
policiesContent,
);
const extensions = await extensionManager.loadExtensions();
expect(extensions).toHaveLength(1);
const extension = extensions[0];
// ALLOW rules and YOLO rules/checkers should be filtered out
expect(extension.rules).toBeDefined();
expect(extension.rules).toHaveLength(0);
expect(extension.checkers).toBeDefined();
expect(extension.checkers).toHaveLength(0);
// Should have logged warnings
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining('attempted to contribute an ALLOW rule'),
);
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining('attempted to contribute a rule for YOLO mode'),
);
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining(
'attempted to contribute a safety checker for YOLO mode',
),
);
consoleSpy.mockRestore();
});
it('should hydrate ${extensionPath} correctly for linked extensions', async () => {
const sourceExtDir = getRealPath(
createExtension({
@@ -540,7 +666,7 @@ describe('extension tests', () => {
// Bad extension
const badExtDir = path.join(userExtensionsDir, 'bad-ext');
fs.mkdirSync(badExtDir);
fs.mkdirSync(badExtDir, { recursive: true });
const badConfigPath = path.join(badExtDir, EXTENSIONS_CONFIG_FILENAME);
fs.writeFileSync(badConfigPath, '{ "name": "bad-ext"'); // Malformed
@@ -548,7 +674,7 @@ describe('extension tests', () => {
expect(extensions).toHaveLength(1);
expect(extensions[0].name).toBe('good-ext');
expect(consoleSpy).toHaveBeenCalledExactlyOnceWith(
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining(
`Warning: Skipping extension in ${badExtDir}: Failed to load extension config from ${badConfigPath}`,
),
@@ -571,7 +697,7 @@ describe('extension tests', () => {
// Bad extension
const badExtDir = path.join(userExtensionsDir, 'bad-ext-no-name');
fs.mkdirSync(badExtDir);
fs.mkdirSync(badExtDir, { recursive: true });
const badConfigPath = path.join(badExtDir, EXTENSIONS_CONFIG_FILENAME);
fs.writeFileSync(badConfigPath, JSON.stringify({ version: '1.0.0' }));
@@ -579,7 +705,7 @@ describe('extension tests', () => {
expect(extensions).toHaveLength(1);
expect(extensions[0].name).toBe('good-ext');
expect(consoleSpy).toHaveBeenCalledExactlyOnceWith(
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining(
`Warning: Skipping extension in ${badExtDir}: Failed to load extension config from ${badConfigPath}: Invalid configuration in ${badConfigPath}: missing "name"`,
),
+1 -1
View File
@@ -489,7 +489,7 @@ export const commandDescriptions: Readonly<Record<Command, string>> = {
[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
@@ -177,13 +177,13 @@ describe('Policy Engine Integration Tests', () => {
);
const engine = new PolicyEngine(config);
// MCP server allowed (priority 3.1) provides general allow for server
// MCP server allowed (priority 3.1) provides general allow for server
// MCP server allowed (priority 4.1) provides general allow for server
// MCP server allowed (priority 4.1) provides general allow for server
expect(
(await engine.check({ name: 'my-server__safe-tool' }, undefined))
.decision,
).toBe(PolicyDecision.ALLOW);
// But specific tool exclude (priority 3.4) wins over server allow
// But specific tool exclude (priority 4.4) wins over server allow
expect(
(await engine.check({ name: 'my-server__dangerous-tool' }, undefined))
.decision,
@@ -476,25 +476,25 @@ describe('Policy Engine Integration Tests', () => {
// Find rules and verify their priorities
const blockedToolRule = rules.find((r) => r.toolName === 'blocked-tool');
expect(blockedToolRule?.priority).toBe(3.4); // Command line exclude
expect(blockedToolRule?.priority).toBe(4.4); // Command line exclude
const blockedServerRule = rules.find(
(r) => r.toolName === 'blocked-server__*',
);
expect(blockedServerRule?.priority).toBe(3.9); // MCP server exclude
expect(blockedServerRule?.priority).toBe(4.9); // MCP server exclude
const specificToolRule = rules.find(
(r) => r.toolName === 'specific-tool',
);
expect(specificToolRule?.priority).toBe(3.3); // Command line allow
expect(specificToolRule?.priority).toBe(4.3); // Command line allow
const trustedServerRule = rules.find(
(r) => r.toolName === 'trusted-server__*',
);
expect(trustedServerRule?.priority).toBe(3.2); // MCP trusted server
expect(trustedServerRule?.priority).toBe(4.2); // MCP trusted server
const mcpServerRule = rules.find((r) => r.toolName === 'mcp-server__*');
expect(mcpServerRule?.priority).toBe(3.1); // MCP allowed server
expect(mcpServerRule?.priority).toBe(4.1); // MCP allowed server
const readOnlyToolRule = rules.find((r) => r.toolName === 'glob');
// Priority 70 in default tier → 1.07 (Overriding Plan Mode Deny)
@@ -641,16 +641,16 @@ describe('Policy Engine Integration Tests', () => {
// Verify each rule has the expected priority
const tool3Rule = rules.find((r) => r.toolName === 'tool3');
expect(tool3Rule?.priority).toBe(3.4); // Excluded tools (user tier)
expect(tool3Rule?.priority).toBe(4.4); // Excluded tools (user tier)
const server2Rule = rules.find((r) => r.toolName === 'server2__*');
expect(server2Rule?.priority).toBe(3.9); // Excluded servers (user tier)
expect(server2Rule?.priority).toBe(4.9); // Excluded servers (user tier)
const tool1Rule = rules.find((r) => r.toolName === 'tool1');
expect(tool1Rule?.priority).toBe(3.3); // Allowed tools (user tier)
expect(tool1Rule?.priority).toBe(4.3); // Allowed tools (user tier)
const server1Rule = rules.find((r) => r.toolName === 'server1__*');
expect(server1Rule?.priority).toBe(3.1); // Allowed servers (user tier)
expect(server1Rule?.priority).toBe(4.1); // Allowed servers (user tier)
const globRule = rules.find((r) => r.toolName === 'glob');
// Priority 70 in default tier → 1.07
+32 -1
View File
@@ -12,6 +12,8 @@ import {
resolveWorkspacePolicyState,
autoAcceptWorkspacePolicies,
setAutoAcceptWorkspacePolicies,
disableWorkspacePolicies,
setDisableWorkspacePolicies,
} from './policy.js';
import { writeToStderr } from '@google/gemini-cli-core';
@@ -45,6 +47,9 @@ describe('resolveWorkspacePolicyState', () => {
fs.mkdirSync(workspaceDir);
policiesDir = path.join(workspaceDir, '.gemini', 'policies');
// Enable policies for these tests to verify loading logic
setDisableWorkspacePolicies(false);
vi.clearAllMocks();
});
@@ -67,6 +72,13 @@ describe('resolveWorkspacePolicyState', () => {
});
});
it('should have disableWorkspacePolicies set to true by default', () => {
// We explicitly set it to false in beforeEach for other tests,
// so here we test that setting it to true works.
setDisableWorkspacePolicies(true);
expect(disableWorkspacePolicies).toBe(true);
});
it('should return policy directory if integrity matches', async () => {
// Set up policies directory with a file
fs.mkdirSync(policiesDir, { recursive: true });
@@ -188,7 +200,26 @@ describe('resolveWorkspacePolicyState', () => {
expect(result.policyUpdateConfirmationRequest).toBeUndefined();
});
it('should not return workspace policies if cwd is a symlink to the home directory', async () => {
it('should return empty state if disableWorkspacePolicies is true even if folder is trusted', async () => {
setDisableWorkspacePolicies(true);
// Set up policies directory with a file
fs.mkdirSync(policiesDir, { recursive: true });
fs.writeFileSync(path.join(policiesDir, 'policy.toml'), 'rules = []');
const result = await resolveWorkspacePolicyState({
cwd: workspaceDir,
trustedFolder: true,
interactive: true,
});
expect(result).toEqual({
workspacePoliciesDir: undefined,
policyUpdateConfirmationRequest: undefined,
});
});
it('should return empty state if cwd is a symlink to the home directory', async () => {
const policiesDir = path.join(tempDir, '.gemini', 'policies');
fs.mkdirSync(policiesDir, { recursive: true });
fs.writeFileSync(path.join(policiesDir, 'policy.toml'), 'rules = []');
+15 -1
View File
@@ -35,6 +35,20 @@ export function setAutoAcceptWorkspacePolicies(value: boolean) {
autoAcceptWorkspacePolicies = value;
}
/**
* Temporary flag to disable workspace level policies altogether.
* Exported as 'let' to allow monkey patching in tests via the setter.
*/
export let disableWorkspacePolicies = true;
/**
* Sets the disableWorkspacePolicies flag.
* Used primarily for testing purposes.
*/
export function setDisableWorkspacePolicies(value: boolean) {
disableWorkspacePolicies = value;
}
export async function createPolicyEngineConfig(
settings: Settings,
approvalMode: ApprovalMode,
@@ -81,7 +95,7 @@ export async function resolveWorkspacePolicyState(options: {
| PolicyUpdateConfirmationRequest
| undefined;
if (trustedFolder) {
if (trustedFolder && !disableWorkspacePolicies) {
const storage = new Storage(cwd);
// If we are in the home directory (or rather, our target Gemini dir is the global one),
@@ -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', () => {
+57 -2
View File
@@ -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 extends SettingsSchema> = {
: T[K]['default']
: T[K]['default'] extends boolean
? boolean
: T[K]['default'];
: T[K]['default'] extends string
? string
: T[K]['default'];
};
type InferMergedSettings<T extends SettingsSchema> = {
@@ -2544,7 +2597,9 @@ type InferMergedSettings<T extends SettingsSchema> = {
: 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<SettingsSchemaType>;
@@ -54,6 +54,7 @@ describe('Workspace-Level Policy CLI Integration', () => {
beforeEach(() => {
vi.clearAllMocks();
Policy.setDisableWorkspacePolicies(false);
// Default to MATCH for existing tests
mockCheckIntegrity.mockResolvedValue({
status: 'match',
+5 -1
View File
@@ -1182,6 +1182,7 @@ describe('startInteractiveUI', () => {
getProjectRoot: () => '/root',
getScreenReader: () => false,
getDebugMode: () => false,
getUseAlternateBuffer: () => true,
});
const mockSettings = {
merged: {
@@ -1216,6 +1217,8 @@ describe('startInteractiveUI', () => {
runExitCleanup: vi.fn(),
registerSyncCleanup: vi.fn(),
registerTelemetryConfig: vi.fn(),
setupSignalHandlers: vi.fn(),
setupTtyCheck: vi.fn(() => vi.fn()),
}));
beforeEach(() => {
@@ -1322,7 +1325,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];
+10 -7
View File
@@ -32,6 +32,8 @@ import {
registerSyncCleanup,
runExitCleanup,
registerTelemetryConfig,
setupSignalHandlers,
setupTtyCheck,
} from './utils/cleanup.js';
import {
cleanupToolOutputFiles,
@@ -100,8 +102,8 @@ import { loadSandboxConfig } from './config/sandboxConfig.js';
import { deleteSession, listSessions } from './utils/sessions.js';
import { createPolicyUpdater } from './config/policy.js';
import { ScrollProvider } from './ui/contexts/ScrollProvider.js';
import { isAlternateBufferEnabled } from './ui/hooks/useAlternateBuffer.js';
import { TerminalProvider } from './ui/contexts/TerminalContext.js';
import { isAlternateBufferEnabled } from './ui/hooks/useAlternateBuffer.js';
import { OverflowProvider } from './ui/contexts/OverflowContext.js';
import { setupTerminalAndTheme } from './utils/terminalTheme.js';
@@ -194,7 +196,7 @@ export async function startInteractiveUI(
// and the Ink alternate buffer mode requires line wrapping harmful to
// screen readers.
const useAlternateBuffer = shouldEnterAlternateScreen(
isAlternateBufferEnabled(settings),
isAlternateBufferEnabled(config),
config.getScreenReader(),
);
const mouseEventsEnabled = useAlternateBuffer;
@@ -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);
});
}
@@ -675,7 +678,7 @@ export async function main() {
let input = config.getQuestion();
const useAlternateBuffer = shouldEnterAlternateScreen(
isAlternateBufferEnabled(settings),
isAlternateBufferEnabled(config),
config.getScreenReader(),
);
const rawStartupWarnings = await getStartupWarnings();
@@ -156,6 +156,7 @@ export const createMockConfig = (overrides: Partial<Config> = {}): Config =>
getExperiments: vi.fn().mockReturnValue(undefined),
getHasAccessToPreviewModel: vi.fn().mockReturnValue(false),
validatePathAccess: vi.fn().mockReturnValue(null),
getUseAlternateBuffer: vi.fn().mockReturnValue(false),
...overrides,
}) as unknown as Config;
+22 -2
View File
@@ -547,6 +547,11 @@ const baseMockUiState = {
},
hintMode: false,
hintBuffer: '',
bannerData: {
defaultText: '',
warningText: '',
},
bannerVisible: false,
};
export const mockAppState: AppState = {
@@ -698,6 +703,21 @@ export const renderWithProviders = (
});
}
// Wrap config in a Proxy so useAlternateBuffer hook (which reads from Config) gets the correct value,
// without replacing the entire config object and its other values.
let finalConfig = config;
if (useAlternateBuffer !== undefined) {
finalConfig = new Proxy(config, {
get(target, prop, receiver) {
if (prop === 'getUseAlternateBuffer') {
return () => useAlternateBuffer;
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return Reflect.get(target, prop, receiver);
},
});
}
const mainAreaWidth = terminalWidth;
const finalUiState = {
@@ -726,7 +746,7 @@ export const renderWithProviders = (
const renderResult = render(
<AppContext.Provider value={appState}>
<ConfigContext.Provider value={config}>
<ConfigContext.Provider value={finalConfig}>
<SettingsContext.Provider value={finalSettings}>
<UIStateContext.Provider value={finalUiState}>
<VimModeProvider settings={finalSettings}>
@@ -738,7 +758,7 @@ export const renderWithProviders = (
<UIActionsContext.Provider value={finalUIActions}>
<OverflowProvider>
<ToolActionsProvider
config={config}
config={finalConfig}
toolCalls={allToolCalls}
>
<AskUserActionsProvider
@@ -2675,6 +2675,10 @@ describe('AppContainer State Management', () => {
isAlternateMode = false,
childHandler?: Mock,
) => {
vi.spyOn(mockConfig, 'getUseAlternateBuffer').mockReturnValue(
isAlternateMode,
);
// Update settings for this test run
const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true);
const testSettings = {
@@ -3364,6 +3368,8 @@ describe('AppContainer State Management', () => {
);
vi.mocked(checkPermissions).mockResolvedValue([]);
vi.spyOn(mockConfig, 'getUseAlternateBuffer').mockReturnValue(true);
let unmount: () => void;
await act(async () => {
unmount = renderAppContainer({
@@ -3596,6 +3602,8 @@ describe('AppContainer State Management', () => {
},
} as unknown as LoadedSettings;
vi.spyOn(mockConfig, 'getUseAlternateBuffer').mockReturnValue(true);
let unmount: () => void;
await act(async () => {
const result = renderAppContainer({
+21 -71
View File
@@ -145,7 +145,6 @@ import { useSessionResume } from './hooks/useSessionResume.js';
import { useIncludeDirsTrust } from './hooks/useIncludeDirsTrust.js';
import { useSessionRetentionCheck } from './hooks/useSessionRetentionCheck.js';
import { isWorkspaceTrusted } from '../config/trustedFolders.js';
import { useAlternateBuffer } from './hooks/useAlternateBuffer.js';
import { useSettings } from './contexts/SettingsContext.js';
import { terminalCapabilityManager } from './utils/terminalCapabilityManager.js';
import { useInputHistoryStore } from './hooks/useInputHistoryStore.js';
@@ -228,7 +227,7 @@ export const AppContainer = (props: AppContainerProps) => {
});
useMemoryMonitor(historyManager);
const isAlternateBuffer = useAlternateBuffer();
const isAlternateBuffer = config.getUseAlternateBuffer();
const [corgiMode, setCorgiMode] = useState(false);
const [forceRerenderKey, setForceRerenderKey] = useState(0);
const [debugMessage, setDebugMessage] = useState<string>('');
@@ -264,14 +263,16 @@ export const AppContainer = (props: AppContainerProps) => {
() => isWorkspaceTrusted(settings.merged).isTrusted,
);
const [queueErrorMessage, setQueueErrorMessage] = useState<string | null>(
null,
const [queueErrorMessage, setQueueErrorMessage] = useTimedMessage<string>(
QUEUE_ERROR_DISPLAY_DURATION_MS,
);
const [newAgents, setNewAgents] = useState<AgentDefinition[] | null>(null);
const [constrainHeight, setConstrainHeight] = useState<boolean>(true);
const [showIsExpandableHint, setShowIsExpandableHint] = useState(false);
const expandHintTimerRef = useRef<NodeJS.Timeout | null>(null);
const [expandHintTrigger, triggerExpandHint] = useTimedMessage<boolean>(
EXPAND_HINT_DURATION_MS,
);
const showIsExpandableHint = Boolean(expandHintTrigger);
const overflowState = useOverflowState();
const overflowingIdsSize = overflowState?.overflowingIds.size ?? 0;
const hasOverflowState = overflowingIdsSize > 0 || !constrainHeight;
@@ -284,39 +285,15 @@ export const AppContainer = (props: AppContainerProps) => {
* boolean dependency (hasOverflowState) to ensure the timer only resets on
* genuine state transitions, preventing it from infinitely resetting during
* active text streaming.
*
* In alternate buffer mode, we don't trigger the hint automatically on overflow
* to avoid noise, but the user can still trigger it manually with Ctrl+O.
*/
useEffect(() => {
if (isAlternateBuffer) {
setShowIsExpandableHint(false);
if (expandHintTimerRef.current) {
clearTimeout(expandHintTimerRef.current);
}
return;
if (hasOverflowState && !isAlternateBuffer) {
triggerExpandHint(true);
}
if (hasOverflowState) {
setShowIsExpandableHint(true);
if (expandHintTimerRef.current) {
clearTimeout(expandHintTimerRef.current);
}
expandHintTimerRef.current = setTimeout(() => {
setShowIsExpandableHint(false);
}, EXPAND_HINT_DURATION_MS);
}
}, [hasOverflowState, isAlternateBuffer, constrainHeight]);
/**
* Safe cleanup to ensure the expansion hint timer is cancelled when the
* component unmounts, preventing memory leaks.
*/
useEffect(
() => () => {
if (expandHintTimerRef.current) {
clearTimeout(expandHintTimerRef.current);
}
},
[],
);
}, [hasOverflowState, isAlternateBuffer, triggerExpandHint]);
const [defaultBannerText, setDefaultBannerText] = useState('');
const [warningBannerText, setWarningBannerText] = useState('');
@@ -567,7 +544,7 @@ export const AppContainer = (props: AppContainerProps) => {
const { consoleMessages, clearConsoleMessages: clearConsoleMessagesState } =
useConsoleMessages();
const mainAreaWidth = calculateMainAreaWidth(terminalWidth, settings);
const mainAreaWidth = calculateMainAreaWidth(terminalWidth, config);
// Derive widths for InputPrompt using shared helper
const { inputWidth, suggestionsWidth } = useMemo(() => {
const { inputWidth, suggestionsWidth } =
@@ -1252,10 +1229,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
async (submittedValue: string) => {
reset();
// Explicitly hide the expansion hint and clear its x-second timer when a new turn begins.
setShowIsExpandableHint(false);
if (expandHintTimerRef.current) {
clearTimeout(expandHintTimerRef.current);
}
triggerExpandHint(null);
if (!constrainHeight) {
setConstrainHeight(true);
if (!isAlternateBuffer) {
@@ -1327,16 +1301,14 @@ Logging in with Google... Restarting Gemini CLI to continue.
refreshStatic,
reset,
handleHintSubmit,
triggerExpandHint,
],
);
const handleClearScreen = useCallback(() => {
reset();
// Explicitly hide the expansion hint and clear its x-second timer when clearing the screen.
setShowIsExpandableHint(false);
if (expandHintTimerRef.current) {
clearTimeout(expandHintTimerRef.current);
}
triggerExpandHint(null);
historyManager.clearItems();
clearConsoleMessagesState();
refreshStatic();
@@ -1345,7 +1317,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
clearConsoleMessagesState,
refreshStatic,
reset,
setShowIsExpandableHint,
triggerExpandHint,
]);
const { handleInput: vimHandleInput } = useVim(buffer, handleFinalSubmit);
@@ -1632,17 +1604,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
}
}, [ideNeedsRestart]);
useEffect(() => {
if (queueErrorMessage) {
const timer = setTimeout(() => {
setQueueErrorMessage(null);
}, QUEUE_ERROR_DISPLAY_DURATION_MS);
return () => clearTimeout(timer);
}
return undefined;
}, [queueErrorMessage, setQueueErrorMessage]);
useEffect(() => {
if (isInitialMount.current) {
isInitialMount.current = false;
@@ -1748,13 +1709,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
setConstrainHeight(true);
if (keyMatchers[Command.SHOW_MORE_LINES](key)) {
// If the user manually collapses the view, show the hint and reset the x-second timer.
setShowIsExpandableHint(true);
if (expandHintTimerRef.current) {
clearTimeout(expandHintTimerRef.current);
}
expandHintTimerRef.current = setTimeout(() => {
setShowIsExpandableHint(false);
}, EXPAND_HINT_DURATION_MS);
triggerExpandHint(true);
}
if (!isAlternateBuffer) {
refreshStatic();
@@ -1803,13 +1758,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
) {
setConstrainHeight(false);
// If the user manually expands the view, show the hint and reset the x-second timer.
setShowIsExpandableHint(true);
if (expandHintTimerRef.current) {
clearTimeout(expandHintTimerRef.current);
}
expandHintTimerRef.current = setTimeout(() => {
setShowIsExpandableHint(false);
}, EXPAND_HINT_DURATION_MS);
triggerExpandHint(true);
if (!isAlternateBuffer) {
refreshStatic();
}
@@ -1914,6 +1863,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
showTransientMessage,
settings.merged.general.devtools,
showErrorDetails,
triggerExpandHint,
],
);
@@ -6,8 +6,8 @@
import { beforeEach, afterEach, describe, it, expect, vi } from 'vitest';
import { render } from '../../test-utils/render.js';
import { act, useEffect } from 'react';
import { Box, Text } from 'ink';
import { useEffect } from 'react';
import { Composer } from './Composer.js';
import { UIStateContext, type UIState } from '../contexts/UIStateContext.js';
import {
@@ -34,6 +34,7 @@ import { StreamingState } from '../types.js';
import { TransientMessageType } from '../../utils/events.js';
import type { LoadedSettings } from '../../config/settings.js';
import type { SessionMetrics } from '../contexts/SessionContext.js';
import type { TextBuffer } from './shared/text-buffer.js';
const composerTestControls = vi.hoisted(() => ({
suggestionsVisible: false,
@@ -263,16 +264,26 @@ const renderComposer = async (
</ConfigContext.Provider>,
);
await result.waitUntilReady();
// Wait for shortcuts hint debounce if using fake timers
if (vi.isFakeTimers()) {
await act(async () => {
await vi.advanceTimersByTimeAsync(250);
});
}
return result;
};
describe('Composer', () => {
beforeEach(() => {
vi.useFakeTimers();
composerTestControls.suggestionsVisible = false;
composerTestControls.isAlternateBuffer = false;
});
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});
@@ -391,7 +402,7 @@ describe('Composer', () => {
expect(output).not.toContain('ShortcutsHint');
});
it('renders LoadingIndicator without thought when loadingPhrases is off', async () => {
it('renders LoadingIndicator with thought when loadingPhrases is off', async () => {
const uiState = createMockUIState({
streamingState: StreamingState.Responding,
thought: { subject: 'Hidden', description: 'Should not show' },
@@ -404,7 +415,7 @@ describe('Composer', () => {
const output = lastFrame();
expect(output).toContain('LoadingIndicator');
expect(output).not.toContain('Should not show');
expect(output).toContain('LoadingIndicator: Hidden');
});
it('does not render LoadingIndicator when waiting for confirmation', async () => {
@@ -809,6 +820,28 @@ describe('Composer', () => {
});
describe('Shortcuts Hint', () => {
it('restores shortcuts hint after 200ms debounce when buffer is empty', async () => {
const { lastFrame } = await renderComposer(
createMockUIState({
buffer: { text: '' } as unknown as TextBuffer,
cleanUiDetailsVisible: false,
}),
);
expect(lastFrame({ allowEmpty: true })).toContain('ShortcutsHint');
});
it('does not show shortcuts hint immediately when buffer has text', async () => {
const uiState = createMockUIState({
buffer: { text: 'hello' } as unknown as TextBuffer,
cleanUiDetailsVisible: false,
});
const { lastFrame } = await renderComposer(uiState);
expect(lastFrame()).not.toContain('ShortcutsHint');
});
it('hides shortcuts hint when showShortcutsHint setting is false', async () => {
const uiState = createMockUIState();
const settings = createMockSettings({
@@ -857,6 +890,27 @@ describe('Composer', () => {
expect(lastFrame()).toContain('ShortcutsHint');
});
it('hides shortcuts hint while loading when full UI details are visible', async () => {
const uiState = createMockUIState({
cleanUiDetailsVisible: true,
streamingState: StreamingState.Responding,
});
const { lastFrame } = await renderComposer(uiState);
expect(lastFrame()).not.toContain('ShortcutsHint');
});
it('hides shortcuts hint when text is typed in buffer', async () => {
const uiState = createMockUIState({
buffer: { text: 'hello' } as unknown as TextBuffer,
});
const { lastFrame } = await renderComposer(uiState);
expect(lastFrame()).not.toContain('ShortcutsHint');
});
it('hides shortcuts hint while loading in minimal mode', async () => {
const uiState = createMockUIState({
cleanUiDetailsVisible: false,
@@ -930,9 +984,10 @@ describe('Composer', () => {
streamingState: StreamingState.Idle,
});
const { lastFrame } = await renderComposer(uiState);
const { lastFrame, unmount } = await renderComposer(uiState);
expect(lastFrame()).toContain('ShortcutsHelp');
unmount();
});
it('hides shortcuts help while streaming', async () => {
@@ -941,9 +996,10 @@ describe('Composer', () => {
streamingState: StreamingState.Responding,
});
const { lastFrame } = await renderComposer(uiState);
const { lastFrame, unmount } = await renderComposer(uiState);
expect(lastFrame()).not.toContain('ShortcutsHelp');
unmount();
});
it('hides shortcuts help when action is required', async () => {
@@ -956,9 +1012,10 @@ describe('Composer', () => {
),
});
const { lastFrame } = await renderComposer(uiState);
const { lastFrame, unmount } = await renderComposer(uiState);
expect(lastFrame()).not.toContain('ShortcutsHelp');
unmount();
});
});
+23 -6
View File
@@ -151,11 +151,30 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
: undefined,
);
const hideShortcutsHintForSuggestions = hideUiDetailsForSuggestions;
const isModelIdle = uiState.streamingState === StreamingState.Idle;
const isBufferEmpty = uiState.buffer.text.length === 0;
const canShowShortcutsHint =
isModelIdle && isBufferEmpty && !hasPendingActionRequired;
const [showShortcutsHintDebounced, setShowShortcutsHintDebounced] =
useState(canShowShortcutsHint);
useEffect(() => {
if (!canShowShortcutsHint) {
setShowShortcutsHintDebounced(false);
return;
}
const timeout = setTimeout(() => {
setShowShortcutsHintDebounced(true);
}, 200);
return () => clearTimeout(timeout);
}, [canShowShortcutsHint]);
const showShortcutsHint =
settings.merged.ui.showShortcutsHint &&
!hideShortcutsHintForSuggestions &&
!hideMinimalModeHintWhileBusy &&
!hasPendingActionRequired;
showShortcutsHintDebounced;
const showMinimalModeBleedThrough =
!hideUiDetailsForSuggestions && Boolean(minimalModeBleedThrough);
const showMinimalInlineLoading = !showUiDetails && showLoadingIndicator;
@@ -210,8 +229,7 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
inline
thought={
uiState.streamingState ===
StreamingState.WaitingForConfirmation ||
settings.merged.ui.loadingPhrases === 'off'
StreamingState.WaitingForConfirmation
? undefined
: uiState.thought
}
@@ -254,8 +272,7 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
inline
thought={
uiState.streamingState ===
StreamingState.WaitingForConfirmation ||
settings.merged.ui.loadingPhrases === 'off'
StreamingState.WaitingForConfirmation
? undefined
: uiState.thought
}
@@ -135,6 +135,7 @@ export const DialogManager = ({
isModelNotFoundError={
!!uiState.quota.proQuotaRequest.isModelNotFoundError
}
authType={uiState.quota.proQuotaRequest.authType}
onChoice={uiActions.handleProQuotaChoice}
/>
);
@@ -167,6 +167,7 @@ Implement a comprehensive authentication system with multiple providers.
readTextFile: vi.fn(),
writeTextFile: vi.fn(),
}),
getUseAlternateBuffer: () => options?.useAlternateBuffer ?? true,
} as unknown as import('@google/gemini-cli-core').Config,
},
);
@@ -443,6 +444,7 @@ Implement a comprehensive authentication system with multiple providers.
readTextFile: vi.fn(),
writeTextFile: vi.fn(),
}),
getUseAlternateBuffer: () => useAlternateBuffer ?? true,
} as unknown as import('@google/gemini-cli-core').Config,
},
);
@@ -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(
<ProQuotaDialog
failedModel="gemini-2.5-pro"
@@ -70,6 +71,7 @@ describe('ProQuotaDialog', () => {
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(
<ProQuotaDialog
failedModel="gemini-2.5-pro"
fallbackModel="gemini-2.5-flash"
message="paid tier quota error"
isTerminalQuotaError={true}
isModelNotFoundError={false}
authType={AuthType.USE_GEMINI}
onChoice={mockOnChoice}
/>,
);
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(
<ProQuotaDialog
@@ -130,7 +165,7 @@ describe('ProQuotaDialog', () => {
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(
<ProQuotaDialog
failedModel="gemini-2.5-pro"
@@ -138,6 +173,7 @@ describe('ProQuotaDialog', () => {
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(
<ProQuotaDialog
failedModel="gemini-3-pro-preview"
@@ -212,6 +248,7 @@ describe('ProQuotaDialog', () => {
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(
<ProQuotaDialog
failedModel="gemini-3-pro-preview"
@@ -249,6 +286,7 @@ describe('ProQuotaDialog', () => {
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',
@@ -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,
@@ -187,7 +187,9 @@ describe('ToastDisplay', () => {
constrainHeight: true,
});
await waitUntilReady();
expect(lastFrame()).toContain('Press Ctrl+O to show more lines');
expect(lastFrame()).toContain(
'Ctrl+O to show more lines of the last response',
);
});
it('renders collapse hint when showIsExpandableHint is true and constrainHeight is false', async () => {
@@ -196,6 +198,8 @@ describe('ToastDisplay', () => {
constrainHeight: false,
});
await waitUntilReady();
expect(lastFrame()).toContain('Press Ctrl+O to collapse lines');
expect(lastFrame()).toContain(
'Ctrl+O to collapse lines of the last response',
);
});
});
@@ -78,7 +78,7 @@ export const ToastDisplay: React.FC = () => {
const action = uiState.constrainHeight ? 'show more' : 'collapse';
return (
<Text color={theme.text.accent}>
Press Ctrl+O to {action} lines for the most recent response
Ctrl+O to {action} lines of the last response
</Text>
);
}
@@ -51,6 +51,7 @@ describe('ToolConfirmationQueue', () => {
storage: {
getPlansDir: () => '/mock/temp/plans',
},
getUseAlternateBuffer: () => false,
} as unknown as Config;
beforeEach(() => {
@@ -227,7 +228,7 @@ describe('ToolConfirmationQueue', () => {
// availableContentHeight = Math.max(9 - 6, 4) = 4
// MaxSizedBox in ToolConfirmationMessage will use 4
// It should show truncation message
await waitFor(() => expect(lastFrame()).toContain('first 49 lines hidden'));
await waitFor(() => expect(lastFrame()).toContain('49 hidden (Ctrl+O)'));
expect(lastFrame()).toMatchSnapshot();
unmount();
});
@@ -35,7 +35,7 @@ Footer
`;
exports[`Composer > Snapshots > matches snapshot while streaming 1`] = `
" LoadingIndicator: Thinking ShortcutsHint
" LoadingIndicator: Thinking
────────────────────────────────────────────────────────────────────────────────────────────────────
ApprovalModeIndicator
InputPrompt: Type your message or @path/to/file
@@ -74,7 +74,7 @@ Implementation Steps
6. Add LDAP provider support in src/auth/providers/LDAPProvider.ts
7. Create token refresh mechanism in src/auth/TokenManager.ts
8. Add multi-factor authentication in src/auth/MFAService.ts
... last 22 lines hidden ...
... last 22 lines hidden (Ctrl+O to show) ...
● 1. Yes, automatically accept edits
Approves plan and allows tools to run automatically
@@ -112,7 +112,7 @@ exports[`<HistoryItemDisplay /> > gemini items (alternateBuffer=false) > should
exports[`<HistoryItemDisplay /> > gemini items (alternateBuffer=false) > should render a truncated gemini item 1`] = `
"✦ Example code block:
... first 42 lines hidden ...
... 42 hidden (Ctrl+O) ...
43 Line 43
44 Line 44
45 Line 45
@@ -126,7 +126,7 @@ exports[`<HistoryItemDisplay /> > gemini items (alternateBuffer=false) > should
exports[`<HistoryItemDisplay /> > gemini items (alternateBuffer=false) > should render a truncated gemini_content item 1`] = `
" Example code block:
... first 42 lines hidden ...
... 42 hidden (Ctrl+O) ...
43 Line 43
44 Line 44
45 Line 45
@@ -49,7 +49,7 @@ exports[`MainContent > MainContent Tool Output Height Logic > 'Normal mode - Con
╭──────────────────────────────────────────────────────────────────────────────────────────────╮
│ ⊷ Shell Command Running a long command... │
│ │
│ ... first 11 lines hidden ...
│ ... first 11 lines hidden (Ctrl+O to show) ...
│ Line 12 │
│ Line 13 │
│ Line 14 │
@@ -6,7 +6,7 @@ exports[`ToolConfirmationQueue > calculates availableContentHeight based on avai
│ │
│ ? replace edit file │
│ │
│ ... first 49 lines hidden ...
│ ... 49 hidden (Ctrl+O) ...
│ 50 line │
│ Apply this change? │
│ │
@@ -96,7 +96,7 @@ exports[`ToolConfirmationQueue > renders expansion hint when content is long and
│ │
│ ? replace edit file │
│ │
│ ... first 49 lines hidden ...
│ ... 49 hidden (Ctrl+O) ...
│ 50 line │
│ Apply this change? │
│ │
@@ -106,7 +106,7 @@ describe('ToolOverflowConsistencyChecks: ToolGroupMessage and ToolResultDisplay
);
// Verify truncation is occurring (standard mode uses MaxSizedBox)
await waitFor(() => expect(lastFrame()).toContain('hidden ...'));
await waitFor(() => expect(lastFrame()).toContain('hidden (Ctrl+O'));
// In Standard mode, ToolGroupMessage calculates hasOverflow correctly now.
// While Standard mode doesn't render the inline hint (ShowMoreLines returns null),
@@ -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(
<OverflowProvider>
<ToolGroupMessage
item={{ id: 1, type: 'tool_group', tools: toolCalls }}
toolCalls={toolCalls}
availableTerminalHeight={15} // Small height to force overflow
terminalWidth={80}
isExpandable={true}
/>
</OverflowProvider>,
const { lastFrame, waitUntilReady } = renderWithProviders(
<ToolGroupMessage
item={{ id: 1, type: 'tool_group', tools: toolCalls }}
toolCalls={toolCalls}
availableTerminalHeight={15} // Small height to force overflow
terminalWidth={80}
isExpandable={true}
/>,
{
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();
@@ -10,7 +10,7 @@ exports[`<OverflowProvider><DiffRenderer /></OverflowProvider> > with useAlterna
`;
exports[`<OverflowProvider><DiffRenderer /></OverflowProvider> > with useAlternateBuffer = false > should correctly render a diff with multiple hunks and a gap indicator > with terminalWidth 30 and height 6 1`] = `
"... first 10 lines hidden ...
"... 10 hidden (Ctrl+O) ...
'test';
21 + const anotherNew =
'test';
@@ -20,7 +20,7 @@ exports[`<OverflowProvider><DiffRenderer /></OverflowProvider> > with useAlterna
`;
exports[`<OverflowProvider><DiffRenderer /></OverflowProvider> > with useAlternateBuffer = false > should correctly render a diff with multiple hunks and a gap indicator > with terminalWidth 80 and height 6 1`] = `
"... first 4 lines hidden ...
"... first 4 lines hidden (Ctrl+O to show) ...
════════════════════════════════════════════════════════════════════════════════
20 console.log('second hunk');
21 - const anotherOld = 'test';
@@ -103,7 +103,7 @@ exports[`<OverflowProvider><DiffRenderer /></OverflowProvider> > with useAlterna
`;
exports[`<OverflowProvider><DiffRenderer /></OverflowProvider> > with useAlternateBuffer = true > should correctly render a diff with multiple hunks and a gap indicator > with terminalWidth 30 and height 6 1`] = `
"... first 10 lines hidden ...
"... 10 hidden (Ctrl+O) ...
'test';
21 + const anotherNew =
'test';
@@ -113,7 +113,7 @@ exports[`<OverflowProvider><DiffRenderer /></OverflowProvider> > with useAlterna
`;
exports[`<OverflowProvider><DiffRenderer /></OverflowProvider> > with useAlternateBuffer = true > should correctly render a diff with multiple hunks and a gap indicator > with terminalWidth 80 and height 6 1`] = `
"... first 4 lines hidden ...
"... first 4 lines hidden (Ctrl+O to show) ...
════════════════════════════════════════════════════════════════════════════════
20 console.log('second hunk');
21 - const anotherOld = 'test';
@@ -37,7 +37,7 @@ exports[`ToolResultDisplay > renders string result as plain text when renderOutp
`;
exports[`ToolResultDisplay > truncates very long string results 1`] = `
"... first 248 lines hidden ...
"... 248 hidden (Ctrl+O) ...
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
@@ -41,7 +41,9 @@ describe('<MaxSizedBox />', () => {
</OverflowProvider>,
);
await waitUntilReady();
expect(lastFrame()).toContain('... first 2 lines hidden ...');
expect(lastFrame()).toContain(
'... first 2 lines hidden (Ctrl+O to show) ...',
);
expect(lastFrame()).toMatchSnapshot();
unmount();
});
@@ -59,7 +61,9 @@ describe('<MaxSizedBox />', () => {
</OverflowProvider>,
);
await waitUntilReady();
expect(lastFrame()).toContain('... last 2 lines hidden ...');
expect(lastFrame()).toContain(
'... last 2 lines hidden (Ctrl+O to show) ...',
);
expect(lastFrame()).toMatchSnapshot();
unmount();
});
@@ -77,7 +81,9 @@ describe('<MaxSizedBox />', () => {
</OverflowProvider>,
);
await waitUntilReady();
expect(lastFrame()).toContain('... first 2 lines hidden ...');
expect(lastFrame()).toContain(
'... first 2 lines hidden (Ctrl+O to show) ...',
);
expect(lastFrame()).toMatchSnapshot();
unmount();
});
@@ -93,7 +99,9 @@ describe('<MaxSizedBox />', () => {
</OverflowProvider>,
);
await waitUntilReady();
expect(lastFrame()).toContain('... first 1 line hidden ...');
expect(lastFrame()).toContain(
'... first 1 line hidden (Ctrl+O to show) ...',
);
expect(lastFrame()).toMatchSnapshot();
unmount();
});
@@ -111,7 +119,9 @@ describe('<MaxSizedBox />', () => {
</OverflowProvider>,
);
await waitUntilReady();
expect(lastFrame()).toContain('... first 7 lines hidden ...');
expect(lastFrame()).toContain(
'... first 7 lines hidden (Ctrl+O to show) ...',
);
expect(lastFrame()).toMatchSnapshot();
unmount();
});
@@ -197,7 +207,9 @@ describe('<MaxSizedBox />', () => {
);
await waitUntilReady();
expect(lastFrame()).toContain('... first 21 lines hidden ...');
expect(lastFrame()).toContain(
'... first 21 lines hidden (Ctrl+O to show) ...',
);
expect(lastFrame()).toMatchSnapshot();
unmount();
});
@@ -218,7 +230,9 @@ describe('<MaxSizedBox />', () => {
);
await waitUntilReady();
expect(lastFrame()).toContain('... last 21 lines hidden ...');
expect(lastFrame()).toContain(
'... last 21 lines hidden (Ctrl+O to show) ...',
);
expect(lastFrame()).toMatchSnapshot();
unmount();
});
@@ -247,7 +261,9 @@ describe('<MaxSizedBox />', () => {
const lastLine = lines[lines.length - 1];
// The last line should only contain the hidden indicator, no leaked content
expect(lastLine).toMatch(/^\.\.\. last \d+ lines? hidden \.\.\.$/);
expect(lastLine).toMatch(
/^\.\.\. last \d+ lines? hidden \(Ctrl\+O to show\) \.\.\.$/,
);
expect(lastFrame()).toMatchSnapshot();
unmount();
});
@@ -9,6 +9,9 @@ import { useCallback, useEffect, useId, useRef, useState } from 'react';
import { Box, Text, ResizeObserver, type DOMElement } from 'ink';
import { theme } from '../../semantic-colors.js';
import { useOverflowActions } from '../../contexts/OverflowContext.js';
import { isNarrowWidth } from '../../utils/isNarrowWidth.js';
import { Command } from '../../../config/keyBindings.js';
import { formatCommand } from '../../utils/keybindingUtils.js';
/**
* Minimum height for the MaxSizedBox component.
@@ -84,6 +87,9 @@ export const MaxSizedBox: React.FC<MaxSizedBoxProps> = ({
const totalHiddenLines = hiddenLinesCount + additionalHiddenLinesCount;
const isNarrow = maxWidth !== undefined && isNarrowWidth(maxWidth);
const showMoreKey = formatCommand(Command.SHOW_MORE_LINES);
useEffect(() => {
if (totalHiddenLines > 0) {
addOverflowingId?.(id);
@@ -116,8 +122,9 @@ export const MaxSizedBox: React.FC<MaxSizedBoxProps> = ({
>
{totalHiddenLines > 0 && overflowDirection === 'top' && (
<Text color={theme.text.secondary} wrap="truncate">
... first {totalHiddenLines} line{totalHiddenLines === 1 ? '' : 's'}{' '}
hidden ...
{isNarrow
? `... ${totalHiddenLines} hidden (${showMoreKey}) ...`
: `... first ${totalHiddenLines} line${totalHiddenLines === 1 ? '' : 's'} hidden (${showMoreKey} to show) ...`}
</Text>
)}
<Box
@@ -137,8 +144,9 @@ export const MaxSizedBox: React.FC<MaxSizedBoxProps> = ({
</Box>
{totalHiddenLines > 0 && overflowDirection === 'bottom' && (
<Text color={theme.text.secondary} wrap="truncate">
... last {totalHiddenLines} line{totalHiddenLines === 1 ? '' : 's'}{' '}
hidden ...
{isNarrow
? `... ${totalHiddenLines} hidden (${showMoreKey}) ...`
: `... last ${totalHiddenLines} line${totalHiddenLines === 1 ? '' : 's'} hidden (${showMoreKey} to show) ...`}
</Text>
)}
</Box>
@@ -6,20 +6,11 @@
import { renderWithProviders } from '../../../test-utils/render.js';
import { Scrollable } from './Scrollable.js';
import { Text } from 'ink';
import { Text, Box } from 'ink';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import * as ScrollProviderModule from '../../contexts/ScrollProvider.js';
import { act } from 'react';
vi.mock('ink', async (importOriginal) => {
const actual = await importOriginal<typeof import('ink')>();
return {
...actual,
getInnerHeight: vi.fn(() => 5),
getScrollHeight: vi.fn(() => 10),
getBoundingBox: vi.fn(() => ({ x: 0, y: 0, width: 10, height: 5 })),
};
});
import { waitFor } from '../../../test-utils/async.js';
vi.mock('../../hooks/useAnimatedScrollbar.js', () => ({
useAnimatedScrollbar: (
@@ -129,20 +120,26 @@ describe('<Scrollable />', () => {
</Scrollable>,
);
await waitUntilReady2();
expect(capturedEntry.getScrollState().scrollTop).toBe(5);
await waitFor(() => {
expect(capturedEntry?.getScrollState().scrollTop).toBe(5);
});
// Call scrollBy multiple times (upwards) in the same tick
await act(async () => {
capturedEntry!.scrollBy(-1);
capturedEntry!.scrollBy(-1);
capturedEntry?.scrollBy(-1);
capturedEntry?.scrollBy(-1);
});
// Should have moved up by 2 (5 -> 3)
expect(capturedEntry.getScrollState().scrollTop).toBe(3);
await waitFor(() => {
expect(capturedEntry?.getScrollState().scrollTop).toBe(3);
});
await act(async () => {
capturedEntry!.scrollBy(-2);
capturedEntry?.scrollBy(-2);
});
await waitFor(() => {
expect(capturedEntry?.getScrollState().scrollTop).toBe(1);
});
expect(capturedEntry.getScrollState().scrollTop).toBe(1);
unmount2();
});
@@ -191,10 +188,6 @@ describe('<Scrollable />', () => {
keySequence,
expectedScrollTop,
}) => {
// Dynamically import ink to mock getScrollHeight
const ink = await import('ink');
vi.mocked(ink.getScrollHeight).mockReturnValue(scrollHeight);
let capturedEntry: ScrollProviderModule.ScrollableEntry | undefined;
vi.spyOn(ScrollProviderModule, 'useScrollable').mockImplementation(
async (entry, isActive) => {
@@ -206,7 +199,9 @@ describe('<Scrollable />', () => {
const { stdin, waitUntilReady, unmount } = renderWithProviders(
<Scrollable hasFocus={true} height={5}>
<Text>Content</Text>
<Box height={scrollHeight}>
<Text>Content</Text>
</Box>
</Scrollable>,
);
await waitUntilReady();
@@ -4,15 +4,9 @@
* SPDX-License-Identifier: Apache-2.0
*/
import React, {
useState,
useEffect,
useRef,
useLayoutEffect,
useCallback,
useMemo,
} from 'react';
import { Box, getInnerHeight, getScrollHeight, type DOMElement } from 'ink';
import type React from 'react';
import { useState, useRef, useCallback, useMemo, useLayoutEffect } from 'react';
import { Box, ResizeObserver, type DOMElement } from 'ink';
import { useKeypress, type Key } from '../../hooks/useKeypress.js';
import { useScrollable } from '../../contexts/ScrollProvider.js';
import { useAnimatedScrollbar } from '../../hooks/useAnimatedScrollbar.js';
@@ -41,62 +35,101 @@ export const Scrollable: React.FC<ScrollableProps> = ({
flexGrow,
}) => {
const [scrollTop, setScrollTop] = useState(0);
const ref = useRef<DOMElement>(null);
const viewportRef = useRef<DOMElement | null>(null);
const contentRef = useRef<DOMElement | null>(null);
const [size, setSize] = useState({
innerHeight: 0,
innerHeight: typeof height === 'number' ? height : 0,
scrollHeight: 0,
});
const sizeRef = useRef(size);
useEffect(() => {
const scrollTopRef = useRef(scrollTop);
useLayoutEffect(() => {
sizeRef.current = size;
}, [size]);
const childrenCountRef = useRef(0);
// This effect needs to run on every render to correctly measure the container
// and scroll to the bottom if new children are added.
// eslint-disable-next-line react-hooks/exhaustive-deps
useLayoutEffect(() => {
if (!ref.current) {
return;
scrollTopRef.current = scrollTop;
}, [scrollTop]);
const viewportObserverRef = useRef<ResizeObserver | null>(null);
const contentObserverRef = useRef<ResizeObserver | null>(null);
const viewportRefCallback = useCallback((node: DOMElement | null) => {
viewportObserverRef.current?.disconnect();
viewportRef.current = node;
if (node) {
const observer = new ResizeObserver((entries) => {
const entry = entries[0];
if (entry) {
const innerHeight = Math.round(entry.contentRect.height);
setSize((prev) => {
const scrollHeight = prev.scrollHeight;
const isAtBottom =
scrollHeight > prev.innerHeight &&
scrollTopRef.current >= scrollHeight - prev.innerHeight - 1;
if (isAtBottom) {
setScrollTop(Number.MAX_SAFE_INTEGER);
}
return { ...prev, innerHeight };
});
}
});
observer.observe(node);
viewportObserverRef.current = observer;
}
const innerHeight = Math.round(getInnerHeight(ref.current));
const scrollHeight = Math.round(getScrollHeight(ref.current));
}, []);
const isAtBottom =
scrollHeight > innerHeight && scrollTop >= scrollHeight - innerHeight - 1;
const contentRefCallback = useCallback(
(node: DOMElement | null) => {
contentObserverRef.current?.disconnect();
contentRef.current = node;
if (
size.innerHeight !== innerHeight ||
size.scrollHeight !== scrollHeight
) {
setSize({ innerHeight, scrollHeight });
if (isAtBottom) {
setScrollTop(Math.max(0, scrollHeight - innerHeight));
if (node) {
const observer = new ResizeObserver((entries) => {
const entry = entries[0];
if (entry) {
const scrollHeight = Math.round(entry.contentRect.height);
setSize((prev) => {
const innerHeight = prev.innerHeight;
const isAtBottom =
prev.scrollHeight > innerHeight &&
scrollTopRef.current >= prev.scrollHeight - innerHeight - 1;
if (
isAtBottom ||
(scrollToBottom && scrollHeight > prev.scrollHeight)
) {
setScrollTop(Number.MAX_SAFE_INTEGER);
}
return { ...prev, scrollHeight };
});
}
});
observer.observe(node);
contentObserverRef.current = observer;
}
}
const childCountCurrent = React.Children.count(children);
if (scrollToBottom && childrenCountRef.current !== childCountCurrent) {
setScrollTop(Math.max(0, scrollHeight - innerHeight));
}
childrenCountRef.current = childCountCurrent;
});
},
[scrollToBottom],
);
const { getScrollTop, setPendingScrollTop } = useBatchedScroll(scrollTop);
const scrollBy = useCallback(
(delta: number) => {
const { scrollHeight, innerHeight } = sizeRef.current;
const current = getScrollTop();
const next = Math.min(
Math.max(0, current + delta),
Math.max(0, scrollHeight - innerHeight),
);
const maxScroll = Math.max(0, scrollHeight - innerHeight);
const current = Math.min(getScrollTop(), maxScroll);
let next = Math.max(0, current + delta);
if (next >= maxScroll) {
next = Number.MAX_SAFE_INTEGER;
}
setPendingScrollTop(next);
setScrollTop(next);
},
[sizeRef, getScrollTop, setPendingScrollTop],
[getScrollTop, setPendingScrollTop],
);
const { scrollbarColor, flashScrollbar, scrollByWithAnimation } =
@@ -107,10 +140,11 @@ export const Scrollable: React.FC<ScrollableProps> = ({
const { scrollHeight, innerHeight } = sizeRef.current;
const scrollTop = getScrollTop();
const maxScroll = Math.max(0, scrollHeight - innerHeight);
const actualScrollTop = Math.min(scrollTop, maxScroll);
// Only capture scroll-up events if there's room;
// otherwise allow events to bubble.
if (scrollTop > 0) {
if (actualScrollTop > 0) {
if (keyMatchers[Command.PAGE_UP](key)) {
scrollByWithAnimation(-innerHeight);
return true;
@@ -123,7 +157,7 @@ export const Scrollable: React.FC<ScrollableProps> = ({
// Only capture scroll-down events if there's room;
// otherwise allow events to bubble.
if (scrollTop < maxScroll) {
if (actualScrollTop < maxScroll) {
if (keyMatchers[Command.PAGE_DOWN](key)) {
scrollByWithAnimation(innerHeight);
return true;
@@ -140,21 +174,21 @@ export const Scrollable: React.FC<ScrollableProps> = ({
{ isActive: hasFocus },
);
const getScrollState = useCallback(
() => ({
scrollTop: getScrollTop(),
const getScrollState = useCallback(() => {
const maxScroll = Math.max(0, size.scrollHeight - size.innerHeight);
return {
scrollTop: Math.min(getScrollTop(), maxScroll),
scrollHeight: size.scrollHeight,
innerHeight: size.innerHeight,
}),
[getScrollTop, size.scrollHeight, size.innerHeight],
);
};
}, [getScrollTop, size.scrollHeight, size.innerHeight]);
const hasFocusCallback = useCallback(() => hasFocus, [hasFocus]);
const scrollableEntry = useMemo(
() => ({
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
ref: ref as React.RefObject<DOMElement>,
ref: viewportRef as React.RefObject<DOMElement>,
getScrollState,
scrollBy: scrollByWithAnimation,
hasFocus: hasFocusCallback,
@@ -167,7 +201,7 @@ export const Scrollable: React.FC<ScrollableProps> = ({
return (
<Box
ref={ref}
ref={viewportRefCallback}
maxHeight={maxHeight}
width={width ?? maxWidth}
height={height}
@@ -183,7 +217,12 @@ export const Scrollable: React.FC<ScrollableProps> = ({
based on the children's content. It also adds a right padding to
make room for the scrollbar.
*/}
<Box flexShrink={0} paddingRight={1} flexDirection="column">
<Box
ref={contentRefCallback}
flexShrink={0}
paddingRight={1}
flexDirection="column"
>
{children}
</Box>
</Box>
@@ -479,4 +479,277 @@ describe('ScrollableList Demo Behavior', () => {
});
});
});
it('regression: remove last item and add 2 items when scrolled to bottom', async () => {
let listRef: ScrollableListRef<Item> | null = null;
let setItemsFunc: React.Dispatch<React.SetStateAction<Item[]>> | null =
null;
const TestComp = () => {
const [items, setItems] = useState<Item[]>(
Array.from({ length: 10 }, (_, i) => ({
id: String(i),
title: `Item ${i}`,
})),
);
useEffect(() => {
setItemsFunc = setItems;
}, []);
return (
<MouseProvider mouseEventsEnabled={false}>
<KeypressProvider>
<ScrollProvider>
<Box flexDirection="column" width={80} height={5}>
<ScrollableList
ref={(ref) => {
listRef = ref;
}}
data={items}
renderItem={({ item }) => <Text>{item.title}</Text>}
estimatedItemHeight={() => 1}
keyExtractor={(item) => item.id}
hasFocus={true}
initialScrollIndex={Number.MAX_SAFE_INTEGER}
/>
</Box>
</ScrollProvider>
</KeypressProvider>
</MouseProvider>
);
};
let result: ReturnType<typeof render>;
await act(async () => {
result = render(<TestComp />);
});
await result!.waitUntilReady();
// Scrolled to bottom, max scroll = 10 - 5 = 5
await waitFor(() => {
expect(listRef?.getScrollState()?.scrollTop).toBe(5);
});
// Remove last element and add 2 elements
await act(async () => {
setItemsFunc!((prev) => {
const next = prev.slice(0, prev.length - 1);
next.push({ id: '10', title: 'Item 10' });
next.push({ id: '11', title: 'Item 11' });
return next;
});
});
await result!.waitUntilReady();
// Auto scrolls to new bottom: max scroll = 11 - 5 = 6
await waitFor(() => {
expect(listRef?.getScrollState()?.scrollTop).toBe(6);
});
// Scroll up slightly
await act(async () => {
listRef?.scrollBy(-2);
});
await result!.waitUntilReady();
await waitFor(() => {
expect(listRef?.getScrollState()?.scrollTop).toBe(4);
});
// Scroll back to bottom
await act(async () => {
listRef?.scrollToEnd();
});
await result!.waitUntilReady();
await waitFor(() => {
expect(listRef?.getScrollState()?.scrollTop).toBe(6);
});
// Add two more elements
await act(async () => {
setItemsFunc!((prev) => [
...prev,
{ id: '12', title: 'Item 12' },
{ id: '13', title: 'Item 13' },
]);
});
await result!.waitUntilReady();
// Auto scrolls to bottom: max scroll = 13 - 5 = 8
await waitFor(() => {
expect(listRef?.getScrollState()?.scrollTop).toBe(8);
});
result!.unmount();
});
it('regression: bottom-most element changes size but list does not update', async () => {
let listRef: ScrollableListRef<Item> | null = null;
let expandLastFunc: (() => void) | null = null;
const ItemWithState = ({
item,
isLast,
}: {
item: Item;
isLast: boolean;
}) => {
const [expanded, setExpanded] = useState(false);
useEffect(() => {
if (isLast) {
expandLastFunc = () => setExpanded(true);
}
}, [isLast]);
return (
<Box flexDirection="column">
<Text>{item.title}</Text>
{expanded && <Text>Expanded content</Text>}
</Box>
);
};
const TestComp = () => {
// items array is stable
const [items] = useState(() =>
Array.from({ length: 5 }, (_, i) => ({
id: String(i),
title: `Item ${i}`,
})),
);
return (
<MouseProvider mouseEventsEnabled={false}>
<KeypressProvider>
<ScrollProvider>
<Box flexDirection="column" width={80} height={4}>
<ScrollableList
ref={(ref) => {
listRef = ref;
}}
data={items}
renderItem={({ item, index }) => (
<ItemWithState item={item} isLast={index === 4} />
)}
estimatedItemHeight={() => 1}
keyExtractor={(item) => item.id}
hasFocus={true}
initialScrollIndex={Number.MAX_SAFE_INTEGER}
/>
</Box>
</ScrollProvider>
</KeypressProvider>
</MouseProvider>
);
};
let result: ReturnType<typeof render>;
await act(async () => {
result = render(<TestComp />);
});
await result!.waitUntilReady();
// Initially, total height is 5. viewport is 4. scroll is 1.
await waitFor(() => {
expect(listRef?.getScrollState()?.scrollTop).toBe(1);
});
// Expand the last item locally, without re-rendering the list!
await act(async () => {
expandLastFunc!();
});
await result!.waitUntilReady();
// The total height becomes 6. It should remain scrolled to bottom, so scroll becomes 2.
// This is expected to FAIL currently because VirtualizedList won't remeasure
// unless data changes or container height changes.
await waitFor(
() => {
expect(listRef?.getScrollState()?.scrollTop).toBe(2);
},
{ timeout: 1000 },
);
result!.unmount();
});
it('regression: prepending items does not corrupt heights (total height correct)', async () => {
let listRef: ScrollableListRef<Item> | null = null;
let setItemsFunc: React.Dispatch<React.SetStateAction<Item[]>> | null =
null;
const TestComp = () => {
// Items 1 to 5. Item 1 is very tall.
const [items, setItems] = useState<Item[]>(
Array.from({ length: 5 }, (_, i) => ({
id: String(i + 1),
title: `Item ${i + 1}`,
})),
);
useEffect(() => {
setItemsFunc = setItems;
}, []);
return (
<MouseProvider mouseEventsEnabled={false}>
<KeypressProvider>
<ScrollProvider>
<Box flexDirection="column" width={80} height={10}>
<ScrollableList
ref={(ref) => {
listRef = ref;
}}
data={items}
renderItem={({ item }) => (
<Box height={item.id === '1' ? 10 : 2}>
<Text>{item.title}</Text>
</Box>
)}
estimatedItemHeight={() => 2}
keyExtractor={(item) => item.id}
hasFocus={true}
initialScrollIndex={Number.MAX_SAFE_INTEGER}
/>
</Box>
</ScrollProvider>
</KeypressProvider>
</MouseProvider>
);
};
let result: ReturnType<typeof render>;
await act(async () => {
result = render(<TestComp />);
});
await result!.waitUntilReady();
// Scroll is at bottom.
// Heights: Item 1: 10, Item 2: 2, Item 3: 2, Item 4: 2, Item 5: 2.
// Total height = 18. Container = 10. Max scroll = 8.
await waitFor(() => {
expect(listRef?.getScrollState()?.scrollTop).toBe(8);
});
// Prepend an item!
await act(async () => {
setItemsFunc!((prev) => [{ id: '0', title: 'Item 0' }, ...prev]);
});
await result!.waitUntilReady();
// Now items: 0(2), 1(10), 2(2), 3(2), 4(2), 5(2).
// Total height = 20. Container = 10. Max scroll = 10.
// Auto-scrolls to bottom because it was sticking!
await waitFor(() => {
expect(listRef?.getScrollState()?.scrollTop).toBe(10);
});
result!.unmount();
});
});
@@ -10,7 +10,7 @@ import {
useImperativeHandle,
useCallback,
useMemo,
useEffect,
useLayoutEffect,
} from 'react';
import type React from 'react';
import {
@@ -105,7 +105,7 @@ function ScrollableList<T>(
smoothScrollState.current.active = false;
}, []);
useEffect(() => stopSmoothScroll, [stopSmoothScroll]);
useLayoutEffect(() => stopSmoothScroll, [stopSmoothScroll]);
const smoothScrollTo = useCallback(
(
@@ -120,15 +120,19 @@ function ScrollableList<T>(
innerHeight: 0,
};
const {
scrollTop: startScrollTop,
scrollTop: rawStartScrollTop,
scrollHeight,
innerHeight,
} = scrollState;
const maxScrollTop = Math.max(0, scrollHeight - innerHeight);
const startScrollTop = Math.min(rawStartScrollTop, maxScrollTop);
let effectiveTarget = targetScrollTop;
if (targetScrollTop === SCROLL_TO_ITEM_END) {
if (
targetScrollTop === SCROLL_TO_ITEM_END ||
targetScrollTop >= maxScrollTop
) {
effectiveTarget = maxScrollTop;
}
@@ -138,8 +142,11 @@ function ScrollableList<T>(
);
if (duration === 0) {
if (targetScrollTop === SCROLL_TO_ITEM_END) {
virtualizedListRef.current?.scrollTo(SCROLL_TO_ITEM_END);
if (
targetScrollTop === SCROLL_TO_ITEM_END ||
targetScrollTop >= maxScrollTop
) {
virtualizedListRef.current?.scrollTo(Number.MAX_SAFE_INTEGER);
} else {
virtualizedListRef.current?.scrollTo(Math.round(clampedTarget));
}
@@ -168,8 +175,11 @@ function ScrollableList<T>(
ease;
if (progress >= 1) {
if (targetScrollTop === SCROLL_TO_ITEM_END) {
virtualizedListRef.current?.scrollTo(SCROLL_TO_ITEM_END);
if (
targetScrollTop === SCROLL_TO_ITEM_END ||
targetScrollTop >= maxScrollTop
) {
virtualizedListRef.current?.scrollTo(Number.MAX_SAFE_INTEGER);
} else {
virtualizedListRef.current?.scrollTo(Math.round(current));
}
@@ -200,9 +210,13 @@ function ScrollableList<T>(
) {
const direction = keyMatchers[Command.PAGE_UP](key) ? -1 : 1;
const scrollState = getScrollState();
const maxScroll = Math.max(
0,
scrollState.scrollHeight - scrollState.innerHeight,
);
const current = smoothScrollState.current.active
? smoothScrollState.current.to
: scrollState.scrollTop;
: Math.min(scrollState.scrollTop, maxScroll);
const innerHeight = scrollState.innerHeight;
smoothScrollTo(current + direction * innerHeight);
return true;
@@ -5,6 +5,7 @@
*/
import { render } from '../../../test-utils/render.js';
import { waitFor } from '../../../test-utils/async.js';
import { VirtualizedList, type VirtualizedListRef } from './VirtualizedList.js';
import { Text, Box } from 'ink';
import {
@@ -275,9 +276,11 @@ describe('<VirtualizedList />', () => {
await waitUntilReady();
// Now Item 0 is 1px, so Items 1-9 should also be visible to fill 10px
expect(lastFrame()).toContain('Item 0');
expect(lastFrame()).toContain('Item 1');
expect(lastFrame()).toContain('Item 9');
await waitFor(() => {
expect(lastFrame()).toContain('Item 0');
expect(lastFrame()).toContain('Item 1');
expect(lastFrame()).toContain('Item 9');
});
unmount();
});
@@ -10,7 +10,6 @@ import {
useLayoutEffect,
forwardRef,
useImperativeHandle,
useEffect,
useMemo,
useCallback,
} from 'react';
@@ -19,7 +18,7 @@ import { theme } from '../../semantic-colors.js';
import { useBatchedScroll } from '../../hooks/useBatchedScroll.js';
import { useUIState } from '../../contexts/UIStateContext.js';
import { type DOMElement, measureElement, Box } from 'ink';
import { type DOMElement, Box, ResizeObserver } from 'ink';
export const SCROLL_TO_ITEM_END = Number.MAX_SAFE_INTEGER;
@@ -81,7 +80,7 @@ function VirtualizedList<T>(
} = props;
const { copyModeEnabled } = useUIState();
const dataRef = useRef(data);
useEffect(() => {
useLayoutEffect(() => {
dataRef.current = data;
}, [data]);
@@ -108,6 +107,7 @@ function VirtualizedList<T>(
return { index: 0, offset: 0 };
});
const [isStickingToBottom, setIsStickingToBottom] = useState(() => {
const scrollToEnd =
initialScrollIndex === SCROLL_TO_ITEM_END ||
@@ -116,73 +116,75 @@ function VirtualizedList<T>(
initialScrollOffsetInIndex === SCROLL_TO_ITEM_END);
return scrollToEnd;
});
const containerRef = useRef<DOMElement>(null);
const containerRef = useRef<DOMElement | null>(null);
const [containerHeight, setContainerHeight] = useState(0);
const itemRefs = useRef<Array<DOMElement | null>>([]);
const [heights, setHeights] = useState<number[]>([]);
const [heights, setHeights] = useState<Record<string, number>>({});
const isInitialScrollSet = useRef(false);
const containerObserverRef = useRef<ResizeObserver | null>(null);
const nodeToKeyRef = useRef(new WeakMap<DOMElement, string>());
const containerRefCallback = useCallback((node: DOMElement | null) => {
containerObserverRef.current?.disconnect();
containerRef.current = node;
if (node) {
const observer = new ResizeObserver((entries) => {
const entry = entries[0];
if (entry) {
setContainerHeight(Math.round(entry.contentRect.height));
}
});
observer.observe(node);
containerObserverRef.current = observer;
}
}, []);
const itemsObserver = useMemo(
() =>
new ResizeObserver((entries) => {
setHeights((prev) => {
let next: Record<string, number> | null = null;
for (const entry of entries) {
const key = nodeToKeyRef.current.get(entry.target);
if (key !== undefined) {
const height = Math.round(entry.contentRect.height);
if (prev[key] !== height) {
if (!next) {
next = { ...prev };
}
next[key] = height;
}
}
}
return next ?? prev;
});
}),
[],
);
useLayoutEffect(
() => () => {
containerObserverRef.current?.disconnect();
itemsObserver.disconnect();
},
[itemsObserver],
);
const { totalHeight, offsets } = useMemo(() => {
const offsets: number[] = [0];
let totalHeight = 0;
for (let i = 0; i < data.length; i++) {
const height = heights[i] ?? estimatedItemHeight(i);
const key = keyExtractor(data[i], i);
const height = heights[key] ?? estimatedItemHeight(i);
totalHeight += height;
offsets.push(totalHeight);
}
return { totalHeight, offsets };
}, [heights, data, estimatedItemHeight]);
}, [heights, data, estimatedItemHeight, keyExtractor]);
useEffect(() => {
setHeights((prevHeights) => {
if (data.length === prevHeights.length) {
return prevHeights;
}
const newHeights = [...prevHeights];
if (data.length < prevHeights.length) {
newHeights.length = data.length;
} else {
for (let i = prevHeights.length; i < data.length; i++) {
newHeights[i] = estimatedItemHeight(i);
}
}
return newHeights;
});
}, [data, estimatedItemHeight]);
// This layout effect needs to run on every render to correctly measure the
// container and ensure we recompute the layout if it has changed.
// eslint-disable-next-line react-hooks/exhaustive-deps
useLayoutEffect(() => {
if (containerRef.current) {
const height = Math.round(measureElement(containerRef.current).height);
if (containerHeight !== height) {
setContainerHeight(height);
}
}
let newHeights: number[] | null = null;
for (let i = startIndex; i <= endIndex; i++) {
const itemRef = itemRefs.current[i];
if (itemRef) {
const height = Math.round(measureElement(itemRef).height);
if (height !== heights[i]) {
if (!newHeights) {
newHeights = [...heights];
}
newHeights[i] = height;
}
}
}
if (newHeights) {
setHeights(newHeights);
}
});
const scrollableContainerHeight = containerRef.current
? Math.round(measureElement(containerRef.current).height)
: containerHeight;
const scrollableContainerHeight = containerHeight;
const getAnchorForScrollTop = useCallback(
(
@@ -199,23 +201,36 @@ function VirtualizedList<T>(
[],
);
const scrollTop = useMemo(() => {
const actualScrollTop = useMemo(() => {
const offset = offsets[scrollAnchor.index];
if (typeof offset !== 'number') {
return 0;
}
if (scrollAnchor.offset === SCROLL_TO_ITEM_END) {
const itemHeight = heights[scrollAnchor.index] ?? 0;
const item = data[scrollAnchor.index];
const key = item ? keyExtractor(item, scrollAnchor.index) : '';
const itemHeight = heights[key] ?? 0;
return offset + itemHeight - scrollableContainerHeight;
}
return offset + scrollAnchor.offset;
}, [scrollAnchor, offsets, heights, scrollableContainerHeight]);
}, [
scrollAnchor,
offsets,
heights,
scrollableContainerHeight,
data,
keyExtractor,
]);
const scrollTop = isStickingToBottom
? Number.MAX_SAFE_INTEGER
: actualScrollTop;
const prevDataLength = useRef(data.length);
const prevTotalHeight = useRef(totalHeight);
const prevScrollTop = useRef(scrollTop);
const prevScrollTop = useRef(actualScrollTop);
const prevContainerHeight = useRef(scrollableContainerHeight);
useLayoutEffect(() => {
@@ -226,9 +241,7 @@ function VirtualizedList<T>(
prevTotalHeight.current - prevContainerHeight.current - 1;
const wasAtBottom = contentPreviouslyFit || wasScrolledToBottomPixels;
// If the user was at the bottom, they are now sticking. This handles
// manually scrolling back to the bottom.
if (wasAtBottom && scrollTop >= prevScrollTop.current) {
if (wasAtBottom && actualScrollTop >= prevScrollTop.current) {
setIsStickingToBottom(true);
}
@@ -236,9 +249,6 @@ function VirtualizedList<T>(
const containerChanged =
prevContainerHeight.current !== scrollableContainerHeight;
// We scroll to the end if:
// 1. The list grew AND we were already at the bottom (or sticking).
// 2. We are sticking to the bottom AND the container size changed.
if (
(listGrew && (isStickingToBottom || wasAtBottom)) ||
(isStickingToBottom && containerChanged)
@@ -247,34 +257,28 @@ function VirtualizedList<T>(
index: data.length > 0 ? data.length - 1 : 0,
offset: SCROLL_TO_ITEM_END,
});
// If we are scrolling to the bottom, we are by definition sticking.
if (!isStickingToBottom) {
setIsStickingToBottom(true);
}
}
// Scenario 2: The list has changed (shrunk) in a way that our
// current scroll position or anchor is invalid. We should adjust to the bottom.
else if (
} else if (
(scrollAnchor.index >= data.length ||
scrollTop > totalHeight - scrollableContainerHeight) &&
actualScrollTop > totalHeight - scrollableContainerHeight) &&
data.length > 0
) {
const newScrollTop = Math.max(0, totalHeight - scrollableContainerHeight);
setScrollAnchor(getAnchorForScrollTop(newScrollTop, offsets));
} else if (data.length === 0) {
// List is now empty, reset scroll to top.
setScrollAnchor({ index: 0, offset: 0 });
}
// Update refs for the next render cycle.
prevDataLength.current = data.length;
prevTotalHeight.current = totalHeight;
prevScrollTop.current = scrollTop;
prevScrollTop.current = actualScrollTop;
prevContainerHeight.current = scrollableContainerHeight;
}, [
data.length,
totalHeight,
scrollTop,
actualScrollTop,
scrollableContainerHeight,
scrollAnchor.index,
getAnchorForScrollTop,
@@ -334,10 +338,10 @@ function VirtualizedList<T>(
const startIndex = Math.max(
0,
findLastIndex(offsets, (offset) => offset <= scrollTop) - 1,
findLastIndex(offsets, (offset) => offset <= actualScrollTop) - 1,
);
const endIndexOffset = offsets.findIndex(
(offset) => offset > scrollTop + scrollableContainerHeight,
(offset) => offset > actualScrollTop + scrollableContainerHeight,
);
const endIndex =
endIndexOffset === -1
@@ -348,6 +352,32 @@ function VirtualizedList<T>(
const bottomSpacerHeight =
totalHeight - (offsets[endIndex + 1] ?? totalHeight);
// Maintain a stable set of observed nodes using useLayoutEffect
const observedNodes = useRef<Set<DOMElement>>(new Set());
useLayoutEffect(() => {
const currentNodes = new Set<DOMElement>();
for (let i = startIndex; i <= endIndex; i++) {
const node = itemRefs.current[i];
const item = data[i];
if (node && item) {
currentNodes.add(node);
const key = keyExtractor(item, i);
// Always update the key mapping because React can reuse nodes at different indices/keys
nodeToKeyRef.current.set(node, key);
if (!observedNodes.current.has(node)) {
itemsObserver.observe(node);
}
}
}
for (const node of observedNodes.current) {
if (!currentNodes.has(node)) {
itemsObserver.unobserve(node);
nodeToKeyRef.current.delete(node);
}
}
observedNodes.current = currentNodes;
});
const renderedItems = [];
for (let i = startIndex; i <= endIndex; i++) {
const item = data[i];
@@ -356,6 +386,8 @@ function VirtualizedList<T>(
<Box
key={keyExtractor(item, i)}
width="100%"
flexDirection="column"
flexShrink={0}
ref={(el) => {
itemRefs.current[i] = el;
}}
@@ -376,27 +408,39 @@ function VirtualizedList<T>(
setIsStickingToBottom(false);
}
const currentScrollTop = getScrollTop();
const newScrollTop = Math.max(
0,
Math.min(
totalHeight - scrollableContainerHeight,
currentScrollTop + delta,
),
);
const maxScroll = Math.max(0, totalHeight - scrollableContainerHeight);
const actualCurrent = Math.min(currentScrollTop, maxScroll);
let newScrollTop = Math.max(0, actualCurrent + delta);
if (newScrollTop >= maxScroll) {
setIsStickingToBottom(true);
newScrollTop = Number.MAX_SAFE_INTEGER;
}
setPendingScrollTop(newScrollTop);
setScrollAnchor(getAnchorForScrollTop(newScrollTop, offsets));
setScrollAnchor(
getAnchorForScrollTop(Math.min(newScrollTop, maxScroll), offsets),
);
},
scrollTo: (offset: number) => {
setIsStickingToBottom(false);
const newScrollTop = Math.max(
0,
Math.min(totalHeight - scrollableContainerHeight, offset),
);
setPendingScrollTop(newScrollTop);
setScrollAnchor(getAnchorForScrollTop(newScrollTop, offsets));
const maxScroll = Math.max(0, totalHeight - scrollableContainerHeight);
if (offset >= maxScroll || offset === SCROLL_TO_ITEM_END) {
setIsStickingToBottom(true);
setPendingScrollTop(Number.MAX_SAFE_INTEGER);
if (data.length > 0) {
setScrollAnchor({
index: data.length - 1,
offset: SCROLL_TO_ITEM_END,
});
}
} else {
setIsStickingToBottom(false);
const newScrollTop = Math.max(0, offset);
setPendingScrollTop(newScrollTop);
setScrollAnchor(getAnchorForScrollTop(newScrollTop, offsets));
}
},
scrollToEnd: () => {
setIsStickingToBottom(true);
setPendingScrollTop(Number.MAX_SAFE_INTEGER);
if (data.length > 0) {
setScrollAnchor({
index: data.length - 1,
@@ -416,10 +460,14 @@ function VirtualizedList<T>(
setIsStickingToBottom(false);
const offset = offsets[index];
if (offset !== undefined) {
const maxScroll = Math.max(
0,
totalHeight - scrollableContainerHeight,
);
const newScrollTop = Math.max(
0,
Math.min(
totalHeight - scrollableContainerHeight,
maxScroll,
offset - viewPosition * scrollableContainerHeight + viewOffset,
),
);
@@ -441,10 +489,14 @@ function VirtualizedList<T>(
if (index !== -1) {
const offset = offsets[index];
if (offset !== undefined) {
const maxScroll = Math.max(
0,
totalHeight - scrollableContainerHeight,
);
const newScrollTop = Math.max(
0,
Math.min(
totalHeight - scrollableContainerHeight,
maxScroll,
offset - viewPosition * scrollableContainerHeight + viewOffset,
),
);
@@ -454,11 +506,14 @@ function VirtualizedList<T>(
}
},
getScrollIndex: () => scrollAnchor.index,
getScrollState: () => ({
scrollTop: getScrollTop(),
scrollHeight: totalHeight,
innerHeight: containerHeight,
}),
getScrollState: () => {
const maxScroll = Math.max(0, totalHeight - containerHeight);
return {
scrollTop: Math.min(getScrollTop(), maxScroll),
scrollHeight: totalHeight,
innerHeight: containerHeight,
};
},
}),
[
offsets,
@@ -475,7 +530,7 @@ function VirtualizedList<T>(
return (
<Box
ref={containerRef}
ref={containerRefCallback}
overflowY={copyModeEnabled ? 'hidden' : 'scroll'}
overflowX="hidden"
scrollTop={copyModeEnabled ? 0 : scrollTop}
@@ -489,7 +544,7 @@ function VirtualizedList<T>(
flexShrink={0}
width="100%"
flexDirection="column"
marginTop={copyModeEnabled ? -scrollTop : 0}
marginTop={copyModeEnabled ? -actualScrollTop : 0}
>
<Box height={topSpacerHeight} flexShrink={0} />
{renderedItems}
@@ -1,7 +1,7 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`<MaxSizedBox /> > accounts for additionalHiddenLinesCount 1`] = `
"... first 7 lines hidden ...
"... first 7 lines hidden (Ctrl+O to show) ...
Line 3
"
`;
@@ -16,12 +16,12 @@ Line 6
Line 7
Line 8
Line 9
... last 21 lines hidden ...
... last 21 lines hidden (Ctrl+O to show) ...
"
`;
exports[`<MaxSizedBox /> > clips a long single text child from the top 1`] = `
"... first 21 lines hidden ...
"... first 21 lines hidden (Ctrl+O to show) ...
Line 22
Line 23
Line 24
@@ -39,7 +39,7 @@ exports[`<MaxSizedBox /> > does not leak content after hidden indicator with bot
- Step 1: Do something important
- Step 2: Do something important
... last 18 lines hidden ...
... last 18 lines hidden (Ctrl+O to show) ...
"
`;
@@ -58,12 +58,12 @@ Line 3 direct child
exports[`<MaxSizedBox /> > hides lines at the end when content exceeds maxHeight and overflowDirection is bottom 1`] = `
"Line 1
... last 2 lines hidden ...
... last 2 lines hidden (Ctrl+O to show) ...
"
`;
exports[`<MaxSizedBox /> > hides lines when content exceeds maxHeight 1`] = `
"... first 2 lines hidden ...
"... first 2 lines hidden (Ctrl+O to show) ...
Line 3
"
`;
@@ -74,13 +74,13 @@ exports[`<MaxSizedBox /> > renders children without truncation when they fit 1`]
`;
exports[`<MaxSizedBox /> > shows plural "lines" when more than one line is hidden 1`] = `
"... first 2 lines hidden ...
"... first 2 lines hidden (Ctrl+O to show) ...
Line 3
"
`;
exports[`<MaxSizedBox /> > shows singular "line" when exactly one line is hidden 1`] = `
"... first 1 line hidden ...
"... first 1 line hidden (Ctrl+O to show) ...
Line 1
"
`;
@@ -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;
}
@@ -0,0 +1,80 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { renderHook } from '../../test-utils/render.js';
import {
useAlternateBuffer,
isAlternateBufferEnabled,
} from './useAlternateBuffer.js';
import type { Config } from '@google/gemini-cli-core';
vi.mock('../contexts/ConfigContext.js', () => ({
useConfig: vi.fn(),
}));
const mockUseConfig = vi.mocked(
await import('../contexts/ConfigContext.js').then((m) => m.useConfig),
);
describe('useAlternateBuffer', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('should return false when config.getUseAlternateBuffer returns false', () => {
mockUseConfig.mockReturnValue({
getUseAlternateBuffer: () => false,
} as unknown as ReturnType<typeof mockUseConfig>);
const { result } = renderHook(() => useAlternateBuffer());
expect(result.current).toBe(false);
});
it('should return true when config.getUseAlternateBuffer returns true', () => {
mockUseConfig.mockReturnValue({
getUseAlternateBuffer: () => true,
} as unknown as ReturnType<typeof mockUseConfig>);
const { result } = renderHook(() => useAlternateBuffer());
expect(result.current).toBe(true);
});
it('should return the immutable config value, not react to settings changes', () => {
const mockConfig = {
getUseAlternateBuffer: () => true,
} as unknown as ReturnType<typeof mockUseConfig>;
mockUseConfig.mockReturnValue(mockConfig);
const { result, rerender } = renderHook(() => useAlternateBuffer());
// Value should remain true even after rerender
expect(result.current).toBe(true);
rerender();
expect(result.current).toBe(true);
});
});
describe('isAlternateBufferEnabled', () => {
it('should return true when config.getUseAlternateBuffer returns true', () => {
const config = {
getUseAlternateBuffer: () => true,
} as unknown as Config;
expect(isAlternateBufferEnabled(config)).toBe(true);
});
it('should return false when config.getUseAlternateBuffer returns false', () => {
const config = {
getUseAlternateBuffer: () => false,
} as unknown as Config;
expect(isAlternateBufferEnabled(config)).toBe(false);
});
});
@@ -4,13 +4,14 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { useSettings } from '../contexts/SettingsContext.js';
import type { LoadedSettings } from '../../config/settings.js';
import { useConfig } from '../contexts/ConfigContext.js';
import type { Config } from '@google/gemini-cli-core';
export const isAlternateBufferEnabled = (settings: LoadedSettings): boolean =>
settings.merged.ui.useAlternateBuffer === true;
export const isAlternateBufferEnabled = (config: Config): boolean =>
config.getUseAlternateBuffer();
// This is read from Config so that the UI reads the same value per application session
export const useAlternateBuffer = (): boolean => {
const settings = useSettings();
return isAlternateBufferEnabled(settings);
const config = useConfig();
return isAlternateBufferEnabled(config);
};
@@ -25,6 +25,7 @@ import type {
Config,
EditorType,
AnyToolInvocation,
SpanMetadata,
} from '@google/gemini-cli-core';
import {
CoreToolCallStatus,
@@ -39,6 +40,7 @@ import {
coreEvents,
CoreEvent,
MCPDiscoveryState,
GeminiCliOperation,
getPlanModeExitMessage,
} from '@google/gemini-cli-core';
import type { Part, PartListUnion } from '@google/genai';
@@ -101,6 +103,19 @@ const MockValidationRequiredError = vi.hoisted(
},
);
const mockRunInDevTraceSpan = vi.hoisted(() =>
vi.fn(async (opts, fn) => {
const metadata: SpanMetadata = {
name: opts.operation,
attributes: opts.attributes || {},
};
return await fn({
metadata,
endSpan: vi.fn(),
});
}),
);
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const actualCoreModule = (await importOriginal()) as any;
return {
@@ -113,6 +128,7 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
tokenLimit: vi.fn().mockReturnValue(100), // Mock tokenLimit
recordToolCallInteractions: vi.fn().mockResolvedValue(undefined),
getCodeAssistServer: vi.fn().mockReturnValue(undefined),
runInDevTraceSpan: mockRunInDevTraceSpan,
};
});
@@ -794,6 +810,23 @@ describe('useGeminiStream', () => {
item.text.includes('Got it. Focusing on tests only.'),
),
).toBe(true);
expect(mockRunInDevTraceSpan).toHaveBeenCalledWith(
expect.objectContaining({
operation: GeminiCliOperation.SystemPrompt,
}),
expect.any(Function),
);
const spanArgs = mockRunInDevTraceSpan.mock.calls[0];
const fn = spanArgs[1];
const metadata = { attributes: {} };
await act(async () => {
await fn({ metadata, endSpan: vi.fn() });
});
expect(metadata).toMatchObject({
input: sentParts,
});
});
it('should handle all tool calls being cancelled', async () => {
@@ -2452,6 +2485,11 @@ describe('useGeminiStream', () => {
// This is the core fix validation: Rationale comes before tools are even scheduled (awaited)
expect(rationaleIndex).toBeLessThan(scheduleIndex);
expect(rationaleIndex).toBeLessThan(toolGroupIndex);
// Ensure all state updates from recursive submitQuery are settled
await waitFor(() => {
expect(result.current.streamingState).toBe(StreamingState.Idle);
});
});
it('should process @include commands, adding user turn after processing to prevent race conditions', async () => {
@@ -3554,4 +3592,31 @@ describe('useGeminiStream', () => {
expect(result.current.pendingHistoryItems.length).toEqual(0);
});
});
it('should trace UserPrompt telemetry on submitQuery', async () => {
const { result } = renderTestHook();
mockSendMessageStream.mockReturnValue(
(async function* () {
yield { type: ServerGeminiEventType.Content, value: 'Response' };
})(),
);
await act(async () => {
await result.current.submitQuery('telemetry test query');
});
const userPromptCall = mockRunInDevTraceSpan.mock.calls.find(
(call) =>
call[0].operation === GeminiCliOperation.UserPrompt ||
call[0].operation === 'UserPrompt',
);
expect(userPromptCall).toBeDefined();
const spanMetadata = {} as SpanMetadata;
await act(async () => {
await userPromptCall![1]({ metadata: spanMetadata, endSpan: vi.fn() });
});
expect(spanMetadata.input).toBe('telemetry test query');
});
});
+6 -1
View File
@@ -36,6 +36,7 @@ import {
CoreToolCallStatus,
buildUserSteeringHintPrompt,
generateSteeringAckMessage,
GeminiCliOperation,
getPlanModeExitMessage,
} from '@google/gemini-cli-core';
import type {
@@ -1262,7 +1263,11 @@ export const useGeminiStream = (
prompt_id?: string,
) =>
runInDevTraceSpan(
{ name: 'submitQuery' },
{
operation: options?.isContinuation
? GeminiCliOperation.SystemPrompt
: GeminiCliOperation.UserPrompt,
},
async ({ metadata: spanMetadata }) => {
spanMetadata.input = query;
@@ -10,6 +10,7 @@ import {
type CodeAssistServer,
UserTierId,
getCodeAssistServer,
debugLogger,
} from '@google/gemini-cli-core';
export interface PrivacyState {
@@ -103,7 +104,12 @@ async function getRemoteDataCollectionOptIn(
): Promise<boolean> {
try {
const resp = await server.getCodeAssistGlobalUserSetting();
return resp.freeTierDataCollectionOptin;
if (resp.freeTierDataCollectionOptin === undefined) {
debugLogger.warn(
'Warning: Code Assist API did not return freeTierDataCollectionOptin. Defaulting to true.',
);
}
return resp.freeTierDataCollectionOptin ?? true;
} catch (error: unknown) {
if (error && typeof error === 'object' && 'response' in error) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
@@ -128,5 +134,10 @@ async function setRemoteDataCollectionOptIn(
cloudaicompanionProject: server.projectId,
freeTierDataCollectionOptin: optIn,
});
return resp.freeTierDataCollectionOptin;
if (resp.freeTierDataCollectionOptin === undefined) {
debugLogger.warn(
`Warning: Code Assist API did not return freeTierDataCollectionOptin. Defaulting to ${optIn}.`,
);
}
return resp.freeTierDataCollectionOptin ?? optIn;
}
@@ -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', () => {
@@ -55,14 +55,7 @@ export function useQuotaAndFallback({
fallbackModel,
error,
): Promise<FallbackIntent | null> => {
// 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,
});
},
);
+6 -4
View File
@@ -15,14 +15,16 @@ export function useTimedMessage<T>(durationMs: number) {
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
const showMessage = useCallback(
(msg: T) => {
(msg: T | null) => {
setMessage(msg);
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
timeoutRef.current = setTimeout(() => {
setMessage(null);
}, durationMs);
if (msg !== null) {
timeoutRef.current = setTimeout(() => {
setMessage(null);
}, durationMs);
}
},
[durationMs],
);
@@ -6,18 +6,9 @@
import React from 'react';
import { Text } from 'ink';
import { theme } from '../semantic-colors.js';
import { debugLogger } from '@google/gemini-cli-core';
import { parseMarkdownToANSI } from './markdownParsingUtils.js';
import { stripUnsafeCharacters } from './textUtils.js';
// Constants for Markdown parsing
const BOLD_MARKER_LENGTH = 2; // For "**"
const ITALIC_MARKER_LENGTH = 1; // For "*" or "_"
const STRIKETHROUGH_MARKER_LENGTH = 2; // For "~~")
const INLINE_CODE_MARKER_LENGTH = 1; // For "`"
const UNDERLINE_TAG_START_LENGTH = 3; // For "<u>"
const UNDERLINE_TAG_END_LENGTH = 4; // For "</u>"
interface RenderInlineProps {
text: string;
defaultColor?: string;
@@ -28,147 +19,9 @@ const RenderInlineInternal: React.FC<RenderInlineProps> = ({
defaultColor,
}) => {
const text = stripUnsafeCharacters(rawText);
const baseColor = defaultColor ?? theme.text.primary;
// Early return for plain text without markdown or URLs
if (!/[*_~`<[https?:]/.test(text)) {
return <Text color={baseColor}>{text}</Text>;
}
const ansiText = parseMarkdownToANSI(text, defaultColor);
const nodes: React.ReactNode[] = [];
let lastIndex = 0;
const inlineRegex =
/(\*\*.*?\*\*|\*.*?\*|_.*?_|~~.*?~~|\[.*?\]\(.*?\)|`+.+?`+|<u>.*?<\/u>|https?:\/\/\S+)/g;
let match;
while ((match = inlineRegex.exec(text)) !== null) {
if (match.index > lastIndex) {
nodes.push(
<Text key={`t-${lastIndex}`} color={baseColor}>
{text.slice(lastIndex, match.index)}
</Text>,
);
}
const fullMatch = match[0];
let renderedNode: React.ReactNode = null;
const key = `m-${match.index}`;
try {
if (
fullMatch.startsWith('**') &&
fullMatch.endsWith('**') &&
fullMatch.length > BOLD_MARKER_LENGTH * 2
) {
renderedNode = (
<Text key={key} bold color={baseColor}>
{fullMatch.slice(BOLD_MARKER_LENGTH, -BOLD_MARKER_LENGTH)}
</Text>
);
} else if (
fullMatch.length > ITALIC_MARKER_LENGTH * 2 &&
((fullMatch.startsWith('*') && fullMatch.endsWith('*')) ||
(fullMatch.startsWith('_') && fullMatch.endsWith('_'))) &&
!/\w/.test(text.substring(match.index - 1, match.index)) &&
!/\w/.test(
text.substring(inlineRegex.lastIndex, inlineRegex.lastIndex + 1),
) &&
!/\S[./\\]/.test(text.substring(match.index - 2, match.index)) &&
!/[./\\]\S/.test(
text.substring(inlineRegex.lastIndex, inlineRegex.lastIndex + 2),
)
) {
renderedNode = (
<Text key={key} italic color={baseColor}>
{fullMatch.slice(ITALIC_MARKER_LENGTH, -ITALIC_MARKER_LENGTH)}
</Text>
);
} else if (
fullMatch.startsWith('~~') &&
fullMatch.endsWith('~~') &&
fullMatch.length > STRIKETHROUGH_MARKER_LENGTH * 2
) {
renderedNode = (
<Text key={key} strikethrough color={baseColor}>
{fullMatch.slice(
STRIKETHROUGH_MARKER_LENGTH,
-STRIKETHROUGH_MARKER_LENGTH,
)}
</Text>
);
} else if (
fullMatch.startsWith('`') &&
fullMatch.endsWith('`') &&
fullMatch.length > INLINE_CODE_MARKER_LENGTH
) {
const codeMatch = fullMatch.match(/^(`+)(.+?)\1$/s);
if (codeMatch && codeMatch[2]) {
renderedNode = (
<Text key={key} color={theme.text.accent}>
{codeMatch[2]}
</Text>
);
}
} else if (
fullMatch.startsWith('[') &&
fullMatch.includes('](') &&
fullMatch.endsWith(')')
) {
const linkMatch = fullMatch.match(/\[(.*?)\]\((.*?)\)/);
if (linkMatch) {
const linkText = linkMatch[1];
const url = linkMatch[2];
renderedNode = (
<Text key={key} color={baseColor}>
{linkText}
<Text color={theme.text.link}> ({url})</Text>
</Text>
);
}
} else if (
fullMatch.startsWith('<u>') &&
fullMatch.endsWith('</u>') &&
fullMatch.length >
UNDERLINE_TAG_START_LENGTH + UNDERLINE_TAG_END_LENGTH - 1 // -1 because length is compared to combined length of start and end tags
) {
renderedNode = (
<Text key={key} underline color={baseColor}>
{fullMatch.slice(
UNDERLINE_TAG_START_LENGTH,
-UNDERLINE_TAG_END_LENGTH,
)}
</Text>
);
} else if (fullMatch.match(/^https?:\/\//)) {
renderedNode = (
<Text key={key} color={theme.text.link}>
{fullMatch}
</Text>
);
}
} catch (e) {
debugLogger.warn('Error parsing inline markdown part:', fullMatch, e);
renderedNode = null;
}
nodes.push(
renderedNode ?? (
<Text key={key} color={baseColor}>
{fullMatch}
</Text>
),
);
lastIndex = inlineRegex.lastIndex;
}
if (lastIndex < text.length) {
nodes.push(
<Text key={`t-${lastIndex}`} color={baseColor}>
{text.slice(lastIndex)}
</Text>,
);
}
return <>{nodes.filter((node) => node !== null)}</>;
return <Text>{ansiText}</Text>;
};
export const RenderInline = React.memo(RenderInlineInternal);
@@ -267,7 +267,6 @@ describe('TableRenderer', () => {
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('Comprehensive Architectural');
expect(output).toContain('protocol buffers');
expect(output).toContain('exponential backoff');
@@ -378,4 +377,134 @@ describe('TableRenderer', () => {
await expect(renderResult).toMatchSvgSnapshot();
unmount();
});
it.each([
{
name: 'renders complex markdown in rows and calculates widths correctly',
headers: ['Feature', 'Markdown'],
rows: [
['Bold', '**Bold Text**'],
['Italic', '_Italic Text_'],
['Combined', '***Bold and Italic***'],
['Link', '[Google](https://google.com)'],
['Code', '`const x = 1`'],
['Strikethrough', '~~Strike~~'],
['Underline', '<u>Underline</u>'],
],
terminalWidth: 80,
waitForText: 'Bold Text',
assertions: (output: string) => {
expect(output).not.toContain('**Bold Text**');
expect(output).toContain('Bold Text');
expect(output).not.toContain('_Italic Text_');
expect(output).toContain('Italic Text');
expect(output).toContain('Bold and Italic');
expect(output).toContain('Google');
expect(output).toContain('https://google.com');
expect(output).toContain('(https://google.com)');
expect(output).toContain('const x = 1');
expect(output).not.toContain('`const x = 1`');
expect(output).toContain('Strike');
expect(output).toContain('Underline');
},
},
{
name: 'calculates column widths based on rendered text, not raw markdown',
headers: ['Col 1', 'Col 2', 'Col 3'],
rows: [
['**123456**', 'Normal', 'Short'],
['Short', '**123456**', 'Normal'],
['Normal', 'Short', '**123456**'],
],
terminalWidth: 40,
waitForText: '123456',
assertions: (output: string) => {
expect(output).toContain('123456');
const dataLines = output.split('\n').filter((l) => /123456/.test(l));
expect(dataLines.length).toBe(3);
},
},
{
name: 'handles nested markdown styles recursively',
headers: ['Header 1', 'Header 2', 'Header 3'],
rows: [
['**Bold with _Italic_ and ~~Strike~~**', 'Normal', 'Short'],
['Short', '**Bold with _Italic_ and ~~Strike~~**', 'Normal'],
['Normal', 'Short', '**Bold with _Italic_ and ~~Strike~~**'],
],
terminalWidth: 100,
waitForText: 'Bold with Italic and Strike',
assertions: (output: string) => {
expect(output).not.toContain('**');
expect(output).not.toContain('_');
expect(output).not.toContain('~~');
expect(output).toContain('Bold with Italic and Strike');
},
},
{
name: 'calculates width correctly for content with URLs and styles',
headers: ['Col 1', 'Col 2', 'Col 3'],
rows: [
['Visit [Google](https://google.com)', 'Plain Text', 'More Info'],
['Info Here', 'Visit [Bing](https://bing.com)', 'Links'],
['Check This', 'Search', 'Visit [Yahoo](https://yahoo.com)'],
],
terminalWidth: 120,
waitForText: 'Visit Google',
assertions: (output: string) => {
expect(output).toContain('Visit Google');
expect(output).toContain('Visit Bing');
expect(output).toContain('Visit Yahoo');
expect(output).toContain('https://google.com');
expect(output).toContain('https://bing.com');
expect(output).toContain('https://yahoo.com');
expect(output).toContain('(https://google.com)');
const dataLine = output
.split('\n')
.find((l) => l.includes('Visit Google'));
expect(dataLine).toContain('Visit Google');
},
},
{
name: 'does not parse markdown inside code snippets',
headers: ['Col 1', 'Col 2', 'Col 3'],
rows: [
['`**not bold**`', '`_not italic_`', '`~~not strike~~`'],
['`[not link](url)`', '`<u>not underline</u>`', '`https://not.link`'],
['Normal Text', 'More Code: `*test*`', '`***nested***`'],
],
terminalWidth: 100,
waitForText: '**not bold**',
assertions: (output: string) => {
expect(output).toContain('**not bold**');
expect(output).toContain('_not italic_');
expect(output).toContain('~~not strike~~');
expect(output).toContain('[not link](url)');
expect(output).toContain('<u>not underline</u>');
expect(output).toContain('https://not.link');
expect(output).toContain('***nested***');
},
},
])(
'$name',
async ({ headers, rows, terminalWidth, waitForText, assertions }) => {
const renderResult = renderWithProviders(
<TableRenderer
headers={headers}
rows={rows}
terminalWidth={terminalWidth}
/>,
{ width: terminalWidth },
);
const { lastFrame, waitUntilReady, unmount } = renderResult;
await waitUntilReady();
const output = lastFrame();
expect(output).toBeDefined();
expect(output).toContain(waitForText);
assertions(output);
await expect(renderResult).toMatchSvgSnapshot();
unmount();
},
);
});

Some files were not shown because too many files have changed in this diff Show More