mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-07-30 03:30:59 -07:00
Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c498284996 | |||
| 973092df50 | |||
| e446733b53 | |||
| 3344f6849c | |||
| 84936dc85d | |||
| 18cdbbf81a | |||
| 13ccc16457 | |||
| ca78a0f177 | |||
| cb7f7d6c72 | |||
| 0d7e778e08 | |||
| b5f568fefe | |||
| d00b43733c | |||
| 55f5d3923c | |||
| 2d432c1489 |
@@ -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.*
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -284,7 +284,7 @@ export class LinkExtensionCommand implements Command {
|
||||
|
||||
try {
|
||||
await stat(sourceFilepath);
|
||||
} catch (_error) {
|
||||
} catch {
|
||||
return { name: this.name, data: `Invalid source: ${sourceFilepath}` };
|
||||
}
|
||||
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -25,7 +25,7 @@ async function pathExists(path: string) {
|
||||
try {
|
||||
await access(path);
|
||||
return true;
|
||||
} catch (_e) {
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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."
|
||||
`;
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
|
||||
@@ -612,7 +612,7 @@ export function loadEnvironment(
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (_e) {
|
||||
} catch {
|
||||
// Errors are ignored to match the behavior of `dotenv.config({ quiet: true })`.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 () =>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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());
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
+7
-14
@@ -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"
|
||||
`;
|
||||
|
||||
+1
-1
@@ -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) {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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[] = [
|
||||
{
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
`;
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>();
|
||||
|
||||
@@ -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.`,
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user