Compare commits

..

14 Commits

Author SHA1 Message Date
Keith Guerin c498284996 fix(ui): ensure autocomplete allows immediate execution and subcommand disclosure (Team Consensus) 2026-04-02 14:22:36 -07:00
Alisa 973092df50 feat: implement high-signal PR regression check for evaluations (#23937) 2026-04-02 05:14:43 +00:00
Sandy Tao e446733b53 feat(core): add background memory service for skill extraction (#24274) 2026-04-02 05:05:31 +00:00
Alisa 3344f6849c Unused error variables in catch block are not allowed (#24487) 2026-04-02 04:33:07 +00:00
Jacob Richman 84936dc85d Auto configure memory. (#24474) 2026-04-02 03:15:27 +00:00
Jacob Richman 18cdbbf81a Terminal Serializer Optimization (#24485) 2026-04-02 02:56:52 +00:00
Gal Zahavi 13ccc16457 fix(core): enhance sandbox usability and fix build error (#24460)
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-04-01 23:51:06 +00:00
N. Taylor Mullen ca78a0f177 fix(cli): ensure agent stops when all declinable tools are cancelled (#24479) 2026-04-01 23:16:34 +00:00
Keith Guerin cb7f7d6c72 feat(cli): change default loadingPhrases to 'off' to hide tips (#24342) 2026-04-01 23:04:43 +00:00
gemini-cli-robot 0d7e778e08 Changelog for v0.36.0-preview.8 (#24453)
Co-authored-by: gemini-cli-robot <224641728+gemini-cli-robot@users.noreply.github.com>
2026-04-01 20:47:06 +00:00
Adam Weidman b5f568fefe feat(config): add experimental.adk.agentSessionNoninteractiveEnabled setting (#24439) 2026-04-01 20:41:38 +00:00
Emily Hedlund d00b43733c fix(core): refactor linux sandbox to fix ARG_MAX crashes (#24286) 2026-04-01 20:17:10 +00:00
Adib234 55f5d3923c feat(policy): explicitly allow web_fetch in plan mode with ask_user (#24456)
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-04-01 19:57:56 +00:00
Abhi 2d432c1489 fix(core): ensure complete_task tool calls are recorded in chat history (#24437) 2026-04-01 19:53:46 +00:00
207 changed files with 6565 additions and 2269 deletions
-69
View File
@@ -1,69 +0,0 @@
name: 'Evals: PR Guidance'
on:
pull_request:
paths:
- 'packages/core/src/**/*.ts'
- '!**/*.test.ts'
- '!**/*.test.tsx'
permissions:
pull-requests: 'write'
contents: 'read'
jobs:
provide-guidance:
name: 'Model Steering Guidance'
runs-on: 'ubuntu-latest'
if: "github.repository == 'google-gemini/gemini-cli'"
steps:
- name: 'Checkout'
uses: 'actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955' # ratchet:actions/checkout@v4
with:
fetch-depth: 0
- name: 'Set up Node.js'
uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' # ratchet:actions/setup-node@v4.4.0
with:
node-version-file: '.nvmrc'
cache: 'npm'
- name: 'Detect Steering Changes'
id: 'detect'
run: |
STEERING_DETECTED=$(node scripts/changed_prompt.js --steering-only)
echo "STEERING_DETECTED=$STEERING_DETECTED" >> "$GITHUB_OUTPUT"
- name: 'Analyze PR Content'
if: "steps.detect.outputs.STEERING_DETECTED == 'true'"
id: 'analysis'
env:
GH_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
run: |
# Check for behavioral eval changes
EVAL_CHANGES=$(git diff --name-only origin/${{ github.base_ref }}...HEAD | grep "^evals/" || true)
if [ -z "$EVAL_CHANGES" ]; then
echo "MISSING_EVALS=true" >> "$GITHUB_OUTPUT"
fi
# Check if user is a maintainer (has write/admin access)
USER_PERMISSION=$(gh api repos/${{ github.repository }}/collaborators/${{ github.actor }}/permission --jq '.permission')
if [[ "$USER_PERMISSION" == "admin" || "$USER_PERMISSION" == "write" ]]; then
echo "IS_MAINTAINER=true" >> "$GITHUB_OUTPUT"
fi
- name: 'Post Guidance Comment'
if: "steps.detect.outputs.STEERING_DETECTED == 'true'"
uses: 'thollander/actions-comment-pull-request@65f9e5c9a1f2cd378bd74b2e057c9736982a8e74' # ratchet:thollander/actions-comment-pull-request@v3
with:
comment-tag: 'eval-guidance-bot'
message: |
### 🧠 Model Steering Guidance
This PR modifies files that affect the model's behavior (prompts, tools, or instructions).
${{ steps.analysis.outputs.MISSING_EVALS == 'true' && '- ⚠️ **Consider adding Evals:** No behavioral evaluations (`evals/*.eval.ts`) were added or updated in this PR. Consider adding a test case to verify the new behavior and prevent regressions.' || '' }}
${{ steps.analysis.outputs.IS_MAINTAINER == 'true' && '- 🚀 **Maintainer Reminder:** Please ensure that these changes do not regress results on benchmark evals before merging.' || '' }}
---
*This is an automated guidance message triggered by steering logic signatures.*
+137
View File
@@ -0,0 +1,137 @@
name: 'Evals: PR Evaluation & Regression'
on:
pull_request:
types: ['opened', 'synchronize', 'reopened', 'ready_for_review']
paths:
- 'packages/core/src/prompts/**'
- 'packages/core/src/tools/**'
- 'packages/core/src/agents/**'
- 'evals/**'
- '!**/*.test.ts'
- '!**/*.test.tsx'
workflow_dispatch:
# Prevents multiple runs for the same PR simultaneously (saves tokens)
concurrency:
group: '${{ github.workflow }}-${{ github.head_ref || github.ref }}'
cancel-in-progress: true
permissions:
pull-requests: 'write'
contents: 'read'
actions: 'read'
jobs:
pr-evaluation:
name: 'Evaluate Steering & Regressions'
runs-on: 'gemini-cli-ubuntu-16-core'
if: "github.repository == 'google-gemini/gemini-cli' && (github.event_name != 'pull_request' || (github.event.pull_request.draft == false && github.event.pull_request.head.repo.full_name == github.repository))"
# External contributors' PRs will wait for approval in this environment
environment: |-
${{ (github.event.pull_request.head.repo.full_name == github.repository) && 'internal' || 'external-evals' }}
env:
# CENTRALIZED MODEL LIST
MODEL_LIST: 'gemini-3-flash-preview'
steps:
- name: 'Checkout'
uses: 'actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955' # ratchet:actions/checkout@v5
with:
fetch-depth: 0
- name: 'Set up Node.js'
uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' # ratchet:actions/setup-node@v4.4.0
with:
node-version-file: '.nvmrc'
cache: 'npm'
- name: 'Install dependencies'
run: 'npm ci'
- name: 'Build project'
run: 'npm run build'
- name: 'Detect Steering Changes'
id: 'detect'
run: |
SHOULD_RUN=$(node scripts/changed_prompt.js)
STEERING_DETECTED=$(node scripts/changed_prompt.js --steering-only)
echo "SHOULD_RUN=$SHOULD_RUN" >> "$GITHUB_OUTPUT"
echo "STEERING_DETECTED=$STEERING_DETECTED" >> "$GITHUB_OUTPUT"
- name: 'Analyze PR Content (Guidance)'
if: "steps.detect.outputs.STEERING_DETECTED == 'true'"
id: 'analysis'
env:
GH_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
run: |
# Check for behavioral eval changes
EVAL_CHANGES=$(git diff --name-only origin/${{ github.base_ref }}...HEAD | grep "^evals/" || true)
if [ -z "$EVAL_CHANGES" ]; then
echo "MISSING_EVALS=true" >> "$GITHUB_OUTPUT"
fi
# Check if user is a maintainer
USER_PERMISSION=$(gh api repos/${{ github.repository }}/collaborators/${{ github.actor }}/permission --jq '.permission')
if [[ "$USER_PERMISSION" == "admin" || "$USER_PERMISSION" == "write" ]]; then
echo "IS_MAINTAINER=true" >> "$GITHUB_OUTPUT"
fi
- name: 'Execute Regression Check'
if: "steps.detect.outputs.SHOULD_RUN == 'true'"
env:
GEMINI_API_KEY: '${{ secrets.GEMINI_API_KEY }}'
GH_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
MODEL_LIST: '${{ env.MODEL_LIST }}'
run: |
# Run the regression check loop. The script saves the report to a file.
node scripts/run_eval_regression.js
# Use the generated report file if it exists
if [[ -f eval_regression_report.md ]]; then
echo "REPORT_FILE=eval_regression_report.md" >> "$GITHUB_ENV"
fi
- name: 'Post or Update PR Comment'
if: "always() && steps.detect.outputs.STEERING_DETECTED == 'true'"
env:
GH_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
run: |
# 1. Build the full comment body
{
if [[ -f eval_regression_report.md ]]; then
cat eval_regression_report.md
echo ""
fi
echo "### 🧠 Model Steering Guidance"
echo ""
echo "This PR modifies files that affect the model's behavior (prompts, tools, or instructions)."
echo ""
if [[ "${{ steps.analysis.outputs.MISSING_EVALS }}" == "true" ]]; then
echo "- ⚠️ **Consider adding Evals:** No behavioral evaluations (\`evals/*.eval.ts\`) were added or updated in this PR. Consider [adding a test case](https://github.com/google-gemini/gemini-cli/blob/main/evals/README.md#creating-an-evaluation) to verify the new behavior and prevent regressions."
fi
if [[ "${{ steps.analysis.outputs.IS_MAINTAINER }}" == "true" ]]; then
echo "- 🚀 **Maintainer Reminder:** Please ensure that these changes do not regress results on benchmark evals before merging."
fi
echo ""
echo "---"
echo "*This is an automated guidance message triggered by steering logic signatures.*"
echo "<!-- eval-pr-report -->"
} > full_comment.md
# 2. Find if a comment with our unique tag already exists
# We extract the numeric ID from the URL to ensure compatibility with the REST API
COMMENT_ID=$(gh pr view ${{ github.event.pull_request.number }} --json comments --jq '.comments[] | select(.body | contains("<!-- eval-pr-report -->")) | .url' | grep -oE "[0-9]+$" | head -n 1)
# 3. Update or Create the comment
if [ -n "$COMMENT_ID" ]; then
echo "Updating existing comment $COMMENT_ID via API..."
gh api -X PATCH "repos/${{ github.repository }}/issues/comments/$COMMENT_ID" -F body=@full_comment.md
else
echo "Creating new PR comment..."
gh pr comment ${{ github.event.pull_request.number }} --body-file full_comment.md
fi
+3 -3
View File
@@ -1,6 +1,6 @@
# Preview release: v0.36.0-preview.7
# Preview release: v0.36.0-preview.8
Released: March 31, 2026
Released: April 01, 2026
Our preview release includes the latest, new, and experimental features. This
release may not be as stable as our [latest weekly release](latest.md).
@@ -390,4 +390,4 @@ npm install -g @google/gemini-cli@preview
[#23666](https://github.com/google-gemini/gemini-cli/pull/23666)
**Full Changelog**:
https://github.com/google-gemini/gemini-cli/compare/v0.35.0-preview.5...v0.36.0-preview.7
https://github.com/google-gemini/gemini-cli/compare/v0.35.0-preview.5...v0.36.0-preview.8
+1
View File
@@ -123,6 +123,7 @@ These are the only allowed tools:
[`glob`](../tools/file-system.md#4-glob-findfiles)
- **Search:** [`grep_search`](../tools/file-system.md#5-grep_search-searchtext),
[`google_web_search`](../tools/web-search.md),
[`web_fetch`](../tools/web-fetch.md) (requires explicit confirmation),
[`get_internal_docs`](../tools/internal-docs.md)
- **Research Subagents:**
[`codebase_investigator`](../core/subagents.md#codebase-investigator),
+34 -36
View File
@@ -47,41 +47,39 @@ they appear in the UI.
### UI
| UI Label | Setting | Description | Default |
| ------------------------------------ | -------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- |
| Auto Theme Switching | `ui.autoThemeSwitching` | Automatically switch between default light and dark themes based on terminal background color. | `true` |
| Terminal Background Polling Interval | `ui.terminalBackgroundPollingInterval` | Interval in seconds to poll the terminal background color. | `60` |
| Hide Window Title | `ui.hideWindowTitle` | Hide the window title bar | `false` |
| Inline Thinking | `ui.inlineThinkingMode` | Display model thinking inline: off or full. | `"off"` |
| Show Thoughts in Title | `ui.showStatusInTitle` | Show Gemini CLI model thoughts in the terminal window title during the working phase | `false` |
| Dynamic Window Title | `ui.dynamicWindowTitle` | Update the terminal window title with current status icons (Ready: ◇, Action Required: ✋, Working: ✦) | `true` |
| Show Home Directory Warning | `ui.showHomeDirectoryWarning` | Show a warning when running Gemini CLI in the home directory. | `true` |
| Show Compatibility Warnings | `ui.showCompatibilityWarnings` | Show warnings about terminal or OS compatibility issues. | `true` |
| Hide Tips | `ui.hideTips` | Hide helpful tips in the UI | `false` |
| Escape Pasted @ Symbols | `ui.escapePastedAtSymbols` | When enabled, @ symbols in pasted text are escaped to prevent unintended @path expansion. | `false` |
| Show Shortcuts Hint | `ui.showShortcutsHint` | Show the "? for shortcuts" hint above the input. | `true` |
| Compact Tool Output | `ui.compactToolOutput` | Display tool outputs (like directory listings and file reads) in a compact, structured format. | `false` |
| Hide Banner | `ui.hideBanner` | Hide the application banner | `false` |
| Hide Context Summary | `ui.hideContextSummary` | Hide the context summary (GEMINI.md, MCP servers) above the input. | `false` |
| Hide CWD | `ui.footer.hideCWD` | Hide the current working directory in the footer. | `false` |
| Hide Sandbox Status | `ui.footer.hideSandboxStatus` | Hide the sandbox status indicator in the footer. | `false` |
| Hide Model Info | `ui.footer.hideModelInfo` | Hide the model name and context usage in the footer. | `false` |
| Hide Context Window Percentage | `ui.footer.hideContextPercentage` | Hides the context window usage percentage. | `true` |
| Hide Footer | `ui.hideFooter` | Hide the footer from the UI | `false` |
| Show Memory Usage | `ui.showMemoryUsage` | Display memory usage information in the UI | `false` |
| Show Line Numbers | `ui.showLineNumbers` | Show line numbers in the chat. | `true` |
| Show Citations | `ui.showCitations` | Show citations for generated text in the chat. | `false` |
| Show Model Info In Chat | `ui.showModelInfoInChat` | Show the model name in the chat for each model turn. | `false` |
| Show User Identity | `ui.showUserIdentity` | Show the signed-in user's identity (e.g. email) in the UI. | `true` |
| Use Alternate Screen Buffer | `ui.useAlternateBuffer` | Use an alternate screen buffer for the UI, preserving shell history. | `false` |
| Render Process | `ui.renderProcess` | Enable Ink render process for the UI. | `true` |
| Terminal Buffer | `ui.terminalBuffer` | Use the new terminal buffer architecture for rendering. | `true` |
| Use Background Color | `ui.useBackgroundColor` | Whether to use background colors in the UI. | `true` |
| Incremental Rendering | `ui.incrementalRendering` | Enable incremental rendering for the UI. This option will reduce flickering but may cause rendering artifacts. Only supported when useAlternateBuffer is enabled. | `true` |
| Show Spinner | `ui.showSpinner` | Show the spinner during operations. | `true` |
| Loading Phrases | `ui.loadingPhrases` | What to show while the model is working: tips, witty comments, both, or nothing. | `"tips"` |
| Error Verbosity | `ui.errorVerbosity` | Controls whether recoverable errors are hidden (low) or fully shown (full). | `"low"` |
| Screen Reader Mode | `ui.accessibility.screenReader` | Render output in plain-text to be more screen reader accessible | `false` |
| UI Label | Setting | Description | Default |
| ------------------------------------ | -------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- |
| Auto Theme Switching | `ui.autoThemeSwitching` | Automatically switch between default light and dark themes based on terminal background color. | `true` |
| Terminal Background Polling Interval | `ui.terminalBackgroundPollingInterval` | Interval in seconds to poll the terminal background color. | `60` |
| Hide Window Title | `ui.hideWindowTitle` | Hide the window title bar | `false` |
| Inline Thinking | `ui.inlineThinkingMode` | Display model thinking inline: off or full. | `"off"` |
| Show Thoughts in Title | `ui.showStatusInTitle` | Show Gemini CLI model thoughts in the terminal window title during the working phase | `false` |
| Dynamic Window Title | `ui.dynamicWindowTitle` | Update the terminal window title with current status icons (Ready: ◇, Action Required: ✋, Working: ✦) | `true` |
| Show Home Directory Warning | `ui.showHomeDirectoryWarning` | Show a warning when running Gemini CLI in the home directory. | `true` |
| Show Compatibility Warnings | `ui.showCompatibilityWarnings` | Show warnings about terminal or OS compatibility issues. | `true` |
| Hide Tips | `ui.hideTips` | Hide helpful tips in the UI | `false` |
| Escape Pasted @ Symbols | `ui.escapePastedAtSymbols` | When enabled, @ symbols in pasted text are escaped to prevent unintended @path expansion. | `false` |
| Show Shortcuts Hint | `ui.showShortcutsHint` | Show the "? for shortcuts" hint above the input. | `true` |
| Compact Tool Output | `ui.compactToolOutput` | Display tool outputs (like directory listings and file reads) in a compact, structured format. | `false` |
| Hide Banner | `ui.hideBanner` | Hide the application banner | `false` |
| Hide Context Summary | `ui.hideContextSummary` | Hide the context summary (GEMINI.md, MCP servers) above the input. | `false` |
| Hide CWD | `ui.footer.hideCWD` | Hide the current working directory in the footer. | `false` |
| Hide Sandbox Status | `ui.footer.hideSandboxStatus` | Hide the sandbox status indicator in the footer. | `false` |
| Hide Model Info | `ui.footer.hideModelInfo` | Hide the model name and context usage in the footer. | `false` |
| Hide Context Window Percentage | `ui.footer.hideContextPercentage` | Hides the context window usage percentage. | `true` |
| Hide Footer | `ui.hideFooter` | Hide the footer from the UI | `false` |
| Show Memory Usage | `ui.showMemoryUsage` | Display memory usage information in the UI | `false` |
| Show Line Numbers | `ui.showLineNumbers` | Show line numbers in the chat. | `true` |
| Show Citations | `ui.showCitations` | Show citations for generated text in the chat. | `false` |
| Show Model Info In Chat | `ui.showModelInfoInChat` | Show the model name in the chat for each model turn. | `false` |
| Show User Identity | `ui.showUserIdentity` | Show the signed-in user's identity (e.g. email) in the UI. | `true` |
| Use Alternate Screen Buffer | `ui.useAlternateBuffer` | Use an alternate screen buffer for the UI, preserving shell history. | `false` |
| Use Background Color | `ui.useBackgroundColor` | Whether to use background colors in the UI. | `true` |
| Incremental Rendering | `ui.incrementalRendering` | Enable incremental rendering for the UI. This option will reduce flickering but may cause rendering artifacts. Only supported when useAlternateBuffer is enabled. | `true` |
| Show Spinner | `ui.showSpinner` | Show the spinner during operations. | `true` |
| Loading Phrases | `ui.loadingPhrases` | What to show while the model is working: tips, witty comments, all, or off. | `"off"` |
| Error Verbosity | `ui.errorVerbosity` | Controls whether recoverable errors are hidden (low) or fully shown (full). | `"low"` |
| Screen Reader Mode | `ui.accessibility.screenReader` | Render output in plain-text to be more screen reader accessible | `false` |
### IDE
@@ -155,7 +153,7 @@ they appear in the UI.
| UI Label | Setting | Description | Default |
| --------------------------------- | ------------------------------ | --------------------------------------------- | ------- |
| Auto Configure Max Old Space Size | `advanced.autoConfigureMemory` | Automatically configure Node.js memory limits | `false` |
| Auto Configure Max Old Space Size | `advanced.autoConfigureMemory` | Automatically configure Node.js memory limits | `true` |
### Experimental
+8 -13
View File
@@ -337,16 +337,6 @@ their corresponding top-level category object in your `settings.json` file.
- **Default:** `false`
- **Requires restart:** Yes
- **`ui.renderProcess`** (boolean):
- **Description:** Enable Ink render process for the UI.
- **Default:** `true`
- **Requires restart:** Yes
- **`ui.terminalBuffer`** (boolean):
- **Description:** Use the new terminal buffer architecture for rendering.
- **Default:** `true`
- **Requires restart:** Yes
- **`ui.useBackgroundColor`** (boolean):
- **Description:** Whether to use background colors in the UI.
- **Default:** `true`
@@ -364,8 +354,8 @@ their corresponding top-level category object in your `settings.json` file.
- **`ui.loadingPhrases`** (enum):
- **Description:** What to show while the model is working: tips, witty
comments, both, or nothing.
- **Default:** `"tips"`
comments, all, or off.
- **Default:** `"off"`
- **Values:** `"tips"`, `"witty"`, `"all"`, `"off"`
- **`ui.errorVerbosity`** (enum):
@@ -1575,7 +1565,7 @@ their corresponding top-level category object in your `settings.json` file.
- **`advanced.autoConfigureMemory`** (boolean):
- **Description:** Automatically configure Node.js memory limits
- **Default:** `false`
- **Default:** `true`
- **Requires restart:** Yes
- **`advanced.dnsResolutionOrder`** (string):
@@ -1597,6 +1587,11 @@ their corresponding top-level category object in your `settings.json` file.
#### `experimental`
- **`experimental.adk.agentSessionNoninteractiveEnabled`** (boolean):
- **Description:** Enable non-interactive agent sessions.
- **Default:** `false`
- **Requires restart:** Yes
- **`experimental.enableAgents`** (boolean):
- **Description:** Enable local and remote subagents.
- **Default:** `true`
+1 -5
View File
@@ -102,8 +102,7 @@ available combinations.
| `app.showFullTodos` | Toggle the full TODO list. | `Ctrl+T` |
| `app.showIdeContextDetail` | Show IDE context details. | `Ctrl+G` |
| `app.toggleMarkdown` | Toggle Markdown rendering. | `Alt+M` |
| `app.toggleCopyMode` | Toggle copy mode when in alternate buffer mode. | `F9` |
| `app.toggleMouseMode` | Toggle mouse mode (scrolling and clicking). | `Ctrl+S` |
| `app.toggleCopyMode` | Toggle copy mode when in alternate buffer mode. | `Ctrl+S` |
| `app.toggleYolo` | Toggle YOLO (auto-approval) mode for tool calls. | `Ctrl+Y` |
| `app.cycleApprovalMode` | Cycle through approval modes: default (prompt), auto_edit (auto-approve edits), and plan (read-only). Plan mode is skipped when the agent is busy. | `Shift+Tab` |
| `app.showMoreLines` | Expand and collapse blocks of content when not in alternate buffer mode. | `Ctrl+O` |
@@ -127,9 +126,6 @@ available combinations.
| `background.unfocus` | Move focus from background shell to Gemini. | `Shift+Tab` |
| `background.unfocusList` | Move focus from background shell list to Gemini. | `Tab` |
| `background.unfocusWarning` | Show warning when trying to move focus away from background shell. | `Tab` |
| `app.dumpFrame` | Dump the current frame as a snapshot. | `F8` |
| `app.startRecording` | Start recording the session. | `F6` |
| `app.stopRecording` | Stop recording the session. | `F7` |
#### Extension Controls
+4 -4
View File
@@ -115,10 +115,10 @@ each tool.
### Web
| Tool | Kind | Description |
| :-------------------------------------------- | :------- | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| [`google_web_search`](../tools/web-search.md) | `Search` | Performs a Google Search to find up-to-date information. |
| [`web_fetch`](../tools/web-fetch.md) | `Fetch` | Retrieves and processes content from specific URLs. **Warning:** This tool can access local and private network addresses (e.g., localhost), which may pose a security risk if used with untrusted prompts. |
| Tool | Kind | Description |
| :-------------------------------------------- | :------- | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| [`google_web_search`](../tools/web-search.md) | `Search` | Performs a Google Search to find up-to-date information. |
| [`web_fetch`](../tools/web-fetch.md) | `Fetch` | Retrieves and processes content from specific URLs. **Warning:** This tool can access local and private network addresses (e.g., localhost), which may pose a security risk if used with untrusted prompts. In Plan Mode, this tool requires explicit user confirmation. |
## Under the hood
+3
View File
@@ -17,6 +17,9 @@ specific operations like summarization or extraction.
## Technical behavior
- **Confirmation:** Triggers a confirmation dialog showing the converted URLs.
- **Plan Mode:** In [Plan Mode](../cli/plan-mode.md), `web_fetch` is available
but always requires explicit user confirmation (`ask_user`) due to security
implications of accessing external or private network addresses.
- **Processing:** Uses the Gemini API's `urlContext` for retrieval.
- **Fallback:** If API access fails, the tool attempts to fetch raw content
directly from your local machine.
+1 -1
View File
@@ -13,7 +13,7 @@ import { wasmLoader } from 'esbuild-plugin-wasm';
let esbuild;
try {
esbuild = (await import('esbuild')).default;
} catch (_error) {
} catch {
console.error('esbuild not available - cannot build bundle');
process.exit(1);
}
+9 -4
View File
@@ -41,6 +41,11 @@ const commonRestrictedSyntaxRules = [
message:
'Do not use typeof to check object properties. Define a TypeScript interface and a type guard function instead.',
},
{
selector: 'CatchClause > Identifier[name=/^_/]',
message:
'Do not use underscored identifiers in catch blocks. If the error is unused, use "catch {}". If it is used, remove the underscore.',
},
];
export default tseslint.config(
@@ -129,7 +134,7 @@ export default tseslint.config(
{
argsIgnorePattern: '^_',
varsIgnorePattern: '^_',
caughtErrorsIgnorePattern: '^_',
caughtErrors: 'all',
},
],
// Prevent async errors from bypassing catch handlers
@@ -336,7 +341,7 @@ export default tseslint.config(
{
argsIgnorePattern: '^_',
varsIgnorePattern: '^_',
caughtErrorsIgnorePattern: '^_',
caughtErrors: 'all',
},
],
},
@@ -360,7 +365,7 @@ export default tseslint.config(
{
argsIgnorePattern: '^_',
varsIgnorePattern: '^_',
caughtErrorsIgnorePattern: '^_',
caughtErrors: 'all',
},
],
},
@@ -422,7 +427,7 @@ export default tseslint.config(
{
argsIgnorePattern: '^_',
varsIgnorePattern: '^_',
caughtErrorsIgnorePattern: '^_',
caughtErrors: 'all',
},
],
},
+50
View File
@@ -212,6 +212,56 @@ The nightly workflow executes the full evaluation suite multiple times
(currently 3 attempts) to account for non-determinism. These results are
aggregated into a **Nightly Summary** attached to the workflow run.
## Regression Check Scripts
The project includes several scripts to automate high-signal regression checking
in Pull Requests. These can also be run locally for debugging.
- **`scripts/get_trustworthy_evals.js`**: Analyzes nightly history to identify
stable tests (80%+ aggregate pass rate).
- **`scripts/run_regression_check.js`**: Runs a specific set of tests using the
"Best-of-4" logic and "Dynamic Baseline Verification".
- **`scripts/run_eval_regression.js`**: The main orchestrator that loops through
models and generates the final PR report.
### Running Regression Checks Locally
You can simulate the PR regression check locally to verify your changes before
pushing:
```bash
# Run the full regression loop for a specific model
MODEL_LIST=gemini-3-flash-preview node scripts/run_eval_regression.js
```
To debug a specific failing test with the same logic used in CI:
```bash
# 1. Get the Vitest pattern for trustworthy tests
OUTPUT=$(node scripts/get_trustworthy_evals.js "gemini-3-flash-preview")
# 2. Run the regression logic for those tests
node scripts/run_regression_check.js "gemini-3-flash-preview" "$OUTPUT"
```
### The Regression Quality Bar
Because LLMs are non-deterministic, the PR regression check uses a high-signal
probabilistic approach rather than a 100% pass requirement:
1. **Trustworthiness (60/80 Filter):** Only tests with a proven track record
are run. A test must score at least **60% (2/3)** every single night and
maintain an **80% aggregate** pass rate over the last 6 days.
2. **The 50% Pass Rule:** In a PR, a test is considered a **Pass** if the model
correctly performs the behavior at least half the time (**2 successes** out
of up to 4 attempts).
3. **Dynamic Baseline Verification:** If a test fails in a PR (e.g., 0/3), the
system automatically checks the `main` branch. If it fails there too, it is
marked as **Pre-existing** and cleared for the PR, ensuring you are only
blocked by regressions caused by your specific changes.
## Fixing Evaluations
#### How to interpret the report:
- **Pass Rate (%)**: Each cell represents the percentage of successful runs for
+2 -2
View File
@@ -98,7 +98,7 @@ export class RestoreCommand implements Command {
name: this.name,
data: restoreResult,
};
} catch (_error) {
} catch {
return {
name: this.name,
data: {
@@ -142,7 +142,7 @@ export class ListCheckpointsCommand implements Command {
content: JSON.stringify(checkpointInfoList),
},
};
} catch (_error) {
} catch {
return {
name: this.name,
data: {
+1 -1
View File
@@ -284,7 +284,7 @@ export class LinkExtensionCommand implements Command {
try {
await stat(sourceFilepath);
} catch (_error) {
} catch {
return { name: this.name, data: `Invalid source: ${sourceFilepath}` };
}
+2 -2
View File
@@ -130,7 +130,7 @@ export class ListCheckpointsCommand implements Command {
const checkpointDir = config.storage.getProjectTempCheckpointsDir();
try {
await fs.mkdir(checkpointDir, { recursive: true });
} catch (_e) {
} catch {
// Ignore
}
@@ -169,7 +169,7 @@ export class ListCheckpointsCommand implements Command {
name: this.name,
data: `Available Checkpoints:\n${formatted}`,
};
} catch (_error) {
} catch {
return {
name: this.name,
data: 'An unexpected error occurred while listing checkpoints.',
+1 -1
View File
@@ -25,7 +25,7 @@ async function pathExists(path: string) {
try {
await access(path);
return true;
} catch (_e) {
} catch {
return false;
}
}
+1 -1
View File
@@ -32,7 +32,7 @@ describe('mcp command', () => {
try {
await parser.parse('mcp');
} catch (_error) {
} catch {
// yargs might throw an error when demandCommand is not met
}
+2 -2
View File
@@ -121,7 +121,7 @@ async function testMCPConnection(
try {
// Use the same transport creation logic as core
transport = await createTransport(serverName, config, false, mcpContext);
} catch (_error) {
} catch {
await client.close();
return MCPServerStatus.DISCONNECTED;
}
@@ -135,7 +135,7 @@ async function testMCPConnection(
await client.close();
return MCPServerStatus.CONNECTED;
} catch (_error) {
} catch {
await transport.close();
return MCPServerStatus.DISCONNECTED;
}
+2 -3
View File
@@ -993,8 +993,6 @@ export async function loadCliConfig(
trustedFolder,
useBackgroundColor: settings.ui?.useBackgroundColor,
useAlternateBuffer: settings.ui?.useAlternateBuffer,
useTerminalBuffer: settings.ui?.terminalBuffer,
useRenderProcess: settings.ui?.renderProcess,
useRipgrep: settings.tools?.useRipgrep,
enableInteractiveShell: settings.tools?.shell?.enableInteractiveShell,
shellBackgroundCompletionBehavior: settings.tools?.shell
@@ -1011,6 +1009,7 @@ export async function loadCliConfig(
format: (argv.outputFormat ?? settings.output?.format) as OutputFormat,
},
gemmaModelRouter: settings.experimental?.gemmaModelRouter,
adk: settings.experimental?.adk,
fakeResponses: argv.fakeResponses,
recordResponses: argv.recordResponses,
retryFetchErrors: settings.general?.retryFetchErrors,
@@ -1065,7 +1064,7 @@ async function resolveWorktreeSettings(
if (isGeminiWorktree(toplevel, projectRoot)) {
worktreePath = toplevel;
}
} catch (_e) {
} catch {
return undefined;
}
@@ -33,7 +33,7 @@ describe('copyExtension permissions', () => {
makeWritableSync(path.join(p, child)),
);
}
} catch (_e) {
} catch {
// Ignore errors during cleanup
}
};
@@ -101,7 +101,7 @@ describe('ExtensionManager', () => {
themeManager.clearExtensionThemes();
try {
fs.rmSync(tempHomeDir, { recursive: true, force: true });
} catch (_e) {
} catch {
// Ignore
}
});
+1 -1
View File
@@ -63,7 +63,7 @@ export function loadInstallMetadata(
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const metadata = JSON.parse(configContent) as ExtensionInstallMetadata;
return metadata;
} catch (_e) {
} catch {
return undefined;
}
}
@@ -11,8 +11,7 @@ This extension will exclude the following core tools: tool1,tool2
The extension you are about to install may have been created by a third-party developer and sourced
from a public repository. Google does not vet, endorse, or guarantee the functionality or security
of extensions. Please carefully inspect any extension and its source code before installing to
understand the permissions it requires and the actions it may perform.
"
understand the permissions it requires and the actions it may perform."
`;
exports[`consent > maybeRequestConsentOrFail > consent string generation > should include warning when hooks are present 1`] = `
@@ -22,8 +21,7 @@ exports[`consent > maybeRequestConsentOrFail > consent string generation > shoul
The extension you are about to install may have been created by a third-party developer and sourced
from a public repository. Google does not vet, endorse, or guarantee the functionality or security
of extensions. Please carefully inspect any extension and its source code before installing to
understand the permissions it requires and the actions it may perform.
"
understand the permissions it requires and the actions it may perform."
`;
exports[`consent > maybeRequestConsentOrFail > consent string generation > should request consent if extension is migrated 1`] = `
@@ -32,8 +30,7 @@ exports[`consent > maybeRequestConsentOrFail > consent string generation > shoul
The extension you are about to install may have been created by a third-party developer and sourced
from a public repository. Google does not vet, endorse, or guarantee the functionality or security
of extensions. Please carefully inspect any extension and its source code before installing to
understand the permissions it requires and the actions it may perform.
"
understand the permissions it requires and the actions it may perform."
`;
exports[`consent > maybeRequestConsentOrFail > consent string generation > should request consent if skills change 1`] = `
@@ -63,8 +60,7 @@ understand the permissions it requires and the actions it may perform.
Agent skills inject specialized instructions and domain-specific knowledge into the agent's system
prompt. This can change how the agent interprets your requests and interacts with your environment.
Review the skill definitions at the location(s) provided below to ensure they meet your security
standards.
"
standards."
`;
exports[`consent > maybeRequestConsentOrFail > consent string generation > should show a warning if the skill directory cannot be read 1`] = `
@@ -86,8 +82,7 @@ understand the permissions it requires and the actions it may perform.
Agent skills inject specialized instructions and domain-specific knowledge into the agent's system
prompt. This can change how the agent interprets your requests and interacts with your environment.
Review the skill definitions at the location(s) provided below to ensure they meet your security
standards.
"
standards."
`;
exports[`consent > skillsConsentString > should generate a consent string for skills 1`] = `
@@ -103,6 +98,5 @@ Install Destination: /mock/target/dir
Agent skills inject specialized instructions and domain-specific knowledge into the agent's system
prompt. This can change how the agent interprets your requests and interacts with your environment.
Review the skill definitions at the location(s) provided below to ensure they meet your security
standards.
"
standards."
`;
+1 -1
View File
@@ -151,7 +151,7 @@ export async function fetchReleaseFromGithub(
return await fetchJson(
`https://api.github.com/repos/${owner}/${repo}/releases/latest`,
);
} catch (_) {
} catch {
// This can fail if there is no release marked latest. In that case
// we want to just try the pre-release logic below.
}
+1 -1
View File
@@ -612,7 +612,7 @@ export function loadEnvironment(
}
}
}
} catch (_e) {
} catch {
// Errors are ignored to match the behavior of `dotenv.config({ quiet: true })`.
}
}
+26 -1
View File
@@ -87,7 +87,7 @@ describe('SettingsSchema', () => {
const definition = getSettingsSchema().ui?.properties?.loadingPhrases;
expect(definition).toBeDefined();
expect(definition?.type).toBe('enum');
expect(definition?.default).toBe('tips');
expect(definition?.default).toBe('off');
expect(definition?.options?.map((o) => o.value)).toEqual([
'tips',
'witty',
@@ -505,6 +505,31 @@ describe('SettingsSchema', () => {
'The model to use for the classifier. Only tested on `gemma3-1b-gpu-custom`.',
);
});
it('should have adk setting in schema', () => {
const adk = getSettingsSchema().experimental.properties.adk;
expect(adk).toBeDefined();
expect(adk.type).toBe('object');
expect(adk.category).toBe('Experimental');
expect(adk.default).toEqual({});
expect(adk.requiresRestart).toBe(true);
expect(adk.showInDialog).toBe(false);
expect(adk.description).toBe(
'Settings for the Agent Development Kit (ADK).',
);
const agentSessionNoninteractiveEnabled =
adk.properties.agentSessionNoninteractiveEnabled;
expect(agentSessionNoninteractiveEnabled).toBeDefined();
expect(agentSessionNoninteractiveEnabled.type).toBe('boolean');
expect(agentSessionNoninteractiveEnabled.category).toBe('Experimental');
expect(agentSessionNoninteractiveEnabled.default).toBe(false);
expect(agentSessionNoninteractiveEnabled.requiresRestart).toBe(true);
expect(agentSessionNoninteractiveEnabled.showInDialog).toBe(false);
expect(agentSessionNoninteractiveEnabled.description).toBe(
'Enable non-interactive agent sessions.',
);
});
});
it('has JSON schema definitions for every referenced ref', () => {
+23 -21
View File
@@ -743,24 +743,6 @@ const SETTINGS_SCHEMA = {
'Use an alternate screen buffer for the UI, preserving shell history.',
showInDialog: true,
},
renderProcess: {
type: 'boolean',
label: 'Render Process',
category: 'UI',
requiresRestart: true,
default: true,
description: 'Enable Ink render process for the UI.',
showInDialog: true,
},
terminalBuffer: {
type: 'boolean',
label: 'Terminal Buffer',
category: 'UI',
requiresRestart: true,
default: true,
description: 'Use the new terminal buffer architecture for rendering.',
showInDialog: true,
},
useBackgroundColor: {
type: 'boolean',
label: 'Use Background Color',
@@ -794,9 +776,9 @@ const SETTINGS_SCHEMA = {
label: 'Loading Phrases',
category: 'UI',
requiresRestart: false,
default: 'tips',
default: 'off',
description:
'What to show while the model is working: tips, witty comments, both, or nothing.',
'What to show while the model is working: tips, witty comments, all, or off.',
showInDialog: true,
options: [
{ value: 'tips', label: 'Tips' },
@@ -1905,7 +1887,7 @@ const SETTINGS_SCHEMA = {
label: 'Auto Configure Max Old Space Size',
category: 'Advanced',
requiresRestart: true,
default: false,
default: true,
description: 'Automatically configure Node.js memory limits',
showInDialog: true,
},
@@ -1951,6 +1933,26 @@ const SETTINGS_SCHEMA = {
description: 'Setting to enable experimental features',
showInDialog: false,
properties: {
adk: {
type: 'object',
label: 'ADK',
category: 'Experimental',
requiresRestart: true,
default: {},
description: 'Settings for the Agent Development Kit (ADK).',
showInDialog: false,
properties: {
agentSessionNoninteractiveEnabled: {
type: 'boolean',
label: 'Agent Session Non-interactive Enabled',
category: 'Experimental',
requiresRestart: true,
default: false,
description: 'Enable non-interactive agent sessions.',
showInDialog: false,
},
},
},
enableAgents: {
type: 'boolean',
label: 'Enable Agents',
-1
View File
@@ -327,7 +327,6 @@ describe('gemini.tsx main function cleanup', () => {
refreshAuth: vi.fn(),
getRemoteAdminSettings: vi.fn(() => undefined),
getUseAlternateBuffer: vi.fn(() => false),
getUseTerminalBuffer: vi.fn(() => false),
...overrides,
} as unknown as Config;
}
+3 -5
View File
@@ -43,6 +43,7 @@ import { KeypressProvider } from './ui/contexts/KeypressContext.js';
import { useKittyKeyboardProtocol } from './ui/hooks/useKittyKeyboardProtocol.js';
import { ScrollProvider } from './ui/contexts/ScrollProvider.js';
import { TerminalProvider } from './ui/contexts/TerminalContext.js';
import { isAlternateBufferEnabled } from './ui/hooks/useAlternateBuffer.js';
import { OverflowProvider } from './ui/contexts/OverflowContext.js';
import { profiler } from './ui/components/DebugProfiler.js';
import { initializeConsoleStore } from './ui/hooks/useConsoleMessages.js';
@@ -63,7 +64,7 @@ export async function startInteractiveUI(
// and the Ink alternate buffer mode requires line wrapping harmful to
// screen readers.
const useAlternateBuffer = shouldEnterAlternateScreen(
config.getUseAlternateBuffer(),
isAlternateBufferEnabled(config),
config.getScreenReader(),
);
const mouseEventsEnabled = useAlternateBuffer;
@@ -132,6 +133,7 @@ export async function startInteractiveUI(
// Wait a moment for shpool to stabilize terminal size and state.
await new Promise((resolve) => setTimeout(resolve, 100));
}
const instance = render(
process.env['DEBUG'] ? (
<React.StrictMode>
@@ -152,12 +154,8 @@ export async function startInteractiveUI(
}
profiler.reportFrameRendered();
},
standardReactLayoutTiming:
useAlternateBuffer || config.getUseTerminalBuffer(),
patchConsole: false,
alternateBuffer: useAlternateBuffer,
renderProcess: config.getUseRenderProcess(),
terminalBuffer: config.getUseTerminalBuffer(),
incrementalRendering:
settings.merged.ui.incrementalRendering !== false &&
useAlternateBuffer &&
+1 -1
View File
@@ -1712,7 +1712,7 @@ describe('runNonInteractive', () => {
input,
prompt_id: promptId,
});
} catch (_error) {
} catch {
// Expected exit
}
@@ -36,11 +36,15 @@ export async function toMatchSvgSnapshot(
}
let textContent: string;
if (renderInstance.lastFrame) {
if (renderInstance.lastFrameRaw) {
textContent = renderInstance.lastFrameRaw({
allowEmpty: options?.allowEmpty,
});
} else if (renderInstance.lastFrame) {
textContent = renderInstance.lastFrame({ allowEmpty: options?.allowEmpty });
} else {
throw new Error(
'toMatchSvgSnapshot requires a renderInstance with lastFrame',
'toMatchSvgSnapshot requires a renderInstance with either lastFrameRaw or lastFrame',
);
}
const svgContent = renderInstance.generateSvg();
+1 -2
View File
@@ -38,6 +38,7 @@ export const createMockConfig = (overrides: Partial<Config> = {}): Config =>
fireSessionEndEvent: vi.fn().mockResolvedValue(undefined),
fireSessionStartEvent: vi.fn().mockResolvedValue(undefined),
})),
isMemoryManagerEnabled: vi.fn(() => false),
getListExtensions: vi.fn(() => false),
getExtensions: vi.fn(() => []),
getListSessions: vi.fn(() => false),
@@ -175,8 +176,6 @@ export const createMockConfig = (overrides: Partial<Config> = {}): Config =>
getHasAccessToPreviewModel: vi.fn().mockReturnValue(false),
validatePathAccess: vi.fn().mockReturnValue(null),
getUseAlternateBuffer: vi.fn().mockReturnValue(false),
getUseTerminalBuffer: vi.fn().mockReturnValue(false),
getUseRenderProcess: vi.fn().mockReturnValue(false),
...overrides,
}) as unknown as Config;
+2 -7
View File
@@ -223,7 +223,7 @@ class XtermStdout extends EventEmitter {
this.once('render', resolve),
);
const timeoutPromise = new Promise((resolve) =>
setTimeout(resolve, 1000),
setTimeout(resolve, 50),
);
await Promise.race([renderPromise, timeoutPromise]);
}
@@ -254,12 +254,7 @@ class XtermStdout extends EventEmitter {
const isMatch = () => {
if (expectedFrame === '...') {
// '...' is our fallback when output isn't in metrics, meaning Ink rendered *something*
// but we don't know what it is. If terminal has content, we consider it a match.
// However, if the component rendered null, both would be empty, but our fallback
// made expectedFrame '...'. In that case, we can't easily know if it's ready,
// but we can assume if there are no pending writes, it's ready.
return currentFrame !== '' || this.pendingWrites === 0;
return currentFrame !== '';
}
// If Ink expects nothing (no new static content and no dynamic output),
+7 -22
View File
@@ -346,7 +346,6 @@ describe('AppContainer State Management', () => {
// Initialize mock stdout for terminal title tests
mocks.mockStdout.write.mockClear();
(disableMouseEvents as import('vitest').Mock).mockClear();
capturedUIState = null!;
@@ -471,7 +470,6 @@ describe('AppContainer State Management', () => {
// Mock Config
mockConfig = makeFakeConfig();
vi.spyOn(mockConfig, 'getUseRenderProcess').mockReturnValue(false);
// Mock config's getTargetDir to return consistent workspace directory
vi.spyOn(mockConfig, 'getTargetDir').mockReturnValue('/test/workspace');
@@ -1358,7 +1356,6 @@ describe('AppContainer State Management', () => {
beforeEach(() => {
// Reset mock stdout for each test
mocks.mockStdout.write.mockClear();
(disableMouseEvents as import('vitest').Mock).mockClear();
});
it('verifies useStdout is mocked', async () => {
@@ -2462,7 +2459,7 @@ describe('AppContainer State Management', () => {
});
});
describe('Copy Mode (F9)', () => {
describe('Copy Mode (CTRL+S)', () => {
let rerender: () => void;
let unmount: () => void;
let stdin: Awaited<ReturnType<typeof render>>['stdin'];
@@ -2471,8 +2468,6 @@ describe('AppContainer State Management', () => {
isAlternateMode = false,
childHandler?: Mock,
) => {
vi.spyOn(mockConfig, 'getUseTerminalBuffer').mockReturnValue(false);
vi.spyOn(mockConfig, 'getUseAlternateBuffer').mockReturnValue(
isAlternateMode,
);
@@ -2517,8 +2512,6 @@ describe('AppContainer State Management', () => {
beforeEach(() => {
mocks.mockStdout.write.mockClear();
(disableMouseEvents as import('vitest').Mock).mockClear();
vi.useFakeTimers();
});
@@ -2539,13 +2532,12 @@ describe('AppContainer State Management', () => {
modeName: 'Alternate Buffer Mode',
},
])('$modeName', ({ isAlternateMode, shouldEnable }) => {
it(`should ${shouldEnable ? 'toggle' : 'NOT toggle'} mouse off when F9 is pressed`, async () => {
it(`should ${shouldEnable ? 'toggle' : 'NOT toggle'} mouse off when Ctrl+S is pressed`, async () => {
await setupCopyModeTest(isAlternateMode);
mocks.mockStdout.write.mockClear(); // Clear initial enable call
(disableMouseEvents as import('vitest').Mock).mockClear();
act(() => {
stdin.write('\x1b[20~'); // F9
stdin.write('\x13'); // Ctrl+S
});
rerender();
@@ -2558,13 +2550,13 @@ describe('AppContainer State Management', () => {
});
if (shouldEnable) {
it('should toggle mouse back on when F9 is pressed again', async () => {
it('should toggle mouse back on when Ctrl+S is pressed again', async () => {
await setupCopyModeTest(isAlternateMode);
(writeToStdout as Mock).mockClear();
// Turn it on (disable mouse)
act(() => {
stdin.write('\x1b[20~'); // F9
stdin.write('\x13'); // Ctrl+S
});
rerender();
expect(disableMouseEvents).toHaveBeenCalled();
@@ -2584,7 +2576,7 @@ describe('AppContainer State Management', () => {
// Enter copy mode
act(() => {
stdin.write('\x1b[20~'); // F9
stdin.write('\x13'); // Ctrl+S
});
rerender();
@@ -2664,7 +2656,7 @@ describe('AppContainer State Management', () => {
// 2. Enter copy mode
act(() => {
stdin.write('\x1b[20~'); // F9
stdin.write('\x13'); // Ctrl+S
});
rerender();
@@ -3101,7 +3093,6 @@ describe('AppContainer State Management', () => {
// Clear previous calls
mocks.mockStdout.write.mockClear();
(disableMouseEvents as import('vitest').Mock).mockClear();
const { unmount } = await act(async () => renderAppContainer());
@@ -3144,7 +3135,6 @@ describe('AppContainer State Management', () => {
// Reset mock stdout to clear any initial writes
mocks.mockStdout.write.mockClear();
(disableMouseEvents as import('vitest').Mock).mockClear();
// Submit
await act(async () => capturedUIActions.handleFinalSubmit('test prompt'));
@@ -3164,8 +3154,6 @@ describe('AppContainer State Management', () => {
);
vi.mocked(checkPermissions).mockResolvedValue([]);
vi.spyOn(mockConfig, 'getUseTerminalBuffer').mockReturnValue(false);
vi.spyOn(mockConfig, 'getUseAlternateBuffer').mockReturnValue(true);
const { unmount } = await act(async () =>
@@ -3182,7 +3170,6 @@ describe('AppContainer State Management', () => {
// Reset mock stdout
mocks.mockStdout.write.mockClear();
(disableMouseEvents as import('vitest').Mock).mockClear();
// Submit
await act(async () => capturedUIActions.handleFinalSubmit('test prompt'));
@@ -3416,8 +3403,6 @@ describe('AppContainer State Management', () => {
ui: { useAlternateBuffer: true },
});
vi.spyOn(mockConfig, 'getUseTerminalBuffer').mockReturnValue(false);
vi.spyOn(mockConfig, 'getUseAlternateBuffer').mockReturnValue(true);
const { unmount } = await act(async () =>
+13 -83
View File
@@ -11,7 +11,6 @@ import {
useEffect,
useRef,
useLayoutEffect,
useContext,
} from 'react';
import {
type DOMElement,
@@ -20,7 +19,6 @@ import {
useStdout,
useStdin,
type AppProps,
AppContext as InkAppContext,
} from 'ink';
import { App } from './App.js';
import { AppContext } from './contexts/AppContext.js';
@@ -40,8 +38,6 @@ import {
import { checkPermissions } from './hooks/atCommandProcessor.js';
import { MessageType, StreamingState } from './types.js';
import { ToolActionsProvider } from './contexts/ToolActionsContext.js';
import { MouseProvider } from './contexts/MouseContext.js';
import { ScrollProvider } from './contexts/ScrollProvider.js';
import {
type StartupWarning,
type EditorType,
@@ -87,6 +83,7 @@ import {
logBillingEvent,
ApiKeyUpdatedEvent,
type InjectionSource,
startMemoryService,
} from '@google/gemini-cli-core';
import { validateAuthMethod } from '../config/auth.js';
import process from 'node:process';
@@ -213,30 +210,12 @@ export const AppContainer = (props: AppContainerProps) => {
const { reset } = useOverflowActions()!;
const notificationsEnabled = isNotificationsEnabled(settings);
const { setOptions, dumpCurrentFrame, startRecording, stopRecording } =
useContext(InkAppContext);
const recordingFilenameRef = useRef<string | null>(null);
const historyManager = useHistory({
chatRecordingService: config.getGeminiClient()?.getChatRecordingService(),
});
useMemoryMonitor(historyManager);
const isAlternateBuffer = config.getUseAlternateBuffer();
const [mouseMode, setMouseMode] = useState(() =>
config.getUseAlternateBuffer(),
);
useEffect(() => {
setOptions({
stickyHeadersInBackbuffer: mouseMode,
});
if (mouseMode) {
enableMouseEvents();
} else {
disableMouseEvents();
}
}, [mouseMode, setOptions]);
const [corgiMode, setCorgiMode] = useState(false);
const [forceRerenderKey, setForceRerenderKey] = useState(0);
const [debugMessage, setDebugMessage] = useState<string>('');
@@ -469,6 +448,13 @@ export const AppContainer = (props: AppContainerProps) => {
setConfigInitialized(true);
startupProfiler.flush(config);
// Fire-and-forget memory service (skill extraction from past sessions)
if (config.isMemoryManagerEnabled()) {
startMemoryService(config).catch((e) => {
debugLogger.error('Failed to start memory service:', e);
});
}
const sessionStartSource = resumedSessionData
? SessionStartSource.Resume
: SessionStartSource.Startup;
@@ -635,11 +621,11 @@ export const AppContainer = (props: AppContainerProps) => {
});
const refreshStatic = useCallback(() => {
if (!isAlternateBuffer && !config.getUseTerminalBuffer()) {
if (!isAlternateBuffer) {
stdout.write(ansiEscapes.clearTerminal);
setHistoryRemountKey((prev) => prev + 1);
}
}, [setHistoryRemountKey, isAlternateBuffer, stdout, config]);
setHistoryRemountKey((prev) => prev + 1);
}, [setHistoryRemountKey, isAlternateBuffer, stdout]);
const shouldUseAlternateScreen = shouldEnterAlternateScreen(
isAlternateBuffer,
@@ -1448,14 +1434,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
!copyModeEnabled;
const observerRef = useRef<ResizeObserver | null>(null);
useEffect(
() => () => {
observerRef.current?.disconnect();
},
[],
);
const [controlsHeight, setControlsHeight] = useState(0);
const [lastNonCopyControlsHeight, setLastNonCopyControlsHeight] = useState(0);
@@ -1754,17 +1732,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
setShortcutsHelpVisible(false);
}
if (keyMatchers[Command.TOGGLE_MOUSE_MODE](key)) {
setMouseMode((prev) => {
const next = !prev;
if (!next && !isAlternateBuffer) {
appEvents.emit(AppEvent.ScrollToBottom);
}
return next;
});
return true;
}
if (isAlternateBuffer && keyMatchers[Command.TOGGLE_COPY_MODE](key)) {
setCopyModeEnabled(true);
disableMouseEvents();
@@ -1787,32 +1754,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
return true;
} else if (keyMatchers[Command.SUSPEND_APP](key)) {
handleSuspend();
} else if (keyMatchers[Command.DUMP_FRAME](key)) {
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const filename = `snapshot-${timestamp}.json`;
if (dumpCurrentFrame) {
dumpCurrentFrame(filename);
debugLogger.log(`Dumped frame to: ${filename}`);
}
return true;
} else if (keyMatchers[Command.START_RECORDING](key)) {
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const filename = `recording-${timestamp}.json`;
if (startRecording) {
startRecording(filename);
recordingFilenameRef.current = filename;
debugLogger.log(`Started recording to: ${filename}`);
}
return true;
} else if (keyMatchers[Command.STOP_RECORDING](key)) {
if (stopRecording) {
stopRecording();
debugLogger.log(
`Stopped recording, saved to: ${recordingFilenameRef.current ?? 'unknown'}`,
);
recordingFilenameRef.current = null;
}
return true;
} else if (
keyMatchers[Command.TOGGLE_COPY_MODE](key) &&
!isAlternateBuffer
@@ -1999,9 +1940,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
historyManager.history,
pendingHistoryItems,
toggleAllExpansion,
dumpCurrentFrame,
startRecording,
stopRecording,
],
);
@@ -2021,9 +1959,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
}
setCopyModeEnabled(false);
if (mouseMode) {
enableMouseEvents();
}
enableMouseEvents();
return true;
},
{
@@ -2340,7 +2276,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
editorError,
isEditorDialogOpen,
showPrivacyNotice,
mouseMode,
corgiMode,
debugMessage,
quittingMessages,
@@ -2467,7 +2402,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
editorError,
isEditorDialogOpen,
showPrivacyNotice,
mouseMode,
corgiMode,
debugMessage,
quittingMessages,
@@ -2768,11 +2702,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
toggleAllExpansion={toggleAllExpansion}
>
<ShellFocusContext.Provider value={isFocused}>
<MouseProvider mouseEventsEnabled={mouseMode}>
<ScrollProvider>
<App key={`app-${forceRerenderKey}`} />
</ScrollProvider>
</MouseProvider>
<App key={`app-${forceRerenderKey}`} />
</ShellFocusContext.Provider>
</ToolActionsProvider>
</AppContext.Provider>
+1 -1
View File
@@ -65,7 +65,7 @@ const getSavedChatTags = async (
);
return chatDetails;
} catch (_err) {
} catch {
return [];
}
};
@@ -198,7 +198,7 @@ export const directoryCommand: SlashCommand = {
alreadyAdded.push(trimmedPath);
continue;
}
} catch (_e) {
} catch {
// Path might not exist or be inaccessible.
// We'll let batchAddDirectories handle it later.
}
@@ -321,7 +321,7 @@ async function exploreAction(
});
try {
await open(extensionsUrl);
} catch (_error) {
} catch {
context.ui.addItem({
type: MessageType.ERROR,
text: `Failed to open browser. Check out the extensions gallery at ${extensionsUrl}`,
@@ -151,7 +151,7 @@ async function completion(
const files = await fs.readdir(checkpointDir);
const jsonFiles = files.filter((file) => file.endsWith('.json'));
return getTruncatedCheckpointNames(jsonFiles);
} catch (_err) {
} catch {
return [];
}
}
@@ -76,7 +76,7 @@ export async function updateGitignore(gitRepoRoot: string): Promise<void> {
let fileExists = true;
try {
existingContent = await fs.promises.readFile(gitignorePath, 'utf8');
} catch (_error) {
} catch {
// File doesn't exist
fileExists = false;
}
@@ -168,8 +168,8 @@ async function downloadFiles({
async function createDirectory(dirPath: string): Promise<void> {
try {
await fs.promises.mkdir(dirPath, { recursive: true });
} catch (_error) {
debugLogger.debug(`Failed to create ${dirPath} directory:`, _error);
} catch (error) {
debugLogger.debug(`Failed to create ${dirPath} directory:`, error);
throw new Error(
`Unable to create ${dirPath} directory. Do you have file permissions in the current directory?`,
);
@@ -222,8 +222,8 @@ export const setupGithubCommand: SlashCommand = {
let gitRepoRoot: string;
try {
gitRepoRoot = getGitRepoRoot();
} catch (_error) {
debugLogger.debug(`Failed to get git repo root:`, _error);
} catch (error) {
debugLogger.debug(`Failed to get git repo root:`, error);
throw new Error(
'Unable to determine the GitHub repository. /setup-github must be run from a git repository.',
);
@@ -26,7 +26,6 @@ import { OverflowProvider } from '../contexts/OverflowContext.js';
import { ConfigInitDisplay } from './ConfigInitDisplay.js';
import { TodoTray } from './messages/Todo.js';
import { useComposerStatus } from '../hooks/useComposerStatus.js';
import { appEvents, AppEvent } from '../../utils/events.js';
export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
const uiState = useUIState();
@@ -56,12 +55,6 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
const { setShortcutsHelpVisible } = uiActions;
useEffect(() => {
if (hasPendingActionRequired) {
appEvents.emit(AppEvent.ScrollToBottom);
}
}, [hasPendingActionRequired]);
useEffect(() => {
if (uiState.shortcutsHelpVisible && !isPassiveShortcutsHelpState) {
setShortcutsHelpVisible(false);
@@ -166,7 +166,6 @@ Implement a comprehensive authentication system with multiple providers.
writeTextFile: vi.fn(),
}),
getUseAlternateBuffer: () => useAlternateBuffer,
getUseTerminalBuffer: () => false,
} as unknown as import('@google/gemini-cli-core').Config,
settings: createMockSettings({ ui: { useAlternateBuffer } }),
},
@@ -467,7 +466,6 @@ Implement a comprehensive authentication system with multiple providers.
writeTextFile: vi.fn(),
}),
getUseAlternateBuffer: () => useAlternateBuffer ?? true,
getUseTerminalBuffer: () => false,
} as unknown as import('@google/gemini-cli-core').Config,
settings: createMockSettings({
ui: { useAlternateBuffer: useAlternateBuffer ?? true },
@@ -18,7 +18,7 @@ vi.mock('../../utils/processUtils.js', () => ({
}));
const mockedExit = vi.hoisted(() => vi.fn());
const mockedCwd = vi.hoisted(() => vi.fn().mockReturnValue('/mock/cwd'));
const mockedCwd = vi.hoisted(() => vi.fn());
const mockedRows = vi.hoisted(() => ({ current: 24 }));
vi.mock('node:process', async () => {
+1 -1
View File
@@ -72,7 +72,7 @@ describe('Help Component', () => {
expect(output).toContain('Keyboard Shortcuts:');
expect(output).toContain('Ctrl+C');
expect(output).toContain('Shift+Tab');
expect(output).toContain('Ctrl+S');
expect(output).toContain('Page Up/Page Down');
unmount();
});
@@ -1445,9 +1445,101 @@ describe('InputPrompt', () => {
});
await waitFor(() => {
// Should autocomplete to allow adding file argument
// Should submit the full command constructed from buffer + suggestion
// even if autoExecute is false, because the user explicitly hit Enter on the suggestion.
expect(props.onSubmit).toHaveBeenCalledWith('/share');
expect(mockCommandCompletion.handleAutocomplete).not.toHaveBeenCalled();
});
unmount();
});
it('should add a space on Tab when command is already complete in the buffer', async () => {
const executableCommand: SlashCommand = {
name: 'about',
kind: CommandKind.BUILT_IN,
description: 'About info',
action: vi.fn(),
autoExecute: true,
};
const suggestion = { label: 'about', value: 'about' };
mockedUseCommandCompletion.mockReturnValue({
...mockCommandCompletion,
showSuggestions: true,
suggestions: [suggestion],
activeSuggestionIndex: 0,
getCommandFromSuggestion: vi.fn().mockReturnValue(executableCommand),
getCompletedText: vi.fn().mockReturnValue('/about'),
});
// Buffer is already complete
props.buffer.setText('/about');
props.buffer.lines = ['/about'];
props.buffer.cursor = [0, 6];
const { stdin, unmount } = await renderWithProviders(
<InputPrompt {...props} />,
{
uiActions,
},
);
await act(async () => {
stdin.write('\t'); // Tab
});
await waitFor(() => {
// Should call handleAutocomplete which will add the space
expect(mockCommandCompletion.handleAutocomplete).toHaveBeenCalledWith(0);
expect(props.onSubmit).not.toHaveBeenCalled();
});
unmount();
});
it('should submit the base command when Enter is pressed on its suggestion even with a trailing space', async () => {
const statsCommand: SlashCommand = {
name: 'stats',
kind: CommandKind.BUILT_IN,
description: 'Stats info',
action: vi.fn(),
autoExecute: false,
};
const suggestion = {
label: 'stats',
value: 'stats',
sectionTitle: 'command',
insertValue: 'stats',
};
mockedUseCommandCompletion.mockReturnValue({
...mockCommandCompletion,
showSuggestions: true,
suggestions: [suggestion],
activeSuggestionIndex: 0,
getCommandFromSuggestion: vi.fn().mockReturnValue(statsCommand),
getCompletedText: vi.fn().mockReturnValue('/stats'),
});
// Buffer has trailing space: "/stats "
props.buffer.setText('/stats ');
props.buffer.lines = ['/stats '];
props.buffer.cursor = [0, 7];
const { stdin, unmount } = await renderWithProviders(
<InputPrompt {...props} />,
{
uiActions,
},
);
await act(async () => {
stdin.write('\r'); // Enter
});
await waitFor(() => {
// Should submit "/stats"
expect(props.onSubmit).toHaveBeenCalledWith('/stats');
});
unmount();
});
@@ -1564,9 +1656,9 @@ describe('InputPrompt', () => {
});
await waitFor(() => {
// Should autocomplete (not execute) since autoExecute is undefined
expect(mockCommandCompletion.handleAutocomplete).toHaveBeenCalledWith(0);
expect(props.onSubmit).not.toHaveBeenCalled();
// Should submit the command
expect(props.onSubmit).toHaveBeenCalledWith('/find-capital');
expect(mockCommandCompletion.handleAutocomplete).not.toHaveBeenCalled();
});
unmount();
});
+5 -13
View File
@@ -338,10 +338,6 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
const showCursor =
focus && isShellFocused && !isEmbeddedShellFocused && !copyModeEnabled;
useEffect(() => {
appEvents.emit(AppEvent.ScrollToBottom);
}, [buffer.text, buffer.cursor]);
// Notify parent component about escape prompt state changes
useEffect(() => {
if (onEscapePromptChange) {
@@ -1086,16 +1082,12 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
const command =
completion.getCommandFromSuggestion(suggestion);
// Only auto-execute if the command has no completion function
// (i.e., it doesn't require an argument to be selected)
if (
command &&
isAutoExecutableCommand(command) &&
!command.completion
) {
const completedText =
completion.getCompletedText(suggestion);
const completedText = completion.getCompletedText(suggestion);
// If the user hits Enter on a suggestion, we should submit it
// IF it has an action and doesn't require further arguments (no completion function).
// This is separate from autoExecute, which handles automatic execution while typing.
if (command && command.action && !command.completion) {
if (completedText) {
setExpandedSuggestionIndex(-1);
handleSubmit(completedText.trim());
+17 -74
View File
@@ -12,7 +12,6 @@ import { useAppContext } from '../contexts/AppContext.js';
import { AppHeader } from './AppHeader.js';
import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js';
import { useConfig } from '../contexts/ConfigContext.js';
import {
SCROLL_TO_ITEM_END,
type VirtualizedListRef,
@@ -23,7 +22,6 @@ import { MAX_GEMINI_MESSAGE_LINES } from '../constants.js';
import { useConfirmingTool } from '../hooks/useConfirmingTool.js';
import { ToolConfirmationQueue } from './ToolConfirmationQueue.js';
import { isTopicTool } from './messages/TopicMessage.js';
import { appEvents, AppEvent } from '../../utils/events.js';
const MemoizedHistoryItemDisplay = memo(HistoryItemDisplay);
const MemoizedAppHeader = memo(AppHeader);
@@ -36,11 +34,6 @@ export const MainContent = () => {
const { version } = useAppContext();
const uiState = useUIState();
const isAlternateBuffer = useAlternateBuffer();
const config = useConfig();
const useTerminalBuffer =
typeof config.getUseTerminalBuffer === 'function'
? config.getUseTerminalBuffer()
: false;
const confirmingTool = useConfirmingTool();
const showConfirmationQueue = confirmingTool !== null;
@@ -54,23 +47,12 @@ export const MainContent = () => {
}
}, [showConfirmationQueue, confirmingToolCallId]);
useEffect(() => {
const handleScroll = () => {
scrollableListRef.current?.scrollToEnd();
};
appEvents.on(AppEvent.ScrollToBottom, handleScroll);
return () => {
appEvents.off(AppEvent.ScrollToBottom, handleScroll);
};
}, []);
const {
pendingHistoryItems,
mainAreaWidth,
staticAreaMaxItemHeight,
availableTerminalHeight,
cleanUiDetailsVisible,
mouseMode,
} = uiState;
const showHeaderDetails = cleanUiDetailsVisible;
@@ -302,63 +284,24 @@ export const MainContent = () => {
],
);
const estimatedItemHeight = useCallback(() => 100, []);
const keyExtractor = useCallback(
(item: (typeof virtualizedData)[number], _index: number) => {
if (item.type === 'header') return 'header';
if (item.type === 'history') return item.item.id.toString();
return 'pending';
},
[],
);
const isStaticItem = useCallback(
(item: (typeof virtualizedData)[number]) => item.type !== 'pending',
[],
);
const scrollableList = useMemo(() => {
if (isAlternateBuffer) {
return (
<ScrollableList
ref={scrollableListRef}
hasFocus={
!uiState.isEditorDialogOpen && !uiState.embeddedShellFocused
}
width={uiState.terminalWidth}
data={virtualizedData}
renderItem={renderItem}
estimatedItemHeight={estimatedItemHeight}
keyExtractor={keyExtractor}
initialScrollIndex={SCROLL_TO_ITEM_END}
initialScrollOffsetInIndex={SCROLL_TO_ITEM_END}
renderStatic={useTerminalBuffer}
isStaticItem={useTerminalBuffer ? isStaticItem : undefined}
overflowToBackbuffer={useTerminalBuffer}
scrollbar={mouseMode}
/>
// TODO(jacobr): consider adding stableScrollback={!config.getUseAlternateBuffer()}
// but need to work out ensuring we only attempt it within a smaller range of scrollback vals.
);
}
return null;
}, [
isAlternateBuffer,
uiState.isEditorDialogOpen,
uiState.embeddedShellFocused,
uiState.terminalWidth,
virtualizedData,
renderItem,
estimatedItemHeight,
keyExtractor,
useTerminalBuffer,
isStaticItem,
mouseMode,
]);
if (isAlternateBuffer) {
return scrollableList;
return (
<ScrollableList
ref={scrollableListRef}
hasFocus={!uiState.isEditorDialogOpen && !uiState.embeddedShellFocused}
width={uiState.terminalWidth}
data={virtualizedData}
renderItem={renderItem}
estimatedItemHeight={() => 100}
keyExtractor={(item, _index) => {
if (item.type === 'header') return 'header';
if (item.type === 'history') return item.item.id.toString();
return 'pending';
}}
initialScrollIndex={SCROLL_TO_ITEM_END}
initialScrollOffsetInIndex={SCROLL_TO_ITEM_END}
/>
);
}
return (
@@ -22,7 +22,7 @@ import * as processUtils from '../../utils/processUtils.js';
import { usePermissionsModifyTrust } from '../hooks/usePermissionsModifyTrust.js';
// Hoist mocks for dependencies of the usePermissionsModifyTrust hook
const mockedCwd = vi.hoisted(() => vi.fn().mockReturnValue('/mock/cwd'));
const mockedCwd = vi.hoisted(() => vi.fn());
const mockedLoadTrustedFolders = vi.hoisted(() => vi.fn());
const mockedIsWorkspaceTrusted = vi.hoisted(() => vi.fn());
+1 -15
View File
@@ -5,7 +5,7 @@
*/
import type React from 'react';
import { useCallback, useRef, useState, useEffect } from 'react';
import { useCallback, useRef, useState } from 'react';
import { Box, Text, ResizeObserver, type DOMElement } from 'ink';
import {
isUserVisibleHook,
@@ -77,13 +77,6 @@ export const StatusNode: React.FC<{
}) => {
const observerRef = useRef<ResizeObserver | null>(null);
useEffect(
() => () => {
observerRef.current?.disconnect();
},
[],
);
const onRefChange = useCallback(
(node: DOMElement | null) => {
if (observerRef.current) {
@@ -176,13 +169,6 @@ export const StatusRow: React.FC<StatusRowProps> = ({
const [tipWidth, setTipWidth] = useState(0);
const tipObserverRef = useRef<ResizeObserver | null>(null);
useEffect(
() => () => {
tipObserverRef.current?.disconnect();
},
[],
);
const onTipRefChange = useCallback((node: DOMElement | null) => {
if (tipObserverRef.current) {
tipObserverRef.current.disconnect();
@@ -59,7 +59,6 @@ describe('ToolConfirmationQueue', () => {
getPlansDir: () => '/mock/temp/plans',
},
getUseAlternateBuffer: () => false,
getUseTerminalBuffer: () => false,
} as unknown as Config;
beforeEach(() => {
@@ -15,8 +15,7 @@ Tips for getting started:
1. Create GEMINI.md files to customize your interactions
2. /help for more information
3. Ask coding questions, edit code or run commands
4. Be specific for the best results
"
4. Be specific for the best results"
`;
exports[`AppHeader Icon Rendering > renders the symmetric icon in Apple Terminal 1`] = `
@@ -34,6 +33,5 @@ Tips for getting started:
1. Create GEMINI.md files to customize your interactions
2. /help for more information
3. Ask coding questions, edit code or run commands
4. Be specific for the best results
"
4. Be specific for the best results"
`;
@@ -42,8 +42,7 @@ exports[`<FooterConfigDialog /> > highlights the active item in the preview 1`]
│ │ ~/project/path main docker gemini-2.5-pro 97% +12 -4 │ │
│ └────────────────────────────────────────────────────────────────────────────────────────────┘ │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
"
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`<FooterConfigDialog /> > renders correctly with default settings 1`] = `
@@ -134,8 +133,7 @@ exports[`<FooterConfigDialog /> > renders correctly with default settings 2`] =
│ │ ~/project/path main docker gemini-2.5-pro 97% │ │
│ └────────────────────────────────────────────────────────────────────────────────────────────┘ │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
"
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`<FooterConfigDialog /> > updates the preview when Show footer labels is toggled off 1`] = `
@@ -179,6 +177,5 @@ exports[`<FooterConfigDialog /> > updates the preview when Show footer labels is
│ │ ~/project/path · main · docker · gemini-2.5-pro · 97% │ │
│ └────────────────────────────────────────────────────────────────────────────────────────────┘ │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
"
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
@@ -4,16 +4,14 @@ exports[`InputPrompt > Highlighting and Cursor Display > multi-line scenarios >
"────────────────────────────────────────────────────────────────────────────────────────────────────
│ > first line │
│ second line │
────────────────────────────────────────────────────────────────────────────────────────────────────
"
────────────────────────────────────────────────────────────────────────────────────────────────────"
`;
exports[`InputPrompt > Highlighting and Cursor Display > multi-line scenarios > should display cursor correctly 'at the end of a line' in a multiline block 1`] = `
"────────────────────────────────────────────────────────────────────────────────────────────────────
│ > first line │
│ second line │
────────────────────────────────────────────────────────────────────────────────────────────────────
"
────────────────────────────────────────────────────────────────────────────────────────────────────"
`;
exports[`InputPrompt > Highlighting and Cursor Display > multi-line scenarios > should display cursor correctly 'in the middle of a line' in a multiline block 1`] = `
@@ -21,8 +19,7 @@ exports[`InputPrompt > Highlighting and Cursor Display > multi-line scenarios >
│ > first line │
│ second line │
│ third line │
────────────────────────────────────────────────────────────────────────────────────────────────────
"
────────────────────────────────────────────────────────────────────────────────────────────────────"
`;
exports[`InputPrompt > Highlighting and Cursor Display > multi-line scenarios > should display cursor on a blank line in a multiline block 1`] = `
@@ -30,78 +27,67 @@ exports[`InputPrompt > Highlighting and Cursor Display > multi-line scenarios >
│ > first line │
│ │
│ third line │
────────────────────────────────────────────────────────────────────────────────────────────────────
"
────────────────────────────────────────────────────────────────────────────────────────────────────"
`;
exports[`InputPrompt > Highlighting and Cursor Display > single-line scenarios > should display cursor correctly 'after multi-byte unicode characters' 1`] = `
"────────────────────────────────────────────────────────────────────────────────────────────────────
│ > 👍A │
────────────────────────────────────────────────────────────────────────────────────────────────────
"
────────────────────────────────────────────────────────────────────────────────────────────────────"
`;
exports[`InputPrompt > Highlighting and Cursor Display > single-line scenarios > should display cursor correctly 'at the beginning of the line' 1`] = `
"────────────────────────────────────────────────────────────────────────────────────────────────────
│ > hello │
────────────────────────────────────────────────────────────────────────────────────────────────────
"
────────────────────────────────────────────────────────────────────────────────────────────────────"
`;
exports[`InputPrompt > Highlighting and Cursor Display > single-line scenarios > should display cursor correctly 'at the end of a line with unicode cha…' 1`] = `
"────────────────────────────────────────────────────────────────────────────────────────────────────
│ > hello 👍 │
────────────────────────────────────────────────────────────────────────────────────────────────────
"
────────────────────────────────────────────────────────────────────────────────────────────────────"
`;
exports[`InputPrompt > Highlighting and Cursor Display > single-line scenarios > should display cursor correctly 'at the end of a short line with unico…' 1`] = `
"────────────────────────────────────────────────────────────────────────────────────────────────────
│ > 👍 │
────────────────────────────────────────────────────────────────────────────────────────────────────
"
────────────────────────────────────────────────────────────────────────────────────────────────────"
`;
exports[`InputPrompt > Highlighting and Cursor Display > single-line scenarios > should display cursor correctly 'at the end of the line' 1`] = `
"────────────────────────────────────────────────────────────────────────────────────────────────────
│ > hello │
────────────────────────────────────────────────────────────────────────────────────────────────────
"
────────────────────────────────────────────────────────────────────────────────────────────────────"
`;
exports[`InputPrompt > Highlighting and Cursor Display > single-line scenarios > should display cursor correctly 'for multi-byte unicode characters' 1`] = `
"────────────────────────────────────────────────────────────────────────────────────────────────────
│ > hello 👍 world │
────────────────────────────────────────────────────────────────────────────────────────────────────
"
────────────────────────────────────────────────────────────────────────────────────────────────────"
`;
exports[`InputPrompt > Highlighting and Cursor Display > single-line scenarios > should display cursor correctly 'mid-word' 1`] = `
"────────────────────────────────────────────────────────────────────────────────────────────────────
│ > hello world │
────────────────────────────────────────────────────────────────────────────────────────────────────
"
────────────────────────────────────────────────────────────────────────────────────────────────────"
`;
exports[`InputPrompt > Highlighting and Cursor Display > single-line scenarios > should display cursor correctly 'on a highlighted token' 1`] = `
"────────────────────────────────────────────────────────────────────────────────────────────────────
│ > run @path/to/file │
────────────────────────────────────────────────────────────────────────────────────────────────────
"
────────────────────────────────────────────────────────────────────────────────────────────────────"
`;
exports[`InputPrompt > Highlighting and Cursor Display > single-line scenarios > should display cursor correctly 'on a space between words' 1`] = `
"────────────────────────────────────────────────────────────────────────────────────────────────────
│ > hello world │
────────────────────────────────────────────────────────────────────────────────────────────────────
"
────────────────────────────────────────────────────────────────────────────────────────────────────"
`;
exports[`InputPrompt > Highlighting and Cursor Display > single-line scenarios > should display cursor correctly 'on an empty line' 1`] = `
"────────────────────────────────────────────────────────────────────────────────────────────────────
│ > Type your message or @path/to/file │
────────────────────────────────────────────────────────────────────────────────────────────────────
"
────────────────────────────────────────────────────────────────────────────────────────────────────"
`;
exports[`InputPrompt > History Navigation and Completion Suppression > should not render suggestions during history navigation 1`] = `
@@ -187,15 +173,13 @@ exports[`InputPrompt > multiline rendering > should correctly render multiline i
│ > hello │
│ │
│ world │
────────────────────────────────────────────────────────────────────────────────────────────────────
"
────────────────────────────────────────────────────────────────────────────────────────────────────"
`;
exports[`InputPrompt > snapshots > should not show inverted cursor when shell is focused 1`] = `
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
> Type your message or @path/to/file
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
"
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄"
`;
exports[`InputPrompt > snapshots > should render correctly in shell mode 1`] = `
@@ -225,6 +225,5 @@ AppHeader(full)
│ Refining approach
│ And finally a third multiple line paragraph for the third thinking message to
│ refine the solution.
"
│ refine the solution."
`;
@@ -43,8 +43,7 @@ exports[`SettingsDialog > Initial Rendering > should render settings list with v
│ │
│ (Use Enter to select, Ctrl+L to reset, Tab to change focus, Esc to close) │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
"
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`SettingsDialog > Snapshot Tests > should render 'accessibility settings enabled' correctly 1`] = `
@@ -90,8 +89,7 @@ exports[`SettingsDialog > Snapshot Tests > should render 'accessibility settings
│ │
│ (Use Enter to select, Ctrl+L to reset, Tab to change focus, Esc to close) │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
"
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`SettingsDialog > Snapshot Tests > should render 'all boolean settings disabled' correctly 1`] = `
@@ -137,8 +135,7 @@ exports[`SettingsDialog > Snapshot Tests > should render 'all boolean settings d
│ │
│ (Use Enter to select, Ctrl+L to reset, Tab to change focus, Esc to close) │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
"
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`SettingsDialog > Snapshot Tests > should render 'default state' correctly 1`] = `
@@ -184,8 +181,7 @@ exports[`SettingsDialog > Snapshot Tests > should render 'default state' correct
│ │
│ (Use Enter to select, Ctrl+L to reset, Tab to change focus, Esc to close) │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
"
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`SettingsDialog > Snapshot Tests > should render 'file filtering settings configured' correctly 1`] = `
@@ -231,8 +227,7 @@ exports[`SettingsDialog > Snapshot Tests > should render 'file filtering setting
│ │
│ (Use Enter to select, Ctrl+L to reset, Tab to change focus, Esc to close) │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
"
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`SettingsDialog > Snapshot Tests > should render 'focused on scope selector' correctly 1`] = `
@@ -278,8 +273,7 @@ exports[`SettingsDialog > Snapshot Tests > should render 'focused on scope selec
│ │
│ (Use Enter to select, Ctrl+L to reset, Tab to change focus, Esc to close) │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
"
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`SettingsDialog > Snapshot Tests > should render 'mixed boolean and number settings' correctly 1`] = `
@@ -325,8 +319,7 @@ exports[`SettingsDialog > Snapshot Tests > should render 'mixed boolean and numb
│ │
│ (Use Enter to select, Ctrl+L to reset, Tab to change focus, Esc to close) │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
"
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`SettingsDialog > Snapshot Tests > should render 'tools and security settings' correctly 1`] = `
@@ -372,8 +365,7 @@ exports[`SettingsDialog > Snapshot Tests > should render 'tools and security set
│ │
│ (Use Enter to select, Ctrl+L to reset, Tab to change focus, Esc to close) │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
"
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`SettingsDialog > Snapshot Tests > should render 'various boolean settings enabled' correctly 1`] = `
@@ -419,6 +411,5 @@ exports[`SettingsDialog > Snapshot Tests > should render 'various boolean settin
│ │
│ (Use Enter to select, Ctrl+L to reset, Tab to change focus, Esc to close) │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
"
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
@@ -4,20 +4,17 @@ exports[`Table > should render headers and data correctly 1`] = `
"ID Name
────────────────────────────────────────────────────────────────────────────────────────────────────
1 Alice
2 Bob
"
2 Bob"
`;
exports[`Table > should support custom cell rendering 1`] = `
"Value
────────────────────────────────────────────────────────────────────────────────────────────────────
20
"
20"
`;
exports[`Table > should support inverse text rendering 1`] = `
"Status
────────────────────────────────────────────────────────────────────────────────────────────────────
Active
"
Active"
`;
@@ -6,8 +6,6 @@
import { describe, it, expect } from 'vitest';
import { renderWithProviders } from '../../../test-utils/render.js';
import { createMockSettings } from '../../../test-utils/settings.js';
import { waitFor } from '../../../test-utils/async.js';
import { DenseToolMessage } from './DenseToolMessage.js';
import {
CoreToolCallStatus,
@@ -23,6 +21,8 @@ import type {
ToolResultDisplay,
} from '../../types.js';
import { createMockSettings } from '../../../test-utils/settings.js';
describe('DenseToolMessage', () => {
const defaultProps = {
callId: 'call-1',
@@ -92,23 +92,17 @@ describe('DenseToolMessage', () => {
model_removed_chars: 40,
},
};
const { lastFrame } = await renderWithProviders(
const { lastFrame, waitUntilReady } = await renderWithProviders(
<DenseToolMessage
{...defaultProps}
resultDisplay={diffResult as ToolResultDisplay}
/>,
{
settings: createMockSettings({
merged: { useAlternateBuffer: false, useTerminalBuffer: false },
}),
},
{},
);
await waitFor(() => expect(lastFrame()).toContain('test-tool'));
await waitFor(() =>
expect(lastFrame()).toContain('test.ts → Accepted (+15, -6)'),
);
await waitFor(() => expect(lastFrame()).toContain('diff content'));
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('test.ts → Accepted (+15, -6)');
expect(output).toContain('diff content');
expect(output).toMatchSnapshot();
});
@@ -90,13 +90,6 @@ export const ToolConfirmationMessage: React.FC<
useState(0);
const observerRef = useRef<ResizeObserver | null>(null);
useEffect(
() => () => {
observerRef.current?.disconnect();
},
[],
);
const deceptiveUrlWarnings = useMemo(() => {
const urls: string[] = [];
if (confirmationDetails.type === 'info' && confirmationDetails.urls) {
@@ -51,7 +51,7 @@ export const ToolResultDisplay: React.FC<ToolResultDisplayProps> = ({
hasFocus = false,
overflowDirection = 'top',
}) => {
const { renderMarkdown, terminalHeight } = useUIState();
const { renderMarkdown } = useUIState();
const isAlternateBuffer = useAlternateBuffer();
const availableHeight = calculateToolContentMaxLines({
@@ -202,8 +202,7 @@ export const ToolResultDisplay: React.FC<ToolResultDisplayProps> = ({
if (isAlternateBuffer) {
// Virtualized path for large ANSI arrays
if (Array.isArray(resultDisplay)) {
const limit =
maxLines ?? availableHeight ?? terminalHeight ?? ACTIVE_SHELL_MAX_LINES;
const limit = maxLines ?? availableHeight ?? ACTIVE_SHELL_MAX_LINES;
const listHeight = Math.min(
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
(resultDisplay as AnsiOutput).length,
@@ -12,8 +12,7 @@ exports[`ThinkingMessage > filters out progress dots and empty lines 2`] = `
" Thinking...
│ Thinking
│ Done
"
│ Done"
`;
exports[`ThinkingMessage > normalizes escaped newline tokens 1`] = `
@@ -28,8 +27,7 @@ exports[`ThinkingMessage > normalizes escaped newline tokens 2`] = `
" Thinking...
│ Matching the Blocks
│ Some more text
"
│ Some more text"
`;
exports[`ThinkingMessage > renders "Thinking..." header when isFirstThinking is true 1`] = `
@@ -44,8 +42,7 @@ exports[`ThinkingMessage > renders "Thinking..." header when isFirstThinking is
" Thinking...
│ Summary line
│ First body line
"
│ First body line"
`;
exports[`ThinkingMessage > renders full mode with left border and full text 1`] = `
@@ -60,8 +57,7 @@ exports[`ThinkingMessage > renders full mode with left border and full text 2`]
" Thinking...
│ Planning
│ I am planning the solution.
"
│ I am planning the solution."
`;
exports[`ThinkingMessage > renders multiple thinking messages sequentially correctly 1`] = `
@@ -94,8 +90,7 @@ exports[`ThinkingMessage > renders multiple thinking messages sequentially corre
│ Refining approach
│ And finally a third multiple line paragraph for the third thinking message to
│ refine the solution.
"
│ refine the solution."
`;
exports[`ThinkingMessage > renders subject line with vertical rule and "Thinking..." header 1`] = `
@@ -110,8 +105,7 @@ exports[`ThinkingMessage > renders subject line with vertical rule and "Thinking
" Thinking...
│ Planning
│ test
"
│ test"
`;
exports[`ThinkingMessage > uses description when subject is empty 1`] = `
@@ -124,6 +118,5 @@ exports[`ThinkingMessage > uses description when subject is empty 1`] = `
exports[`ThinkingMessage > uses description when subject is empty 2`] = `
" Thinking...
│ Processing details
"
│ Processing details"
`;
@@ -6,7 +6,7 @@ exports[`ToolResultDisplay > does not fall back to plain text if availableHeight
`;
exports[`ToolResultDisplay > keeps markdown if in alternate buffer even with availableHeight 1`] = `
"Some result
"Some result
"
`;
@@ -42,14 +42,6 @@ export const MaxSizedBox: React.FC<MaxSizedBoxProps> = ({
const id = useId();
const { addOverflowingId, removeOverflowingId } = useOverflowActions() || {};
const observerRef = useRef<ResizeObserver | null>(null);
useEffect(
() => () => {
observerRef.current?.disconnect();
},
[],
);
const [contentHeight, setContentHeight] = useState(0);
const onRefChange = useCallback(
@@ -33,9 +33,6 @@ interface ScrollableProps {
scrollToBottom?: boolean;
flexGrow?: number;
reportOverflow?: boolean;
overflowToBackbuffer?: boolean;
scrollbar?: boolean;
stableScrollback?: boolean;
}
export const Scrollable: React.FC<ScrollableProps> = ({
@@ -48,9 +45,6 @@ export const Scrollable: React.FC<ScrollableProps> = ({
scrollToBottom,
flexGrow,
reportOverflow = false,
overflowToBackbuffer,
scrollbar = true,
stableScrollback,
}) => {
const keyMatchers = useKeyMatchers();
const [scrollTop, setScrollTop] = useState(0);
@@ -97,14 +91,6 @@ export const Scrollable: React.FC<ScrollableProps> = ({
const viewportObserverRef = useRef<ResizeObserver | null>(null);
const contentObserverRef = useRef<ResizeObserver | null>(null);
useEffect(
() => () => {
viewportObserverRef.current?.disconnect();
contentObserverRef.current?.disconnect();
},
[],
);
const viewportRefCallback = useCallback((node: DOMElement | null) => {
viewportObserverRef.current?.disconnect();
viewportRef.current = node;
@@ -261,9 +247,6 @@ export const Scrollable: React.FC<ScrollableProps> = ({
scrollTop={scrollTop}
flexGrow={flexGrow}
scrollbarThumbColor={scrollbarColor}
overflowToBackbuffer={overflowToBackbuffer}
scrollbar={scrollbar}
stableScrollback={stableScrollback}
>
{/*
This inner box is necessary to prevent the parent from shrinking
@@ -16,7 +16,6 @@ import type React from 'react';
import {
VirtualizedList,
type VirtualizedListRef,
type VirtualizedListProps,
SCROLL_TO_ITEM_END,
} from './VirtualizedList.js';
import { useScrollable } from '../../contexts/ScrollProvider.js';
@@ -28,14 +27,18 @@ import { useKeyMatchers } from '../../hooks/useKeyMatchers.js';
const ANIMATION_FRAME_DURATION_MS = 33;
type VirtualizedListProps<T> = {
data: T[];
renderItem: (info: { item: T; index: number }) => React.ReactElement;
estimatedItemHeight: (index: number) => number;
keyExtractor: (item: T, index: number) => string;
initialScrollIndex?: number;
initialScrollOffsetInIndex?: number;
};
interface ScrollableListProps<T> extends VirtualizedListProps<T> {
hasFocus: boolean;
width?: string | number;
scrollbar?: boolean;
stableScrollback?: boolean;
copyModeEnabled?: boolean;
isStatic?: boolean;
fixedItemHeight?: boolean;
}
export type ScrollableListRef<T> = VirtualizedListRef<T>;
@@ -45,7 +48,7 @@ function ScrollableList<T>(
ref: React.Ref<ScrollableListRef<T>>,
) {
const keyMatchers = useKeyMatchers();
const { hasFocus, width, scrollbar = true, stableScrollback } = props;
const { hasFocus, width } = props;
const virtualizedListRef = useRef<VirtualizedListRef<T>>(null);
const containerRef = useRef<DOMElement>(null);
@@ -255,13 +258,17 @@ function ScrollableList<T>(
useScrollable(scrollableEntry, true);
return (
<Box ref={containerRef} flexGrow={1} flexDirection="column" width={width}>
<Box
ref={containerRef}
flexGrow={1}
flexDirection="column"
overflow="hidden"
width={width}
>
<VirtualizedList
ref={virtualizedListRef}
{...props}
scrollbar={scrollbar}
scrollbarThumbColor={scrollbarColor}
stableScrollback={stableScrollback}
/>
</Box>
);
@@ -17,6 +17,13 @@ import {
useState,
} from 'react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import type { UIState } from '../../contexts/UIStateContext.js';
vi.mock('../../contexts/UIStateContext.js', () => ({
useUIState: vi.fn(() => ({
copyModeEnabled: false,
})),
}));
describe('<VirtualizedList />', () => {
const keyExtractor = (item: string) => item;
@@ -317,6 +324,11 @@ describe('<VirtualizedList />', () => {
});
it('renders correctly in copyModeEnabled when scrolled', async () => {
const { useUIState } = await import('../../contexts/UIStateContext.js');
vi.mocked(useUIState).mockReturnValue({
copyModeEnabled: true,
} as Partial<UIState> as UIState);
const longData = Array.from({ length: 100 }, (_, i) => `Item ${i}`);
// Use copy mode
const { lastFrame, unmount } = await render(
@@ -331,7 +343,6 @@ describe('<VirtualizedList />', () => {
keyExtractor={(item) => item}
estimatedItemHeight={() => 1}
initialScrollIndex={50}
copyModeEnabled={true}
/>
</Box>,
);
@@ -12,18 +12,17 @@ import {
useImperativeHandle,
useMemo,
useCallback,
memo,
} from 'react';
import type React from 'react';
import { theme } from '../../semantic-colors.js';
import { useBatchedScroll } from '../../hooks/useBatchedScroll.js';
import { debugLogger } from '@google/gemini-cli-core';
import { useUIState } from '../../contexts/UIStateContext.js';
import { type DOMElement, Box, ResizeObserver, StaticRender } from 'ink';
import { type DOMElement, Box, ResizeObserver } from 'ink';
export const SCROLL_TO_ITEM_END = Number.MAX_SAFE_INTEGER;
export type VirtualizedListProps<T> = {
type VirtualizedListProps<T> = {
data: T[];
renderItem: (info: { item: T; index: number }) => React.ReactElement;
estimatedItemHeight: (index: number) => number;
@@ -31,15 +30,6 @@ export type VirtualizedListProps<T> = {
initialScrollIndex?: number;
initialScrollOffsetInIndex?: number;
scrollbarThumbColor?: string;
renderStatic?: boolean;
isStatic?: boolean;
isStaticItem?: (item: T, index: number) => boolean;
width?: number | string;
overflowToBackbuffer?: boolean;
scrollbar?: boolean;
stableScrollback?: boolean;
copyModeEnabled?: boolean;
fixedItemHeight?: boolean;
};
export type VirtualizedListRef<T> = {
@@ -76,43 +66,6 @@ function findLastIndex<T>(
return -1;
}
const VirtualizedListItem = memo(
({
content,
shouldBeStatic,
width,
containerWidth,
itemKey,
itemRef,
}: {
content: React.ReactElement;
shouldBeStatic: boolean;
width: number | string | undefined;
containerWidth: number;
itemKey: string;
itemRef: (el: DOMElement | null) => void;
}) => (
<Box width="100%" flexDirection="column" flexShrink={0} ref={itemRef}>
{shouldBeStatic ? (
<StaticRender
width={typeof width === 'number' ? width : containerWidth}
key={
itemKey +
'-static-' +
(typeof width === 'number' ? width : containerWidth)
}
>
{content}
</StaticRender>
) : (
content
)}
</Box>
),
);
VirtualizedListItem.displayName = 'VirtualizedListItem';
function VirtualizedList<T>(
props: VirtualizedListProps<T>,
ref: React.Ref<VirtualizedListRef<T>>,
@@ -124,16 +77,8 @@ function VirtualizedList<T>(
keyExtractor,
initialScrollIndex,
initialScrollOffsetInIndex,
renderStatic,
isStatic,
isStaticItem,
width,
overflowToBackbuffer,
scrollbar = true,
stableScrollback,
copyModeEnabled = false,
fixedItemHeight = false,
} = props;
const { copyModeEnabled } = useUIState();
const dataRef = useRef(data);
useLayoutEffect(() => {
dataRef.current = data;
@@ -174,7 +119,6 @@ function VirtualizedList<T>(
const containerRef = useRef<DOMElement | null>(null);
const [containerHeight, setContainerHeight] = useState(0);
const [containerWidth, setContainerWidth] = useState(0);
const itemRefs = useRef<Array<DOMElement | null>>([]);
const [heights, setHeights] = useState<Record<string, number>>({});
const isInitialScrollSet = useRef(false);
@@ -189,10 +133,7 @@ function VirtualizedList<T>(
const observer = new ResizeObserver((entries) => {
const entry = entries[0];
if (entry) {
const newHeight = Math.round(entry.contentRect.height);
const newWidth = Math.round(entry.contentRect.width);
setContainerHeight((prev) => (prev !== newHeight ? newHeight : prev));
setContainerWidth((prev) => (prev !== newWidth ? newWidth : prev));
setContainerHeight(Math.round(entry.contentRect.height));
}
});
observer.observe(node);
@@ -243,8 +184,7 @@ function VirtualizedList<T>(
return { totalHeight, offsets };
}, [heights, data, estimatedItemHeight, keyExtractor]);
const scrollableContainerHeight =
containerHeight || (process.env['NODE_ENV'] === 'test' ? 1000 : 0);
const scrollableContainerHeight = containerHeight;
const getAnchorForScrollTop = useCallback(
(
@@ -302,9 +242,7 @@ function VirtualizedList<T>(
const wasAtBottom = contentPreviouslyFit || wasScrolledToBottomPixels;
if (wasAtBottom && actualScrollTop >= prevScrollTop.current) {
if (!isStickingToBottom) {
setIsStickingToBottom(true);
}
setIsStickingToBottom(true);
}
const listGrew = data.length > prevDataLength.current;
@@ -315,16 +253,10 @@ function VirtualizedList<T>(
(listGrew && (isStickingToBottom || wasAtBottom)) ||
(isStickingToBottom && containerChanged)
) {
const newIndex = data.length > 0 ? data.length - 1 : 0;
if (
scrollAnchor.index !== newIndex ||
scrollAnchor.offset !== SCROLL_TO_ITEM_END
) {
setScrollAnchor({
index: newIndex,
offset: SCROLL_TO_ITEM_END,
});
}
setScrollAnchor({
index: data.length > 0 ? data.length - 1 : 0,
offset: SCROLL_TO_ITEM_END,
});
if (!isStickingToBottom) {
setIsStickingToBottom(true);
}
@@ -334,17 +266,9 @@ function VirtualizedList<T>(
data.length > 0
) {
const newScrollTop = Math.max(0, totalHeight - scrollableContainerHeight);
const newAnchor = getAnchorForScrollTop(newScrollTop, offsets);
if (
scrollAnchor.index !== newAnchor.index ||
scrollAnchor.offset !== newAnchor.offset
) {
setScrollAnchor(newAnchor);
}
setScrollAnchor(getAnchorForScrollTop(newScrollTop, offsets));
} else if (data.length === 0) {
if (scrollAnchor.index !== 0 || scrollAnchor.offset !== 0) {
setScrollAnchor({ index: 0, offset: 0 });
}
setScrollAnchor({ index: 0, offset: 0 });
}
prevDataLength.current = data.length;
@@ -357,7 +281,6 @@ function VirtualizedList<T>(
actualScrollTop,
scrollableContainerHeight,
scrollAnchor.index,
scrollAnchor.offset,
getAnchorForScrollTop,
offsets,
isStickingToBottom,
@@ -425,20 +348,15 @@ function VirtualizedList<T>(
? data.length - 1
: Math.min(data.length - 1, endIndexOffset);
const topSpacerHeight =
renderStatic || overflowToBackbuffer ? 0 : (offsets[startIndex] ?? 0);
const bottomSpacerHeight = renderStatic
? 0
: totalHeight - (offsets[endIndex + 1] ?? totalHeight);
const topSpacerHeight = offsets[startIndex] ?? 0;
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>();
const observeStart = renderStatic || overflowToBackbuffer ? 0 : startIndex;
const observeEnd = renderStatic ? data.length - 1 : endIndex;
for (let i = observeStart; i <= observeEnd; i++) {
for (let i = startIndex; i <= endIndex; i++) {
const node = itemRefs.current[i];
const item = data[i];
if (node && item) {
@@ -446,16 +364,14 @@ function VirtualizedList<T>(
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 (!isStatic && !fixedItemHeight && !observedNodes.current.has(node)) {
if (!observedNodes.current.has(node)) {
itemsObserver.observe(node);
}
}
}
for (const node of observedNodes.current) {
if (!currentNodes.has(node)) {
if (!isStatic && !fixedItemHeight) {
itemsObserver.unobserve(node);
}
itemsObserver.unobserve(node);
nodeToKeyRef.current.delete(node);
}
}
@@ -463,61 +379,25 @@ function VirtualizedList<T>(
});
const renderedItems = [];
const renderRangeStart =
renderStatic || overflowToBackbuffer ? 0 : startIndex;
const renderRangeEnd = renderStatic ? data.length - 1 : endIndex;
let staticCount = 0;
// Always evaluate shouldBeStatic, width, etc. if we have a known width from the prop.
// If containerHeight or containerWidth is 0 we defer rendering unless a static render or defined width overrides.
// Wait, if it's not static and no width we need to wait for measure.
// BUT the initial render MUST render *something* with a width if width prop is provided to avoid layout shifts.
// We MUST wait for containerHeight > 0 before rendering, especially if renderStatic is true.
// If containerHeight is 0, we will misclassify items as isOutsideViewport and permanently print them to StaticRender!
const isReady =
containerHeight > 0 ||
process.env['NODE_ENV'] === 'test' ||
(width !== undefined && typeof width === 'number');
if (isReady) {
for (let i = renderRangeStart; i <= renderRangeEnd; i++) {
const item = data[i];
if (item) {
const isOutsideViewport = i < startIndex || i > endIndex;
const shouldBeStatic =
!!((renderStatic && isOutsideViewport) || isStaticItem?.(item, i)) &&
false; // XXX
if (shouldBeStatic) {
staticCount++;
}
const content = renderItem({ item, index: i });
const key = keyExtractor(item, i);
renderedItems.push(
<VirtualizedListItem
key={key}
itemKey={key}
content={content}
shouldBeStatic={shouldBeStatic}
width={width}
containerWidth={containerWidth}
itemRef={(el: DOMElement | null) => {
if (i >= renderRangeStart && i <= renderRangeEnd) {
itemRefs.current[i] = el;
}
}}
/>,
);
}
for (let i = startIndex; i <= endIndex; i++) {
const item = data[i];
if (item) {
renderedItems.push(
<Box
key={keyExtractor(item, i)}
width="100%"
flexDirection="column"
flexShrink={0}
ref={(el) => {
itemRefs.current[i] = el;
}}
>
{renderItem({ item, index: i })}
</Box>,
);
}
}
debugLogger.log(
`VirtualizedList rendered items: ${renderedItems.length}, isStatic property: ${!!isStatic}, static elements: ${staticCount}, total height: ${totalHeight}, item heights: ${data.map((_, i) => offsets[i + 1] - offsets[i]).join(', ')}`,
);
const { getScrollTop, setPendingScrollTop } = useBatchedScroll(scrollTop);
useImperativeHandle(
@@ -659,9 +539,6 @@ function VirtualizedList<T>(
height="100%"
flexDirection="column"
paddingRight={copyModeEnabled ? 0 : 1}
overflowToBackbuffer={overflowToBackbuffer}
scrollbar={scrollbar}
stableScrollback={stableScrollback}
>
<Box
flexShrink={0}
@@ -2,39 +2,26 @@
exports[`ExpandableText > creates centered window around match when collapsed 1`] = `
"...ry/long/path/that/keeps/going/cd_/very/long/path/that/keeps/going/search-here/and/then/some/more/
components//and/then/some/more/components//and/...
"
components//and/then/some/more/components//and/..."
`;
exports[`ExpandableText > highlights matched substring when expanded (text only visible) 1`] = `
"run: git commit -m "feat: add search"
"
`;
exports[`ExpandableText > highlights matched substring when expanded (text only visible) 1`] = `"run: git commit -m "feat: add search""`;
exports[`ExpandableText > renders plain label when no match (short label) 1`] = `
"simple command
"
`;
exports[`ExpandableText > renders plain label when no match (short label) 1`] = `"simple command"`;
exports[`ExpandableText > respects custom maxWidth 1`] = `
"zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz...
"
`;
exports[`ExpandableText > respects custom maxWidth 1`] = `"zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz..."`;
exports[`ExpandableText > shows full long label when expanded and no match 1`] = `
"yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy
yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy
"
yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy"
`;
exports[`ExpandableText > truncates long label when collapsed and no match 1`] = `
"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx...
"
xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx..."
`;
exports[`ExpandableText > truncates match itself when match is very long 1`] = `
"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx...
"
xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx..."
`;
@@ -450,7 +450,7 @@ function* emitKeys(
insertable: true,
sequence: decoded,
});
} catch (_e) {
} catch {
debugLogger.log('Failed to decode OSC 52 clipboard data');
}
}
@@ -117,7 +117,6 @@ export interface UIState {
editorError: string | null;
isEditorDialogOpen: boolean;
showPrivacyNotice: boolean;
mouseMode: boolean;
corgiMode: boolean;
debugMessage: string;
quittingMessages: HistoryItem[] | null;
@@ -192,7 +191,7 @@ export interface UIState {
sessionStats: SessionStatsState;
terminalWidth: number;
terminalHeight: number;
mainControlsRef: (node: DOMElement | null) => void;
mainControlsRef: React.RefCallback<DOMElement | null>;
// NOTE: This is for performance profiling only.
rootUiRef: React.MutableRefObject<DOMElement | null>;
currentIDE: IdeInfo | null;
@@ -28,7 +28,6 @@ describe('useAlternateBuffer', () => {
it('should return false when config.getUseAlternateBuffer returns false', async () => {
mockUseConfig.mockReturnValue({
getUseAlternateBuffer: () => false,
getUseTerminalBuffer: () => false,
} as unknown as ReturnType<typeof mockUseConfig>);
const { result } = await renderHook(() => useAlternateBuffer());
@@ -38,7 +37,6 @@ describe('useAlternateBuffer', () => {
it('should return true when config.getUseAlternateBuffer returns true', async () => {
mockUseConfig.mockReturnValue({
getUseAlternateBuffer: () => true,
getUseTerminalBuffer: () => false,
} as unknown as ReturnType<typeof mockUseConfig>);
const { result } = await renderHook(() => useAlternateBuffer());
@@ -48,7 +46,6 @@ describe('useAlternateBuffer', () => {
it('should return the immutable config value, not react to settings changes', async () => {
const mockConfig = {
getUseAlternateBuffer: () => true,
getUseTerminalBuffer: () => false,
} as unknown as ReturnType<typeof mockUseConfig>;
mockUseConfig.mockReturnValue(mockConfig);
@@ -68,7 +65,6 @@ describe('isAlternateBufferEnabled', () => {
it('should return true when config.getUseAlternateBuffer returns true', () => {
const config = {
getUseAlternateBuffer: () => true,
getUseTerminalBuffer: () => false,
} as unknown as Config;
expect(isAlternateBufferEnabled(config)).toBe(true);
@@ -77,7 +73,6 @@ describe('isAlternateBufferEnabled', () => {
it('should return false when config.getUseAlternateBuffer returns false', () => {
const config = {
getUseAlternateBuffer: () => false,
getUseTerminalBuffer: () => false,
} as unknown as Config;
expect(isAlternateBufferEnabled(config)).toBe(false);
@@ -8,7 +8,7 @@ import { useConfig } from '../contexts/ConfigContext.js';
import type { Config } from '@google/gemini-cli-core';
export const isAlternateBufferEnabled = (config: Config): boolean =>
config.getUseAlternateBuffer() || config.getUseTerminalBuffer();
config.getUseAlternateBuffer();
// This is read from Config so that the UI reads the same value per application session
export const useAlternateBuffer = (): boolean => {
@@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { useState, useLayoutEffect, useRef, useCallback } from 'react';
import { useState, useEffect, useRef, useCallback } from 'react';
import { theme } from '../semantic-colors.js';
import { interpolateColor } from '../themes/color-utils.js';
import { debugState } from '../debug.js';
@@ -107,7 +107,7 @@ export function useAnimatedScrollbar(
}, [cleanup]);
const wasFocused = useRef(isFocused);
useLayoutEffect(() => {
useEffect(() => {
if (isFocused && !wasFocused.current) {
flashScrollbar();
} else if (!isFocused && wasFocused.current) {
+1 -1
View File
@@ -319,7 +319,7 @@ export function useAtCompletion(props: UseAtCompletionProps): void {
if (state.pattern !== null) {
dispatch({ type: 'SEARCH', payload: state.pattern });
}
} catch (_) {
} catch {
if (initEpoch.current === currentEpoch) {
dispatch({ type: 'ERROR' });
}
@@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { useRef, useLayoutEffect, useCallback } from 'react';
import { useRef, useEffect, useCallback } from 'react';
/**
* A hook to manage batched scroll state updates.
@@ -17,7 +17,7 @@ export function useBatchedScroll(currentScrollTop: number) {
// and not depend on the currentScrollTop value directly in its dependency array.
const currentScrollTopRef = useRef(currentScrollTop);
useLayoutEffect(() => {
useEffect(() => {
currentScrollTopRef.current = currentScrollTop;
pendingScrollTopRef.current = null;
});
@@ -481,7 +481,7 @@ describe('useCommandCompletion', () => {
});
describe('handleAutocomplete', () => {
it('should complete a partial command and NOT add a space if it has an action', async () => {
it('should complete a partial command and ALWAYS add a space if it is a slash command', async () => {
setupMocks({
slashSuggestions: [{ label: 'memory', value: 'memory' }],
slashCompletionRange: {
@@ -502,7 +502,31 @@ describe('useCommandCompletion', () => {
result.current.handleAutocomplete(0);
});
expect(result.current.textBuffer.text).toBe('/memory');
expect(result.current.textBuffer.text).toBe('/memory ');
});
it('should ADD a space even even if it is ALREADY complete', async () => {
setupMocks({
slashSuggestions: [{ label: 'stats', value: 'stats' }],
slashCompletionRange: {
completionStart: 1,
completionEnd: 6, // "/stats"
getCommandFromSuggestion: () =>
({ action: vi.fn() }) as unknown as SlashCommand,
},
});
const { result } = await renderCommandCompletionHook('/stats');
await waitFor(() => {
expect(result.current.suggestions.length).toBe(1);
});
act(() => {
result.current.handleAutocomplete(0);
});
expect(result.current.textBuffer.text).toBe('/stats ');
});
it('should complete a partial command and ADD a space if it has NO action (e.g. just a parent)', async () => {
@@ -440,13 +440,10 @@ export function useCommandCompletion({
let shouldAddSpace = true;
if (completionMode === CompletionMode.SLASH) {
const command =
slashCompletionRange.getCommandFromSuggestion(suggestion);
// Don't add a space if the command has an action (can be executed)
// and doesn't have a completion function (doesn't REQUIRE more arguments)
const isExecutableCommand = !!(command && command.action);
const requiresArguments = !!(command && command.completion);
shouldAddSpace = !isExecutableCommand || requiresArguments;
// ALWAYS add a space when autocompleting a slash command via Tab.
// This ensures subcommands are immediately disclosed.
// The base command will still be accessible at the top of the suggestions list.
shouldAddSpace = true;
}
if (
@@ -51,7 +51,7 @@ describe('useConsoleMessages', () => {
for (const unmount of unmounts) {
try {
unmount();
} catch (_e) {
} catch {
// Ignore unmount errors
}
}
@@ -161,7 +161,7 @@ describe('useErrorCount', () => {
for (const unmount of unmounts) {
try {
unmount();
} catch (_e) {
} catch {
// Ignore unmount errors
}
}
@@ -525,81 +525,47 @@ export const useExecutionLifecycle = (
dispatch({ type: 'SET_ACTIVE_PTY', pid: null });
}
let finalDisplayOutput: string | AnsiOutput;
let finalTextOutput: string;
let mainContent: string;
if (isBinaryStream || isBinary(result.rawOutput)) {
finalTextOutput =
mainContent =
'[Command produced binary output, which is not shown.]';
finalDisplayOutput = finalTextOutput;
} else {
finalTextOutput =
mainContent =
result.output.trim() || '(Command produced no output)';
finalDisplayOutput = Array.isArray(cumulativeStdout)
? cumulativeStdout
: finalTextOutput;
}
let finalOutput = mainContent;
let finalStatus = CoreToolCallStatus.Success;
const prefixWarnings: string[] = [];
if (result.error) {
finalStatus = CoreToolCallStatus.Error;
prefixWarnings.push(result.error.message);
finalOutput = `${result.error.message}\n${finalOutput}`;
} else if (result.aborted) {
finalStatus = CoreToolCallStatus.Cancelled;
prefixWarnings.push('Command was cancelled.');
finalOutput = `Command was cancelled.\n${finalOutput}`;
} else if (result.backgrounded) {
finalStatus = CoreToolCallStatus.Success;
finalDisplayOutput = `Command moved to background (PID: ${result.pid}). Output hidden. Press Ctrl+B to view.`;
finalTextOutput = finalDisplayOutput;
prefixWarnings.length = 0;
finalOutput = `Command moved to background (PID: ${result.pid}). Output hidden. Press Ctrl+B to view.`;
} else if (result.signal) {
finalStatus = CoreToolCallStatus.Error;
prefixWarnings.push(
`Command terminated by signal: ${result.signal}.`,
);
} else if (result.exitCode !== null && result.exitCode !== 0) {
finalOutput = `Command terminated by signal: ${result.signal}.\n${finalOutput}`;
} else if (result.exitCode !== 0) {
finalStatus = CoreToolCallStatus.Error;
prefixWarnings.push(`Command exited with code ${result.exitCode}.`);
finalOutput = `Command exited with code ${result.exitCode}.\n${finalOutput}`;
}
if (pwdFilePath && fs.existsSync(pwdFilePath)) {
const finalPwd = fs.readFileSync(pwdFilePath, 'utf8').trim();
if (finalPwd && finalPwd !== targetDir) {
prefixWarnings.push(
`WARNING: shell mode is stateless; the directory change to '${finalPwd}' will not persist.\n`,
);
}
}
if (prefixWarnings.length > 0) {
const prefixString = prefixWarnings.join('\n') + '\n';
finalTextOutput = prefixString + finalTextOutput;
if (typeof finalDisplayOutput === 'string') {
finalDisplayOutput = prefixString + finalDisplayOutput;
} else {
const ansiPrefix = prefixString.split('\n').map((line) => [
{
text: line,
bold: false,
italic: false,
underline: false,
dim: false,
inverse: false,
fg: '',
bg: '',
},
]);
finalDisplayOutput = [...ansiPrefix, ...finalDisplayOutput];
const warning = `WARNING: shell mode is stateless; the directory change to '${finalPwd}' will not persist.`;
finalOutput = `${warning}\n\n${finalOutput}`;
}
}
const finalToolDisplay: IndividualToolCallDisplay = {
...initialToolDisplay,
status: finalStatus,
resultDisplay: finalDisplayOutput,
resultDisplay: finalOutput,
};
if (finalStatus !== CoreToolCallStatus.Cancelled) {
@@ -612,11 +578,7 @@ export const useExecutionLifecycle = (
);
}
addShellCommandToGeminiHistory(
geminiClient,
rawQuery,
finalTextOutput,
);
addShellCommandToGeminiHistory(geminiClient, rawQuery, finalOutput);
} catch (err) {
setPendingHistoryItem(null);
const errorMessage = err instanceof Error ? err.message : String(err);
@@ -28,7 +28,7 @@ import * as trustedFolders from '../../config/trustedFolders.js';
import { coreEvents, ExitCodes, isHeadlessMode } from '@google/gemini-cli-core';
import { MessageType } from '../types.js';
const mockedCwd = vi.hoisted(() => vi.fn().mockReturnValue('/mock/cwd'));
const mockedCwd = vi.hoisted(() => vi.fn());
const mockedExit = vi.hoisted(() => vi.fn());
vi.mock('@google/gemini-cli-core', async () => {
+1 -1
View File
@@ -102,7 +102,7 @@ export const useFolderTrust = (
try {
await trustedFolders.setValue(cwd, trustLevel);
} catch (_e) {
} catch {
coreEvents.emitFeedback(
'error',
'Failed to save trust settings. Exiting Gemini CLI.',
@@ -52,6 +52,7 @@ import {
MCPDiscoveryState,
GeminiCliOperation,
getPlanModeExitMessage,
UPDATE_TOPIC_TOOL_NAME,
} from '@google/gemini-cli-core';
import type { Part, PartListUnion } from '@google/genai';
import type { UseHistoryManagerReturn } from './useHistoryManager.js';
@@ -904,6 +905,30 @@ describe('useGeminiStream', () => {
it('should handle all tool calls being cancelled', async () => {
const cancelledToolCalls: TrackedToolCall[] = [
{
request: {
callId: 'topic1',
name: UPDATE_TOPIC_TOOL_NAME,
args: {},
isClientInitiated: false,
prompt_id: 'prompt-id-3',
},
status: CoreToolCallStatus.Success,
response: {
callId: 'topic1',
responseParts: [
{
functionResponse: {
name: UPDATE_TOPIC_TOOL_NAME,
id: 'topic1',
response: {},
},
},
],
},
tool: { displayName: 'Update Topic Context' },
invocation: { getDescription: () => 'Updating topic' },
} as any,
{
request: {
callId: '1',
@@ -924,8 +949,8 @@ describe('useGeminiStream', () => {
},
invocation: {
getDescription: () => `Mock description`,
} as unknown as AnyToolInvocation,
} as TrackedCancelledToolCall,
},
} as any,
];
const client = new MockedGeminiClientClass(mockConfig);
@@ -978,16 +1003,109 @@ describe('useGeminiStream', () => {
});
await waitFor(() => {
expect(mockMarkToolsAsSubmitted).toHaveBeenCalledWith(['1']);
expect(mockMarkToolsAsSubmitted).toHaveBeenCalledWith(['topic1', '1']);
expect(client.addHistory).toHaveBeenCalledWith({
role: 'user',
parts: [{ text: CoreToolCallStatus.Cancelled }],
parts: [
{
functionResponse: {
name: UPDATE_TOPIC_TOOL_NAME,
id: 'topic1',
response: {},
},
},
{ text: CoreToolCallStatus.Cancelled },
],
});
// Ensure we do NOT call back to the API
expect(mockSendMessageStream).not.toHaveBeenCalled();
});
});
it('should NOT stop responding when only update_topic is called', async () => {
const topicToolCalls: TrackedToolCall[] = [
{
request: {
callId: 'topic1',
name: UPDATE_TOPIC_TOOL_NAME,
args: {},
isClientInitiated: false,
prompt_id: 'prompt-id-3',
},
status: CoreToolCallStatus.Success,
response: {
callId: 'topic1',
responseParts: [
{
functionResponse: {
name: UPDATE_TOPIC_TOOL_NAME,
id: 'topic1',
response: {},
},
},
],
},
tool: { displayName: 'Update Topic Context' },
invocation: { getDescription: () => 'Updating topic' },
} as any,
];
const client = new MockedGeminiClientClass(mockConfig);
// Capture the onComplete callback
let capturedOnComplete:
| ((completedTools: TrackedToolCall[]) => Promise<void>)
| null = null;
mockUseToolScheduler.mockImplementation((onComplete) => {
capturedOnComplete = onComplete;
return [
topicToolCalls,
vi.fn(),
mockMarkToolsAsSubmitted,
vi.fn(),
vi.fn(),
0,
];
});
await renderHookWithProviders(() =>
useGeminiStream(
client,
[],
mockAddItem,
mockConfig,
mockLoadedSettings,
mockOnDebugMessage,
mockHandleSlashCommand,
false,
() => 'vscode' as EditorType,
() => {},
() => Promise.resolve(),
false,
() => {},
() => {},
() => {},
80,
24,
),
);
// Trigger the onComplete callback with the topic tool
await act(async () => {
if (capturedOnComplete) {
await capturedOnComplete(topicToolCalls);
}
});
await waitFor(() => {
// The streaming state should still be Responding because we didn't cancel anything important
// and we expect a continuation.
expect(mockMarkToolsAsSubmitted).toHaveBeenCalledWith(['topic1']);
// Should HAVE called back to the API for continuation
expect(mockSendMessageStream).toHaveBeenCalled();
});
});
it('should stop agent execution immediately when a tool call returns STOP_EXECUTION error', async () => {
const stopExecutionToolCalls: TrackedCompletedToolCall[] = [
{
+12 -3
View File
@@ -1968,11 +1968,20 @@ export const useGeminiStream = (
}
// If all the tools were cancelled, don't submit a response to Gemini.
const allToolsCancelled = geminiTools.every(
(tc) => tc.status === CoreToolCallStatus.Cancelled,
// Note: we ignore the topic tool because the user doesn't have a chance to decline it.
const declinableTools = geminiTools.filter(
(tc) => !isTopicTool(tc.request.name),
);
const allDeclinableToolsCancelled =
declinableTools.length > 0 &&
declinableTools.every(
(tc) => tc.status === CoreToolCallStatus.Cancelled,
);
const allToolsCancelled =
geminiTools.length > 0 &&
geminiTools.every((tc) => tc.status === CoreToolCallStatus.Cancelled);
if (allToolsCancelled) {
if (allDeclinableToolsCancelled || allToolsCancelled) {
// If the turn was cancelled via the imperative escape key flow,
// the cancellation message is added there. We check the ref to avoid duplication.
if (!turnCancelledRef.current) {
@@ -31,7 +31,7 @@ export function useGitBranchName(cwd: string): string | undefined {
);
setBranchName(hashStdout.toString().trim());
}
} catch (_error) {
} catch {
setBranchName(undefined);
}
}, [cwd, setBranchName]);
@@ -57,7 +57,7 @@ export function useGitBranchName(cwd: string): string | undefined {
fetchBranchName();
}
});
} catch (_watchError) {
} catch {
// Silently ignore watcher errors (e.g. permissions or file not existing),
// similar to how exec errors are handled.
// The branch name will simply not update automatically.
@@ -24,7 +24,7 @@ import type { LoadedSettings } from '../../config/settings.js';
import { coreEvents } from '@google/gemini-cli-core';
// Hoist mocks
const mockedCwd = vi.hoisted(() => vi.fn().mockReturnValue('/mock/cwd'));
const mockedCwd = vi.hoisted(() => vi.fn());
const mockedLoadTrustedFolders = vi.hoisted(() => vi.fn());
const mockedIsWorkspaceTrusted = vi.hoisted(() => vi.fn());
const mockedUseSettings = vi.hoisted(() => vi.fn());
@@ -141,7 +141,7 @@ export const usePermissionsModifyTrust = (
const folders = loadTrustedFolders();
try {
await folders.setValue(cwd, trustLevel);
} catch (_e) {
} catch {
coreEvents.emitFeedback(
'error',
'Failed to save trust settings. Your changes may not persist.',
@@ -159,7 +159,7 @@ export const usePermissionsModifyTrust = (
try {
await folders.setValue(cwd, pendingTrustLevel);
return true;
} catch (_e) {
} catch {
coreEvents.emitFeedback(
'error',
'Failed to save trust settings. Your changes may not persist.',
@@ -501,13 +501,14 @@ describe('useSlashCompletion', () => {
});
const chatCheckpointLabels = chatResult.current.suggestions
.slice(1)
.slice(0)
.map((s) => s.label);
const resumeCheckpointLabels = resumeResult.current.suggestions
.slice(1)
.slice(0)
.map((s) => s.label);
expect(chatCheckpointLabels).toEqual(resumeCheckpointLabels);
expect(chatCheckpointLabels).toEqual(['list', 'chat', 'save']);
expect(resumeCheckpointLabels).toEqual(['list', 'resume', 'save']);
unmountChat();
unmountResume();
@@ -773,6 +774,46 @@ describe('useSlashCompletion', () => {
unmount();
});
it('should suggest the parent command itself at the top when a trailing space is present (if it has an action)', async () => {
const slashCommands = [
createTestCommand({
name: 'stats',
description: 'Check session stats',
action: vi.fn(),
subCommands: [
createTestCommand({
name: 'session',
description: 'Show session-specific usage statistics',
}),
],
}),
];
const { result, unmount } = await renderHook(() =>
useTestHarnessForSlashCompletion(
true,
'/stats ',
slashCommands,
mockCommandContext,
),
);
await resolveMatch();
await waitFor(() => {
// Should show "stats" as the first suggestion, followed by its subcommands
expect(result.current.suggestions.length).toBe(2);
expect(result.current.suggestions[0]).toMatchObject({
label: 'stats',
sectionTitle: 'command',
});
expect(result.current.suggestions[1]).toMatchObject({
label: 'session',
});
});
unmount();
});
it('should suggest parent command (and siblings) instead of sub-commands when no trailing space', async () => {
const slashCommands = [
createTestCommand({
+63 -28
View File
@@ -313,7 +313,56 @@ function useCommandSuggestions(
return 0; // Maintain FZF score order for other matches
});
const finalSuggestions = sortedSuggestions.map((cmd) => {
const isTopLevelChatOrResumeContext = !!(
leafCommand &&
(leafCommand.name === 'chat' || leafCommand.name === 'resume') &&
(commandPathParts.length === 0 ||
(commandPathParts.length === 1 &&
matchesCommand(leafCommand, commandPathParts[0])))
);
const resultSuggestions: Suggestion[] = [];
if (isTopLevelChatOrResumeContext && leafCommand) {
const canonicalParentName = leafCommand.name;
const autoSectionSuggestion: Suggestion = {
label: 'list',
value: 'list',
insertValue: canonicalParentName,
description: 'Browse auto-saved chats',
commandKind: CommandKind.BUILT_IN,
sectionTitle: 'auto',
submitValue: `/${canonicalParentName}`,
};
resultSuggestions.push(autoSectionSuggestion);
}
// If we have a leaf command with an action and a trailing space,
// add it to the suggestions list so it is still accessible.
if (
parserResult.hasTrailingSpace &&
leafCommand &&
leafCommand.action &&
!isArgumentCompletion
) {
const selfSuggestion: Suggestion = {
label: leafCommand.name,
value: leafCommand.name,
description: leafCommand.description,
commandKind: leafCommand.kind,
sectionTitle: 'command',
// Use insertValue to ensure that if selected, we don't add another space
insertValue: leafCommand.name,
};
resultSuggestions.push(selfSuggestion);
}
sortedSuggestions.forEach((cmd) => {
// Avoid duplicate 'list' for chat/resume context
if (isTopLevelChatOrResumeContext && cmd.name === 'list') {
return;
}
const suggestion: Suggestion = {
label: cmd.name,
value: cmd.name,
@@ -325,33 +374,10 @@ function useCommandSuggestions(
suggestion.sectionTitle = cmd.suggestionGroup;
}
return suggestion;
resultSuggestions.push(suggestion);
});
const isTopLevelChatOrResumeContext = !!(
leafCommand &&
(leafCommand.name === 'chat' || leafCommand.name === 'resume') &&
(commandPathParts.length === 0 ||
(commandPathParts.length === 1 &&
matchesCommand(leafCommand, commandPathParts[0])))
);
if (isTopLevelChatOrResumeContext) {
const canonicalParentName = leafCommand.name;
const autoSectionSuggestion: Suggestion = {
label: 'list',
value: 'list',
insertValue: canonicalParentName,
description: 'Browse auto-saved chats',
commandKind: CommandKind.BUILT_IN,
sectionTitle: 'auto',
submitValue: `/${canonicalParentName}`,
};
setSuggestions([autoSectionSuggestion, ...finalSuggestions]);
return;
}
setSuggestions(finalSuggestions);
setSuggestions(resultSuggestions);
}
};
@@ -448,7 +474,15 @@ function getCommandFromSuggestion(
suggestion: Suggestion,
parserResult: CommandParserResult,
): SlashCommand | undefined {
const { currentLevel } = parserResult;
const { currentLevel, leafCommand, hasTrailingSpace } = parserResult;
if (
hasTrailingSpace &&
leafCommand &&
suggestion.value === leafCommand.name
) {
return leafCommand;
}
if (!currentLevel) {
return undefined;
@@ -456,7 +490,8 @@ function getCommandFromSuggestion(
// suggestion.value is just the command name at the current level (e.g., "list")
// Find it in the current level's commands
const command = currentLevel.find((cmd) =>
const command = currentLevel.find(
(cmd) => matchesCommand(cmd, suggestion.value),
matchesCommand(cmd, suggestion.value),
);
+1 -19
View File
@@ -85,7 +85,6 @@ export enum Command {
SHOW_IDE_CONTEXT_DETAIL = 'app.showIdeContextDetail',
TOGGLE_MARKDOWN = 'app.toggleMarkdown',
TOGGLE_COPY_MODE = 'app.toggleCopyMode',
TOGGLE_MOUSE_MODE = 'app.toggleMouseMode',
TOGGLE_YOLO = 'app.toggleYolo',
CYCLE_APPROVAL_MODE = 'app.cycleApprovalMode',
SHOW_MORE_LINES = 'app.showMoreLines',
@@ -110,10 +109,6 @@ export enum Command {
// Extension Controls
UPDATE_EXTENSION = 'extension.update',
LINK_EXTENSION = 'extension.link',
DUMP_FRAME = 'app.dumpFrame',
START_RECORDING = 'app.startRecording',
STOP_RECORDING = 'app.stopRecording',
}
/**
@@ -390,8 +385,7 @@ export const defaultKeyBindingConfig: KeyBindingConfig = new Map([
[Command.SHOW_FULL_TODOS, [new KeyBinding('ctrl+t')]],
[Command.SHOW_IDE_CONTEXT_DETAIL, [new KeyBinding('ctrl+g')]],
[Command.TOGGLE_MARKDOWN, [new KeyBinding('alt+m')]],
[Command.TOGGLE_COPY_MODE, [new KeyBinding('f9')]],
[Command.TOGGLE_MOUSE_MODE, [new KeyBinding('ctrl+s')]],
[Command.TOGGLE_COPY_MODE, [new KeyBinding('ctrl+s')]],
[Command.TOGGLE_YOLO, [new KeyBinding('ctrl+y')]],
[Command.CYCLE_APPROVAL_MODE, [new KeyBinding('shift+tab')]],
[Command.SHOW_MORE_LINES, [new KeyBinding('ctrl+o')]],
@@ -402,9 +396,6 @@ export const defaultKeyBindingConfig: KeyBindingConfig = new Map([
[Command.RESTART_APP, [new KeyBinding('r'), new KeyBinding('shift+r')]],
[Command.SUSPEND_APP, [new KeyBinding('ctrl+z')]],
[Command.SHOW_SHELL_INPUT_UNFOCUS_WARNING, [new KeyBinding('tab')]],
[Command.DUMP_FRAME, [new KeyBinding('f8')]],
[Command.START_RECORDING, [new KeyBinding('f6')]],
[Command.STOP_RECORDING, [new KeyBinding('f7')]],
// Background Shell Controls
[Command.BACKGROUND_SHELL_ESCAPE, [new KeyBinding('escape')]],
@@ -521,7 +512,6 @@ export const commandCategories: readonly CommandCategory[] = [
Command.SHOW_IDE_CONTEXT_DETAIL,
Command.TOGGLE_MARKDOWN,
Command.TOGGLE_COPY_MODE,
Command.TOGGLE_MOUSE_MODE,
Command.TOGGLE_YOLO,
Command.CYCLE_APPROVAL_MODE,
Command.SHOW_MORE_LINES,
@@ -545,9 +535,6 @@ export const commandCategories: readonly CommandCategory[] = [
Command.UNFOCUS_BACKGROUND_SHELL,
Command.UNFOCUS_BACKGROUND_SHELL_LIST,
Command.SHOW_BACKGROUND_SHELL_UNFOCUS_WARNING,
Command.DUMP_FRAME,
Command.START_RECORDING,
Command.STOP_RECORDING,
],
},
{
@@ -634,7 +621,6 @@ export const commandDescriptions: Readonly<Record<Command, string>> = {
[Command.SHOW_IDE_CONTEXT_DETAIL]: 'Show IDE context details.',
[Command.TOGGLE_MARKDOWN]: 'Toggle Markdown rendering.',
[Command.TOGGLE_COPY_MODE]: 'Toggle copy mode when in alternate buffer mode.',
[Command.TOGGLE_MOUSE_MODE]: 'Toggle mouse mode (scrolling and clicking).',
[Command.TOGGLE_YOLO]: 'Toggle YOLO (auto-approval) mode for tool calls.',
[Command.CYCLE_APPROVAL_MODE]:
'Cycle through approval modes: default (prompt), auto_edit (auto-approve edits), and plan (read-only). Plan mode is skipped when the agent is busy.',
@@ -668,10 +654,6 @@ export const commandDescriptions: Readonly<Record<Command, string>> = {
// Extension Controls
[Command.UPDATE_EXTENSION]: 'Update the current extension if available.',
[Command.LINK_EXTENSION]: 'Link the current extension to a local path.',
[Command.DUMP_FRAME]: 'Dump the current frame as a snapshot.',
[Command.START_RECORDING]: 'Start recording the session.',
[Command.STOP_RECORDING]: 'Stop recording the session.',
};
const keybindingsSchema = z.array(
@@ -346,11 +346,6 @@ describe('keyMatchers', () => {
},
{
command: Command.TOGGLE_COPY_MODE,
positive: [createKey('f9')],
negative: [createKey('f8'), createKey('f10')],
},
{
command: Command.TOGGLE_MOUSE_MODE,
positive: [createKey('s', { ctrl: true })],
negative: [createKey('s'), createKey('s', { alt: true })],
},
@@ -34,6 +34,7 @@ export const DefaultAppLayout: React.FC = () => {
paddingBottom={isAlternateBuffer ? 1 : undefined}
flexShrink={0}
flexGrow={0}
overflow="hidden"
ref={uiState.rootUiRef}
>
<MainContent />
@@ -29,7 +29,7 @@ export const ScreenReaderAppLayout: React.FC = () => {
>
<Notifications />
<Footer />
<Box flexGrow={1}>
<Box flexGrow={1} overflow="hidden">
<MainContent />
</Box>
{uiState.dialogsVisible ? (
+1 -1
View File
@@ -135,7 +135,7 @@ export function interpolateColor(
const gradient = tinygradient(color1, color2);
const color = gradient.rgbAt(factor);
return color.toHexString();
} catch (_e) {
} catch {
return color1;
}
}
+1 -1
View File
@@ -108,7 +108,7 @@ function highlightAndRenderLine(
const renderedNode = renderHastNode(getHighlightedLine(), theme, undefined);
return renderedNode !== null ? renderedNode : strippedLine;
} catch (_error) {
} catch {
return stripAnsi(line);
}
}
@@ -3,6 +3,5 @@
exports[`colorizeCode > does not let colors from ansi escape codes leak into colorized code 1`] = `
"line 1
line 2 with red background
line 3
"
line 3"
`;
+1 -1
View File
@@ -135,7 +135,7 @@ export async function getDirectorySuggestions(
.sort()
.slice(0, MAX_SUGGESTIONS)
.map((name) => resultPrefix + name + userSep);
} catch (_) {
} catch {
return [];
}
}
@@ -21,7 +21,6 @@ describe('ui-sizing', () => {
(expected, width, altBuffer) => {
const mockConfig = {
getUseAlternateBuffer: () => altBuffer,
getUseTerminalBuffer: () => false,
} as unknown as Config;
expect(calculateMainAreaWidth(width, mockConfig)).toBe(expected);
},
+5 -5
View File
@@ -43,7 +43,7 @@ export function runSyncCleanup() {
for (const fn of syncCleanupFunctions) {
try {
fn();
} catch (_) {
} catch {
// Ignore errors during cleanup.
}
}
@@ -67,7 +67,7 @@ export async function runExitCleanup() {
for (const fn of cleanupFunctions) {
try {
await fn();
} catch (_) {
} catch {
// Ignore errors during cleanup.
}
}
@@ -76,14 +76,14 @@ export async function runExitCleanup() {
// Close persistent browser sessions before disposing config
try {
await resetBrowserSession();
} catch (_) {
} catch {
// Ignore errors during browser cleanup
}
if (configForTelemetry) {
try {
await configForTelemetry.dispose();
} catch (_) {
} catch {
// Ignore errors during disposal
}
}
@@ -93,7 +93,7 @@ export async function runExitCleanup() {
if (configForTelemetry && isTelemetrySdkInitialized()) {
try {
await shutdownTelemetry(configForTelemetry);
} catch (_) {
} catch {
// Ignore errors during telemetry shutdown
}
}
-2
View File
@@ -23,7 +23,6 @@ export enum AppEvent {
PasteTimeout = 'paste-timeout',
TerminalBackground = 'terminal-background',
TransientMessage = 'transient-message',
ScrollToBottom = 'scroll-to-bottom',
}
export interface AppEvents {
@@ -33,7 +32,6 @@ export interface AppEvents {
[AppEvent.PasteTimeout]: never[];
[AppEvent.TerminalBackground]: [string];
[AppEvent.TransientMessage]: [TransientMessagePayload];
[AppEvent.ScrollToBottom]: never[];
}
export const appEvents = new EventEmitter<AppEvents>();
+4 -4
View File
@@ -23,9 +23,9 @@ export const isGitHubRepository = (): boolean => {
const pattern = /github\.com/;
return pattern.test(remotes);
} catch (_error) {
} catch (error) {
// If any filesystem error occurs, assume not a git repo
debugLogger.debug(`Failed to get git remote:`, _error);
debugLogger.debug(`Failed to get git remote:`, error);
return false;
}
};
@@ -85,10 +85,10 @@ export const getLatestGitHubRelease = async (
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return releaseTag;
} catch (_error) {
} catch (error) {
debugLogger.debug(
`Failed to determine latest run-gemini-cli release:`,
_error,
error,
);
throw new Error(
`Unable to determine the latest run-gemini-cli release on GitHub.`,
+1 -1
View File
@@ -110,7 +110,7 @@ export function getInstallationInfo(
'Installed via Homebrew. Please update with "brew upgrade gemini-cli".',
};
}
} catch (_error) {
} catch {
// Brew is not installed or gemini-cli is not installed via brew.
// Continue to the next check.
}

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