mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-08-02 13:11:03 -07:00
Compare commits
39 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 545a23f120 | |||
| 4a48d7cf93 | |||
| 31522045cd | |||
| 11951592aa | |||
| bc8ffa6631 | |||
| 86bd7dbd4f | |||
| eee95c509d | |||
| a1f2aacd1e | |||
| 979bbee485 | |||
| 7450c926d1 | |||
| be6723ebcc | |||
| af606aed9b | |||
| a37844e5a1 | |||
| 6f1a5bf81d | |||
| 9178b31629 | |||
| 19dc40825e | |||
| bc9b3052ee | |||
| 3b0649d408 | |||
| 7409ce5df6 | |||
| e3796d137a | |||
| ee68a10e9c | |||
| 28805a4b2d | |||
| fd72a8c40f | |||
| 63f7e30790 | |||
| e844d4f45f | |||
| 7a8d6f6095 | |||
| 95d79b7cbe | |||
| 601f0606da | |||
| ad6d3fd902 | |||
| 1f1cf756c8 | |||
| ec5836c4d6 | |||
| f062f56b43 | |||
| 61d92c4a21 | |||
| 1495294cc0 | |||
| d86b1f7b7e | |||
| 099fea2434 | |||
| 1d70aa5c1b | |||
| 6fb3b09003 | |||
| 30354892b3 |
@@ -0,0 +1,125 @@
|
||||
---
|
||||
name: docs-changelog
|
||||
description: Provides a step-by-step procedure for generating Gemini CLI changelog files based on github release information.
|
||||
---
|
||||
|
||||
# Procedure: Updating Changelog for New Releases
|
||||
|
||||
The following instructions are run by Gemini CLI when processing new releases.
|
||||
|
||||
## Objective
|
||||
|
||||
To standardize the process of updating the Gemini CLI changelog files for a new
|
||||
release, ensuring accuracy, consistency, and adherence to project style
|
||||
guidelines.
|
||||
|
||||
## Release Types
|
||||
|
||||
This skill covers two types of releases:
|
||||
|
||||
* **Standard Releases:** Regular, versioned releases that are announced to all
|
||||
users. These updates modify `docs/changelogs/latest.md` and
|
||||
`docs/changelogs/index.md`.
|
||||
* **Preview Releases:** Pre-release versions for testing and feedback. These
|
||||
updates only modify `docs/changelogs/preview.md`.
|
||||
|
||||
Ignore all other releases, such as nightly releases.
|
||||
|
||||
### Expected Inputs
|
||||
|
||||
Regardless of the type of release, the following information is expected:
|
||||
|
||||
* **New version number:** The version number for the new release
|
||||
(e.g., `v0.27.0`).
|
||||
* **Release date:** The date of the new release (e.g., `2026-02-03`).
|
||||
* **Raw changelog data:** A list of all pull requests and changes
|
||||
included in the release, in the format `description by @author in
|
||||
#pr_number`.
|
||||
* **Previous version number:** The version number of the last release can be
|
||||
calculated by decreasing the minor version number by one and setting the
|
||||
patch or bug fix version number.
|
||||
|
||||
## Procedure
|
||||
|
||||
### Initial Setup
|
||||
|
||||
1. Identify the files to be modified:
|
||||
|
||||
For standard releases, update `docs/changelogs/latest.md` and
|
||||
`docs/changelogs/index.md`. For preview releases, update
|
||||
`docs/changelogs/preview.md`.
|
||||
|
||||
2. Activate the `docs-writer` skill.
|
||||
|
||||
### Analyze Raw Changelog Data
|
||||
|
||||
1. Review the complete list of changes. If it is a patch or a bug fix with few
|
||||
changes, skip to the "Update `docs/changelogs/latest.md` or
|
||||
`docs/changelogs/preview.md`" section.
|
||||
|
||||
2. Group related changes into high-level categories such as
|
||||
important features, "UI/UX Improvements", and "Bug Fixes". Use the existing
|
||||
announcements in `docs/changelogs/index.md` as an example.
|
||||
|
||||
### Create Highlight Summaries
|
||||
|
||||
Create two distinct versions of the release highlights.
|
||||
|
||||
**Important:** Carefully inspect highlights for "experimental" or
|
||||
"preview" features before public announcement, and do not include them.
|
||||
|
||||
#### Version 1: Comprehensive Highlights (for `latest.md` or `preview.md`)
|
||||
|
||||
Write a detailed summary for each category focusing on user-facing
|
||||
impact.
|
||||
|
||||
#### Version 2: Concise Highlights (for `index.md`)
|
||||
|
||||
Skip this step for preview releases.
|
||||
|
||||
Write concise summaries including the primary PR and author
|
||||
(e.g., `([#12345](link) by @author)`).
|
||||
|
||||
### Update `docs/changelogs/latest.md` or `docs/changelogs/preview.md`
|
||||
|
||||
1. Read current content and use `write_file` to replace it with the new
|
||||
version number, and date.
|
||||
|
||||
If it is a patch or bug fix with few changes, simply add these
|
||||
changes to the "What's Changed" list. Otherwise, replace comprehensive
|
||||
highlights, and the full "What's Changed" list.
|
||||
|
||||
2. For each item in the "What's Changed" list, keep usernames in plaintext, and
|
||||
add github links for each issue number. Example:
|
||||
|
||||
"- feat: implement /rewind command by @username in
|
||||
[#12345](https://github.com/google-gemini/gemini-cli/pull/12345)"
|
||||
|
||||
3. Skip entries by @gemini-cli-robot.
|
||||
|
||||
4. Do not add the "New Contributors" section.
|
||||
|
||||
5. Update the "Full changelog:" link by doing one of following:
|
||||
|
||||
If it is a patch or bug fix with few changes, retain the original link
|
||||
but replace the latter version with the new version. For example, if the
|
||||
patch is version is "v0.28.1", replace the latter version:
|
||||
"https://github.com/google-gemini/gemini-cli/compare/v0.27.0...v0.28.0" with
|
||||
"https://github.com/google-gemini/gemini-cli/compare/v0.27.0...v0.28.1".
|
||||
|
||||
Otherwise, for minor and major version changes, replace the link with the
|
||||
one included at the end of the changelog data.
|
||||
|
||||
6. Ensure lines are wrapped to 80 characters.
|
||||
|
||||
### Update `docs/changelogs/index.md`
|
||||
|
||||
Skip this step for patches, bug fixes, or preview releases.
|
||||
|
||||
Insert a new "Announcements" section for the new version directly
|
||||
above the previous version's section. Ensure lines are wrapped to
|
||||
80 characters.
|
||||
|
||||
### Finalize
|
||||
|
||||
Run `npm run format` to ensure consistency.
|
||||
@@ -0,0 +1,84 @@
|
||||
# This workflow is triggered on every new release.
|
||||
# It uses Gemini to generate release notes and creates a PR with the changes.
|
||||
name: 'Generate Release Notes'
|
||||
|
||||
on:
|
||||
release:
|
||||
types: ['created']
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: 'New version (e.g., v1.2.3)'
|
||||
required: true
|
||||
type: 'string'
|
||||
body:
|
||||
description: 'Release notes body'
|
||||
required: true
|
||||
type: 'string'
|
||||
time:
|
||||
description: 'Release time'
|
||||
required: true
|
||||
type: 'string'
|
||||
|
||||
jobs:
|
||||
generate-release-notes:
|
||||
runs-on: 'ubuntu-latest'
|
||||
permissions:
|
||||
contents: 'write'
|
||||
pull-requests: 'write'
|
||||
steps:
|
||||
- name: 'Checkout repository'
|
||||
uses: 'actions/checkout@v4'
|
||||
with:
|
||||
# The user-level skills need to be available to the workflow
|
||||
fetch-depth: 0
|
||||
|
||||
- name: 'Set up Node.js'
|
||||
uses: 'actions/setup-node@v4'
|
||||
with:
|
||||
node-version: '20'
|
||||
|
||||
- name: 'Get release information'
|
||||
id: 'release_info'
|
||||
run: |
|
||||
VERSION="${{ github.event.inputs.version || github.event.release.tag_name }}"
|
||||
BODY="${{ github.event.inputs.body || github.event.release.body }}"
|
||||
TIME="${{ github.event.inputs.time || github.event.release.created_at }}"
|
||||
|
||||
echo "VERSION=${VERSION}" >> "$GITHUB_OUTPUT"
|
||||
echo "TIME=${TIME}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Use a heredoc to preserve multiline release body
|
||||
echo 'RAW_CHANGELOG<<EOF' >> "$GITHUB_OUTPUT"
|
||||
echo "${BODY}" >> "$GITHUB_OUTPUT"
|
||||
echo 'EOF' >> "$GITHUB_OUTPUT"
|
||||
env:
|
||||
GH_TOKEN: '${{ secrets.GEMINI_CLI_ROBOT_GITHUB_PAT }}'
|
||||
|
||||
- name: 'Generate Changelog with Gemini'
|
||||
uses: 'google-github-actions/run-gemini-cli@a3bf79042542528e91937b3a3a6fbc4967ee3c31' # ratchet:google-github-actions/run-gemini-cli@v0
|
||||
with:
|
||||
gemini_api_key: '${{ secrets.GEMINI_API_KEY }}'
|
||||
prompt: |
|
||||
Activate the 'docs-changelog' skill.
|
||||
|
||||
**Release Information:**
|
||||
- New Version: ${{ steps.release_info.outputs.VERSION }}
|
||||
- Release Date: ${{ steps.release_info.outputs.TIME }}
|
||||
- Raw Changelog Data: ${{ steps.release_info.outputs.RAW_CHANGELOG }}
|
||||
|
||||
Execute the release notes generation process using the information provided.
|
||||
|
||||
- name: 'Create Pull Request'
|
||||
uses: 'peter-evans/create-pull-request@v6'
|
||||
with:
|
||||
token: '${{ secrets.GEMINI_CLI_ROBOT_GITHUB_PAT }}'
|
||||
commit-message: 'docs(changelog): update for ${{ steps.release_info.outputs.VERSION }}'
|
||||
title: 'Changelog for ${{ steps.release_info.outputs.VERSION }}'
|
||||
body: |
|
||||
This PR contains the auto-generated changelog for the ${{ steps.release_info.outputs.VERSION }} release.
|
||||
|
||||
Please review and merge.
|
||||
branch: 'changelog-${{ steps.release_info.outputs.VERSION }}'
|
||||
team-reviewers: 'gemini-cli-docs, gemini-cli-maintainers'
|
||||
delete-branch: true
|
||||
@@ -55,6 +55,8 @@ powerful tool for developers.
|
||||
- **Contributions:** Follow the process outlined in `CONTRIBUTING.md`. Requires
|
||||
signing the Google CLA.
|
||||
- **Pull Requests:** Keep PRs small, focused, and linked to an existing issue.
|
||||
Always activate the `pr-creator` skill for PR generation, even when using the
|
||||
`gh` CLI.
|
||||
- **Commit Messages:** Follow the
|
||||
[Conventional Commits](https://www.conventionalcommits.org/) standard.
|
||||
- **Coding Style:** Adhere to existing patterns in `packages/cli` (React/Ink)
|
||||
|
||||
@@ -113,10 +113,14 @@ Slash commands provide meta-level control over the CLI itself.
|
||||
- **Description:** Lists all active extensions in the current Gemini CLI
|
||||
session. See [Gemini CLI Extensions](../extensions/index.md).
|
||||
|
||||
- **`/help`** (or **`/?`**)
|
||||
- **`/help`**
|
||||
- **Description:** Display help information about Gemini CLI, including
|
||||
available commands and their usage.
|
||||
|
||||
- **`/shortcuts`**
|
||||
- **Description:** Toggle the shortcuts panel above the input.
|
||||
- **Shortcut:** Press `?` when the prompt is empty.
|
||||
|
||||
- **`/hooks`**
|
||||
- **Description:** Manage hooks, which allow you to intercept and customize
|
||||
Gemini CLI behavior at specific lifecycle events.
|
||||
|
||||
@@ -106,16 +106,17 @@ available combinations.
|
||||
| Toggle YOLO (auto-approval) mode for tool calls. | `Ctrl + Y` |
|
||||
| Cycle through approval modes: default (prompt), auto_edit (auto-approve edits), and plan (read-only). | `Shift + Tab` |
|
||||
| Expand a height-constrained response to show additional lines when not in alternate buffer mode. | `Ctrl + O`<br />`Ctrl + S` |
|
||||
| Ctrl+B | `Ctrl + B` |
|
||||
| Ctrl+L | `Ctrl + L` |
|
||||
| Ctrl+K | `Ctrl + K` |
|
||||
| Enter | `Enter` |
|
||||
| Esc | `Esc` |
|
||||
| Shift+Tab | `Shift + Tab` |
|
||||
| Tab | `Tab (no Shift)` |
|
||||
| Tab | `Tab (no Shift)` |
|
||||
| Focus the shell input from the gemini input. | `Tab (no Shift)` |
|
||||
| Focus the Gemini input from the shell input. | `Tab` |
|
||||
| Toggle current background shell visibility. | `Ctrl + B` |
|
||||
| Toggle background shell list. | `Ctrl + L` |
|
||||
| Kill the active background shell. | `Ctrl + K` |
|
||||
| Confirm selection in background shell list. | `Enter` |
|
||||
| Dismiss background shell list. | `Esc` |
|
||||
| Move focus from background shell to Gemini. | `Shift + Tab` |
|
||||
| Move focus from background shell list to Gemini. | `Tab (no Shift)` |
|
||||
| Show warning when trying to unfocus background shell via Tab. | `Tab (no Shift)` |
|
||||
| Show warning when trying to unfocus shell input via Tab. | `Tab (no Shift)` |
|
||||
| Move focus from Gemini to the active shell. | `Tab (no Shift)` |
|
||||
| Move focus from the shell back to Gemini. | `Shift + Tab` |
|
||||
| Clear the terminal screen and redraw the UI. | `Ctrl + L` |
|
||||
| Restart the application. | `R` |
|
||||
| Suspend the application (not yet implemented). | `Ctrl + Z` |
|
||||
@@ -127,6 +128,9 @@ available combinations.
|
||||
- `Option+B/F/M` (macOS only): Are interpreted as `Cmd+B/F/M` even if your
|
||||
terminal isn't configured to send Meta with Option.
|
||||
- `!` on an empty prompt: Enter or exit shell mode.
|
||||
- `?` on an empty prompt: Toggle the shortcuts panel above the input. Press
|
||||
`Esc`, `Backspace`, or any printable key to close it. Press `?` again to close
|
||||
the panel and insert a `?` into the prompt.
|
||||
- `\` (at end of a line) + `Enter`: Insert a newline without leaving single-line
|
||||
mode.
|
||||
- `Esc` pressed twice quickly: Clear the input prompt if it is not empty,
|
||||
|
||||
+8
-11
@@ -22,14 +22,13 @@ they appear in the UI.
|
||||
|
||||
### General
|
||||
|
||||
| UI Label | Setting | Description | Default |
|
||||
| ------------------------------- | ---------------------------------- | ------------------------------------------------------------- | ------- |
|
||||
| Preview Features (e.g., models) | `general.previewFeatures` | Enable preview features (e.g., preview models). | `false` |
|
||||
| Vim Mode | `general.vimMode` | Enable Vim keybindings | `false` |
|
||||
| Enable Auto Update | `general.enableAutoUpdate` | Enable automatic updates. | `true` |
|
||||
| Enable Prompt Completion | `general.enablePromptCompletion` | Enable AI-powered prompt completion suggestions while typing. | `false` |
|
||||
| Debug Keystroke Logging | `general.debugKeystrokeLogging` | Enable debug logging of keystrokes to the console. | `false` |
|
||||
| Enable Session Cleanup | `general.sessionRetention.enabled` | Enable automatic session cleanup | `false` |
|
||||
| UI Label | Setting | Description | Default |
|
||||
| ------------------------ | ---------------------------------- | ------------------------------------------------------------- | ------- |
|
||||
| Vim Mode | `general.vimMode` | Enable Vim keybindings | `false` |
|
||||
| Enable Auto Update | `general.enableAutoUpdate` | Enable automatic updates. | `true` |
|
||||
| Enable Prompt Completion | `general.enablePromptCompletion` | Enable AI-powered prompt completion suggestions while typing. | `false` |
|
||||
| Debug Keystroke Logging | `general.debugKeystrokeLogging` | Enable debug logging of keystrokes to the console. | `false` |
|
||||
| Enable Session Cleanup | `general.sessionRetention.enabled` | Enable automatic session cleanup | `false` |
|
||||
|
||||
### Output
|
||||
|
||||
@@ -102,9 +101,7 @@ they appear in the UI.
|
||||
| Show Color | `tools.shell.showColor` | Show color in shell output. | `false` |
|
||||
| Approval Mode | `tools.approvalMode` | The default approval mode for tool execution. 'default' prompts for approval, 'auto_edit' auto-approves edit tools, and 'plan' is read-only mode. 'yolo' is not supported yet. | `"default"` |
|
||||
| Use Ripgrep | `tools.useRipgrep` | Use ripgrep for file content search instead of the fallback implementation. Provides faster search performance. | `true` |
|
||||
| Enable Tool Output Truncation | `tools.enableToolOutputTruncation` | Enable truncation of large tool outputs. | `true` |
|
||||
| Tool Output Truncation Threshold | `tools.truncateToolOutputThreshold` | Truncate tool output if it is larger than this many characters. Set to -1 to disable. | `4000000` |
|
||||
| Tool Output Truncation Lines | `tools.truncateToolOutputLines` | The number of lines to keep when truncating tool output. | `1000` |
|
||||
| Tool Output Truncation Threshold | `tools.truncateToolOutputThreshold` | Maximum characters to show when truncating large tool outputs. Set to 0 or negative to disable truncation. | `40000` |
|
||||
| Disable LLM Correction | `tools.disableLLMCorrection` | Disable LLM-based error correction for edit tools. When enabled, tools will fail immediately if exact string matches are not found, instead of attempting to self-correct. | `true` |
|
||||
|
||||
### Security
|
||||
|
||||
@@ -98,10 +98,6 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
|
||||
#### `general`
|
||||
|
||||
- **`general.previewFeatures`** (boolean):
|
||||
- **Description:** Enable preview features (e.g., preview models).
|
||||
- **Default:** `false`
|
||||
|
||||
- **`general.preferredEditor`** (string):
|
||||
- **Description:** The preferred editor to open files in.
|
||||
- **Default:** `undefined`
|
||||
@@ -720,20 +716,10 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
implementation. Provides faster search performance.
|
||||
- **Default:** `true`
|
||||
|
||||
- **`tools.enableToolOutputTruncation`** (boolean):
|
||||
- **Description:** Enable truncation of large tool outputs.
|
||||
- **Default:** `true`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`tools.truncateToolOutputThreshold`** (number):
|
||||
- **Description:** Truncate tool output if it is larger than this many
|
||||
characters. Set to -1 to disable.
|
||||
- **Default:** `4000000`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`tools.truncateToolOutputLines`** (number):
|
||||
- **Description:** The number of lines to keep when truncating tool output.
|
||||
- **Default:** `1000`
|
||||
- **Description:** Maximum characters to show when truncating large tool
|
||||
outputs. Set to 0 or negative to disable truncation.
|
||||
- **Default:** `40000`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`tools.disableLLMCorrection`** (boolean):
|
||||
@@ -866,11 +852,6 @@ their corresponding top-level category object in your `settings.json` file.
|
||||
|
||||
- **`experimental.extensionConfig`** (boolean):
|
||||
- **Description:** Enable requesting and fetching of extension settings.
|
||||
- **Default:** `false`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
- **`experimental.enableEventDrivenScheduler`** (boolean):
|
||||
- **Description:** Enables event-driven scheduler within the CLI session.
|
||||
- **Default:** `true`
|
||||
- **Requires restart:** Yes
|
||||
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, expect } from 'vitest';
|
||||
import { ApprovalMode } from '@google/gemini-cli-core';
|
||||
import { evalTest } from './test-helper.js';
|
||||
import {
|
||||
assertModelHasOutput,
|
||||
checkModelOutputContent,
|
||||
} from './test-helper.js';
|
||||
|
||||
describe('plan_mode', () => {
|
||||
const TEST_PREFIX = 'Plan Mode: ';
|
||||
const settings = {
|
||||
experimental: { plan: true },
|
||||
};
|
||||
|
||||
evalTest('USUALLY_PASSES', {
|
||||
name: 'should refuse file modification when in plan mode',
|
||||
approvalMode: ApprovalMode.PLAN,
|
||||
params: {
|
||||
settings,
|
||||
},
|
||||
files: {
|
||||
'README.md': '# Original Content',
|
||||
},
|
||||
prompt: 'Please overwrite README.md with the text "Hello World"',
|
||||
assert: async (rig, result) => {
|
||||
await rig.waitForTelemetryReady();
|
||||
const toolLogs = rig.readToolLogs();
|
||||
|
||||
const writeTargets = toolLogs
|
||||
.filter((log) =>
|
||||
['write_file', 'replace'].includes(log.toolRequest.name),
|
||||
)
|
||||
.map((log) => {
|
||||
try {
|
||||
return JSON.parse(log.toolRequest.args).file_path;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
expect(
|
||||
writeTargets,
|
||||
'Should not attempt to modify README.md in plan mode',
|
||||
).not.toContain('README.md');
|
||||
|
||||
assertModelHasOutput(result);
|
||||
checkModelOutputContent(result, {
|
||||
expectedContent: [/plan mode|read-only|cannot modify|refuse|exiting/i],
|
||||
testName: `${TEST_PREFIX}should refuse file modification`,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
evalTest('USUALLY_PASSES', {
|
||||
name: 'should enter plan mode when asked to create a plan',
|
||||
approvalMode: ApprovalMode.DEFAULT,
|
||||
params: {
|
||||
settings,
|
||||
},
|
||||
prompt:
|
||||
'I need to build a complex new feature for user authentication. Please create a detailed implementation plan.',
|
||||
assert: async (rig, result) => {
|
||||
const wasToolCalled = await rig.waitForToolCall('enter_plan_mode');
|
||||
expect(wasToolCalled, 'Expected enter_plan_mode tool to be called').toBe(
|
||||
true,
|
||||
);
|
||||
assertModelHasOutput(result);
|
||||
},
|
||||
});
|
||||
|
||||
evalTest('USUALLY_PASSES', {
|
||||
name: 'should exit plan mode when plan is complete and implementation is requested',
|
||||
approvalMode: ApprovalMode.PLAN,
|
||||
params: {
|
||||
settings,
|
||||
},
|
||||
files: {
|
||||
'plans/my-plan.md':
|
||||
'# My Implementation Plan\n\n1. Step one\n2. Step two',
|
||||
},
|
||||
prompt:
|
||||
'The plan in plans/my-plan.md is solid. Please proceed with the implementation.',
|
||||
assert: async (rig, result) => {
|
||||
const wasToolCalled = await rig.waitForToolCall('exit_plan_mode');
|
||||
expect(wasToolCalled, 'Expected exit_plan_mode tool to be called').toBe(
|
||||
true,
|
||||
);
|
||||
assertModelHasOutput(result);
|
||||
},
|
||||
});
|
||||
|
||||
evalTest('USUALLY_PASSES', {
|
||||
name: 'should allow file modification in plans directory when in plan mode',
|
||||
approvalMode: ApprovalMode.PLAN,
|
||||
params: {
|
||||
settings,
|
||||
},
|
||||
prompt: 'Create a plan for a new login feature.',
|
||||
assert: async (rig, result) => {
|
||||
await rig.waitForTelemetryReady();
|
||||
const toolLogs = rig.readToolLogs();
|
||||
|
||||
const writeCall = toolLogs.find(
|
||||
(log) => log.toolRequest.name === 'write_file',
|
||||
);
|
||||
|
||||
expect(
|
||||
writeCall,
|
||||
'Should attempt to modify a file in the plans directory when in plan mode',
|
||||
).toBeDefined();
|
||||
|
||||
if (writeCall) {
|
||||
const args = JSON.parse(writeCall.toolRequest.args);
|
||||
expect(args.file_path).toContain('.gemini/tmp');
|
||||
expect(args.file_path).toContain('/plans/');
|
||||
expect(args.file_path).toMatch(/\.md$/);
|
||||
}
|
||||
|
||||
assertModelHasOutput(result);
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -125,7 +125,7 @@ export function evalTest(policy: EvalPolicy, evalCase: EvalCase) {
|
||||
approvalMode: evalCase.approvalMode ?? 'yolo',
|
||||
timeout: evalCase.timeout,
|
||||
env: {
|
||||
GEMINI_CLI_ACTIVITY_LOG_FILE: activityLogFile,
|
||||
GEMINI_CLI_ACTIVITY_LOG_TARGET: activityLogFile,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"Session started."}],"role":"model"},"finishReason":"STOP","index":0}]}]}
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { TestRig } from './test-helper.js';
|
||||
import * as path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
describe('resume-repro', () => {
|
||||
let rig: TestRig;
|
||||
|
||||
beforeEach(() => {
|
||||
rig = new TestRig();
|
||||
});
|
||||
|
||||
afterEach(async () => await rig.cleanup());
|
||||
|
||||
it('should be able to resume a session without "Storage must be initialized before use"', async () => {
|
||||
const responsesPath = path.join(__dirname, 'resume_repro.responses');
|
||||
await rig.setup('should be able to resume a session', {
|
||||
fakeResponsesPath: responsesPath,
|
||||
});
|
||||
|
||||
// 1. First run to create a session
|
||||
await rig.run({
|
||||
args: 'hello',
|
||||
});
|
||||
|
||||
// 2. Second run with --resume latest
|
||||
// This should NOT fail with "Storage must be initialized before use"
|
||||
const result = await rig.run({
|
||||
args: ['--resume', 'latest', 'continue'],
|
||||
});
|
||||
|
||||
expect(result).toContain('Session started');
|
||||
});
|
||||
});
|
||||
Generated
+58
-24
@@ -13,6 +13,7 @@
|
||||
"dependencies": {
|
||||
"ink": "npm:@jrichman/ink@6.4.8",
|
||||
"latest-version": "^9.0.0",
|
||||
"proper-lockfile": "^4.1.2",
|
||||
"simple-git": "^3.28.0"
|
||||
},
|
||||
"bin": {
|
||||
@@ -26,6 +27,7 @@
|
||||
"@types/minimatch": "^5.1.2",
|
||||
"@types/mock-fs": "^4.13.4",
|
||||
"@types/prompts": "^2.4.9",
|
||||
"@types/proper-lockfile": "^4.1.4",
|
||||
"@types/react": "^19.2.0",
|
||||
"@types/react-dom": "^19.2.0",
|
||||
"@types/shell-quote": "^1.7.5",
|
||||
@@ -2251,7 +2253,6 @@
|
||||
"integrity": "sha512-t54CUOsFMappY1Jbzb7fetWeO0n6K0k/4+/ZpkS+3Joz8I4VcvY9OiEBFRYISqaI2fq5sCiPtAjRDOzVYG8m+Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@octokit/auth-token": "^6.0.0",
|
||||
"@octokit/graphql": "^9.0.2",
|
||||
@@ -2432,7 +2433,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz",
|
||||
"integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=8.0.0"
|
||||
}
|
||||
@@ -2466,7 +2466,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.0.1.tgz",
|
||||
"integrity": "sha512-MaZk9SJIDgo1peKevlbhP6+IwIiNPNmswNL4AF0WaQJLbHXjr9SrZMgS12+iqr9ToV4ZVosCcc0f8Rg67LXjxw==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@opentelemetry/semantic-conventions": "^1.29.0"
|
||||
},
|
||||
@@ -2835,7 +2834,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.0.1.tgz",
|
||||
"integrity": "sha512-dZOB3R6zvBwDKnHDTB4X1xtMArB/d324VsbiPkX/Yu0Q8T2xceRthoIVFhJdvgVM2QhGVUyX9tzwiNxGtoBJUw==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@opentelemetry/core": "2.0.1",
|
||||
"@opentelemetry/semantic-conventions": "^1.29.0"
|
||||
@@ -2869,7 +2867,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.0.1.tgz",
|
||||
"integrity": "sha512-wf8OaJoSnujMAHWR3g+/hGvNcsC16rf9s1So4JlMiFaFHiE4HpIA3oUh+uWZQ7CNuK8gVW/pQSkgoa5HkkOl0g==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@opentelemetry/core": "2.0.1",
|
||||
"@opentelemetry/resources": "2.0.1"
|
||||
@@ -2922,7 +2919,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.0.1.tgz",
|
||||
"integrity": "sha512-xYLlvk/xdScGx1aEqvxLwf6sXQLXCjk3/1SQT9X9AoN5rXRhkdvIFShuNNmtTEPRBqcsMbS4p/gJLNI2wXaDuQ==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@opentelemetry/core": "2.0.1",
|
||||
"@opentelemetry/resources": "2.0.1",
|
||||
@@ -4108,6 +4104,16 @@
|
||||
"kleur": "^3.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/proper-lockfile": {
|
||||
"version": "4.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/proper-lockfile/-/proper-lockfile-4.1.4.tgz",
|
||||
"integrity": "sha512-uo2ABllncSqg9F1D4nugVl9v93RmjxF6LJzQLMLDdPaXCUIDPeOJ21Gbqi43xNKzBi/WQ0Q0dICqufzQbMjipQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/retry": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/qs": {
|
||||
"version": "6.14.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz",
|
||||
@@ -4128,7 +4134,6 @@
|
||||
"integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"csstype": "^3.0.2"
|
||||
}
|
||||
@@ -4203,6 +4208,13 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/retry": {
|
||||
"version": "0.12.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.5.tgz",
|
||||
"integrity": "sha512-3xSjTp3v03X/lSQLkczaN9UIEwJMoMCA1+Nb5HfbJEQWogdeQIyVtTvxPXDQjZ5zws8rFQfVfRdz03ARihPJgw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/sarif": {
|
||||
"version": "2.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/sarif/-/sarif-2.1.7.tgz",
|
||||
@@ -4333,6 +4345,16 @@
|
||||
"boxen": "^7.1.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/ws": {
|
||||
"version": "8.18.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
|
||||
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/yargs": {
|
||||
"version": "17.0.33",
|
||||
"resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.33.tgz",
|
||||
@@ -4406,7 +4428,6 @@
|
||||
"integrity": "sha512-6sMvZePQrnZH2/cJkwRpkT7DxoAWh+g6+GFRK6bV3YQo7ogi3SX5rgF6099r5Q53Ma5qeT7LGmOmuIutF4t3lA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.35.0",
|
||||
"@typescript-eslint/types": "8.35.0",
|
||||
@@ -5399,7 +5420,6 @@
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
|
||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@@ -8409,7 +8429,6 @@
|
||||
"integrity": "sha512-GsGizj2Y1rCWDu6XoEekL3RLilp0voSePurjZIkxL3wlm5o5EC9VpgaP7lrCvjnkuLvzFBQWB3vWB3K5KQTveQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.2.0",
|
||||
"@eslint-community/regexpp": "^4.12.1",
|
||||
@@ -8950,7 +8969,6 @@
|
||||
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
|
||||
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"accepts": "^2.0.0",
|
||||
"body-parser": "^2.2.1",
|
||||
@@ -10552,7 +10570,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@jrichman/ink/-/ink-6.4.8.tgz",
|
||||
"integrity": "sha512-v0thcXIKl9hqF/1w4HqA6MKxIcMoWSP3YtEZIAA+eeJngXpN5lGnMkb6rllB7FnOdwyEyYaFTcu1ZVr4/JZpWQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@alcalzone/ansi-tokenize": "^0.2.1",
|
||||
"ansi-escapes": "^7.0.0",
|
||||
@@ -14052,6 +14069,32 @@
|
||||
"react-is": "^16.13.1"
|
||||
}
|
||||
},
|
||||
"node_modules/proper-lockfile": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz",
|
||||
"integrity": "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"graceful-fs": "^4.2.4",
|
||||
"retry": "^0.12.0",
|
||||
"signal-exit": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/proper-lockfile/node_modules/retry": {
|
||||
"version": "0.12.0",
|
||||
"resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz",
|
||||
"integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 4"
|
||||
}
|
||||
},
|
||||
"node_modules/proper-lockfile/node_modules/signal-exit": {
|
||||
"version": "3.0.7",
|
||||
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz",
|
||||
"integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/proto-list": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/proto-list/-/proto-list-1.2.4.tgz",
|
||||
@@ -14311,7 +14354,6 @@
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz",
|
||||
"integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
@@ -14322,7 +14364,6 @@
|
||||
"integrity": "sha512-ePrwPfxAnB+7hgnEr8vpKxL9cmnp7F322t8oqcPshbIQQhDKgFDW4tjhF2wjVbdXF9O/nyuy3sQWd9JGpiLPvA==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"shell-quote": "^1.6.1",
|
||||
"ws": "^7"
|
||||
@@ -16559,7 +16600,6 @@
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -16783,8 +16823,7 @@
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"dev": true,
|
||||
"license": "0BSD",
|
||||
"peer": true
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/tsx": {
|
||||
"version": "4.20.3",
|
||||
@@ -16792,7 +16831,6 @@
|
||||
"integrity": "sha512-qjbnuR9Tr+FJOMBqJCW5ehvIo/buZq7vH7qD7JziU98h6l3qGy0a/yPFjwO+y0/T7GFpNgNAvEcPPVfyT8rrPQ==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "~0.25.0",
|
||||
"get-tsconfig": "^4.7.5"
|
||||
@@ -16965,7 +17003,6 @@
|
||||
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@@ -17173,7 +17210,6 @@
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.2.2.tgz",
|
||||
"integrity": "sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.25.0",
|
||||
"fdir": "^6.5.0",
|
||||
@@ -17287,7 +17323,6 @@
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -17300,7 +17335,6 @@
|
||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz",
|
||||
"integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/chai": "^5.2.2",
|
||||
"@vitest/expect": "3.2.4",
|
||||
@@ -18005,7 +18039,6 @@
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
||||
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
@@ -18116,6 +18149,7 @@
|
||||
"tinygradient": "^1.1.5",
|
||||
"undici": "^7.10.0",
|
||||
"wrap-ansi": "9.0.2",
|
||||
"ws": "^8.16.0",
|
||||
"yargs": "^17.7.2",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
@@ -18134,6 +18168,7 @@
|
||||
"@types/semver": "^7.7.0",
|
||||
"@types/shell-quote": "^1.7.5",
|
||||
"@types/tar": "^6.1.13",
|
||||
"@types/ws": "^8.5.10",
|
||||
"@types/yargs": "^17.0.32",
|
||||
"archiver": "^7.0.1",
|
||||
"ink-testing-library": "^4.0.0",
|
||||
@@ -18300,7 +18335,6 @@
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
|
||||
+3
-1
@@ -32,7 +32,7 @@
|
||||
"docs:settings": "tsx ./scripts/generate-settings-doc.ts",
|
||||
"docs:keybindings": "tsx ./scripts/generate-keybindings-doc.ts",
|
||||
"build": "node scripts/build.js",
|
||||
"build-and-start": "npm run build && npm run start",
|
||||
"build-and-start": "npm run build && npm run start --",
|
||||
"build:vscode": "node scripts/build_vscode_companion.js",
|
||||
"build:all": "npm run build && npm run build:sandbox && npm run build:vscode",
|
||||
"build:packages": "npm run build --workspaces",
|
||||
@@ -86,6 +86,7 @@
|
||||
"@types/minimatch": "^5.1.2",
|
||||
"@types/mock-fs": "^4.13.4",
|
||||
"@types/prompts": "^2.4.9",
|
||||
"@types/proper-lockfile": "^4.1.4",
|
||||
"@types/react": "^19.2.0",
|
||||
"@types/react-dom": "^19.2.0",
|
||||
"@types/shell-quote": "^1.7.5",
|
||||
@@ -126,6 +127,7 @@
|
||||
"dependencies": {
|
||||
"ink": "npm:@jrichman/ink@6.4.8",
|
||||
"latest-version": "^9.0.0",
|
||||
"proper-lockfile": "^4.1.2",
|
||||
"simple-git": "^3.28.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
|
||||
@@ -18,7 +18,6 @@ import {
|
||||
loadServerHierarchicalMemory,
|
||||
GEMINI_DIR,
|
||||
DEFAULT_GEMINI_EMBEDDING_MODEL,
|
||||
DEFAULT_GEMINI_MODEL,
|
||||
type ExtensionLoader,
|
||||
startupProfiler,
|
||||
PREVIEW_GEMINI_MODEL,
|
||||
@@ -60,9 +59,7 @@ export async function loadConfig(
|
||||
|
||||
const configParams: ConfigParameters = {
|
||||
sessionId: taskId,
|
||||
model: settings.general?.previewFeatures
|
||||
? PREVIEW_GEMINI_MODEL
|
||||
: DEFAULT_GEMINI_MODEL,
|
||||
model: PREVIEW_GEMINI_MODEL,
|
||||
embeddingModel: DEFAULT_GEMINI_EMBEDDING_MODEL,
|
||||
sandbox: undefined, // Sandbox might not be relevant for a server-side agent
|
||||
targetDir: workspaceDir, // Or a specific directory the agent operates on
|
||||
@@ -104,7 +101,6 @@ export async function loadConfig(
|
||||
trustedFolder: true,
|
||||
extensionLoader,
|
||||
checkpointing,
|
||||
previewFeatures: settings.general?.previewFeatures,
|
||||
interactive: true,
|
||||
enableInteractiveShell: true,
|
||||
ptyInfo: 'auto',
|
||||
|
||||
@@ -89,67 +89,6 @@ describe('loadSettings', () => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should load nested previewFeatures from user settings', () => {
|
||||
const settings = {
|
||||
general: {
|
||||
previewFeatures: true,
|
||||
},
|
||||
};
|
||||
fs.writeFileSync(USER_SETTINGS_PATH, JSON.stringify(settings));
|
||||
|
||||
const result = loadSettings(mockWorkspaceDir);
|
||||
expect(result.general?.previewFeatures).toBe(true);
|
||||
});
|
||||
|
||||
it('should load nested previewFeatures from workspace settings', () => {
|
||||
const settings = {
|
||||
general: {
|
||||
previewFeatures: true,
|
||||
},
|
||||
};
|
||||
const workspaceSettingsPath = path.join(
|
||||
mockGeminiWorkspaceDir,
|
||||
'settings.json',
|
||||
);
|
||||
fs.writeFileSync(workspaceSettingsPath, JSON.stringify(settings));
|
||||
|
||||
const result = loadSettings(mockWorkspaceDir);
|
||||
expect(result.general?.previewFeatures).toBe(true);
|
||||
});
|
||||
|
||||
it('should prioritize workspace settings over user settings', () => {
|
||||
const userSettings = {
|
||||
general: {
|
||||
previewFeatures: false,
|
||||
},
|
||||
};
|
||||
fs.writeFileSync(USER_SETTINGS_PATH, JSON.stringify(userSettings));
|
||||
|
||||
const workspaceSettings = {
|
||||
general: {
|
||||
previewFeatures: true,
|
||||
},
|
||||
};
|
||||
const workspaceSettingsPath = path.join(
|
||||
mockGeminiWorkspaceDir,
|
||||
'settings.json',
|
||||
);
|
||||
fs.writeFileSync(workspaceSettingsPath, JSON.stringify(workspaceSettings));
|
||||
|
||||
const result = loadSettings(mockWorkspaceDir);
|
||||
expect(result.general?.previewFeatures).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle missing previewFeatures', () => {
|
||||
const settings = {
|
||||
general: {},
|
||||
};
|
||||
fs.writeFileSync(USER_SETTINGS_PATH, JSON.stringify(settings));
|
||||
|
||||
const result = loadSettings(mockWorkspaceDir);
|
||||
expect(result.general?.previewFeatures).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should load other top-level settings correctly', () => {
|
||||
const settings = {
|
||||
showMemoryUsage: true,
|
||||
|
||||
@@ -31,9 +31,6 @@ export interface Settings {
|
||||
showMemoryUsage?: boolean;
|
||||
checkpointing?: CheckpointingSettings;
|
||||
folderTrust?: boolean;
|
||||
general?: {
|
||||
previewFeatures?: boolean;
|
||||
};
|
||||
|
||||
// Git-aware file filtering settings
|
||||
fileFiltering?: {
|
||||
|
||||
@@ -12,7 +12,6 @@ import type {
|
||||
import {
|
||||
ApprovalMode,
|
||||
DEFAULT_GEMINI_MODEL,
|
||||
DEFAULT_TRUNCATE_TOOL_OUTPUT_LINES,
|
||||
DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD,
|
||||
GeminiClient,
|
||||
HookSystem,
|
||||
@@ -47,7 +46,6 @@ export function createMockConfig(
|
||||
} as Storage,
|
||||
getTruncateToolOutputThreshold: () =>
|
||||
DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD,
|
||||
getTruncateToolOutputLines: () => DEFAULT_TRUNCATE_TOOL_OUTPUT_LINES,
|
||||
getActiveModel: vi.fn().mockReturnValue(DEFAULT_GEMINI_MODEL),
|
||||
getDebugMode: vi.fn().mockReturnValue(false),
|
||||
getContentGeneratorConfig: vi.fn().mockReturnValue({ model: 'gemini-pro' }),
|
||||
|
||||
@@ -65,6 +65,7 @@
|
||||
"tinygradient": "^1.1.5",
|
||||
"undici": "^7.10.0",
|
||||
"wrap-ansi": "9.0.2",
|
||||
"ws": "^8.16.0",
|
||||
"yargs": "^17.7.2",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
@@ -80,6 +81,7 @@
|
||||
"@types/semver": "^7.7.0",
|
||||
"@types/shell-quote": "^1.7.5",
|
||||
"@types/tar": "^6.1.13",
|
||||
"@types/ws": "^8.5.10",
|
||||
"@types/yargs": "^17.0.32",
|
||||
"archiver": "^7.0.1",
|
||||
"ink-testing-library": "^4.0.0",
|
||||
|
||||
@@ -32,6 +32,7 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
return {
|
||||
...original,
|
||||
createTransport: vi.fn(),
|
||||
|
||||
MCPServerStatus: {
|
||||
CONNECTED: 'CONNECTED',
|
||||
CONNECTING: 'CONNECTING',
|
||||
@@ -223,4 +224,46 @@ describe('mcp list command', () => {
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('should filter servers based on admin allowlist passed in settings', async () => {
|
||||
const settingsWithAllowlist = mergeSettings({}, {}, {}, {}, true);
|
||||
settingsWithAllowlist.admin = {
|
||||
secureModeEnabled: false,
|
||||
extensions: { enabled: true },
|
||||
skills: { enabled: true },
|
||||
mcp: {
|
||||
enabled: true,
|
||||
config: {
|
||||
'allowed-server': { url: 'http://allowed' },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
settingsWithAllowlist.mcpServers = {
|
||||
'allowed-server': { command: 'cmd1' },
|
||||
'forbidden-server': { command: 'cmd2' },
|
||||
};
|
||||
|
||||
mockedLoadSettings.mockReturnValue({
|
||||
merged: settingsWithAllowlist,
|
||||
});
|
||||
|
||||
mockClient.connect.mockResolvedValue(undefined);
|
||||
mockClient.ping.mockResolvedValue(undefined);
|
||||
|
||||
await listMcpServers(settingsWithAllowlist);
|
||||
|
||||
expect(debugLogger.log).toHaveBeenCalledWith(
|
||||
expect.stringContaining('allowed-server'),
|
||||
);
|
||||
expect(debugLogger.log).not.toHaveBeenCalledWith(
|
||||
expect.stringContaining('forbidden-server'),
|
||||
);
|
||||
expect(mockedCreateTransport).toHaveBeenCalledWith(
|
||||
'allowed-server',
|
||||
expect.objectContaining({ url: 'http://allowed' }), // Should use admin config
|
||||
false,
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,12 +6,14 @@
|
||||
|
||||
// File for 'gemini mcp list' command
|
||||
import type { CommandModule } from 'yargs';
|
||||
import { loadSettings } from '../../config/settings.js';
|
||||
import { type MergedSettings, loadSettings } from '../../config/settings.js';
|
||||
import type { MCPServerConfig } from '@google/gemini-cli-core';
|
||||
import {
|
||||
MCPServerStatus,
|
||||
createTransport,
|
||||
debugLogger,
|
||||
applyAdminAllowlist,
|
||||
getAdminBlockedMcpServersMessage,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||
import { ExtensionManager } from '../../config/extension-manager.js';
|
||||
@@ -24,18 +26,24 @@ const COLOR_YELLOW = '\u001b[33m';
|
||||
const COLOR_RED = '\u001b[31m';
|
||||
const RESET_COLOR = '\u001b[0m';
|
||||
|
||||
export async function getMcpServersFromConfig(): Promise<
|
||||
Record<string, MCPServerConfig>
|
||||
> {
|
||||
const settings = loadSettings();
|
||||
export async function getMcpServersFromConfig(
|
||||
settings?: MergedSettings,
|
||||
): Promise<{
|
||||
mcpServers: Record<string, MCPServerConfig>;
|
||||
blockedServerNames: string[];
|
||||
}> {
|
||||
if (!settings) {
|
||||
settings = loadSettings().merged;
|
||||
}
|
||||
|
||||
const extensionManager = new ExtensionManager({
|
||||
settings: settings.merged,
|
||||
settings,
|
||||
workspaceDir: process.cwd(),
|
||||
requestConsent: requestConsentNonInteractive,
|
||||
requestSetting: promptForSetting,
|
||||
});
|
||||
const extensions = await extensionManager.loadExtensions();
|
||||
const mcpServers = { ...settings.merged.mcpServers };
|
||||
const mcpServers = { ...settings.mcpServers };
|
||||
for (const extension of extensions) {
|
||||
Object.entries(extension.mcpServers || {}).forEach(([key, server]) => {
|
||||
if (mcpServers[key]) {
|
||||
@@ -47,7 +55,11 @@ export async function getMcpServersFromConfig(): Promise<
|
||||
};
|
||||
});
|
||||
}
|
||||
return mcpServers;
|
||||
|
||||
const adminAllowlist = settings.admin?.mcp?.config;
|
||||
const filteredResult = applyAdminAllowlist(mcpServers, adminAllowlist);
|
||||
|
||||
return filteredResult;
|
||||
}
|
||||
|
||||
async function testMCPConnection(
|
||||
@@ -103,12 +115,23 @@ async function getServerStatus(
|
||||
return testMCPConnection(serverName, server);
|
||||
}
|
||||
|
||||
export async function listMcpServers(): Promise<void> {
|
||||
const mcpServers = await getMcpServersFromConfig();
|
||||
export async function listMcpServers(settings?: MergedSettings): Promise<void> {
|
||||
const { mcpServers, blockedServerNames } =
|
||||
await getMcpServersFromConfig(settings);
|
||||
const serverNames = Object.keys(mcpServers);
|
||||
|
||||
if (blockedServerNames.length > 0) {
|
||||
const message = getAdminBlockedMcpServersMessage(
|
||||
blockedServerNames,
|
||||
undefined,
|
||||
);
|
||||
debugLogger.log(COLOR_YELLOW + message + RESET_COLOR + '\n');
|
||||
}
|
||||
|
||||
if (serverNames.length === 0) {
|
||||
debugLogger.log('No MCP servers configured.');
|
||||
if (blockedServerNames.length === 0) {
|
||||
debugLogger.log('No MCP servers configured.');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -154,11 +177,15 @@ export async function listMcpServers(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
export const listCommand: CommandModule = {
|
||||
interface ListArgs {
|
||||
settings?: MergedSettings;
|
||||
}
|
||||
|
||||
export const listCommand: CommandModule<object, ListArgs> = {
|
||||
command: 'list',
|
||||
describe: 'List all configured MCP servers',
|
||||
handler: async () => {
|
||||
await listMcpServers();
|
||||
handler: async (argv) => {
|
||||
await listMcpServers(argv.settings);
|
||||
await exitCli();
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1511,7 +1511,7 @@ describe('loadCliConfig with admin.mcp.config', () => {
|
||||
});
|
||||
const config = await loadCliConfig(settings, 'test-session', argv);
|
||||
|
||||
const mergedServers = config.getMcpServers();
|
||||
const mergedServers = config.getMcpServers() ?? {};
|
||||
expect(mergedServers).toHaveProperty('serverA');
|
||||
expect(mergedServers).not.toHaveProperty('serverB');
|
||||
});
|
||||
@@ -1569,9 +1569,9 @@ describe('loadCliConfig with admin.mcp.config', () => {
|
||||
});
|
||||
const config = await loadCliConfig(settings, 'test-session', argv);
|
||||
|
||||
const mergedServers = config.getMcpServers();
|
||||
const mergedServers = config.getMcpServers() ?? {};
|
||||
expect(mergedServers).not.toHaveProperty('serverC');
|
||||
expect(Object.keys(mergedServers || {})).toHaveLength(0);
|
||||
expect(Object.keys(mergedServers)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should merge local fields and prefer admin tool filters', async () => {
|
||||
@@ -1601,7 +1601,7 @@ describe('loadCliConfig with admin.mcp.config', () => {
|
||||
});
|
||||
const config = await loadCliConfig(settings, 'test-session', argv);
|
||||
|
||||
const serverA = config.getMcpServers()?.['serverA'];
|
||||
const serverA = (config.getMcpServers() ?? {})['serverA'];
|
||||
expect(serverA).toMatchObject({
|
||||
timeout: 1234,
|
||||
includeTools: ['admin_tool'],
|
||||
@@ -1683,7 +1683,7 @@ describe('loadCliConfig model selection', () => {
|
||||
argv,
|
||||
);
|
||||
|
||||
expect(config.getModel()).toBe('auto-gemini-2.5');
|
||||
expect(config.getModel()).toBe('auto-gemini-3');
|
||||
});
|
||||
|
||||
it('always prefers model from argv', async () => {
|
||||
@@ -1727,7 +1727,7 @@ describe('loadCliConfig model selection', () => {
|
||||
argv,
|
||||
);
|
||||
|
||||
expect(config.getModel()).toBe('auto-gemini-2.5');
|
||||
expect(config.getModel()).toBe('auto-gemini-3');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
setGeminiMdFilename as setServerGeminiMdFilename,
|
||||
getCurrentGeminiMdFilename,
|
||||
ApprovalMode,
|
||||
DEFAULT_GEMINI_MODEL_AUTO,
|
||||
DEFAULT_GEMINI_EMBEDDING_MODEL,
|
||||
DEFAULT_FILE_FILTERING_OPTIONS,
|
||||
DEFAULT_MEMORY_FILE_FILTERING_OPTIONS,
|
||||
@@ -37,9 +36,10 @@ import {
|
||||
GEMINI_MODEL_ALIAS_AUTO,
|
||||
getAdminErrorMessage,
|
||||
Config,
|
||||
applyAdminAllowlist,
|
||||
getAdminBlockedMcpServersMessage,
|
||||
} from '@google/gemini-cli-core';
|
||||
import type {
|
||||
MCPServerConfig,
|
||||
HookDefinition,
|
||||
HookEventName,
|
||||
OutputFormat,
|
||||
@@ -662,9 +662,7 @@ export async function loadCliConfig(
|
||||
);
|
||||
policyEngineConfig.nonInteractive = !interactive;
|
||||
|
||||
const defaultModel = settings.general?.previewFeatures
|
||||
? PREVIEW_GEMINI_MODEL_AUTO
|
||||
: DEFAULT_GEMINI_MODEL_AUTO;
|
||||
const defaultModel = PREVIEW_GEMINI_MODEL_AUTO;
|
||||
const specifiedModel =
|
||||
argv.model || process.env['GEMINI_MODEL'] || settings.model?.name;
|
||||
|
||||
@@ -695,38 +693,17 @@ export async function loadCliConfig(
|
||||
let mcpServers = mcpEnabled ? settings.mcpServers : {};
|
||||
|
||||
if (mcpEnabled && adminAllowlist && Object.keys(adminAllowlist).length > 0) {
|
||||
const filteredMcpServers: Record<string, MCPServerConfig> = {};
|
||||
for (const [serverId, localConfig] of Object.entries(mcpServers)) {
|
||||
const adminConfig = adminAllowlist[serverId];
|
||||
if (adminConfig) {
|
||||
const mergedConfig = {
|
||||
...localConfig,
|
||||
url: adminConfig.url,
|
||||
type: adminConfig.type,
|
||||
trust: adminConfig.trust,
|
||||
};
|
||||
|
||||
// Remove local connection details
|
||||
delete mergedConfig.command;
|
||||
delete mergedConfig.args;
|
||||
delete mergedConfig.env;
|
||||
delete mergedConfig.cwd;
|
||||
delete mergedConfig.httpUrl;
|
||||
delete mergedConfig.tcp;
|
||||
|
||||
if (
|
||||
(adminConfig.includeTools && adminConfig.includeTools.length > 0) ||
|
||||
(adminConfig.excludeTools && adminConfig.excludeTools.length > 0)
|
||||
) {
|
||||
mergedConfig.includeTools = adminConfig.includeTools;
|
||||
mergedConfig.excludeTools = adminConfig.excludeTools;
|
||||
}
|
||||
|
||||
filteredMcpServers[serverId] = mergedConfig;
|
||||
}
|
||||
}
|
||||
mcpServers = filteredMcpServers;
|
||||
const result = applyAdminAllowlist(mcpServers, adminAllowlist);
|
||||
mcpServers = result.mcpServers;
|
||||
mcpServerCommand = undefined;
|
||||
|
||||
if (result.blockedServerNames && result.blockedServerNames.length > 0) {
|
||||
const message = getAdminBlockedMcpServersMessage(
|
||||
result.blockedServerNames,
|
||||
undefined,
|
||||
);
|
||||
coreEvents.emitConsoleLog('warn', message);
|
||||
}
|
||||
}
|
||||
|
||||
return new Config({
|
||||
@@ -740,7 +717,6 @@ export async function loadCliConfig(
|
||||
settings.context?.loadMemoryFromIncludeDirectories || false,
|
||||
debugMode,
|
||||
question,
|
||||
previewFeatures: settings.general?.previewFeatures,
|
||||
|
||||
coreTools: settings.tools?.core || undefined,
|
||||
allowedTools: allowedTools.length > 0 ? allowedTools : undefined,
|
||||
@@ -801,8 +777,7 @@ export async function loadCliConfig(
|
||||
enableExtensionReloading: settings.experimental?.extensionReloading,
|
||||
enableAgents: settings.experimental?.enableAgents,
|
||||
plan: settings.experimental?.plan,
|
||||
enableEventDrivenScheduler:
|
||||
settings.experimental?.enableEventDrivenScheduler,
|
||||
enableEventDrivenScheduler: true,
|
||||
skillsSupport: settings.skills?.enabled ?? true,
|
||||
disabledSkills: settings.skills?.disabled,
|
||||
experimentalJitContext: settings.experimental?.jitContext,
|
||||
@@ -824,8 +799,6 @@ export async function loadCliConfig(
|
||||
skipNextSpeakerCheck: settings.model?.skipNextSpeakerCheck,
|
||||
enablePromptCompletion: settings.general?.enablePromptCompletion,
|
||||
truncateToolOutputThreshold: settings.tools?.truncateToolOutputThreshold,
|
||||
truncateToolOutputLines: settings.tools?.truncateToolOutputLines,
|
||||
enableToolOutputTruncation: settings.tools?.enableToolOutputTruncation,
|
||||
eventEmitter: coreEvents,
|
||||
useWriteTodos: argv.useWriteTodos ?? settings.useWriteTodos,
|
||||
output: {
|
||||
|
||||
@@ -48,6 +48,8 @@ import {
|
||||
type HookEventName,
|
||||
type ResolvedExtensionSetting,
|
||||
coreEvents,
|
||||
applyAdminAllowlist,
|
||||
getAdminBlockedMcpServersMessage,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { maybeRequestConsentOrFail } from './extensions/consent.js';
|
||||
import { resolveEnvVarsInObject } from '../utils/envVarResolver.js';
|
||||
@@ -661,12 +663,33 @@ Would you like to attempt to install via "git clone" instead?`,
|
||||
if (this.settings.admin.mcp.enabled === false) {
|
||||
config.mcpServers = undefined;
|
||||
} else {
|
||||
config.mcpServers = Object.fromEntries(
|
||||
Object.entries(config.mcpServers).map(([key, value]) => [
|
||||
key,
|
||||
filterMcpConfig(value),
|
||||
]),
|
||||
);
|
||||
// Apply admin allowlist if configured
|
||||
const adminAllowlist = this.settings.admin.mcp.config;
|
||||
if (adminAllowlist && Object.keys(adminAllowlist).length > 0) {
|
||||
const result = applyAdminAllowlist(
|
||||
config.mcpServers,
|
||||
adminAllowlist,
|
||||
);
|
||||
config.mcpServers = result.mcpServers;
|
||||
|
||||
if (result.blockedServerNames.length > 0) {
|
||||
const message = getAdminBlockedMcpServersMessage(
|
||||
result.blockedServerNames,
|
||||
undefined,
|
||||
);
|
||||
coreEvents.emitConsoleLog('warn', message);
|
||||
}
|
||||
}
|
||||
|
||||
// Then apply local filtering/sanitization
|
||||
if (config.mcpServers) {
|
||||
config.mcpServers = Object.fromEntries(
|
||||
Object.entries(config.mcpServers).map(([key, value]) => [
|
||||
key,
|
||||
filterMcpConfig(value),
|
||||
]),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import {
|
||||
describe,
|
||||
it,
|
||||
expect,
|
||||
vi,
|
||||
beforeEach,
|
||||
afterEach,
|
||||
type Mock,
|
||||
} from 'vitest';
|
||||
import {
|
||||
ExtensionRegistryClient,
|
||||
type RegistryExtension,
|
||||
} from './extensionRegistryClient.js';
|
||||
import { fetchWithTimeout } from '@google/gemini-cli-core';
|
||||
|
||||
vi.mock('@google/gemini-cli-core', () => ({
|
||||
fetchWithTimeout: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockExtensions: RegistryExtension[] = [
|
||||
{
|
||||
id: 'ext1',
|
||||
rank: 1,
|
||||
url: 'https://github.com/test/ext1',
|
||||
fullName: 'test/ext1',
|
||||
repoDescription: 'Test extension 1',
|
||||
stars: 100,
|
||||
lastUpdated: '2025-01-01T00:00:00Z',
|
||||
extensionName: 'extension-one',
|
||||
extensionVersion: '1.0.0',
|
||||
extensionDescription: 'First test extension',
|
||||
avatarUrl: 'https://example.com/avatar1.png',
|
||||
hasMCP: true,
|
||||
hasContext: false,
|
||||
isGoogleOwned: false,
|
||||
licenseKey: 'mit',
|
||||
hasHooks: false,
|
||||
hasCustomCommands: false,
|
||||
hasSkills: false,
|
||||
},
|
||||
{
|
||||
id: 'ext2',
|
||||
rank: 2,
|
||||
url: 'https://github.com/test/ext2',
|
||||
fullName: 'test/ext2',
|
||||
repoDescription: 'Test extension 2',
|
||||
stars: 50,
|
||||
lastUpdated: '2025-01-02T00:00:00Z',
|
||||
extensionName: 'extension-two',
|
||||
extensionVersion: '0.5.0',
|
||||
extensionDescription: 'Second test extension',
|
||||
avatarUrl: 'https://example.com/avatar2.png',
|
||||
hasMCP: false,
|
||||
hasContext: true,
|
||||
isGoogleOwned: true,
|
||||
licenseKey: 'apache-2.0',
|
||||
hasHooks: false,
|
||||
hasCustomCommands: false,
|
||||
hasSkills: false,
|
||||
},
|
||||
{
|
||||
id: 'ext3',
|
||||
rank: 3,
|
||||
url: 'https://github.com/test/ext3',
|
||||
fullName: 'test/ext3',
|
||||
repoDescription: 'Test extension 3',
|
||||
stars: 10,
|
||||
lastUpdated: '2025-01-03T00:00:00Z',
|
||||
extensionName: 'extension-three',
|
||||
extensionVersion: '0.1.0',
|
||||
extensionDescription: 'Third test extension',
|
||||
avatarUrl: 'https://example.com/avatar3.png',
|
||||
hasMCP: true,
|
||||
hasContext: true,
|
||||
isGoogleOwned: false,
|
||||
licenseKey: 'gpl-3.0',
|
||||
hasHooks: false,
|
||||
hasCustomCommands: false,
|
||||
hasSkills: false,
|
||||
},
|
||||
];
|
||||
|
||||
describe('ExtensionRegistryClient', () => {
|
||||
let client: ExtensionRegistryClient;
|
||||
let fetchMock: Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
ExtensionRegistryClient.resetCache();
|
||||
client = new ExtensionRegistryClient();
|
||||
fetchMock = fetchWithTimeout as Mock;
|
||||
fetchMock.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should fetch and return extensions with pagination (default ranking)', async () => {
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => mockExtensions,
|
||||
});
|
||||
|
||||
const result = await client.getExtensions(1, 2);
|
||||
expect(result.extensions).toHaveLength(2);
|
||||
expect(result.extensions[0].id).toBe('ext1'); // rank 1
|
||||
expect(result.extensions[1].id).toBe('ext2'); // rank 2
|
||||
expect(result.total).toBe(3);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'https://geminicli.com/extensions.json',
|
||||
10000,
|
||||
);
|
||||
});
|
||||
|
||||
it('should return extensions sorted alphabetically', async () => {
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => mockExtensions,
|
||||
});
|
||||
|
||||
const result = await client.getExtensions(1, 3, 'alphabetical');
|
||||
expect(result.extensions).toHaveLength(3);
|
||||
expect(result.extensions[0].id).toBe('ext1');
|
||||
expect(result.extensions[1].id).toBe('ext3');
|
||||
expect(result.extensions[2].id).toBe('ext2');
|
||||
});
|
||||
|
||||
it('should return the second page of extensions', async () => {
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => mockExtensions,
|
||||
});
|
||||
|
||||
const result = await client.getExtensions(2, 2);
|
||||
expect(result.extensions).toHaveLength(1);
|
||||
expect(result.extensions[0].id).toBe('ext3');
|
||||
expect(result.total).toBe(3);
|
||||
});
|
||||
|
||||
it('should search extensions by name', async () => {
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => mockExtensions,
|
||||
});
|
||||
|
||||
const results = await client.searchExtensions('one');
|
||||
expect(results.length).toBeGreaterThanOrEqual(1);
|
||||
expect(results[0].id).toBe('ext1');
|
||||
});
|
||||
|
||||
it('should search extensions by description', async () => {
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => mockExtensions,
|
||||
});
|
||||
|
||||
const results = await client.searchExtensions('Second');
|
||||
expect(results.length).toBeGreaterThanOrEqual(1);
|
||||
expect(results[0].id).toBe('ext2');
|
||||
});
|
||||
|
||||
it('should get an extension by ID', async () => {
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => mockExtensions,
|
||||
});
|
||||
|
||||
const result = await client.getExtension('ext2');
|
||||
expect(result).toBeDefined();
|
||||
expect(result?.id).toBe('ext2');
|
||||
});
|
||||
|
||||
it('should return undefined if extension not found', async () => {
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => mockExtensions,
|
||||
});
|
||||
|
||||
const result = await client.getExtension('non-existent');
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should cache the fetch result', async () => {
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => mockExtensions,
|
||||
});
|
||||
|
||||
await client.getExtensions();
|
||||
await client.getExtensions();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should share the fetch result across instances', async () => {
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => mockExtensions,
|
||||
});
|
||||
|
||||
const client1 = new ExtensionRegistryClient();
|
||||
const client2 = new ExtensionRegistryClient();
|
||||
|
||||
await client1.getExtensions();
|
||||
await client2.getExtensions();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should throw an error if fetch fails', async () => {
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: false,
|
||||
statusText: 'Not Found',
|
||||
});
|
||||
|
||||
await expect(client.getExtensions()).rejects.toThrow(
|
||||
'Failed to fetch extensions: Not Found',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { fetchWithTimeout } from '@google/gemini-cli-core';
|
||||
import { AsyncFzf } from 'fzf';
|
||||
|
||||
export interface RegistryExtension {
|
||||
id: string;
|
||||
rank: number;
|
||||
url: string;
|
||||
fullName: string;
|
||||
repoDescription: string;
|
||||
stars: number;
|
||||
lastUpdated: string;
|
||||
extensionName: string;
|
||||
extensionVersion: string;
|
||||
extensionDescription: string;
|
||||
avatarUrl: string;
|
||||
hasMCP: boolean;
|
||||
hasContext: boolean;
|
||||
hasHooks: boolean;
|
||||
hasSkills: boolean;
|
||||
hasCustomCommands: boolean;
|
||||
isGoogleOwned: boolean;
|
||||
licenseKey: string;
|
||||
}
|
||||
|
||||
export class ExtensionRegistryClient {
|
||||
private static readonly REGISTRY_URL =
|
||||
'https://geminicli.com/extensions.json';
|
||||
private static readonly FETCH_TIMEOUT_MS = 10000; // 10 seconds
|
||||
|
||||
private static fetchPromise: Promise<RegistryExtension[]> | null = null;
|
||||
|
||||
/** @internal */
|
||||
static resetCache() {
|
||||
ExtensionRegistryClient.fetchPromise = null;
|
||||
}
|
||||
|
||||
async getExtensions(
|
||||
page: number = 1,
|
||||
limit: number = 10,
|
||||
orderBy: 'ranking' | 'alphabetical' = 'ranking',
|
||||
): Promise<{ extensions: RegistryExtension[]; total: number }> {
|
||||
const allExtensions = [...(await this.fetchAllExtensions())];
|
||||
|
||||
switch (orderBy) {
|
||||
case 'ranking':
|
||||
allExtensions.sort((a, b) => a.rank - b.rank);
|
||||
break;
|
||||
case 'alphabetical':
|
||||
allExtensions.sort((a, b) =>
|
||||
a.extensionName.localeCompare(b.extensionName),
|
||||
);
|
||||
break;
|
||||
default: {
|
||||
const _exhaustiveCheck: never = orderBy;
|
||||
throw new Error(`Unhandled orderBy: ${_exhaustiveCheck}`);
|
||||
}
|
||||
}
|
||||
|
||||
const startIndex = (page - 1) * limit;
|
||||
const endIndex = startIndex + limit;
|
||||
return {
|
||||
extensions: allExtensions.slice(startIndex, endIndex),
|
||||
total: allExtensions.length,
|
||||
};
|
||||
}
|
||||
|
||||
async searchExtensions(query: string): Promise<RegistryExtension[]> {
|
||||
const allExtensions = await this.fetchAllExtensions();
|
||||
if (!query.trim()) {
|
||||
return allExtensions;
|
||||
}
|
||||
|
||||
const fzf = new AsyncFzf(allExtensions, {
|
||||
selector: (ext: RegistryExtension) =>
|
||||
`${ext.extensionName} ${ext.extensionDescription} ${ext.fullName}`,
|
||||
fuzzy: 'v2',
|
||||
});
|
||||
const results = await fzf.find(query);
|
||||
return results.map((r: { item: RegistryExtension }) => r.item);
|
||||
}
|
||||
|
||||
async getExtension(id: string): Promise<RegistryExtension | undefined> {
|
||||
const allExtensions = await this.fetchAllExtensions();
|
||||
return allExtensions.find((ext) => ext.id === id);
|
||||
}
|
||||
|
||||
private async fetchAllExtensions(): Promise<RegistryExtension[]> {
|
||||
if (ExtensionRegistryClient.fetchPromise) {
|
||||
return ExtensionRegistryClient.fetchPromise;
|
||||
}
|
||||
|
||||
ExtensionRegistryClient.fetchPromise = (async () => {
|
||||
try {
|
||||
const response = await fetchWithTimeout(
|
||||
ExtensionRegistryClient.REGISTRY_URL,
|
||||
ExtensionRegistryClient.FETCH_TIMEOUT_MS,
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch extensions: ${response.statusText}`);
|
||||
}
|
||||
|
||||
return (await response.json()) as RegistryExtension[];
|
||||
} catch (error) {
|
||||
// Clear the promise on failure so that subsequent calls can try again
|
||||
ExtensionRegistryClient.fetchPromise = null;
|
||||
throw error;
|
||||
}
|
||||
})();
|
||||
|
||||
return ExtensionRegistryClient.fetchPromise;
|
||||
}
|
||||
}
|
||||
@@ -80,6 +80,7 @@ export enum Command {
|
||||
UNFOCUS_BACKGROUND_SHELL = 'backgroundShell.unfocus',
|
||||
UNFOCUS_BACKGROUND_SHELL_LIST = 'backgroundShell.listUnfocus',
|
||||
SHOW_BACKGROUND_SHELL_UNFOCUS_WARNING = 'backgroundShell.unfocusWarning',
|
||||
SHOW_SHELL_INPUT_UNFOCUS_WARNING = 'shellInput.unfocusWarning',
|
||||
|
||||
// App Controls
|
||||
SHOW_ERROR_DETAILS = 'app.showErrorDetails',
|
||||
@@ -281,6 +282,7 @@ export const defaultKeyBindings: KeyBindingConfig = {
|
||||
[Command.SHOW_BACKGROUND_SHELL_UNFOCUS_WARNING]: [
|
||||
{ key: 'tab', shift: false },
|
||||
],
|
||||
[Command.SHOW_SHELL_INPUT_UNFOCUS_WARNING]: [{ key: 'tab', shift: false }],
|
||||
[Command.BACKGROUND_SHELL_SELECT]: [{ key: 'return' }],
|
||||
[Command.BACKGROUND_SHELL_ESCAPE]: [{ key: 'escape' }],
|
||||
[Command.SHOW_MORE_LINES]: [
|
||||
@@ -288,7 +290,7 @@ export const defaultKeyBindings: KeyBindingConfig = {
|
||||
{ key: 's', ctrl: true },
|
||||
],
|
||||
[Command.FOCUS_SHELL_INPUT]: [{ key: 'tab', shift: false }],
|
||||
[Command.UNFOCUS_SHELL_INPUT]: [{ key: 'tab' }],
|
||||
[Command.UNFOCUS_SHELL_INPUT]: [{ key: 'tab', shift: true }],
|
||||
[Command.CLEAR_SCREEN]: [{ key: 'l', ctrl: true }],
|
||||
[Command.RESTART_APP]: [{ key: 'r' }],
|
||||
[Command.SUSPEND_APP]: [{ key: 'z', ctrl: true }],
|
||||
@@ -405,6 +407,7 @@ export const commandCategories: readonly CommandCategory[] = [
|
||||
Command.UNFOCUS_BACKGROUND_SHELL,
|
||||
Command.UNFOCUS_BACKGROUND_SHELL_LIST,
|
||||
Command.SHOW_BACKGROUND_SHELL_UNFOCUS_WARNING,
|
||||
Command.SHOW_SHELL_INPUT_UNFOCUS_WARNING,
|
||||
Command.FOCUS_SHELL_INPUT,
|
||||
Command.UNFOCUS_SHELL_INPUT,
|
||||
Command.CLEAR_SCREEN,
|
||||
@@ -496,16 +499,23 @@ export const commandDescriptions: Readonly<Record<Command, string>> = {
|
||||
'Cycle through approval modes: default (prompt), auto_edit (auto-approve edits), and plan (read-only).',
|
||||
[Command.SHOW_MORE_LINES]:
|
||||
'Expand a height-constrained response to show additional lines when not in alternate buffer mode.',
|
||||
[Command.BACKGROUND_SHELL_SELECT]: 'Enter',
|
||||
[Command.BACKGROUND_SHELL_ESCAPE]: 'Esc',
|
||||
[Command.TOGGLE_BACKGROUND_SHELL]: 'Ctrl+B',
|
||||
[Command.TOGGLE_BACKGROUND_SHELL_LIST]: 'Ctrl+L',
|
||||
[Command.KILL_BACKGROUND_SHELL]: 'Ctrl+K',
|
||||
[Command.UNFOCUS_BACKGROUND_SHELL]: 'Shift+Tab',
|
||||
[Command.UNFOCUS_BACKGROUND_SHELL_LIST]: 'Tab',
|
||||
[Command.SHOW_BACKGROUND_SHELL_UNFOCUS_WARNING]: 'Tab',
|
||||
[Command.FOCUS_SHELL_INPUT]: 'Focus the shell input from the gemini input.',
|
||||
[Command.UNFOCUS_SHELL_INPUT]: 'Focus the Gemini input from the shell input.',
|
||||
[Command.BACKGROUND_SHELL_SELECT]:
|
||||
'Confirm selection in background shell list.',
|
||||
[Command.BACKGROUND_SHELL_ESCAPE]: 'Dismiss background shell list.',
|
||||
[Command.TOGGLE_BACKGROUND_SHELL]:
|
||||
'Toggle current background shell visibility.',
|
||||
[Command.TOGGLE_BACKGROUND_SHELL_LIST]: 'Toggle background shell list.',
|
||||
[Command.KILL_BACKGROUND_SHELL]: 'Kill the active background shell.',
|
||||
[Command.UNFOCUS_BACKGROUND_SHELL]:
|
||||
'Move focus from background shell to Gemini.',
|
||||
[Command.UNFOCUS_BACKGROUND_SHELL_LIST]:
|
||||
'Move focus from background shell list to Gemini.',
|
||||
[Command.SHOW_BACKGROUND_SHELL_UNFOCUS_WARNING]:
|
||||
'Show warning when trying to unfocus background shell via Tab.',
|
||||
[Command.SHOW_SHELL_INPUT_UNFOCUS_WARNING]:
|
||||
'Show warning when trying to unfocus shell input via Tab.',
|
||||
[Command.FOCUS_SHELL_INPUT]: 'Move focus from Gemini to the active shell.',
|
||||
[Command.UNFOCUS_SHELL_INPUT]: 'Move focus from the shell back to Gemini.',
|
||||
[Command.CLEAR_SCREEN]: 'Clear the terminal screen and redraw the UI.',
|
||||
[Command.RESTART_APP]: 'Restart the application.',
|
||||
[Command.SUSPEND_APP]: 'Suspend the application (not yet implemented).',
|
||||
|
||||
@@ -338,6 +338,7 @@ describe('Policy Engine Integration Tests', () => {
|
||||
const validPaths = [
|
||||
'/home/user/.gemini/tmp/a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2/plans/my-plan.md',
|
||||
'/home/user/.gemini/tmp/a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2/plans/feature_auth.md',
|
||||
'/home/user/.gemini/tmp/new-temp_dir_123/plans/plan.md', // new style of temp directory
|
||||
];
|
||||
|
||||
for (const file_path of validPaths) {
|
||||
@@ -364,8 +365,8 @@ describe('Policy Engine Integration Tests', () => {
|
||||
'/project/src/file.ts', // Workspace
|
||||
'/home/user/.gemini/tmp/a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2/plans/script.js', // Wrong extension
|
||||
'/home/user/.gemini/tmp/a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2/plans/../../../etc/passwd.md', // Path traversal
|
||||
'/home/user/.gemini/tmp/abc123/plans/plan.md', // Invalid hash length
|
||||
'/home/user/.gemini/tmp/a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2/plans/subdir/plan.md', // Subdirectory
|
||||
'/home/user/.gemini/non-tmp/new-temp_dir_123/plans/plan.md', // outside of temp dir
|
||||
];
|
||||
|
||||
for (const file_path of invalidPaths) {
|
||||
@@ -433,8 +434,8 @@ describe('Policy Engine Integration Tests', () => {
|
||||
expect(mcpServerRule?.priority).toBe(2.1); // MCP allowed server
|
||||
|
||||
const readOnlyToolRule = rules.find((r) => r.toolName === 'glob');
|
||||
// Priority 50 in default tier → 1.05
|
||||
expect(readOnlyToolRule?.priority).toBeCloseTo(1.05, 5);
|
||||
// Priority 70 in default tier → 1.07 (Overriding Plan Mode Deny)
|
||||
expect(readOnlyToolRule?.priority).toBeCloseTo(1.07, 5);
|
||||
|
||||
// Verify the engine applies these priorities correctly
|
||||
expect(
|
||||
@@ -589,8 +590,8 @@ describe('Policy Engine Integration Tests', () => {
|
||||
expect(server1Rule?.priority).toBe(2.1); // Allowed servers (user tier)
|
||||
|
||||
const globRule = rules.find((r) => r.toolName === 'glob');
|
||||
// Priority 50 in default tier → 1.05
|
||||
expect(globRule?.priority).toBeCloseTo(1.05, 5); // Auto-accept read-only
|
||||
// Priority 70 in default tier → 1.07
|
||||
expect(globRule?.priority).toBeCloseTo(1.07, 5); // Auto-accept read-only
|
||||
|
||||
// The PolicyEngine will sort these by priority when it's created
|
||||
const engine = new PolicyEngine(config);
|
||||
|
||||
@@ -2078,7 +2078,7 @@ describe('Settings Loading and Merging', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should migrate disableUpdateNag to enableAutoUpdateNotification in system and system defaults settings', () => {
|
||||
it('should migrate disableUpdateNag to enableAutoUpdateNotification in memory but not save for system and system defaults settings', () => {
|
||||
const systemSettingsContent = {
|
||||
general: {
|
||||
disableUpdateNag: true,
|
||||
@@ -2103,9 +2103,10 @@ describe('Settings Loading and Merging', () => {
|
||||
},
|
||||
);
|
||||
|
||||
const feedbackSpy = mockCoreEvents.emitFeedback;
|
||||
const settings = loadSettings(MOCK_WORKSPACE_DIR);
|
||||
|
||||
// Verify system settings were migrated
|
||||
// Verify system settings were migrated in memory
|
||||
expect(settings.system.settings.general).toHaveProperty(
|
||||
'enableAutoUpdateNotification',
|
||||
);
|
||||
@@ -2115,7 +2116,7 @@ describe('Settings Loading and Merging', () => {
|
||||
],
|
||||
).toBe(false);
|
||||
|
||||
// Verify system defaults settings were migrated
|
||||
// Verify system defaults settings were migrated in memory
|
||||
expect(settings.systemDefaults.settings.general).toHaveProperty(
|
||||
'enableAutoUpdateNotification',
|
||||
);
|
||||
@@ -2127,6 +2128,74 @@ describe('Settings Loading and Merging', () => {
|
||||
|
||||
// Merged should also reflect it (system overrides defaults, but both are migrated)
|
||||
expect(settings.merged.general?.enableAutoUpdateNotification).toBe(false);
|
||||
|
||||
// Verify it was NOT saved back to disk
|
||||
expect(updateSettingsFilePreservingFormat).not.toHaveBeenCalledWith(
|
||||
getSystemSettingsPath(),
|
||||
expect.anything(),
|
||||
);
|
||||
expect(updateSettingsFilePreservingFormat).not.toHaveBeenCalledWith(
|
||||
getSystemDefaultsPath(),
|
||||
expect.anything(),
|
||||
);
|
||||
|
||||
// Verify warnings were shown
|
||||
expect(feedbackSpy).toHaveBeenCalledWith(
|
||||
'warning',
|
||||
expect.stringContaining(
|
||||
'The system configuration contains deprecated settings',
|
||||
),
|
||||
);
|
||||
expect(feedbackSpy).toHaveBeenCalledWith(
|
||||
'warning',
|
||||
expect.stringContaining(
|
||||
'The system default configuration contains deprecated settings',
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('should migrate experimental agent settings in system scope in memory but not save', () => {
|
||||
const systemSettingsContent = {
|
||||
experimental: {
|
||||
codebaseInvestigatorSettings: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
(fs.readFileSync as Mock).mockImplementation(
|
||||
(p: fs.PathOrFileDescriptor) => {
|
||||
if (p === getSystemSettingsPath()) {
|
||||
return JSON.stringify(systemSettingsContent);
|
||||
}
|
||||
return '{}';
|
||||
},
|
||||
);
|
||||
|
||||
const feedbackSpy = mockCoreEvents.emitFeedback;
|
||||
const settings = loadSettings(MOCK_WORKSPACE_DIR);
|
||||
|
||||
// Verify it was migrated in memory
|
||||
expect(settings.system.settings.agents?.overrides).toMatchObject({
|
||||
codebase_investigator: {
|
||||
enabled: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Verify it was NOT saved back to disk
|
||||
expect(updateSettingsFilePreservingFormat).not.toHaveBeenCalledWith(
|
||||
getSystemSettingsPath(),
|
||||
expect.anything(),
|
||||
);
|
||||
|
||||
// Verify warnings were shown
|
||||
expect(feedbackSpy).toHaveBeenCalledWith(
|
||||
'warning',
|
||||
expect.stringContaining(
|
||||
'The system configuration contains deprecated settings: [experimental.codebaseInvestigatorSettings]',
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('should migrate experimental agent settings to agents overrides', () => {
|
||||
|
||||
@@ -194,6 +194,7 @@ export interface SettingsFile {
|
||||
originalSettings: Settings;
|
||||
path: string;
|
||||
rawJson?: string;
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
function setNestedProperty(
|
||||
@@ -378,25 +379,32 @@ export class LoadedSettings {
|
||||
}
|
||||
}
|
||||
|
||||
private isPersistable(settingsFile: SettingsFile): boolean {
|
||||
return !settingsFile.readOnly;
|
||||
}
|
||||
|
||||
setValue(scope: LoadableSettingScope, key: string, value: unknown): void {
|
||||
const settingsFile = this.forScope(scope);
|
||||
|
||||
// Clone value to prevent reference sharing between settings and originalSettings
|
||||
// Clone value to prevent reference sharing
|
||||
const valueToSet =
|
||||
typeof value === 'object' && value !== null
|
||||
? structuredClone(value)
|
||||
: value;
|
||||
|
||||
setNestedProperty(settingsFile.settings, key, valueToSet);
|
||||
// Use a fresh clone for originalSettings to ensure total independence
|
||||
setNestedProperty(
|
||||
settingsFile.originalSettings,
|
||||
key,
|
||||
structuredClone(valueToSet),
|
||||
);
|
||||
|
||||
if (this.isPersistable(settingsFile)) {
|
||||
// Use a fresh clone for originalSettings to ensure total independence
|
||||
setNestedProperty(
|
||||
settingsFile.originalSettings,
|
||||
key,
|
||||
structuredClone(valueToSet),
|
||||
);
|
||||
saveSettings(settingsFile);
|
||||
}
|
||||
|
||||
this._merged = this.computeMergedSettings();
|
||||
saveSettings(settingsFile);
|
||||
coreEvents.emitSettingsChanged();
|
||||
}
|
||||
|
||||
@@ -716,24 +724,28 @@ export function loadSettings(
|
||||
settings: systemSettings,
|
||||
originalSettings: systemOriginalSettings,
|
||||
rawJson: systemResult.rawJson,
|
||||
readOnly: true,
|
||||
},
|
||||
{
|
||||
path: systemDefaultsPath,
|
||||
settings: systemDefaultSettings,
|
||||
originalSettings: systemDefaultsOriginalSettings,
|
||||
rawJson: systemDefaultsResult.rawJson,
|
||||
readOnly: true,
|
||||
},
|
||||
{
|
||||
path: USER_SETTINGS_PATH,
|
||||
settings: userSettings,
|
||||
originalSettings: userOriginalSettings,
|
||||
rawJson: userResult.rawJson,
|
||||
readOnly: false,
|
||||
},
|
||||
{
|
||||
path: workspaceSettingsPath,
|
||||
settings: workspaceSettings,
|
||||
originalSettings: workspaceOriginalSettings,
|
||||
rawJson: workspaceResult.rawJson,
|
||||
readOnly: false,
|
||||
},
|
||||
isTrusted,
|
||||
settingsErrors,
|
||||
@@ -758,17 +770,26 @@ export function migrateDeprecatedSettings(
|
||||
removeDeprecated = false,
|
||||
): boolean {
|
||||
let anyModified = false;
|
||||
const systemWarnings: Map<LoadableSettingScope, string[]> = new Map();
|
||||
|
||||
/**
|
||||
* Helper to migrate a boolean setting and track it if it's deprecated.
|
||||
*/
|
||||
const migrateBoolean = (
|
||||
settings: Record<string, unknown>,
|
||||
oldKey: string,
|
||||
newKey: string,
|
||||
prefix: string,
|
||||
foundDeprecated?: string[],
|
||||
): boolean => {
|
||||
let modified = false;
|
||||
const oldValue = settings[oldKey];
|
||||
const newValue = settings[newKey];
|
||||
|
||||
if (typeof oldValue === 'boolean') {
|
||||
if (foundDeprecated) {
|
||||
foundDeprecated.push(prefix ? `${prefix}.${oldKey}` : oldKey);
|
||||
}
|
||||
if (typeof newValue === 'boolean') {
|
||||
// Both exist, trust the new one
|
||||
if (removeDeprecated) {
|
||||
@@ -788,7 +809,9 @@ export function migrateDeprecatedSettings(
|
||||
};
|
||||
|
||||
const processScope = (scope: LoadableSettingScope) => {
|
||||
const settings = loadedSettings.forScope(scope).settings;
|
||||
const settingsFile = loadedSettings.forScope(scope);
|
||||
const settings = settingsFile.settings;
|
||||
const foundDeprecated: string[] = [];
|
||||
|
||||
// Migrate general settings
|
||||
const generalSettings = settings.general as
|
||||
@@ -799,18 +822,27 @@ export function migrateDeprecatedSettings(
|
||||
let modified = false;
|
||||
|
||||
modified =
|
||||
migrateBoolean(newGeneral, 'disableAutoUpdate', 'enableAutoUpdate') ||
|
||||
modified;
|
||||
migrateBoolean(
|
||||
newGeneral,
|
||||
'disableAutoUpdate',
|
||||
'enableAutoUpdate',
|
||||
'general',
|
||||
foundDeprecated,
|
||||
) || modified;
|
||||
modified =
|
||||
migrateBoolean(
|
||||
newGeneral,
|
||||
'disableUpdateNag',
|
||||
'enableAutoUpdateNotification',
|
||||
'general',
|
||||
foundDeprecated,
|
||||
) || modified;
|
||||
|
||||
if (modified) {
|
||||
loadedSettings.setValue(scope, 'general', newGeneral);
|
||||
anyModified = true;
|
||||
if (!settingsFile.readOnly) {
|
||||
anyModified = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -829,11 +861,15 @@ export function migrateDeprecatedSettings(
|
||||
newAccessibility,
|
||||
'disableLoadingPhrases',
|
||||
'enableLoadingPhrases',
|
||||
'ui.accessibility',
|
||||
foundDeprecated,
|
||||
)
|
||||
) {
|
||||
newUi['accessibility'] = newAccessibility;
|
||||
loadedSettings.setValue(scope, 'ui', newUi);
|
||||
anyModified = true;
|
||||
if (!settingsFile.readOnly) {
|
||||
anyModified = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -855,23 +891,37 @@ export function migrateDeprecatedSettings(
|
||||
newFileFiltering,
|
||||
'disableFuzzySearch',
|
||||
'enableFuzzySearch',
|
||||
'context.fileFiltering',
|
||||
foundDeprecated,
|
||||
)
|
||||
) {
|
||||
newContext['fileFiltering'] = newFileFiltering;
|
||||
loadedSettings.setValue(scope, 'context', newContext);
|
||||
anyModified = true;
|
||||
if (!settingsFile.readOnly) {
|
||||
anyModified = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Migrate experimental agent settings
|
||||
anyModified =
|
||||
migrateExperimentalSettings(
|
||||
settings,
|
||||
loadedSettings,
|
||||
scope,
|
||||
removeDeprecated,
|
||||
) || anyModified;
|
||||
const experimentalModified = migrateExperimentalSettings(
|
||||
settings,
|
||||
loadedSettings,
|
||||
scope,
|
||||
removeDeprecated,
|
||||
foundDeprecated,
|
||||
);
|
||||
|
||||
if (experimentalModified) {
|
||||
if (!settingsFile.readOnly) {
|
||||
anyModified = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (settingsFile.readOnly && foundDeprecated.length > 0) {
|
||||
systemWarnings.set(scope, foundDeprecated);
|
||||
}
|
||||
};
|
||||
|
||||
processScope(SettingScope.User);
|
||||
@@ -879,6 +929,19 @@ export function migrateDeprecatedSettings(
|
||||
processScope(SettingScope.System);
|
||||
processScope(SettingScope.SystemDefaults);
|
||||
|
||||
if (systemWarnings.size > 0) {
|
||||
for (const [scope, flags] of systemWarnings) {
|
||||
const scopeName =
|
||||
scope === SettingScope.SystemDefaults
|
||||
? 'system default'
|
||||
: scope.toLowerCase();
|
||||
coreEvents.emitFeedback(
|
||||
'warning',
|
||||
`The ${scopeName} configuration contains deprecated settings: [${flags.join(', ')}]. These could not be migrated automatically as system settings are read-only. Please update the system configuration manually.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return anyModified;
|
||||
}
|
||||
|
||||
@@ -926,10 +989,12 @@ function migrateExperimentalSettings(
|
||||
loadedSettings: LoadedSettings,
|
||||
scope: LoadableSettingScope,
|
||||
removeDeprecated: boolean,
|
||||
foundDeprecated?: string[],
|
||||
): boolean {
|
||||
const experimentalSettings = settings.experimental as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
|
||||
if (experimentalSettings) {
|
||||
const agentsSettings = {
|
||||
...(settings.agents as Record<string, unknown> | undefined),
|
||||
@@ -939,11 +1004,20 @@ function migrateExperimentalSettings(
|
||||
};
|
||||
let modified = false;
|
||||
|
||||
const migrateExperimental = (
|
||||
oldKey: string,
|
||||
migrateFn: (oldValue: Record<string, unknown>) => void,
|
||||
) => {
|
||||
const old = experimentalSettings[oldKey];
|
||||
if (old) {
|
||||
foundDeprecated?.push(`experimental.${oldKey}`);
|
||||
migrateFn(old as Record<string, unknown>);
|
||||
modified = true;
|
||||
}
|
||||
};
|
||||
|
||||
// Migrate codebaseInvestigatorSettings -> agents.overrides.codebase_investigator
|
||||
if (experimentalSettings['codebaseInvestigatorSettings']) {
|
||||
const old = experimentalSettings[
|
||||
'codebaseInvestigatorSettings'
|
||||
] as Record<string, unknown>;
|
||||
migrateExperimental('codebaseInvestigatorSettings', (old) => {
|
||||
const override = {
|
||||
...(agentsOverrides['codebase_investigator'] as
|
||||
| Record<string, unknown>
|
||||
@@ -985,22 +1059,16 @@ function migrateExperimentalSettings(
|
||||
}
|
||||
|
||||
agentsOverrides['codebase_investigator'] = override;
|
||||
modified = true;
|
||||
}
|
||||
});
|
||||
|
||||
// Migrate cliHelpAgentSettings -> agents.overrides.cli_help
|
||||
if (experimentalSettings['cliHelpAgentSettings']) {
|
||||
const old = experimentalSettings['cliHelpAgentSettings'] as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
migrateExperimental('cliHelpAgentSettings', (old) => {
|
||||
const override = {
|
||||
...(agentsOverrides['cli_help'] as Record<string, unknown> | undefined),
|
||||
};
|
||||
if (old['enabled'] !== undefined) override['enabled'] = old['enabled'];
|
||||
agentsOverrides['cli_help'] = override;
|
||||
modified = true;
|
||||
}
|
||||
});
|
||||
|
||||
if (modified) {
|
||||
agentsSettings['overrides'] = agentsOverrides;
|
||||
|
||||
@@ -328,30 +328,6 @@ describe('SettingsSchema', () => {
|
||||
).toBe('Enable debug logging of keystrokes to the console.');
|
||||
});
|
||||
|
||||
it('should have previewFeatures setting in schema', () => {
|
||||
expect(
|
||||
getSettingsSchema().general.properties.previewFeatures,
|
||||
).toBeDefined();
|
||||
expect(getSettingsSchema().general.properties.previewFeatures.type).toBe(
|
||||
'boolean',
|
||||
);
|
||||
expect(
|
||||
getSettingsSchema().general.properties.previewFeatures.category,
|
||||
).toBe('General');
|
||||
expect(
|
||||
getSettingsSchema().general.properties.previewFeatures.default,
|
||||
).toBe(false);
|
||||
expect(
|
||||
getSettingsSchema().general.properties.previewFeatures.requiresRestart,
|
||||
).toBe(false);
|
||||
expect(
|
||||
getSettingsSchema().general.properties.previewFeatures.showInDialog,
|
||||
).toBe(true);
|
||||
expect(
|
||||
getSettingsSchema().general.properties.previewFeatures.description,
|
||||
).toBe('Enable preview features (e.g., preview models).');
|
||||
});
|
||||
|
||||
it('should have enableAgents setting in schema', () => {
|
||||
const setting = getSettingsSchema().experimental.properties.enableAgents;
|
||||
expect(setting).toBeDefined();
|
||||
@@ -389,20 +365,6 @@ describe('SettingsSchema', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should have enableEventDrivenScheduler setting in schema', () => {
|
||||
const setting =
|
||||
getSettingsSchema().experimental.properties.enableEventDrivenScheduler;
|
||||
expect(setting).toBeDefined();
|
||||
expect(setting.type).toBe('boolean');
|
||||
expect(setting.category).toBe('Experimental');
|
||||
expect(setting.default).toBe(true);
|
||||
expect(setting.requiresRestart).toBe(true);
|
||||
expect(setting.showInDialog).toBe(false);
|
||||
expect(setting.description).toBe(
|
||||
'Enables event-driven scheduler within the CLI session.',
|
||||
);
|
||||
});
|
||||
|
||||
it('should have hooksConfig.notifications setting in schema', () => {
|
||||
const setting = getSettingsSchema().hooksConfig?.properties.notifications;
|
||||
expect(setting).toBeDefined();
|
||||
|
||||
@@ -10,7 +10,6 @@
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
import {
|
||||
DEFAULT_TRUNCATE_TOOL_OUTPUT_LINES,
|
||||
DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD,
|
||||
DEFAULT_MODEL_CONFIGS,
|
||||
type MCPServerConfig,
|
||||
@@ -162,15 +161,6 @@ const SETTINGS_SCHEMA = {
|
||||
description: 'General application settings.',
|
||||
showInDialog: false,
|
||||
properties: {
|
||||
previewFeatures: {
|
||||
type: 'boolean',
|
||||
label: 'Preview Features (e.g., models)',
|
||||
category: 'General',
|
||||
requiresRestart: false,
|
||||
default: false,
|
||||
description: 'Enable preview features (e.g., preview models).',
|
||||
showInDialog: true,
|
||||
},
|
||||
preferredEditor: {
|
||||
type: 'string',
|
||||
label: 'Preferred Editor',
|
||||
@@ -1158,15 +1148,6 @@ const SETTINGS_SCHEMA = {
|
||||
'Use ripgrep for file content search instead of the fallback implementation. Provides faster search performance.',
|
||||
showInDialog: true,
|
||||
},
|
||||
enableToolOutputTruncation: {
|
||||
type: 'boolean',
|
||||
label: 'Enable Tool Output Truncation',
|
||||
category: 'General',
|
||||
requiresRestart: true,
|
||||
default: true,
|
||||
description: 'Enable truncation of large tool outputs.',
|
||||
showInDialog: true,
|
||||
},
|
||||
truncateToolOutputThreshold: {
|
||||
type: 'number',
|
||||
label: 'Tool Output Truncation Threshold',
|
||||
@@ -1174,16 +1155,7 @@ const SETTINGS_SCHEMA = {
|
||||
requiresRestart: true,
|
||||
default: DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD,
|
||||
description:
|
||||
'Truncate tool output if it is larger than this many characters. Set to -1 to disable.',
|
||||
showInDialog: true,
|
||||
},
|
||||
truncateToolOutputLines: {
|
||||
type: 'number',
|
||||
label: 'Tool Output Truncation Lines',
|
||||
category: 'General',
|
||||
requiresRestart: true,
|
||||
default: DEFAULT_TRUNCATE_TOOL_OUTPUT_LINES,
|
||||
description: 'The number of lines to keep when truncating tool output.',
|
||||
'Maximum characters to show when truncating large tool outputs. Set to 0 or negative to disable truncation.',
|
||||
showInDialog: true,
|
||||
},
|
||||
disableLLMCorrection: {
|
||||
@@ -1538,17 +1510,8 @@ const SETTINGS_SCHEMA = {
|
||||
label: 'Extension Configuration',
|
||||
category: 'Experimental',
|
||||
requiresRestart: true,
|
||||
default: false,
|
||||
description: 'Enable requesting and fetching of extension settings.',
|
||||
showInDialog: false,
|
||||
},
|
||||
enableEventDrivenScheduler: {
|
||||
type: 'boolean',
|
||||
label: 'Event Driven Scheduler',
|
||||
category: 'Experimental',
|
||||
requiresRestart: true,
|
||||
default: true,
|
||||
description: 'Enables event-driven scheduler within the CLI session.',
|
||||
description: 'Enable requesting and fetching of extension settings.',
|
||||
showInDialog: false,
|
||||
},
|
||||
extensionReloading: {
|
||||
|
||||
@@ -134,7 +134,6 @@ describe('Settings Repro', () => {
|
||||
enablePromptCompletion: false,
|
||||
preferredEditor: 'vim',
|
||||
vimMode: false,
|
||||
previewFeatures: false,
|
||||
},
|
||||
security: {
|
||||
auth: {
|
||||
@@ -150,7 +149,6 @@ describe('Settings Repro', () => {
|
||||
showColor: true,
|
||||
enableInteractiveShell: true,
|
||||
},
|
||||
truncateToolOutputLines: 100,
|
||||
},
|
||||
experimental: {
|
||||
useModelRouter: false,
|
||||
|
||||
@@ -167,7 +167,15 @@ describe('deferred', () => {
|
||||
|
||||
// Now manually run it to verify it captured correctly
|
||||
await runDeferredCommand(createMockSettings().merged);
|
||||
expect(originalHandler).toHaveBeenCalledWith(argv);
|
||||
expect(originalHandler).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
settings: expect.objectContaining({
|
||||
admin: expect.objectContaining({
|
||||
extensions: expect.objectContaining({ enabled: true }),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(mockExit).toHaveBeenCalledWith(ExitCodes.SUCCESS);
|
||||
});
|
||||
|
||||
|
||||
@@ -63,7 +63,13 @@ export async function runDeferredCommand(settings: MergedSettings) {
|
||||
process.exit(ExitCodes.FATAL_CONFIG_ERROR);
|
||||
}
|
||||
|
||||
await deferredCommand.handler(deferredCommand.argv);
|
||||
// Inject settings into argv
|
||||
const argvWithSettings = {
|
||||
...deferredCommand.argv,
|
||||
settings,
|
||||
};
|
||||
|
||||
await deferredCommand.handler(argvWithSettings);
|
||||
await runExitCleanup();
|
||||
process.exit(ExitCodes.SUCCESS);
|
||||
}
|
||||
|
||||
@@ -510,9 +510,15 @@ export async function main() {
|
||||
projectHooks: settings.workspace.settings.hooks,
|
||||
});
|
||||
loadConfigHandle?.end();
|
||||
|
||||
// Initialize storage immediately after loading config to ensure that
|
||||
// storage-related operations (like listing or resuming sessions) have
|
||||
// access to the project identifier.
|
||||
await config.storage.initialize();
|
||||
|
||||
adminControlsListner.setConfig(config);
|
||||
|
||||
if (config.isInteractive() && config.storage && config.getDebugMode()) {
|
||||
if (config.isInteractive() && config.getDebugMode()) {
|
||||
const { registerActivityLogger } = await import(
|
||||
'./utils/activityLogger.js'
|
||||
);
|
||||
|
||||
@@ -38,6 +38,10 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
disableMouseEvents: vi.fn(),
|
||||
enterAlternateScreen: vi.fn(),
|
||||
disableLineWrapping: vi.fn(),
|
||||
ProjectRegistry: vi.fn().mockImplementation(() => ({
|
||||
initialize: vi.fn(),
|
||||
getShortId: vi.fn().mockReturnValue('project-slug'),
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -73,6 +77,7 @@ vi.mock('./config/config.js', () => ({
|
||||
getSandbox: vi.fn(() => false),
|
||||
getQuestion: vi.fn(() => ''),
|
||||
isInteractive: () => false,
|
||||
storage: { initialize: vi.fn().mockResolvedValue(undefined) },
|
||||
} as unknown as Config),
|
||||
parseArguments: vi.fn().mockResolvedValue({}),
|
||||
isDebugMode: vi.fn(() => false),
|
||||
@@ -191,6 +196,7 @@ describe('gemini.tsx main function cleanup', () => {
|
||||
getEnableHooks: vi.fn(() => false),
|
||||
getHookSystem: () => undefined,
|
||||
initialize: vi.fn(),
|
||||
storage: { initialize: vi.fn().mockResolvedValue(undefined) },
|
||||
getContentGeneratorConfig: vi.fn(),
|
||||
getMcpServers: () => ({}),
|
||||
getMcpClientManager: vi.fn(),
|
||||
|
||||
@@ -267,8 +267,8 @@ describe('runNonInteractive', () => {
|
||||
// so we no longer expect shutdownTelemetry to be called directly here
|
||||
});
|
||||
|
||||
it('should register activity logger when GEMINI_CLI_ACTIVITY_LOG_FILE is set', async () => {
|
||||
vi.stubEnv('GEMINI_CLI_ACTIVITY_LOG_FILE', '/tmp/test.jsonl');
|
||||
it('should register activity logger when GEMINI_CLI_ACTIVITY_LOG_TARGET is set', async () => {
|
||||
vi.stubEnv('GEMINI_CLI_ACTIVITY_LOG_TARGET', '/tmp/test.jsonl');
|
||||
const events: ServerGeminiStreamEvent[] = [
|
||||
{
|
||||
type: GeminiEventType.Finished,
|
||||
@@ -290,8 +290,8 @@ describe('runNonInteractive', () => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it('should not register activity logger when GEMINI_CLI_ACTIVITY_LOG_FILE is not set', async () => {
|
||||
vi.stubEnv('GEMINI_CLI_ACTIVITY_LOG_FILE', '');
|
||||
it('should not register activity logger when GEMINI_CLI_ACTIVITY_LOG_TARGET is not set', async () => {
|
||||
vi.stubEnv('GEMINI_CLI_ACTIVITY_LOG_TARGET', '');
|
||||
const events: ServerGeminiStreamEvent[] = [
|
||||
{
|
||||
type: GeminiEventType.Finished,
|
||||
|
||||
@@ -71,7 +71,7 @@ export async function runNonInteractive({
|
||||
},
|
||||
});
|
||||
|
||||
if (config.storage && process.env['GEMINI_CLI_ACTIVITY_LOG_FILE']) {
|
||||
if (process.env['GEMINI_CLI_ACTIVITY_LOG_TARGET']) {
|
||||
const { registerActivityLogger } = await import(
|
||||
'./utils/activityLogger.js'
|
||||
);
|
||||
|
||||
@@ -85,6 +85,9 @@ vi.mock('../ui/commands/extensionsCommand.js', () => ({
|
||||
extensionsCommand: () => ({}),
|
||||
}));
|
||||
vi.mock('../ui/commands/helpCommand.js', () => ({ helpCommand: {} }));
|
||||
vi.mock('../ui/commands/shortcutsCommand.js', () => ({
|
||||
shortcutsCommand: {},
|
||||
}));
|
||||
vi.mock('../ui/commands/memoryCommand.js', () => ({ memoryCommand: {} }));
|
||||
vi.mock('../ui/commands/modelCommand.js', () => ({
|
||||
modelCommand: { name: 'model' },
|
||||
|
||||
@@ -31,6 +31,7 @@ import { directoryCommand } from '../ui/commands/directoryCommand.js';
|
||||
import { editorCommand } from '../ui/commands/editorCommand.js';
|
||||
import { extensionsCommand } from '../ui/commands/extensionsCommand.js';
|
||||
import { helpCommand } from '../ui/commands/helpCommand.js';
|
||||
import { shortcutsCommand } from '../ui/commands/shortcutsCommand.js';
|
||||
import { rewindCommand } from '../ui/commands/rewindCommand.js';
|
||||
import { hooksCommand } from '../ui/commands/hooksCommand.js';
|
||||
import { ideCommand } from '../ui/commands/ideCommand.js';
|
||||
@@ -116,6 +117,7 @@ export class BuiltinCommandLoader implements ICommandLoader {
|
||||
]
|
||||
: [extensionsCommand(this.config?.getEnableExtensionReloading())]),
|
||||
helpCommand,
|
||||
shortcutsCommand,
|
||||
...(this.config?.getEnableHooksUI() ? [hooksCommand] : []),
|
||||
rewindCommand,
|
||||
await ideCommand(),
|
||||
|
||||
@@ -60,6 +60,7 @@ export const createMockCommandContext = (
|
||||
setPendingItem: vi.fn(),
|
||||
loadHistory: vi.fn(),
|
||||
toggleCorgiMode: vi.fn(),
|
||||
toggleShortcutsHelp: vi.fn(),
|
||||
toggleVimEnabled: vi.fn(),
|
||||
openAgentConfigDialog: vi.fn(),
|
||||
closeAgentConfigDialog: vi.fn(),
|
||||
|
||||
@@ -20,6 +20,7 @@ export const createMockConfig = (overrides: Partial<Config> = {}): Config =>
|
||||
setTerminalBackground: vi.fn(),
|
||||
storage: {
|
||||
getProjectTempDir: vi.fn().mockReturnValue('/tmp/gemini-test'),
|
||||
initialize: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
getDebugMode: vi.fn(() => false),
|
||||
getProjectRoot: vi.fn(() => '/'),
|
||||
@@ -151,7 +152,6 @@ export const createMockConfig = (overrides: Partial<Config> = {}): Config =>
|
||||
getAllowedMcpServers: vi.fn().mockReturnValue([]),
|
||||
getBlockedMcpServers: vi.fn().mockReturnValue([]),
|
||||
getExperiments: vi.fn().mockReturnValue(undefined),
|
||||
getPreviewFeatures: vi.fn().mockReturnValue(false),
|
||||
getHasAccessToPreviewModel: vi.fn().mockReturnValue(false),
|
||||
...overrides,
|
||||
}) as unknown as Config;
|
||||
|
||||
@@ -191,6 +191,7 @@ const mockUIActions: UIActions = {
|
||||
handleApiKeySubmit: vi.fn(),
|
||||
handleApiKeyCancel: vi.fn(),
|
||||
setBannerVisible: vi.fn(),
|
||||
setShortcutsHelpVisible: vi.fn(),
|
||||
setEmbeddedShellFocused: vi.fn(),
|
||||
dismissBackgroundShell: vi.fn(),
|
||||
setActiveBackgroundShellPid: vi.fn(),
|
||||
|
||||
@@ -1940,6 +1940,160 @@ describe('AppContainer State Management', () => {
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Focus Handling (Tab / Shift+Tab)', () => {
|
||||
beforeEach(() => {
|
||||
// Mock activePtyId to enable focus
|
||||
mockedUseGeminiStream.mockReturnValue({
|
||||
...DEFAULT_GEMINI_STREAM_MOCK,
|
||||
activePtyId: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('should focus shell input on Tab', async () => {
|
||||
await setupKeypressTest();
|
||||
|
||||
pressKey({ name: 'tab', shift: false });
|
||||
|
||||
expect(capturedUIState.embeddedShellFocused).toBe(true);
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should unfocus shell input on Shift+Tab', async () => {
|
||||
await setupKeypressTest();
|
||||
|
||||
// Focus first
|
||||
pressKey({ name: 'tab', shift: false });
|
||||
expect(capturedUIState.embeddedShellFocused).toBe(true);
|
||||
|
||||
// Unfocus via Shift+Tab
|
||||
pressKey({ name: 'tab', shift: true });
|
||||
expect(capturedUIState.embeddedShellFocused).toBe(false);
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should auto-unfocus when activePtyId becomes null', async () => {
|
||||
// Start with active pty and focused
|
||||
mockedUseGeminiStream.mockReturnValue({
|
||||
...DEFAULT_GEMINI_STREAM_MOCK,
|
||||
activePtyId: 1,
|
||||
});
|
||||
|
||||
const renderResult = render(getAppContainer());
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(0);
|
||||
});
|
||||
|
||||
// Focus it
|
||||
act(() => {
|
||||
handleGlobalKeypress({
|
||||
name: 'tab',
|
||||
shift: false,
|
||||
alt: false,
|
||||
ctrl: false,
|
||||
cmd: false,
|
||||
} as Key);
|
||||
});
|
||||
expect(capturedUIState.embeddedShellFocused).toBe(true);
|
||||
|
||||
// Now mock activePtyId becoming null
|
||||
mockedUseGeminiStream.mockReturnValue({
|
||||
...DEFAULT_GEMINI_STREAM_MOCK,
|
||||
activePtyId: null,
|
||||
});
|
||||
|
||||
// Rerender to trigger useEffect
|
||||
await act(async () => {
|
||||
renderResult.rerender(getAppContainer());
|
||||
});
|
||||
|
||||
expect(capturedUIState.embeddedShellFocused).toBe(false);
|
||||
renderResult.unmount();
|
||||
});
|
||||
|
||||
it('should focus background shell on Tab when already visible (not toggle it off)', async () => {
|
||||
const mockToggleBackgroundShell = vi.fn();
|
||||
mockedUseGeminiStream.mockReturnValue({
|
||||
...DEFAULT_GEMINI_STREAM_MOCK,
|
||||
activePtyId: null,
|
||||
isBackgroundShellVisible: true,
|
||||
backgroundShells: new Map([[123, { pid: 123, status: 'running' }]]),
|
||||
toggleBackgroundShell: mockToggleBackgroundShell,
|
||||
});
|
||||
|
||||
await setupKeypressTest();
|
||||
|
||||
// Initially not focused
|
||||
expect(capturedUIState.embeddedShellFocused).toBe(false);
|
||||
|
||||
// Press Tab
|
||||
pressKey({ name: 'tab', shift: false });
|
||||
|
||||
// Should be focused
|
||||
expect(capturedUIState.embeddedShellFocused).toBe(true);
|
||||
// Should NOT have toggled (closed) the shell
|
||||
expect(mockToggleBackgroundShell).not.toHaveBeenCalled();
|
||||
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Background Shell Toggling (CTRL+B)', () => {
|
||||
it('should toggle background shell on Ctrl+B even if visible but not focused', async () => {
|
||||
const mockToggleBackgroundShell = vi.fn();
|
||||
mockedUseGeminiStream.mockReturnValue({
|
||||
...DEFAULT_GEMINI_STREAM_MOCK,
|
||||
activePtyId: null,
|
||||
isBackgroundShellVisible: true,
|
||||
backgroundShells: new Map([[123, { pid: 123, status: 'running' }]]),
|
||||
toggleBackgroundShell: mockToggleBackgroundShell,
|
||||
});
|
||||
|
||||
await setupKeypressTest();
|
||||
|
||||
// Initially not focused, but visible
|
||||
expect(capturedUIState.embeddedShellFocused).toBe(false);
|
||||
|
||||
// Press Ctrl+B
|
||||
pressKey({ name: 'b', ctrl: true });
|
||||
|
||||
// Should have toggled (closed) the shell
|
||||
expect(mockToggleBackgroundShell).toHaveBeenCalled();
|
||||
// Should be unfocused
|
||||
expect(capturedUIState.embeddedShellFocused).toBe(false);
|
||||
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should show and focus background shell on Ctrl+B if hidden', async () => {
|
||||
const mockToggleBackgroundShell = vi.fn();
|
||||
const geminiStreamMock = {
|
||||
...DEFAULT_GEMINI_STREAM_MOCK,
|
||||
activePtyId: null,
|
||||
isBackgroundShellVisible: false,
|
||||
backgroundShells: new Map([[123, { pid: 123, status: 'running' }]]),
|
||||
toggleBackgroundShell: mockToggleBackgroundShell,
|
||||
};
|
||||
mockedUseGeminiStream.mockReturnValue(geminiStreamMock);
|
||||
|
||||
await setupKeypressTest();
|
||||
|
||||
// Update the mock state when toggled to simulate real behavior
|
||||
mockToggleBackgroundShell.mockImplementation(() => {
|
||||
geminiStreamMock.isBackgroundShellVisible = true;
|
||||
});
|
||||
|
||||
// Press Ctrl+B
|
||||
pressKey({ name: 'b', ctrl: true });
|
||||
|
||||
// Should have toggled (shown) the shell
|
||||
expect(mockToggleBackgroundShell).toHaveBeenCalled();
|
||||
// Should be focused
|
||||
expect(capturedUIState.embeddedShellFocused).toBe(true);
|
||||
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Copy Mode (CTRL+S)', () => {
|
||||
|
||||
@@ -246,7 +246,7 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
[defaultBannerText, warningBannerText],
|
||||
);
|
||||
|
||||
const { bannerText } = useBanner(bannerData, config);
|
||||
const { bannerText } = useBanner(bannerData);
|
||||
|
||||
const extensionManager = config.getExtensionLoader() as ExtensionManager;
|
||||
// We are in the interactive CLI, update how we request consent and settings.
|
||||
@@ -760,6 +760,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
const setIsBackgroundShellListOpenRef = useRef<(open: boolean) => void>(
|
||||
() => {},
|
||||
);
|
||||
const [shortcutsHelpVisible, setShortcutsHelpVisible] = useState(false);
|
||||
|
||||
const slashCommandActions = useMemo(
|
||||
() => ({
|
||||
@@ -795,6 +796,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
}
|
||||
}
|
||||
},
|
||||
toggleShortcutsHelp: () => setShortcutsHelpVisible((visible) => !visible),
|
||||
setText: stableSetText,
|
||||
}),
|
||||
[
|
||||
@@ -813,6 +815,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
openPermissionsDialog,
|
||||
addConfirmUpdateExtensionRequest,
|
||||
toggleDebugProfiler,
|
||||
setShortcutsHelpVisible,
|
||||
stableSetText,
|
||||
],
|
||||
);
|
||||
@@ -1291,24 +1294,26 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
}, WARNING_PROMPT_DURATION_MS);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handleSelectionWarning = () => {
|
||||
handleWarning('Press Ctrl-S to enter selection mode to copy text.');
|
||||
};
|
||||
const handlePasteTimeout = () => {
|
||||
handleWarning('Paste Timed out. Possibly due to slow connection.');
|
||||
};
|
||||
appEvents.on(AppEvent.SelectionWarning, handleSelectionWarning);
|
||||
appEvents.on(AppEvent.PasteTimeout, handlePasteTimeout);
|
||||
return () => {
|
||||
appEvents.off(AppEvent.SelectionWarning, handleSelectionWarning);
|
||||
appEvents.off(AppEvent.PasteTimeout, handlePasteTimeout);
|
||||
// Handle timeout cleanup on unmount
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (warningTimeoutRef.current) {
|
||||
clearTimeout(warningTimeoutRef.current);
|
||||
}
|
||||
if (tabFocusTimeoutRef.current) {
|
||||
clearTimeout(tabFocusTimeoutRef.current);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const handlePasteTimeout = () => {
|
||||
handleWarning('Paste Timed out. Possibly due to slow connection.');
|
||||
};
|
||||
appEvents.on(AppEvent.PasteTimeout, handlePasteTimeout);
|
||||
return () => {
|
||||
appEvents.off(AppEvent.PasteTimeout, handlePasteTimeout);
|
||||
};
|
||||
}, [handleWarning]);
|
||||
|
||||
@@ -1506,71 +1511,60 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
setConstrainHeight(false);
|
||||
return true;
|
||||
} else if (
|
||||
keyMatchers[Command.FOCUS_SHELL_INPUT](key) &&
|
||||
(keyMatchers[Command.FOCUS_SHELL_INPUT](key) ||
|
||||
keyMatchers[Command.UNFOCUS_BACKGROUND_SHELL_LIST](key)) &&
|
||||
(activePtyId || (isBackgroundShellVisible && backgroundShells.size > 0))
|
||||
) {
|
||||
if (key.name === 'tab' && key.shift) {
|
||||
// Always change focus
|
||||
if (embeddedShellFocused) {
|
||||
const capturedTime = lastOutputTimeRef.current;
|
||||
if (tabFocusTimeoutRef.current)
|
||||
clearTimeout(tabFocusTimeoutRef.current);
|
||||
tabFocusTimeoutRef.current = setTimeout(() => {
|
||||
if (lastOutputTimeRef.current === capturedTime) {
|
||||
setEmbeddedShellFocused(false);
|
||||
} else {
|
||||
handleWarning('Use Shift+Tab to unfocus');
|
||||
}
|
||||
}, 150);
|
||||
return false;
|
||||
}
|
||||
|
||||
const isIdle = Date.now() - lastOutputTimeRef.current >= 100;
|
||||
|
||||
if (isIdle && !activePtyId && !isBackgroundShellVisible) {
|
||||
if (tabFocusTimeoutRef.current)
|
||||
clearTimeout(tabFocusTimeoutRef.current);
|
||||
toggleBackgroundShell();
|
||||
setEmbeddedShellFocused(true);
|
||||
if (backgroundShells.size > 1) setIsBackgroundShellListOpen(true);
|
||||
return true;
|
||||
}
|
||||
|
||||
setEmbeddedShellFocused(true);
|
||||
return true;
|
||||
} else if (
|
||||
keyMatchers[Command.UNFOCUS_SHELL_INPUT](key) ||
|
||||
keyMatchers[Command.UNFOCUS_BACKGROUND_SHELL](key)
|
||||
) {
|
||||
if (embeddedShellFocused) {
|
||||
setEmbeddedShellFocused(false);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (embeddedShellFocused) {
|
||||
handleWarning('Press Shift+Tab to focus out.');
|
||||
return true;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
// If the shell hasn't produced output in the last 100ms, it's considered idle.
|
||||
const isIdle = now - lastOutputTimeRef.current >= 100;
|
||||
if (isIdle && !activePtyId) {
|
||||
if (tabFocusTimeoutRef.current) {
|
||||
clearTimeout(tabFocusTimeoutRef.current);
|
||||
}
|
||||
toggleBackgroundShell();
|
||||
if (!isBackgroundShellVisible) {
|
||||
// We are about to show it, so focus it
|
||||
setEmbeddedShellFocused(true);
|
||||
if (backgroundShells.size > 1) {
|
||||
setIsBackgroundShellListOpen(true);
|
||||
}
|
||||
} else {
|
||||
// We are about to hide it
|
||||
tabFocusTimeoutRef.current = setTimeout(() => {
|
||||
tabFocusTimeoutRef.current = null;
|
||||
// If the shell produced output since the tab press, we assume it handled the tab
|
||||
// (e.g. autocomplete) so we should not toggle focus.
|
||||
if (lastOutputTimeRef.current > now) {
|
||||
handleWarning('Press Shift+Tab to focus out.');
|
||||
return;
|
||||
}
|
||||
setEmbeddedShellFocused(false);
|
||||
}, 100);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Not idle, just focus it
|
||||
setEmbeddedShellFocused(true);
|
||||
return true;
|
||||
return false;
|
||||
} else if (keyMatchers[Command.TOGGLE_BACKGROUND_SHELL](key)) {
|
||||
if (activePtyId) {
|
||||
backgroundCurrentShell();
|
||||
// After backgrounding, we explicitly do NOT show or focus the background UI.
|
||||
} else {
|
||||
if (isBackgroundShellVisible && !embeddedShellFocused) {
|
||||
toggleBackgroundShell();
|
||||
// Toggle focus based on intent: if we were hiding, unfocus; if showing, focus.
|
||||
if (!isBackgroundShellVisible && backgroundShells.size > 0) {
|
||||
setEmbeddedShellFocused(true);
|
||||
} else {
|
||||
toggleBackgroundShell();
|
||||
// Toggle focus based on intent: if we were hiding, unfocus; if showing, focus.
|
||||
if (!isBackgroundShellVisible && backgroundShells.size > 0) {
|
||||
setEmbeddedShellFocused(true);
|
||||
if (backgroundShells.size > 1) {
|
||||
setIsBackgroundShellListOpen(true);
|
||||
}
|
||||
} else {
|
||||
setEmbeddedShellFocused(false);
|
||||
if (backgroundShells.size > 1) {
|
||||
setIsBackgroundShellListOpen(true);
|
||||
}
|
||||
} else {
|
||||
setEmbeddedShellFocused(false);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
@@ -1613,7 +1607,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
],
|
||||
);
|
||||
|
||||
useKeypress(handleGlobalKeypress, { isActive: true });
|
||||
useKeypress(handleGlobalKeypress, { isActive: true, priority: true });
|
||||
|
||||
useEffect(() => {
|
||||
// Respect hideWindowTitle settings
|
||||
@@ -1772,7 +1766,8 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
|
||||
const fetchBannerTexts = async () => {
|
||||
const [defaultBanner, warningBanner] = await Promise.all([
|
||||
config.getBannerTextNoCapacityIssues(),
|
||||
// TODO: temporarily disabling the banner, it will be re-added.
|
||||
'',
|
||||
config.getBannerTextCapacityIssues(),
|
||||
]);
|
||||
|
||||
@@ -1780,15 +1775,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
setDefaultBannerText(defaultBanner);
|
||||
setWarningBannerText(warningBanner);
|
||||
setBannerVisible(true);
|
||||
const authType = config.getContentGeneratorConfig()?.authType;
|
||||
if (
|
||||
authType === AuthType.USE_GEMINI ||
|
||||
authType === AuthType.USE_VERTEX_AI
|
||||
) {
|
||||
setDefaultBannerText(
|
||||
'Gemini 3 Flash and Pro are now available. \nEnable "Preview features" in /settings. \nLearn more at https://goo.gle/enable-preview-features',
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
@@ -1857,6 +1843,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
ctrlCPressedOnce: ctrlCPressCount >= 1,
|
||||
ctrlDPressedOnce: ctrlDPressCount >= 1,
|
||||
showEscapePrompt,
|
||||
shortcutsHelpVisible,
|
||||
isFocused,
|
||||
elapsedTime,
|
||||
currentLoadingPhrase,
|
||||
@@ -1962,6 +1949,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
ctrlCPressCount,
|
||||
ctrlDPressCount,
|
||||
showEscapePrompt,
|
||||
shortcutsHelpVisible,
|
||||
isFocused,
|
||||
elapsedTime,
|
||||
currentLoadingPhrase,
|
||||
@@ -2061,6 +2049,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
handleApiKeySubmit,
|
||||
handleApiKeyCancel,
|
||||
setBannerVisible,
|
||||
setShortcutsHelpVisible,
|
||||
handleWarning,
|
||||
setEmbeddedShellFocused,
|
||||
dismissBackgroundShell,
|
||||
@@ -2137,6 +2126,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
handleApiKeySubmit,
|
||||
handleApiKeyCancel,
|
||||
setBannerVisible,
|
||||
setShortcutsHelpVisible,
|
||||
handleWarning,
|
||||
setEmbeddedShellFocused,
|
||||
dismissBackgroundShell,
|
||||
|
||||
@@ -129,6 +129,8 @@ describe('extensionsCommand', () => {
|
||||
let mockContext: CommandContext;
|
||||
const mockDispatchExtensionState = vi.fn();
|
||||
let mockExtensionLoader: unknown;
|
||||
let mockReloadSkills: MockedFunction<() => Promise<void>>;
|
||||
let mockReloadAgents: MockedFunction<() => Promise<void>>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
@@ -148,12 +150,19 @@ describe('extensionsCommand', () => {
|
||||
|
||||
mockGetExtensions.mockReturnValue([inactiveExt, activeExt, allExt]);
|
||||
vi.mocked(open).mockClear();
|
||||
mockReloadAgents = vi.fn().mockResolvedValue(undefined);
|
||||
mockReloadSkills = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
mockContext = createMockCommandContext({
|
||||
services: {
|
||||
config: {
|
||||
getExtensions: mockGetExtensions,
|
||||
getExtensionLoader: vi.fn().mockReturnValue(mockExtensionLoader),
|
||||
getWorkingDir: () => '/test/dir',
|
||||
reloadSkills: mockReloadSkills,
|
||||
getAgentRegistry: vi.fn().mockReturnValue({
|
||||
reload: mockReloadAgents,
|
||||
}),
|
||||
},
|
||||
},
|
||||
ui: {
|
||||
@@ -892,6 +901,27 @@ describe('extensionsCommand', () => {
|
||||
type: 'RESTARTED',
|
||||
payload: { name: 'ext2' },
|
||||
});
|
||||
expect(mockReloadSkills).toHaveBeenCalled();
|
||||
expect(mockReloadAgents).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('handles errors during skill or agent reload', async () => {
|
||||
const mockExtensions = [
|
||||
{ name: 'ext1', isActive: true },
|
||||
] as GeminiCLIExtension[];
|
||||
mockGetExtensions.mockReturnValue(mockExtensions);
|
||||
mockReloadSkills.mockRejectedValue(new Error('Failed to reload skills'));
|
||||
|
||||
await restartAction!(mockContext, '--all');
|
||||
|
||||
expect(mockRestartExtension).toHaveBeenCalledWith(mockExtensions[0]);
|
||||
expect(mockReloadSkills).toHaveBeenCalled();
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: MessageType.ERROR,
|
||||
text: 'Failed to reload skills or agents: Failed to reload skills',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('restarts only specified active extensions', async () => {
|
||||
|
||||
@@ -231,6 +231,18 @@ async function restartAction(
|
||||
(result): result is PromiseRejectedResult => result.status === 'rejected',
|
||||
);
|
||||
|
||||
if (failures.length < extensionsToRestart.length) {
|
||||
try {
|
||||
await context.services.config?.reloadSkills();
|
||||
await context.services.config?.getAgentRegistry()?.reload();
|
||||
} catch (error) {
|
||||
context.ui.addItem({
|
||||
type: MessageType.ERROR,
|
||||
text: `Failed to reload skills or agents: ${getErrorMessage(error)}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (failures.length > 0) {
|
||||
const errorMessages = failures
|
||||
.map((failure, index) => {
|
||||
|
||||
@@ -10,7 +10,6 @@ import { MessageType, type HistoryItemHelp } from '../types.js';
|
||||
|
||||
export const helpCommand: SlashCommand = {
|
||||
name: 'help',
|
||||
altNames: ['?'],
|
||||
kind: CommandKind.BUILT_IN,
|
||||
description: 'For help on gemini-cli',
|
||||
autoExecute: true,
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { SlashCommand } from './types.js';
|
||||
import { CommandKind } from './types.js';
|
||||
|
||||
export const shortcutsCommand: SlashCommand = {
|
||||
name: 'shortcuts',
|
||||
altNames: [],
|
||||
kind: CommandKind.BUILT_IN,
|
||||
description: 'Toggle the shortcuts panel above the input',
|
||||
autoExecute: true,
|
||||
action: (context) => {
|
||||
context.ui.toggleShortcutsHelp();
|
||||
},
|
||||
};
|
||||
@@ -91,6 +91,7 @@ export interface CommandContext {
|
||||
setConfirmationRequest: (value: ConfirmationRequest) => void;
|
||||
removeComponent: () => void;
|
||||
toggleBackgroundShell: () => void;
|
||||
toggleShortcutsHelp: () => void;
|
||||
};
|
||||
// Session-specific data
|
||||
session: {
|
||||
|
||||
@@ -68,8 +68,9 @@ describe('<AnsiOutputText />', () => {
|
||||
const output = lastFrame();
|
||||
expect(output).toBeDefined();
|
||||
const lines = output!.split('\n');
|
||||
expect(lines[0]).toBe('First line');
|
||||
expect(lines[1]).toBe('Third line');
|
||||
expect(lines[0].trim()).toBe('First line');
|
||||
expect(lines[1].trim()).toBe('');
|
||||
expect(lines[2].trim()).toBe('Third line');
|
||||
});
|
||||
|
||||
it('respects the availableTerminalHeight prop and slices the lines correctly', () => {
|
||||
@@ -89,6 +90,45 @@ describe('<AnsiOutputText />', () => {
|
||||
expect(output).toContain('Line 4');
|
||||
});
|
||||
|
||||
it('respects the maxLines prop and slices the lines correctly', () => {
|
||||
const data: AnsiOutput = [
|
||||
[createAnsiToken({ text: 'Line 1' })],
|
||||
[createAnsiToken({ text: 'Line 2' })],
|
||||
[createAnsiToken({ text: 'Line 3' })],
|
||||
[createAnsiToken({ text: 'Line 4' })],
|
||||
];
|
||||
const { lastFrame } = render(
|
||||
<AnsiOutputText data={data} maxLines={2} width={80} />,
|
||||
);
|
||||
const output = lastFrame();
|
||||
expect(output).not.toContain('Line 1');
|
||||
expect(output).not.toContain('Line 2');
|
||||
expect(output).toContain('Line 3');
|
||||
expect(output).toContain('Line 4');
|
||||
});
|
||||
|
||||
it('prioritizes maxLines over availableTerminalHeight if maxLines is smaller', () => {
|
||||
const data: AnsiOutput = [
|
||||
[createAnsiToken({ text: 'Line 1' })],
|
||||
[createAnsiToken({ text: 'Line 2' })],
|
||||
[createAnsiToken({ text: 'Line 3' })],
|
||||
[createAnsiToken({ text: 'Line 4' })],
|
||||
];
|
||||
// availableTerminalHeight=3, maxLines=2 => show 2 lines
|
||||
const { lastFrame } = render(
|
||||
<AnsiOutputText
|
||||
data={data}
|
||||
availableTerminalHeight={3}
|
||||
maxLines={2}
|
||||
width={80}
|
||||
/>,
|
||||
);
|
||||
const output = lastFrame();
|
||||
expect(output).not.toContain('Line 2');
|
||||
expect(output).toContain('Line 3');
|
||||
expect(output).toContain('Line 4');
|
||||
});
|
||||
|
||||
it('renders a large AnsiOutput object without crashing', () => {
|
||||
const largeData: AnsiOutput = [];
|
||||
for (let i = 0; i < 1000; i++) {
|
||||
|
||||
@@ -14,40 +14,56 @@ interface AnsiOutputProps {
|
||||
data: AnsiOutput;
|
||||
availableTerminalHeight?: number;
|
||||
width: number;
|
||||
maxLines?: number;
|
||||
disableTruncation?: boolean;
|
||||
}
|
||||
|
||||
export const AnsiOutputText: React.FC<AnsiOutputProps> = ({
|
||||
data,
|
||||
availableTerminalHeight,
|
||||
width,
|
||||
maxLines,
|
||||
disableTruncation,
|
||||
}) => {
|
||||
const lastLines = data.slice(
|
||||
-(availableTerminalHeight && availableTerminalHeight > 0
|
||||
const availableHeightLimit =
|
||||
availableTerminalHeight && availableTerminalHeight > 0
|
||||
? availableTerminalHeight
|
||||
: DEFAULT_HEIGHT),
|
||||
);
|
||||
: undefined;
|
||||
|
||||
const numLinesRetained =
|
||||
availableHeightLimit !== undefined && maxLines !== undefined
|
||||
? Math.min(availableHeightLimit, maxLines)
|
||||
: (availableHeightLimit ?? maxLines ?? DEFAULT_HEIGHT);
|
||||
|
||||
const lastLines = disableTruncation ? data : data.slice(-numLinesRetained);
|
||||
return (
|
||||
<Box flexDirection="column" width={width} flexShrink={0}>
|
||||
<Box flexDirection="column" width={width} flexShrink={0} overflow="hidden">
|
||||
{lastLines.map((line: AnsiLine, lineIndex: number) => (
|
||||
<Text key={lineIndex} wrap="truncate">
|
||||
{line.length > 0
|
||||
? line.map((token: AnsiToken, tokenIndex: number) => (
|
||||
<Text
|
||||
key={tokenIndex}
|
||||
color={token.fg}
|
||||
backgroundColor={token.bg}
|
||||
inverse={token.inverse}
|
||||
dimColor={token.dim}
|
||||
bold={token.bold}
|
||||
italic={token.italic}
|
||||
underline={token.underline}
|
||||
>
|
||||
{token.text}
|
||||
</Text>
|
||||
))
|
||||
: null}
|
||||
</Text>
|
||||
<Box key={lineIndex} height={1} overflow="hidden">
|
||||
<AnsiLineText line={line} />
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export const AnsiLineText: React.FC<{ line: AnsiLine }> = ({ line }) => (
|
||||
<Text>
|
||||
{line.length > 0
|
||||
? line.map((token: AnsiToken, tokenIndex: number) => (
|
||||
<Text
|
||||
key={tokenIndex}
|
||||
color={token.fg}
|
||||
backgroundColor={token.bg}
|
||||
inverse={token.inverse}
|
||||
dimColor={token.dim}
|
||||
bold={token.bold}
|
||||
italic={token.italic}
|
||||
underline={token.underline}
|
||||
>
|
||||
{token.text}
|
||||
</Text>
|
||||
))
|
||||
: null}
|
||||
</Text>
|
||||
);
|
||||
|
||||
@@ -89,53 +89,6 @@ describe('<AppHeader />', () => {
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should render the banner when previewFeatures is disabled', () => {
|
||||
const mockConfig = makeFakeConfig({ previewFeatures: false });
|
||||
const uiState = {
|
||||
history: [],
|
||||
bannerData: {
|
||||
defaultText: 'This is the default banner',
|
||||
warningText: '',
|
||||
},
|
||||
bannerVisible: true,
|
||||
};
|
||||
|
||||
const { lastFrame, unmount } = renderWithProviders(
|
||||
<AppHeader version="1.0.0" />,
|
||||
{
|
||||
config: mockConfig,
|
||||
uiState,
|
||||
},
|
||||
);
|
||||
|
||||
expect(lastFrame()).toContain('This is the default banner');
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should not render the banner when previewFeatures is enabled', () => {
|
||||
const mockConfig = makeFakeConfig({ previewFeatures: true });
|
||||
const uiState = {
|
||||
history: [],
|
||||
bannerData: {
|
||||
defaultText: 'This is the default banner',
|
||||
warningText: '',
|
||||
},
|
||||
};
|
||||
|
||||
const { lastFrame, unmount } = renderWithProviders(
|
||||
<AppHeader version="1.0.0" />,
|
||||
{
|
||||
config: mockConfig,
|
||||
uiState,
|
||||
},
|
||||
);
|
||||
|
||||
expect(lastFrame()).not.toContain('This is the default banner');
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should not render the default banner if shown count is 5 or more', () => {
|
||||
const mockConfig = makeFakeConfig();
|
||||
const uiState = {
|
||||
|
||||
@@ -24,7 +24,7 @@ export const AppHeader = ({ version }: AppHeaderProps) => {
|
||||
const config = useConfig();
|
||||
const { nightly, terminalWidth, bannerData, bannerVisible } = useUIState();
|
||||
|
||||
const { bannerText } = useBanner(bannerData, config);
|
||||
const { bannerText } = useBanner(bannerData);
|
||||
const { showTips } = useTips();
|
||||
|
||||
return (
|
||||
|
||||
@@ -15,8 +15,20 @@ describe('ApprovalModeIndicator', () => {
|
||||
<ApprovalModeIndicator approvalMode={ApprovalMode.AUTO_EDIT} />,
|
||||
);
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('accepting edits');
|
||||
expect(output).toContain('(shift + tab to cycle)');
|
||||
expect(output).toContain('auto-edit');
|
||||
expect(output).toContain('shift + tab to enter default mode');
|
||||
});
|
||||
|
||||
it('renders correctly for AUTO_EDIT mode with plan enabled', () => {
|
||||
const { lastFrame } = render(
|
||||
<ApprovalModeIndicator
|
||||
approvalMode={ApprovalMode.AUTO_EDIT}
|
||||
isPlanEnabled={true}
|
||||
/>,
|
||||
);
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('auto-edit');
|
||||
expect(output).toContain('shift + tab to enter default mode');
|
||||
});
|
||||
|
||||
it('renders correctly for PLAN mode', () => {
|
||||
@@ -24,8 +36,8 @@ describe('ApprovalModeIndicator', () => {
|
||||
<ApprovalModeIndicator approvalMode={ApprovalMode.PLAN} />,
|
||||
);
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('plan mode');
|
||||
expect(output).toContain('(shift + tab to cycle)');
|
||||
expect(output).toContain('plan');
|
||||
expect(output).toContain('shift + tab to enter auto-edit mode');
|
||||
});
|
||||
|
||||
it('renders correctly for YOLO mode', () => {
|
||||
@@ -33,16 +45,26 @@ describe('ApprovalModeIndicator', () => {
|
||||
<ApprovalModeIndicator approvalMode={ApprovalMode.YOLO} />,
|
||||
);
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('YOLO mode');
|
||||
expect(output).toContain('(ctrl + y to toggle)');
|
||||
expect(output).toContain('YOLO');
|
||||
expect(output).toContain('shift + tab to enter auto-edit mode');
|
||||
});
|
||||
|
||||
it('renders nothing for DEFAULT mode', () => {
|
||||
it('renders correctly for DEFAULT mode', () => {
|
||||
const { lastFrame } = render(
|
||||
<ApprovalModeIndicator approvalMode={ApprovalMode.DEFAULT} />,
|
||||
);
|
||||
const output = lastFrame();
|
||||
expect(output).not.toContain('accepting edits');
|
||||
expect(output).not.toContain('YOLO mode');
|
||||
expect(output).toContain('shift + tab to enter auto-edit mode');
|
||||
});
|
||||
|
||||
it('renders correctly for DEFAULT mode with plan enabled', () => {
|
||||
const { lastFrame } = render(
|
||||
<ApprovalModeIndicator
|
||||
approvalMode={ApprovalMode.DEFAULT}
|
||||
isPlanEnabled={true}
|
||||
/>,
|
||||
);
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('shift + tab to enter plan mode');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,10 +11,12 @@ import { ApprovalMode } from '@google/gemini-cli-core';
|
||||
|
||||
interface ApprovalModeIndicatorProps {
|
||||
approvalMode: ApprovalMode;
|
||||
isPlanEnabled?: boolean;
|
||||
}
|
||||
|
||||
export const ApprovalModeIndicator: React.FC<ApprovalModeIndicatorProps> = ({
|
||||
approvalMode,
|
||||
isPlanEnabled,
|
||||
}) => {
|
||||
let textColor = '';
|
||||
let textContent = '';
|
||||
@@ -23,29 +25,39 @@ export const ApprovalModeIndicator: React.FC<ApprovalModeIndicatorProps> = ({
|
||||
switch (approvalMode) {
|
||||
case ApprovalMode.AUTO_EDIT:
|
||||
textColor = theme.status.warning;
|
||||
textContent = 'accepting edits';
|
||||
subText = ' (shift + tab to cycle)';
|
||||
textContent = 'auto-edit';
|
||||
subText = 'shift + tab to enter default mode';
|
||||
break;
|
||||
case ApprovalMode.PLAN:
|
||||
textColor = theme.status.success;
|
||||
textContent = 'plan mode';
|
||||
subText = ' (shift + tab to cycle)';
|
||||
textContent = 'plan';
|
||||
subText = 'shift + tab to enter auto-edit mode';
|
||||
break;
|
||||
case ApprovalMode.YOLO:
|
||||
textColor = theme.status.error;
|
||||
textContent = 'YOLO mode';
|
||||
subText = ' (ctrl + y to toggle)';
|
||||
textContent = 'YOLO';
|
||||
subText = 'shift + tab to enter auto-edit mode';
|
||||
break;
|
||||
case ApprovalMode.DEFAULT:
|
||||
default:
|
||||
textColor = theme.text.accent;
|
||||
textContent = '';
|
||||
subText = isPlanEnabled
|
||||
? 'shift + tab to enter plan mode'
|
||||
: 'shift + tab to enter auto-edit mode';
|
||||
break;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Text color={textColor}>
|
||||
{textContent}
|
||||
{subText && <Text color={theme.text.secondary}>{subText}</Text>}
|
||||
{textContent ? textContent : null}
|
||||
{subText ? (
|
||||
<Text color={theme.text.secondary}>
|
||||
{textContent ? ' ' : ''}
|
||||
{subText}
|
||||
</Text>
|
||||
) : null}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -405,55 +405,4 @@ describe('<BackgroundShellDisplay />', () => {
|
||||
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('unfocuses the shell when Shift+Tab is pressed', async () => {
|
||||
render(
|
||||
<ScrollProvider>
|
||||
<BackgroundShellDisplay
|
||||
shells={mockShells}
|
||||
activePid={shell1.pid}
|
||||
width={80}
|
||||
height={24}
|
||||
isFocused={true}
|
||||
isListOpenProp={false}
|
||||
/>
|
||||
</ScrollProvider>,
|
||||
);
|
||||
await act(async () => {
|
||||
await delay(0);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
simulateKey({ name: 'tab', shift: true });
|
||||
});
|
||||
|
||||
expect(mockSetEmbeddedShellFocused).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it('shows a warning when Tab is pressed', async () => {
|
||||
render(
|
||||
<ScrollProvider>
|
||||
<BackgroundShellDisplay
|
||||
shells={mockShells}
|
||||
activePid={shell1.pid}
|
||||
width={80}
|
||||
height={24}
|
||||
isFocused={true}
|
||||
isListOpenProp={false}
|
||||
/>
|
||||
</ScrollProvider>,
|
||||
);
|
||||
await act(async () => {
|
||||
await delay(0);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
simulateKey({ name: 'tab' });
|
||||
});
|
||||
|
||||
expect(mockHandleWarning).toHaveBeenCalledWith(
|
||||
'Press Shift+Tab to focus out.',
|
||||
);
|
||||
expect(mockSetEmbeddedShellFocused).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,7 +18,7 @@ import { cpLen, cpSlice, getCachedStringWidth } from '../utils/textUtils.js';
|
||||
import { type BackgroundShell } from '../hooks/shellCommandProcessor.js';
|
||||
import { Command, keyMatchers } from '../keyMatchers.js';
|
||||
import { useKeypress } from '../hooks/useKeypress.js';
|
||||
import { commandDescriptions } from '../../config/keyBindings.js';
|
||||
import { formatCommand } from '../utils/keybindingUtils.js';
|
||||
import {
|
||||
ScrollableList,
|
||||
type ScrollableListRef,
|
||||
@@ -64,8 +64,6 @@ export const BackgroundShellDisplay = ({
|
||||
dismissBackgroundShell,
|
||||
setActiveBackgroundShellPid,
|
||||
setIsBackgroundShellListOpen,
|
||||
handleWarning,
|
||||
setEmbeddedShellFocused,
|
||||
} = useUIActions();
|
||||
const activeShell = shells.get(activePid);
|
||||
const [output, setOutput] = useState<string | AnsiOutput>(
|
||||
@@ -138,27 +136,6 @@ export const BackgroundShellDisplay = ({
|
||||
(key) => {
|
||||
if (!activeShell) return;
|
||||
|
||||
// Handle Shift+Tab or Tab (in list) to focus out
|
||||
if (
|
||||
keyMatchers[Command.UNFOCUS_BACKGROUND_SHELL](key) ||
|
||||
(isListOpenProp &&
|
||||
keyMatchers[Command.UNFOCUS_BACKGROUND_SHELL_LIST](key))
|
||||
) {
|
||||
setEmbeddedShellFocused(false);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Handle Tab to warn but propagate
|
||||
if (
|
||||
!isListOpenProp &&
|
||||
keyMatchers[Command.SHOW_BACKGROUND_SHELL_UNFOCUS_WARNING](key)
|
||||
) {
|
||||
handleWarning(
|
||||
`Press ${commandDescriptions[Command.UNFOCUS_BACKGROUND_SHELL]} to focus out.`,
|
||||
);
|
||||
// Fall through to allow Tab to be sent to the shell
|
||||
}
|
||||
|
||||
if (isListOpenProp) {
|
||||
// Navigation (Up/Down/Enter) is handled by RadioButtonSelect
|
||||
// We only handle special keys not consumed by RadioButtonSelect or overriding them if needed
|
||||
@@ -188,7 +165,7 @@ export const BackgroundShellDisplay = ({
|
||||
}
|
||||
|
||||
if (keyMatchers[Command.TOGGLE_BACKGROUND_SHELL](key)) {
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (keyMatchers[Command.KILL_BACKGROUND_SHELL](key)) {
|
||||
@@ -216,7 +193,27 @@ export const BackgroundShellDisplay = ({
|
||||
{ isActive: isFocused && !!activeShell },
|
||||
);
|
||||
|
||||
const helpText = `${commandDescriptions[Command.TOGGLE_BACKGROUND_SHELL]} Hide | ${commandDescriptions[Command.KILL_BACKGROUND_SHELL]} Kill | ${commandDescriptions[Command.TOGGLE_BACKGROUND_SHELL_LIST]} List`;
|
||||
const helpTextParts = [
|
||||
{ label: 'Close', command: Command.TOGGLE_BACKGROUND_SHELL },
|
||||
{ label: 'Kill', command: Command.KILL_BACKGROUND_SHELL },
|
||||
{ label: 'List', command: Command.TOGGLE_BACKGROUND_SHELL_LIST },
|
||||
];
|
||||
|
||||
const helpTextStr = helpTextParts
|
||||
.map((p) => `${p.label} (${formatCommand(p.command)})`)
|
||||
.join(' | ');
|
||||
|
||||
const renderHelpText = () => (
|
||||
<Text>
|
||||
{helpTextParts.map((p, i) => (
|
||||
<Text key={p.label}>
|
||||
{i > 0 ? ' | ' : ''}
|
||||
{p.label} (
|
||||
<Text color={theme.text.accent}>{formatCommand(p.command)}</Text>)
|
||||
</Text>
|
||||
))}
|
||||
</Text>
|
||||
);
|
||||
|
||||
const renderTabs = () => {
|
||||
const shellList = Array.from(shells.values()).filter(
|
||||
@@ -230,7 +227,7 @@ export const BackgroundShellDisplay = ({
|
||||
const availableWidth =
|
||||
width -
|
||||
TAB_DISPLAY_HORIZONTAL_PADDING -
|
||||
getCachedStringWidth(helpText) -
|
||||
getCachedStringWidth(helpTextStr) -
|
||||
pidInfoWidth;
|
||||
|
||||
let currentWidth = 0;
|
||||
@@ -272,7 +269,7 @@ export const BackgroundShellDisplay = ({
|
||||
}
|
||||
|
||||
if (shellList.length > tabs.length && !isListOpenProp) {
|
||||
const overflowLabel = ` ... (${commandDescriptions[Command.TOGGLE_BACKGROUND_SHELL_LIST]}) `;
|
||||
const overflowLabel = ` ... (${formatCommand(Command.TOGGLE_BACKGROUND_SHELL_LIST)}) `;
|
||||
const overflowWidth = getCachedStringWidth(overflowLabel);
|
||||
|
||||
// If we only have one tab, ensure we don't show the overflow if it's too cramped
|
||||
@@ -324,7 +321,7 @@ export const BackgroundShellDisplay = ({
|
||||
<Box flexDirection="column" height="100%" width="100%">
|
||||
<Box flexShrink={0} marginBottom={1} paddingTop={1}>
|
||||
<Text bold>
|
||||
{`Select Process (${commandDescriptions[Command.BACKGROUND_SHELL_SELECT]} to select, ${commandDescriptions[Command.BACKGROUND_SHELL_ESCAPE]} to cancel):`}
|
||||
{`Select Process (${formatCommand(Command.BACKGROUND_SHELL_SELECT)} to select, ${formatCommand(Command.KILL_BACKGROUND_SHELL)} to kill, ${formatCommand(Command.BACKGROUND_SHELL_ESCAPE)} to cancel):`}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box flexGrow={1} width="100%">
|
||||
@@ -450,7 +447,7 @@ export const BackgroundShellDisplay = ({
|
||||
(PID: {activeShell?.pid}) {isFocused ? '(Focused)' : ''}
|
||||
</Text>
|
||||
</Box>
|
||||
<Text color={theme.text.accent}>{helpText}</Text>
|
||||
{renderHelpText()}
|
||||
</Box>
|
||||
<Box flexGrow={1} overflow="hidden" paddingX={CONTENT_PADDING_X}>
|
||||
{isListOpenProp ? renderProcessList() : renderOutput()}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render } from '../../test-utils/render.js';
|
||||
import { Text } from 'ink';
|
||||
import { Box, Text } from 'ink';
|
||||
import { Composer } from './Composer.js';
|
||||
import { UIStateContext, type UIState } from '../contexts/UIStateContext.js';
|
||||
import {
|
||||
@@ -24,7 +24,7 @@ vi.mock('../contexts/VimModeContext.js', () => ({
|
||||
})),
|
||||
}));
|
||||
import { ApprovalMode } from '@google/gemini-cli-core';
|
||||
import { StreamingState } from '../types.js';
|
||||
import { StreamingState, ToolCallStatus } from '../types.js';
|
||||
|
||||
// Mock child components
|
||||
vi.mock('./LoadingIndicator.js', () => ({
|
||||
@@ -49,6 +49,14 @@ vi.mock('./ShellModeIndicator.js', () => ({
|
||||
ShellModeIndicator: () => <Text>ShellModeIndicator</Text>,
|
||||
}));
|
||||
|
||||
vi.mock('./ShortcutsHint.js', () => ({
|
||||
ShortcutsHint: () => <Text>ShortcutsHint</Text>,
|
||||
}));
|
||||
|
||||
vi.mock('./ShortcutsHelp.js', () => ({
|
||||
ShortcutsHelp: () => <Text>ShortcutsHelp</Text>,
|
||||
}));
|
||||
|
||||
vi.mock('./DetailedMessagesDisplay.js', () => ({
|
||||
DetailedMessagesDisplay: () => <Text>DetailedMessagesDisplay</Text>,
|
||||
}));
|
||||
@@ -95,7 +103,8 @@ vi.mock('../contexts/OverflowContext.js', () => ({
|
||||
// Create mock context providers
|
||||
const createMockUIState = (overrides: Partial<UIState> = {}): UIState =>
|
||||
({
|
||||
streamingState: null,
|
||||
streamingState: StreamingState.Idle,
|
||||
isConfigInitialized: true,
|
||||
contextFileNames: [],
|
||||
showApprovalModeIndicator: ApprovalMode.DEFAULT,
|
||||
messageQueue: [],
|
||||
@@ -116,6 +125,7 @@ const createMockUIState = (overrides: Partial<UIState> = {}): UIState =>
|
||||
ctrlCPressedOnce: false,
|
||||
ctrlDPressedOnce: false,
|
||||
showEscapePrompt: false,
|
||||
shortcutsHelpVisible: false,
|
||||
ideContextState: null,
|
||||
geminiMdFileCount: 0,
|
||||
renderMarkdown: true,
|
||||
@@ -154,6 +164,7 @@ const createMockConfig = (overrides = {}) => ({
|
||||
getDebugMode: vi.fn(() => false),
|
||||
getAccessibility: vi.fn(() => ({})),
|
||||
getMcpServers: vi.fn(() => ({})),
|
||||
isPlanEnabled: vi.fn(() => false),
|
||||
getToolRegistry: () => ({
|
||||
getTool: vi.fn(),
|
||||
}),
|
||||
@@ -268,6 +279,19 @@ describe('Composer', () => {
|
||||
expect(output).toContain('LoadingIndicator');
|
||||
});
|
||||
|
||||
it('keeps shortcuts hint visible while loading', () => {
|
||||
const uiState = createMockUIState({
|
||||
streamingState: StreamingState.Responding,
|
||||
elapsedTime: 1,
|
||||
});
|
||||
|
||||
const { lastFrame } = renderComposer(uiState);
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('LoadingIndicator');
|
||||
expect(output).toContain('ShortcutsHint');
|
||||
});
|
||||
|
||||
it('renders LoadingIndicator without thought when accessibility disables loading phrases', () => {
|
||||
const uiState = createMockUIState({
|
||||
streamingState: StreamingState.Responding,
|
||||
@@ -284,7 +308,7 @@ describe('Composer', () => {
|
||||
expect(output).not.toContain('Should not show');
|
||||
});
|
||||
|
||||
it('suppresses thought when waiting for confirmation', () => {
|
||||
it('does not render LoadingIndicator when waiting for confirmation', () => {
|
||||
const uiState = createMockUIState({
|
||||
streamingState: StreamingState.WaitingForConfirmation,
|
||||
thought: {
|
||||
@@ -296,8 +320,34 @@ describe('Composer', () => {
|
||||
const { lastFrame } = renderComposer(uiState);
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('LoadingIndicator');
|
||||
expect(output).not.toContain('Should not show during confirmation');
|
||||
expect(output).not.toContain('LoadingIndicator');
|
||||
});
|
||||
|
||||
it('does not render LoadingIndicator when a tool confirmation is pending', () => {
|
||||
const uiState = createMockUIState({
|
||||
streamingState: StreamingState.Responding,
|
||||
pendingHistoryItems: [
|
||||
{
|
||||
type: 'tool_group',
|
||||
tools: [
|
||||
{
|
||||
callId: 'call-1',
|
||||
name: 'edit',
|
||||
description: 'edit file',
|
||||
status: ToolCallStatus.Confirming,
|
||||
resultDisplay: undefined,
|
||||
confirmationDetails: undefined,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const { lastFrame } = renderComposer(uiState);
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).not.toContain('LoadingIndicator');
|
||||
expect(output).not.toContain('esc to cancel');
|
||||
});
|
||||
|
||||
it('renders LoadingIndicator when embedded shell is focused but background shell is visible', () => {
|
||||
@@ -436,16 +486,24 @@ describe('Composer', () => {
|
||||
expect(lastFrame()).not.toContain('InputPrompt');
|
||||
});
|
||||
|
||||
it('shows ApprovalModeIndicator when approval mode is not default and shell mode is inactive', () => {
|
||||
const uiState = createMockUIState({
|
||||
showApprovalModeIndicator: ApprovalMode.YOLO,
|
||||
shellModeActive: false,
|
||||
});
|
||||
it.each([
|
||||
[ApprovalMode.DEFAULT],
|
||||
[ApprovalMode.AUTO_EDIT],
|
||||
[ApprovalMode.PLAN],
|
||||
[ApprovalMode.YOLO],
|
||||
])(
|
||||
'shows ApprovalModeIndicator when approval mode is %s and shell mode is inactive',
|
||||
(mode) => {
|
||||
const uiState = createMockUIState({
|
||||
showApprovalModeIndicator: mode,
|
||||
shellModeActive: false,
|
||||
});
|
||||
|
||||
const { lastFrame } = renderComposer(uiState);
|
||||
const { lastFrame } = renderComposer(uiState);
|
||||
|
||||
expect(lastFrame()).toContain('ApprovalModeIndicator');
|
||||
});
|
||||
expect(lastFrame()).toMatch(/ApprovalModeIndic[\s\S]*ator/);
|
||||
},
|
||||
);
|
||||
|
||||
it('shows ShellModeIndicator when shell mode is active', () => {
|
||||
const uiState = createMockUIState({
|
||||
@@ -454,7 +512,7 @@ describe('Composer', () => {
|
||||
|
||||
const { lastFrame } = renderComposer(uiState);
|
||||
|
||||
expect(lastFrame()).toContain('ShellModeIndicator');
|
||||
expect(lastFrame()).toMatch(/ShellModeIndic[\s\S]*tor/);
|
||||
});
|
||||
|
||||
it('shows RawMarkdownIndicator when renderMarkdown is false', () => {
|
||||
@@ -540,4 +598,29 @@ describe('Composer', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Shortcuts Hint', () => {
|
||||
it('hides shortcuts hint when a action is required (e.g. dialog is open)', () => {
|
||||
const uiState = createMockUIState({
|
||||
customDialog: (
|
||||
<Box>
|
||||
<Text>Test Dialog</Text>
|
||||
<Text>Test Content</Text>
|
||||
</Box>
|
||||
),
|
||||
});
|
||||
|
||||
const { lastFrame } = renderComposer(uiState);
|
||||
|
||||
expect(lastFrame()).not.toContain('ShortcutsHint');
|
||||
});
|
||||
|
||||
it('keeps shortcuts hint visible when no action is required', () => {
|
||||
const uiState = createMockUIState();
|
||||
|
||||
const { lastFrame } = renderComposer(uiState);
|
||||
|
||||
expect(lastFrame()).toContain('ShortcutsHint');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,17 +5,20 @@
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Box, useIsScreenReaderEnabled } from 'ink';
|
||||
import { Box, Text, useIsScreenReaderEnabled } from 'ink';
|
||||
import { LoadingIndicator } from './LoadingIndicator.js';
|
||||
import { StatusDisplay } from './StatusDisplay.js';
|
||||
import { ApprovalModeIndicator } from './ApprovalModeIndicator.js';
|
||||
import { ShellModeIndicator } from './ShellModeIndicator.js';
|
||||
import { DetailedMessagesDisplay } from './DetailedMessagesDisplay.js';
|
||||
import { RawMarkdownIndicator } from './RawMarkdownIndicator.js';
|
||||
import { ShortcutsHint } from './ShortcutsHint.js';
|
||||
import { ShortcutsHelp } from './ShortcutsHelp.js';
|
||||
import { InputPrompt } from './InputPrompt.js';
|
||||
import { Footer } from './Footer.js';
|
||||
import { ShowMoreLines } from './ShowMoreLines.js';
|
||||
import { QueuedMessageDisplay } from './QueuedMessageDisplay.js';
|
||||
import { HorizontalLine } from './shared/HorizontalLine.js';
|
||||
import { OverflowProvider } from '../contexts/OverflowContext.js';
|
||||
import { isNarrowWidth } from '../utils/isNarrowWidth.js';
|
||||
import { useUIState } from '../contexts/UIStateContext.js';
|
||||
@@ -24,10 +27,10 @@ import { useVimMode } from '../contexts/VimModeContext.js';
|
||||
import { useConfig } from '../contexts/ConfigContext.js';
|
||||
import { useSettings } from '../contexts/SettingsContext.js';
|
||||
import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js';
|
||||
import { ApprovalMode } from '@google/gemini-cli-core';
|
||||
import { StreamingState } from '../types.js';
|
||||
import { StreamingState, ToolCallStatus } from '../types.js';
|
||||
import { ConfigInitDisplay } from '../components/ConfigInitDisplay.js';
|
||||
import { TodoTray } from './messages/Todo.js';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
|
||||
export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
|
||||
const config = useConfig();
|
||||
@@ -46,6 +49,29 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
|
||||
const suggestionsPosition = isAlternateBuffer ? 'above' : 'below';
|
||||
const hideContextSummary =
|
||||
suggestionsVisible && suggestionsPosition === 'above';
|
||||
const hasPendingToolConfirmation = (uiState.pendingHistoryItems ?? []).some(
|
||||
(item) =>
|
||||
item.type === 'tool_group' &&
|
||||
item.tools.some((tool) => tool.status === ToolCallStatus.Confirming),
|
||||
);
|
||||
const hasPendingActionRequired =
|
||||
hasPendingToolConfirmation ||
|
||||
Boolean(uiState.commandConfirmationRequest) ||
|
||||
Boolean(uiState.authConsentRequest) ||
|
||||
(uiState.confirmUpdateExtensionRequests?.length ?? 0) > 0 ||
|
||||
Boolean(uiState.loopDetectionConfirmationRequest) ||
|
||||
Boolean(uiState.proQuotaRequest) ||
|
||||
Boolean(uiState.validationRequest) ||
|
||||
Boolean(uiState.customDialog);
|
||||
const showLoadingIndicator =
|
||||
(!uiState.embeddedShellFocused || uiState.isBackgroundShellVisible) &&
|
||||
uiState.streamingState === StreamingState.Responding &&
|
||||
!hasPendingActionRequired;
|
||||
const showApprovalIndicator = !uiState.shellModeActive;
|
||||
const showRawMarkdownIndicator = !uiState.renderMarkdown;
|
||||
const showEscToCancelHint =
|
||||
showLoadingIndicator &&
|
||||
uiState.streamingState !== StreamingState.WaitingForConfirmation;
|
||||
|
||||
return (
|
||||
<Box
|
||||
@@ -54,23 +80,6 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
|
||||
flexGrow={0}
|
||||
flexShrink={0}
|
||||
>
|
||||
{(!uiState.embeddedShellFocused || uiState.isBackgroundShellVisible) && (
|
||||
<LoadingIndicator
|
||||
thought={
|
||||
uiState.streamingState === StreamingState.WaitingForConfirmation ||
|
||||
config.getAccessibility()?.enableLoadingPhrases === false
|
||||
? undefined
|
||||
: uiState.thought
|
||||
}
|
||||
currentLoadingPhrase={
|
||||
config.getAccessibility()?.enableLoadingPhrases === false
|
||||
? undefined
|
||||
: uiState.currentLoadingPhrase
|
||||
}
|
||||
elapsedTime={uiState.elapsedTime}
|
||||
/>
|
||||
)}
|
||||
|
||||
{(!uiState.slashCommands ||
|
||||
!uiState.isConfigInitialized ||
|
||||
uiState.isResuming) && (
|
||||
@@ -83,25 +92,122 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
|
||||
|
||||
<TodoTray />
|
||||
|
||||
<Box
|
||||
marginTop={1}
|
||||
justifyContent={
|
||||
settings.merged.ui.hideContextSummary ? 'flex-start' : 'space-between'
|
||||
}
|
||||
width="100%"
|
||||
flexDirection={isNarrow ? 'column' : 'row'}
|
||||
alignItems={isNarrow ? 'flex-start' : 'center'}
|
||||
>
|
||||
<Box marginRight={1}>
|
||||
<StatusDisplay hideContextSummary={hideContextSummary} />
|
||||
</Box>
|
||||
<Box paddingTop={isNarrow ? 1 : 0}>
|
||||
{showApprovalModeIndicator !== ApprovalMode.DEFAULT &&
|
||||
!uiState.shellModeActive && (
|
||||
<ApprovalModeIndicator approvalMode={showApprovalModeIndicator} />
|
||||
<Box marginTop={1} width="100%" flexDirection="column">
|
||||
{showEscToCancelHint && (
|
||||
<Box marginLeft={3}>
|
||||
<Text color={theme.text.secondary}>esc to cancel</Text>
|
||||
</Box>
|
||||
)}
|
||||
<Box
|
||||
width="100%"
|
||||
flexDirection={isNarrow ? 'column' : 'row'}
|
||||
alignItems={isNarrow ? 'flex-start' : 'center'}
|
||||
justifyContent={isNarrow ? 'flex-start' : 'space-between'}
|
||||
>
|
||||
<Box
|
||||
marginLeft={1}
|
||||
marginRight={isNarrow ? 0 : 1}
|
||||
flexDirection="row"
|
||||
alignItems="center"
|
||||
flexGrow={1}
|
||||
>
|
||||
{showLoadingIndicator && (
|
||||
<LoadingIndicator
|
||||
inline
|
||||
thought={
|
||||
uiState.streamingState ===
|
||||
StreamingState.WaitingForConfirmation ||
|
||||
config.getAccessibility()?.enableLoadingPhrases === false
|
||||
? undefined
|
||||
: uiState.thought
|
||||
}
|
||||
currentLoadingPhrase={
|
||||
config.getAccessibility()?.enableLoadingPhrases === false
|
||||
? undefined
|
||||
: uiState.currentLoadingPhrase
|
||||
}
|
||||
elapsedTime={uiState.elapsedTime}
|
||||
showCancelAndTimer={false}
|
||||
/>
|
||||
)}
|
||||
{uiState.shellModeActive && <ShellModeIndicator />}
|
||||
{!uiState.renderMarkdown && <RawMarkdownIndicator />}
|
||||
</Box>
|
||||
<Box
|
||||
marginTop={isNarrow ? 1 : 0}
|
||||
flexDirection="column"
|
||||
alignItems={isNarrow ? 'flex-start' : 'flex-end'}
|
||||
>
|
||||
{!hasPendingActionRequired && <ShortcutsHint />}
|
||||
</Box>
|
||||
</Box>
|
||||
{uiState.shortcutsHelpVisible && <ShortcutsHelp />}
|
||||
<HorizontalLine />
|
||||
<Box
|
||||
justifyContent={
|
||||
settings.merged.ui.hideContextSummary
|
||||
? 'flex-start'
|
||||
: 'space-between'
|
||||
}
|
||||
width="100%"
|
||||
flexDirection={isNarrow ? 'column' : 'row'}
|
||||
alignItems={isNarrow ? 'flex-start' : 'center'}
|
||||
>
|
||||
<Box
|
||||
marginLeft={1}
|
||||
marginRight={isNarrow ? 0 : 1}
|
||||
flexDirection="row"
|
||||
alignItems="center"
|
||||
flexGrow={1}
|
||||
>
|
||||
{!showLoadingIndicator && (
|
||||
<Box
|
||||
flexDirection={isNarrow ? 'column' : 'row'}
|
||||
alignItems={isNarrow ? 'flex-start' : 'center'}
|
||||
>
|
||||
{showApprovalIndicator && (
|
||||
<ApprovalModeIndicator
|
||||
approvalMode={showApprovalModeIndicator}
|
||||
isPlanEnabled={config.isPlanEnabled()}
|
||||
/>
|
||||
)}
|
||||
{uiState.shellModeActive && (
|
||||
<Box
|
||||
marginLeft={showApprovalIndicator && !isNarrow ? 1 : 0}
|
||||
marginTop={showApprovalIndicator && isNarrow ? 1 : 0}
|
||||
>
|
||||
<ShellModeIndicator />
|
||||
</Box>
|
||||
)}
|
||||
{showRawMarkdownIndicator && (
|
||||
<Box
|
||||
marginLeft={
|
||||
(showApprovalIndicator || uiState.shellModeActive) &&
|
||||
!isNarrow
|
||||
? 1
|
||||
: 0
|
||||
}
|
||||
marginTop={
|
||||
(showApprovalIndicator || uiState.shellModeActive) &&
|
||||
isNarrow
|
||||
? 1
|
||||
: 0
|
||||
}
|
||||
>
|
||||
<RawMarkdownIndicator />
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
marginTop={isNarrow ? 1 : 0}
|
||||
flexDirection="column"
|
||||
alignItems={isNarrow ? 'flex-start' : 'flex-end'}
|
||||
>
|
||||
{!showLoadingIndicator && (
|
||||
<StatusDisplay hideContextSummary={hideContextSummary} />
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
|
||||
@@ -147,7 +147,7 @@ export const Footer: React.FC = () => {
|
||||
<Box alignItems="center" justifyContent="flex-end">
|
||||
<Box alignItems="center">
|
||||
<Text color={theme.text.accent}>
|
||||
{getDisplayString(model, config.getPreviewFeatures())}
|
||||
{getDisplayString(model)}
|
||||
<Text color={theme.text.secondary}> /model</Text>
|
||||
{!hideContextPercentage && (
|
||||
<>
|
||||
|
||||
@@ -112,6 +112,7 @@ describe('<Header />', () => {
|
||||
error: '',
|
||||
success: '',
|
||||
warning: '',
|
||||
info: '',
|
||||
},
|
||||
});
|
||||
const Gradient = await import('ink-gradient');
|
||||
|
||||
@@ -4028,6 +4028,55 @@ describe('InputPrompt', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('shortcuts help visibility', () => {
|
||||
it.each([
|
||||
{
|
||||
name: 'terminal paste event occurs',
|
||||
input: '\x1b[200~pasted text\x1b[201~',
|
||||
},
|
||||
{
|
||||
name: 'Ctrl+V (PASTE_CLIPBOARD) is pressed',
|
||||
input: '\x16',
|
||||
setupMocks: () => {
|
||||
vi.mocked(clipboardUtils.clipboardHasImage).mockResolvedValue(false);
|
||||
vi.mocked(clipboardy.read).mockResolvedValue('clipboard text');
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'mouse right-click paste occurs',
|
||||
input: '\x1b[<2;1;1m',
|
||||
mouseEventsEnabled: true,
|
||||
setupMocks: () => {
|
||||
vi.mocked(clipboardUtils.clipboardHasImage).mockResolvedValue(false);
|
||||
vi.mocked(clipboardy.read).mockResolvedValue('clipboard text');
|
||||
},
|
||||
},
|
||||
])(
|
||||
'should close shortcuts help when a $name',
|
||||
async ({ input, setupMocks, mouseEventsEnabled }) => {
|
||||
setupMocks?.();
|
||||
const setShortcutsHelpVisible = vi.fn();
|
||||
const { stdin, unmount } = renderWithProviders(
|
||||
<InputPrompt {...props} />,
|
||||
{
|
||||
uiState: { shortcutsHelpVisible: true },
|
||||
uiActions: { setShortcutsHelpVisible },
|
||||
mouseEventsEnabled,
|
||||
},
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
stdin.write(input);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(setShortcutsHelpVisible).toHaveBeenCalledWith(false);
|
||||
});
|
||||
unmount();
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
function clean(str: string | undefined): string {
|
||||
|
||||
@@ -151,7 +151,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
const { merged: settings } = useSettings();
|
||||
const kittyProtocol = useKittyKeyboardProtocol();
|
||||
const isShellFocused = useShellFocusState();
|
||||
const { setEmbeddedShellFocused } = useUIActions();
|
||||
const { setEmbeddedShellFocused, setShortcutsHelpVisible } = useUIActions();
|
||||
const {
|
||||
terminalWidth,
|
||||
activePtyId,
|
||||
@@ -159,6 +159,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
terminalBackgroundColor,
|
||||
backgroundShells,
|
||||
backgroundShellHeight,
|
||||
shortcutsHelpVisible,
|
||||
} = useUIState();
|
||||
const [suppressCompletion, setSuppressCompletion] = useState(false);
|
||||
const escPressCount = useRef(0);
|
||||
@@ -358,6 +359,9 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
|
||||
// Handle clipboard image pasting with Ctrl+V
|
||||
const handleClipboardPaste = useCallback(async () => {
|
||||
if (shortcutsHelpVisible) {
|
||||
setShortcutsHelpVisible(false);
|
||||
}
|
||||
try {
|
||||
if (await clipboardHasImage()) {
|
||||
const imagePath = await saveClipboardImage(config.getTargetDir());
|
||||
@@ -402,7 +406,14 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
} catch (error) {
|
||||
debugLogger.error('Error handling paste:', error);
|
||||
}
|
||||
}, [buffer, config, stdout, settings]);
|
||||
}, [
|
||||
buffer,
|
||||
config,
|
||||
stdout,
|
||||
settings,
|
||||
shortcutsHelpVisible,
|
||||
setShortcutsHelpVisible,
|
||||
]);
|
||||
|
||||
useMouseClick(
|
||||
innerBoxRef,
|
||||
@@ -535,6 +546,14 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
return false;
|
||||
}
|
||||
|
||||
// Handle escape to close shortcuts panel first, before letting it bubble
|
||||
// up for cancellation. This ensures pressing Escape once closes the panel,
|
||||
// and pressing again cancels the operation.
|
||||
if (shortcutsHelpVisible && key.name === 'escape') {
|
||||
setShortcutsHelpVisible(false);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (
|
||||
key.name === 'escape' &&
|
||||
(streamingState === StreamingState.Responding ||
|
||||
@@ -544,6 +563,9 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
}
|
||||
|
||||
if (key.name === 'paste') {
|
||||
if (shortcutsHelpVisible) {
|
||||
setShortcutsHelpVisible(false);
|
||||
}
|
||||
// Record paste time to prevent accidental auto-submission
|
||||
if (!isTerminalPasteTrusted(kittyProtocol.enabled)) {
|
||||
setRecentUnsafePasteTime(Date.now());
|
||||
@@ -572,6 +594,33 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
return true;
|
||||
}
|
||||
|
||||
if (shortcutsHelpVisible) {
|
||||
if (key.sequence === '?' && key.insertable) {
|
||||
setShortcutsHelpVisible(false);
|
||||
buffer.handleInput(key);
|
||||
return true;
|
||||
}
|
||||
// Escape is handled earlier to ensure it closes the panel before
|
||||
// potentially cancelling an operation
|
||||
if (key.name === 'backspace' || key.sequence === '\b') {
|
||||
setShortcutsHelpVisible(false);
|
||||
return true;
|
||||
}
|
||||
if (key.insertable) {
|
||||
setShortcutsHelpVisible(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
key.sequence === '?' &&
|
||||
key.insertable &&
|
||||
!shortcutsHelpVisible &&
|
||||
buffer.text.length === 0
|
||||
) {
|
||||
setShortcutsHelpVisible(true);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (vimHandleInput && vimHandleInput(key)) {
|
||||
return true;
|
||||
}
|
||||
@@ -982,15 +1031,19 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
return true;
|
||||
}
|
||||
|
||||
if (keyMatchers[Command.TOGGLE_BACKGROUND_SHELL](key)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (keyMatchers[Command.FOCUS_SHELL_INPUT](key)) {
|
||||
// If we got here, Autocomplete didn't handle the key (e.g. no suggestions).
|
||||
if (
|
||||
activePtyId ||
|
||||
(backgroundShells.size > 0 && backgroundShellHeight > 0)
|
||||
) {
|
||||
setEmbeddedShellFocused(true);
|
||||
return true;
|
||||
}
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Fall back to the text buffer's default input handling for all other keys
|
||||
@@ -1040,6 +1093,8 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||
commandSearchActive,
|
||||
commandSearchCompletion,
|
||||
kittyProtocol.enabled,
|
||||
shortcutsHelpVisible,
|
||||
setShortcutsHelpVisible,
|
||||
tryLoadQueuedMessages,
|
||||
setBannerVisible,
|
||||
onSubmit,
|
||||
|
||||
@@ -57,9 +57,9 @@ describe('<LoadingIndicator />', () => {
|
||||
elapsedTime: 5,
|
||||
};
|
||||
|
||||
it('should not render when streamingState is Idle', () => {
|
||||
it('should not render when streamingState is Idle and no loading phrase or thought', () => {
|
||||
const { lastFrame } = renderWithContext(
|
||||
<LoadingIndicator {...defaultProps} />,
|
||||
<LoadingIndicator elapsedTime={5} />,
|
||||
StreamingState.Idle,
|
||||
);
|
||||
expect(lastFrame()).toBe('');
|
||||
@@ -143,10 +143,10 @@ describe('<LoadingIndicator />', () => {
|
||||
|
||||
it('should transition correctly between states using rerender', () => {
|
||||
const { lastFrame, rerender, unmount } = renderWithContext(
|
||||
<LoadingIndicator {...defaultProps} />,
|
||||
<LoadingIndicator elapsedTime={5} />,
|
||||
StreamingState.Idle,
|
||||
);
|
||||
expect(lastFrame()).toBe(''); // Initial: Idle
|
||||
expect(lastFrame()).toBe(''); // Initial: Idle (no loading phrase)
|
||||
|
||||
// Transition to Responding
|
||||
rerender(
|
||||
@@ -180,10 +180,10 @@ describe('<LoadingIndicator />', () => {
|
||||
// Transition back to Idle
|
||||
rerender(
|
||||
<StreamingContext.Provider value={StreamingState.Idle}>
|
||||
<LoadingIndicator {...defaultProps} />
|
||||
<LoadingIndicator elapsedTime={5} />
|
||||
</StreamingContext.Provider>,
|
||||
);
|
||||
expect(lastFrame()).toBe('');
|
||||
expect(lastFrame()).toBe(''); // Idle with no loading phrase
|
||||
unmount();
|
||||
});
|
||||
|
||||
|
||||
@@ -19,21 +19,29 @@ import { INTERACTIVE_SHELL_WAITING_PHRASE } from '../hooks/usePhraseCycler.js';
|
||||
interface LoadingIndicatorProps {
|
||||
currentLoadingPhrase?: string;
|
||||
elapsedTime: number;
|
||||
inline?: boolean;
|
||||
rightContent?: React.ReactNode;
|
||||
thought?: ThoughtSummary | null;
|
||||
showCancelAndTimer?: boolean;
|
||||
}
|
||||
|
||||
export const LoadingIndicator: React.FC<LoadingIndicatorProps> = ({
|
||||
currentLoadingPhrase,
|
||||
elapsedTime,
|
||||
inline = false,
|
||||
rightContent,
|
||||
thought,
|
||||
showCancelAndTimer = true,
|
||||
}) => {
|
||||
const streamingState = useStreamingContext();
|
||||
const { columns: terminalWidth } = useTerminalSize();
|
||||
const isNarrow = isNarrowWidth(terminalWidth);
|
||||
|
||||
if (streamingState === StreamingState.Idle) {
|
||||
if (
|
||||
streamingState === StreamingState.Idle &&
|
||||
!currentLoadingPhrase &&
|
||||
!thought
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -45,10 +53,38 @@ export const LoadingIndicator: React.FC<LoadingIndicatorProps> = ({
|
||||
: thought?.subject || currentLoadingPhrase;
|
||||
|
||||
const cancelAndTimerContent =
|
||||
showCancelAndTimer &&
|
||||
streamingState !== StreamingState.WaitingForConfirmation
|
||||
? `(esc to cancel, ${elapsedTime < 60 ? `${elapsedTime}s` : formatDuration(elapsedTime * 1000)})`
|
||||
: null;
|
||||
|
||||
if (inline) {
|
||||
return (
|
||||
<Box>
|
||||
<Box marginRight={1}>
|
||||
<GeminiRespondingSpinner
|
||||
nonRespondingDisplay={
|
||||
streamingState === StreamingState.WaitingForConfirmation
|
||||
? '⠏'
|
||||
: ''
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
{primaryText && (
|
||||
<Text color={theme.text.accent} wrap="truncate-end">
|
||||
{primaryText}
|
||||
</Text>
|
||||
)}
|
||||
{cancelAndTimerContent && (
|
||||
<>
|
||||
<Box flexShrink={0} width={1} />
|
||||
<Text color={theme.text.secondary}>{cancelAndTimerContent}</Text>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box paddingLeft={0} flexDirection="column">
|
||||
{/* Main loading line */}
|
||||
|
||||
@@ -10,6 +10,10 @@ import { MainContent } from './MainContent.js';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { Box, Text } from 'ink';
|
||||
import type React from 'react';
|
||||
import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js';
|
||||
import { ToolCallStatus } from '../types.js';
|
||||
import { SHELL_COMMAND_NAME } from '../constants.js';
|
||||
import type { UIState } from '../contexts/UIStateContext.js';
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('../contexts/AppContext.js', async () => {
|
||||
@@ -22,53 +26,10 @@ vi.mock('../contexts/AppContext.js', async () => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../contexts/UIStateContext.js', async () => {
|
||||
const actual = await vi.importActual('../contexts/UIStateContext.js');
|
||||
return {
|
||||
...actual,
|
||||
useUIState: () => ({
|
||||
history: [
|
||||
{ id: 1, role: 'user', content: 'Hello' },
|
||||
{ id: 2, role: 'model', content: 'Hi there' },
|
||||
],
|
||||
pendingHistoryItems: [],
|
||||
mainAreaWidth: 80,
|
||||
staticAreaMaxItemHeight: 20,
|
||||
availableTerminalHeight: 24,
|
||||
slashCommands: [],
|
||||
constrainHeight: false,
|
||||
isEditorDialogOpen: false,
|
||||
activePtyId: undefined,
|
||||
embeddedShellFocused: false,
|
||||
historyRemountKey: 0,
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../hooks/useAlternateBuffer.js', () => ({
|
||||
useAlternateBuffer: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('./HistoryItemDisplay.js', () => ({
|
||||
HistoryItemDisplay: ({
|
||||
item,
|
||||
availableTerminalHeight,
|
||||
}: {
|
||||
item: { content: string };
|
||||
availableTerminalHeight?: number;
|
||||
}) => (
|
||||
<Box>
|
||||
<Text>
|
||||
HistoryItem: {item.content} (height:{' '}
|
||||
{availableTerminalHeight === undefined
|
||||
? 'undefined'
|
||||
: availableTerminalHeight}
|
||||
)
|
||||
</Text>
|
||||
</Box>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('./AppHeader.js', () => ({
|
||||
AppHeader: () => <Text>AppHeader</Text>,
|
||||
}));
|
||||
@@ -95,39 +56,169 @@ vi.mock('./shared/ScrollableList.js', () => ({
|
||||
SCROLL_TO_ITEM_END: 0,
|
||||
}));
|
||||
|
||||
import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js';
|
||||
|
||||
describe('MainContent', () => {
|
||||
const defaultMockUiState = {
|
||||
history: [
|
||||
{ id: 1, type: 'user', text: 'Hello' },
|
||||
{ id: 2, type: 'gemini', text: 'Hi there' },
|
||||
],
|
||||
pendingHistoryItems: [],
|
||||
mainAreaWidth: 80,
|
||||
staticAreaMaxItemHeight: 20,
|
||||
availableTerminalHeight: 24,
|
||||
slashCommands: [],
|
||||
constrainHeight: false,
|
||||
isEditorDialogOpen: false,
|
||||
activePtyId: undefined,
|
||||
embeddedShellFocused: false,
|
||||
historyRemountKey: 0,
|
||||
bannerData: { defaultText: '', warningText: '' },
|
||||
bannerVisible: false,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(useAlternateBuffer).mockReturnValue(false);
|
||||
});
|
||||
|
||||
it('renders in normal buffer mode', async () => {
|
||||
const { lastFrame } = renderWithProviders(<MainContent />);
|
||||
const { lastFrame } = renderWithProviders(<MainContent />, {
|
||||
uiState: defaultMockUiState as Partial<UIState>,
|
||||
});
|
||||
await waitFor(() => expect(lastFrame()).toContain('AppHeader'));
|
||||
const output = lastFrame();
|
||||
|
||||
expect(output).toContain('HistoryItem: Hello (height: 20)');
|
||||
expect(output).toContain('HistoryItem: Hi there (height: 20)');
|
||||
expect(output).toContain('Hello');
|
||||
expect(output).toContain('Hi there');
|
||||
});
|
||||
|
||||
it('renders in alternate buffer mode', async () => {
|
||||
vi.mocked(useAlternateBuffer).mockReturnValue(true);
|
||||
const { lastFrame } = renderWithProviders(<MainContent />);
|
||||
const { lastFrame } = renderWithProviders(<MainContent />, {
|
||||
uiState: defaultMockUiState as Partial<UIState>,
|
||||
});
|
||||
await waitFor(() => expect(lastFrame()).toContain('ScrollableList'));
|
||||
const output = lastFrame();
|
||||
|
||||
expect(output).toContain('AppHeader');
|
||||
expect(output).toContain('HistoryItem: Hello (height: undefined)');
|
||||
expect(output).toContain('HistoryItem: Hi there (height: undefined)');
|
||||
expect(output).toContain('Hello');
|
||||
expect(output).toContain('Hi there');
|
||||
});
|
||||
|
||||
it('does not constrain height in alternate buffer mode', async () => {
|
||||
vi.mocked(useAlternateBuffer).mockReturnValue(true);
|
||||
const { lastFrame } = renderWithProviders(<MainContent />);
|
||||
await waitFor(() => expect(lastFrame()).toContain('HistoryItem: Hello'));
|
||||
const { lastFrame } = renderWithProviders(<MainContent />, {
|
||||
uiState: defaultMockUiState as Partial<UIState>,
|
||||
});
|
||||
await waitFor(() => expect(lastFrame()).toContain('Hello'));
|
||||
const output = lastFrame();
|
||||
|
||||
expect(output).toMatchSnapshot();
|
||||
});
|
||||
|
||||
describe('MainContent Tool Output Height Logic', () => {
|
||||
const testCases = [
|
||||
{
|
||||
name: 'ASB mode - Focused shell should expand',
|
||||
isAlternateBuffer: true,
|
||||
embeddedShellFocused: true,
|
||||
constrainHeight: true,
|
||||
shouldShowLine1: true,
|
||||
},
|
||||
{
|
||||
name: 'ASB mode - Unfocused shell',
|
||||
isAlternateBuffer: true,
|
||||
embeddedShellFocused: false,
|
||||
constrainHeight: true,
|
||||
shouldShowLine1: false,
|
||||
},
|
||||
{
|
||||
name: 'Normal mode - Constrained height',
|
||||
isAlternateBuffer: false,
|
||||
embeddedShellFocused: false,
|
||||
constrainHeight: true,
|
||||
shouldShowLine1: false,
|
||||
},
|
||||
{
|
||||
name: 'Normal mode - Unconstrained height',
|
||||
isAlternateBuffer: false,
|
||||
embeddedShellFocused: false,
|
||||
constrainHeight: false,
|
||||
shouldShowLine1: false,
|
||||
},
|
||||
];
|
||||
|
||||
it.each(testCases)(
|
||||
'$name',
|
||||
async ({
|
||||
isAlternateBuffer,
|
||||
embeddedShellFocused,
|
||||
constrainHeight,
|
||||
shouldShowLine1,
|
||||
}) => {
|
||||
vi.mocked(useAlternateBuffer).mockReturnValue(isAlternateBuffer);
|
||||
const ptyId = 123;
|
||||
const uiState = {
|
||||
history: [],
|
||||
pendingHistoryItems: [
|
||||
{
|
||||
type: 'tool_group' as const,
|
||||
id: 1,
|
||||
tools: [
|
||||
{
|
||||
callId: 'call_1',
|
||||
name: SHELL_COMMAND_NAME,
|
||||
status: ToolCallStatus.Executing,
|
||||
description: 'Running a long command...',
|
||||
// 20 lines of output.
|
||||
// Default max is 15, so Line 1-5 will be truncated/scrolled out if not expanded.
|
||||
resultDisplay: Array.from(
|
||||
{ length: 20 },
|
||||
(_, i) => `Line ${i + 1}`,
|
||||
).join('\n'),
|
||||
ptyId,
|
||||
confirmationDetails: undefined,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
availableTerminalHeight: 30, // In ASB mode, focused shell should get ~28 lines
|
||||
terminalHeight: 50,
|
||||
terminalWidth: 100,
|
||||
mainAreaWidth: 100,
|
||||
embeddedShellFocused,
|
||||
activePtyId: embeddedShellFocused ? ptyId : undefined,
|
||||
constrainHeight,
|
||||
isEditorDialogOpen: false,
|
||||
slashCommands: [],
|
||||
historyRemountKey: 0,
|
||||
bannerData: {
|
||||
defaultText: '',
|
||||
warningText: '',
|
||||
},
|
||||
bannerVisible: false,
|
||||
};
|
||||
|
||||
const { lastFrame } = renderWithProviders(<MainContent />, {
|
||||
uiState: uiState as Partial<UIState>,
|
||||
useAlternateBuffer: isAlternateBuffer,
|
||||
});
|
||||
|
||||
const output = lastFrame();
|
||||
|
||||
// Sanity checks - Use regex with word boundary to avoid matching "Line 10" etc.
|
||||
const line1Regex = /\bLine 1\b/;
|
||||
if (shouldShowLine1) {
|
||||
expect(output).toMatch(line1Regex);
|
||||
} else {
|
||||
expect(output).not.toMatch(line1Regex);
|
||||
}
|
||||
|
||||
// All cases should show the last line
|
||||
expect(output).toContain('Line 20');
|
||||
|
||||
// Snapshots for visual verification
|
||||
expect(output).toMatchSnapshot();
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -81,7 +81,8 @@ export const MainContent = () => {
|
||||
<HistoryItemDisplay
|
||||
key={i}
|
||||
availableTerminalHeight={
|
||||
uiState.constrainHeight && !isAlternateBuffer
|
||||
(uiState.constrainHeight && !isAlternateBuffer) ||
|
||||
isAlternateBuffer
|
||||
? availableTerminalHeight
|
||||
: undefined
|
||||
}
|
||||
@@ -148,7 +149,7 @@ export const MainContent = () => {
|
||||
return (
|
||||
<ScrollableList
|
||||
ref={scrollableListRef}
|
||||
hasFocus={!uiState.isEditorDialogOpen}
|
||||
hasFocus={!uiState.isEditorDialogOpen && !uiState.embeddedShellFocused}
|
||||
width={uiState.terminalWidth}
|
||||
data={virtualizedData}
|
||||
renderItem={renderItem}
|
||||
|
||||
@@ -14,8 +14,6 @@ import {
|
||||
DEFAULT_GEMINI_MODEL_AUTO,
|
||||
DEFAULT_GEMINI_FLASH_MODEL,
|
||||
DEFAULT_GEMINI_FLASH_LITE_MODEL,
|
||||
PREVIEW_GEMINI_MODEL,
|
||||
PREVIEW_GEMINI_MODEL_AUTO,
|
||||
} from '@google/gemini-cli-core';
|
||||
import type { Config, ModelSlashCommandEvent } from '@google/gemini-cli-core';
|
||||
|
||||
@@ -42,28 +40,24 @@ vi.mock('@google/gemini-cli-core', async () => {
|
||||
describe('<ModelDialog />', () => {
|
||||
const mockSetModel = vi.fn();
|
||||
const mockGetModel = vi.fn();
|
||||
const mockGetPreviewFeatures = vi.fn();
|
||||
const mockOnClose = vi.fn();
|
||||
const mockGetHasAccessToPreviewModel = vi.fn();
|
||||
|
||||
interface MockConfig extends Partial<Config> {
|
||||
setModel: (model: string, isTemporary?: boolean) => void;
|
||||
getModel: () => string;
|
||||
getPreviewFeatures: () => boolean;
|
||||
getHasAccessToPreviewModel: () => boolean;
|
||||
}
|
||||
|
||||
const mockConfig: MockConfig = {
|
||||
setModel: mockSetModel,
|
||||
getModel: mockGetModel,
|
||||
getPreviewFeatures: mockGetPreviewFeatures,
|
||||
getHasAccessToPreviewModel: mockGetHasAccessToPreviewModel,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
mockGetModel.mockReturnValue(DEFAULT_GEMINI_MODEL_AUTO);
|
||||
mockGetPreviewFeatures.mockReturnValue(false);
|
||||
mockGetHasAccessToPreviewModel.mockReturnValue(false);
|
||||
|
||||
// Default implementation for getDisplayString
|
||||
@@ -94,13 +88,6 @@ describe('<ModelDialog />', () => {
|
||||
expect(lastFrame()).toContain('Manual');
|
||||
});
|
||||
|
||||
it('renders "main" view with preview options when preview features are enabled', () => {
|
||||
mockGetPreviewFeatures.mockReturnValue(true);
|
||||
mockGetHasAccessToPreviewModel.mockReturnValue(true); // Must have access
|
||||
const { lastFrame } = renderComponent();
|
||||
expect(lastFrame()).toContain('Auto (Preview)');
|
||||
});
|
||||
|
||||
it('switches to "manual" view when "Manual" is selected', async () => {
|
||||
const { lastFrame, stdin } = renderComponent();
|
||||
|
||||
@@ -119,26 +106,6 @@ describe('<ModelDialog />', () => {
|
||||
expect(lastFrame()).toContain(DEFAULT_GEMINI_FLASH_LITE_MODEL);
|
||||
});
|
||||
|
||||
it('renders "manual" view with preview options when preview features are enabled', async () => {
|
||||
mockGetPreviewFeatures.mockReturnValue(true);
|
||||
mockGetHasAccessToPreviewModel.mockReturnValue(true); // Must have access
|
||||
mockGetModel.mockReturnValue(PREVIEW_GEMINI_MODEL_AUTO);
|
||||
const { lastFrame, stdin } = renderComponent();
|
||||
|
||||
// Select "Manual" (index 2 because Preview Auto is first, then Auto (Gemini 2.5))
|
||||
// Press down enough times to ensure we reach the bottom (Manual)
|
||||
stdin.write('\u001B[B'); // Arrow Down
|
||||
await waitForUpdate();
|
||||
stdin.write('\u001B[B'); // Arrow Down
|
||||
await waitForUpdate();
|
||||
|
||||
// Press enter to select Manual
|
||||
stdin.write('\r');
|
||||
await waitForUpdate();
|
||||
|
||||
expect(lastFrame()).toContain(PREVIEW_GEMINI_MODEL);
|
||||
});
|
||||
|
||||
it('sets model and closes when a model is selected in "main" view', async () => {
|
||||
const { stdin } = renderComponent();
|
||||
|
||||
@@ -220,50 +187,4 @@ describe('<ModelDialog />', () => {
|
||||
// Should be back to main view (Manual option visible)
|
||||
expect(lastFrame()).toContain('Manual');
|
||||
});
|
||||
|
||||
describe('Preview Logic', () => {
|
||||
it('should NOT show preview options if user has no access', () => {
|
||||
mockGetHasAccessToPreviewModel.mockReturnValue(false);
|
||||
mockGetPreviewFeatures.mockReturnValue(true); // Even if enabled
|
||||
const { lastFrame } = renderComponent();
|
||||
expect(lastFrame()).not.toContain('Auto (Preview)');
|
||||
});
|
||||
|
||||
it('should NOT show preview options if user has access but preview features are disabled', () => {
|
||||
mockGetHasAccessToPreviewModel.mockReturnValue(true);
|
||||
mockGetPreviewFeatures.mockReturnValue(false);
|
||||
const { lastFrame } = renderComponent();
|
||||
expect(lastFrame()).not.toContain('Auto (Preview)');
|
||||
});
|
||||
|
||||
it('should show preview options if user has access AND preview features are enabled', () => {
|
||||
mockGetHasAccessToPreviewModel.mockReturnValue(true);
|
||||
mockGetPreviewFeatures.mockReturnValue(true);
|
||||
const { lastFrame } = renderComponent();
|
||||
expect(lastFrame()).toContain('Auto (Preview)');
|
||||
});
|
||||
|
||||
it('should show "Gemini 3 is now available" header if user has access but preview features disabled', () => {
|
||||
mockGetHasAccessToPreviewModel.mockReturnValue(true);
|
||||
mockGetPreviewFeatures.mockReturnValue(false);
|
||||
const { lastFrame } = renderComponent();
|
||||
expect(lastFrame()).toContain('Gemini 3 is now available.');
|
||||
expect(lastFrame()).toContain('Enable "Preview features" in /settings');
|
||||
});
|
||||
|
||||
it('should show "Gemini 3 is coming soon" header if user has no access', () => {
|
||||
mockGetHasAccessToPreviewModel.mockReturnValue(false);
|
||||
mockGetPreviewFeatures.mockReturnValue(false);
|
||||
const { lastFrame } = renderComponent();
|
||||
expect(lastFrame()).toContain('Gemini 3 is coming soon.');
|
||||
});
|
||||
|
||||
it('should NOT show header/subheader if preview options are shown', () => {
|
||||
mockGetHasAccessToPreviewModel.mockReturnValue(true);
|
||||
mockGetPreviewFeatures.mockReturnValue(true);
|
||||
const { lastFrame } = renderComponent();
|
||||
expect(lastFrame()).not.toContain('Gemini 3 is now available.');
|
||||
expect(lastFrame()).not.toContain('Gemini 3 is coming soon.');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -23,7 +23,6 @@ import { useKeypress } from '../hooks/useKeypress.js';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import { DescriptiveRadioButtonSelect } from './shared/DescriptiveRadioButtonSelect.js';
|
||||
import { ConfigContext } from '../contexts/ConfigContext.js';
|
||||
import { ThemedGradient } from './ThemedGradient.js';
|
||||
|
||||
interface ModelDialogProps {
|
||||
onClose: () => void;
|
||||
@@ -37,8 +36,7 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
|
||||
// Determine the Preferred Model (read once when the dialog opens).
|
||||
const preferredModel = config?.getModel() || DEFAULT_GEMINI_MODEL_AUTO;
|
||||
|
||||
const shouldShowPreviewModels =
|
||||
config?.getPreviewFeatures() && config.getHasAccessToPreviewModel();
|
||||
const shouldShowPreviewModels = config?.getHasAccessToPreviewModel();
|
||||
|
||||
const manualModelSelected = useMemo(() => {
|
||||
const manualModels = [
|
||||
@@ -173,24 +171,6 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
|
||||
[config, onClose, persistMode],
|
||||
);
|
||||
|
||||
let header;
|
||||
let subheader;
|
||||
|
||||
// Do not show any header or subheader since it's already showing preview model
|
||||
// options
|
||||
if (shouldShowPreviewModels) {
|
||||
header = undefined;
|
||||
subheader = undefined;
|
||||
// When a user has the access but has not enabled the preview features.
|
||||
} else if (config?.getHasAccessToPreviewModel()) {
|
||||
header = 'Gemini 3 is now available.';
|
||||
subheader =
|
||||
'Enable "Preview features" in /settings.\nLearn more at https://goo.gle/enable-preview-features';
|
||||
} else {
|
||||
header = 'Gemini 3 is coming soon.';
|
||||
subheader = undefined;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
borderStyle="round"
|
||||
@@ -201,16 +181,6 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
|
||||
>
|
||||
<Text bold>Select Model</Text>
|
||||
|
||||
<Box flexDirection="column">
|
||||
{header && (
|
||||
<Box marginTop={1}>
|
||||
<ThemedGradient>
|
||||
<Text>{header}</Text>
|
||||
</ThemedGradient>
|
||||
</Box>
|
||||
)}
|
||||
{subheader && <Text>{subheader}</Text>}
|
||||
</Box>
|
||||
<Box marginTop={1}>
|
||||
<DescriptiveRadioButtonSelect
|
||||
items={options}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
*/
|
||||
|
||||
import { render, persistentStateMock } from '../../test-utils/render.js';
|
||||
import { waitFor } from '../../test-utils/async.js';
|
||||
import { Notifications } from './Notifications.js';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { useAppContext, type AppState } from '../contexts/AppContext.js';
|
||||
@@ -172,7 +173,7 @@ describe('Notifications', () => {
|
||||
render(<Notifications />);
|
||||
|
||||
await act(async () => {
|
||||
await vi.waitFor(() => {
|
||||
await waitFor(() => {
|
||||
expect(persistentStateMock.set).toHaveBeenCalledWith(
|
||||
'hasSeenScreenReaderNudge',
|
||||
true,
|
||||
|
||||
@@ -368,20 +368,12 @@ describe('SettingsDialog', () => {
|
||||
|
||||
const { stdin, unmount, lastFrame } = renderDialog(settings, onSelect);
|
||||
|
||||
// Wait for initial render and verify we're on Preview Features (first setting)
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain('Preview Features (e.g., models)');
|
||||
});
|
||||
|
||||
// Navigate to Vim Mode setting and verify we're there
|
||||
act(() => {
|
||||
stdin.write(TerminalKeys.DOWN_ARROW as string);
|
||||
});
|
||||
// Wait for initial render and verify we're on Vim Mode (first setting)
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain('Vim Mode');
|
||||
});
|
||||
|
||||
// Toggle the setting
|
||||
// Toggle the setting (Vim Mode is the first setting now)
|
||||
act(() => {
|
||||
stdin.write(TerminalKeys.ENTER as string);
|
||||
});
|
||||
@@ -1404,7 +1396,6 @@ describe('SettingsDialog', () => {
|
||||
},
|
||||
tools: {
|
||||
truncateToolOutputThreshold: 50000,
|
||||
truncateToolOutputLines: 1000,
|
||||
},
|
||||
context: {
|
||||
discoveryMaxDirs: 500,
|
||||
@@ -1473,7 +1464,6 @@ describe('SettingsDialog', () => {
|
||||
enableInteractiveShell: true,
|
||||
useRipgrep: true,
|
||||
truncateToolOutputThreshold: 25000,
|
||||
truncateToolOutputLines: 500,
|
||||
},
|
||||
security: {
|
||||
folderTrust: {
|
||||
|
||||
@@ -355,10 +355,6 @@ export function SettingsDialog({
|
||||
next.delete(key);
|
||||
return next;
|
||||
});
|
||||
|
||||
if (key === 'general.previewFeatures') {
|
||||
config?.setPreviewFeatures(newValue as boolean);
|
||||
}
|
||||
} else {
|
||||
// For restart-required settings, track as modified
|
||||
setModifiedSettings((prev) => {
|
||||
@@ -387,14 +383,7 @@ export function SettingsDialog({
|
||||
});
|
||||
}
|
||||
},
|
||||
[
|
||||
pendingSettings,
|
||||
settings,
|
||||
selectedScope,
|
||||
vimEnabled,
|
||||
toggleVimEnabled,
|
||||
config,
|
||||
],
|
||||
[pendingSettings, settings, selectedScope, vimEnabled, toggleVimEnabled],
|
||||
);
|
||||
|
||||
// Edit commit handler
|
||||
@@ -522,12 +511,6 @@ export function SettingsDialog({
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (key === 'general.previewFeatures') {
|
||||
const booleanDefaultValue =
|
||||
typeof defaultValue === 'boolean' ? defaultValue : false;
|
||||
config?.setPreviewFeatures(booleanDefaultValue);
|
||||
}
|
||||
}
|
||||
|
||||
// Remove from modified sets
|
||||
|
||||
@@ -8,6 +8,12 @@ import { render } from '../../test-utils/render.js';
|
||||
import { ShellInputPrompt } from './ShellInputPrompt.js';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { ShellExecutionService } from '@google/gemini-cli-core';
|
||||
import { useUIActions, type UIActions } from '../contexts/UIActionsContext.js';
|
||||
|
||||
// Mock useUIActions
|
||||
vi.mock('../contexts/UIActionsContext.js', () => ({
|
||||
useUIActions: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock useKeypress
|
||||
const mockUseKeypress = vi.fn();
|
||||
@@ -31,9 +37,13 @@ vi.mock('@google/gemini-cli-core', async () => {
|
||||
describe('ShellInputPrompt', () => {
|
||||
const mockWriteToPty = vi.mocked(ShellExecutionService.writeToPty);
|
||||
const mockScrollPty = vi.mocked(ShellExecutionService.scrollPty);
|
||||
const mockHandleWarning = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(useUIActions).mockReturnValue({
|
||||
handleWarning: mockHandleWarning,
|
||||
} as Partial<UIActions> as UIActions);
|
||||
});
|
||||
|
||||
it('renders nothing', () => {
|
||||
@@ -43,6 +53,23 @@ describe('ShellInputPrompt', () => {
|
||||
expect(lastFrame()).toBe('');
|
||||
});
|
||||
|
||||
it('sends tab to pty', () => {
|
||||
render(<ShellInputPrompt activeShellPtyId={1} focus={true} />);
|
||||
|
||||
const handler = mockUseKeypress.mock.calls[0][0];
|
||||
|
||||
handler({
|
||||
name: 'tab',
|
||||
shift: false,
|
||||
alt: false,
|
||||
ctrl: false,
|
||||
cmd: false,
|
||||
sequence: '\t',
|
||||
});
|
||||
|
||||
expect(mockWriteToPty).toHaveBeenCalledWith(1, '\t');
|
||||
});
|
||||
|
||||
it.each([
|
||||
['a', 'a'],
|
||||
['b', 'b'],
|
||||
@@ -68,16 +95,64 @@ describe('ShellInputPrompt', () => {
|
||||
it.each([
|
||||
['up', -1],
|
||||
['down', 1],
|
||||
])('handles scroll %s (Ctrl+Shift+%s)', (key, direction) => {
|
||||
])('handles scroll %s (Command.SCROLL_%s)', (key, direction) => {
|
||||
render(<ShellInputPrompt activeShellPtyId={1} focus={true} />);
|
||||
|
||||
const handler = mockUseKeypress.mock.calls[0][0];
|
||||
|
||||
handler({ name: key, shift: true, alt: false, ctrl: true, cmd: false });
|
||||
handler({ name: key, shift: true, alt: false, ctrl: false, cmd: false });
|
||||
|
||||
expect(mockScrollPty).toHaveBeenCalledWith(1, direction);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['pageup', -15],
|
||||
['pagedown', 15],
|
||||
])(
|
||||
'handles page scroll %s (Command.PAGE_%s) with default size',
|
||||
(key, expectedScroll) => {
|
||||
render(<ShellInputPrompt activeShellPtyId={1} focus={true} />);
|
||||
|
||||
const handler = mockUseKeypress.mock.calls[0][0];
|
||||
|
||||
handler({ name: key, shift: false, alt: false, ctrl: false, cmd: false });
|
||||
|
||||
expect(mockScrollPty).toHaveBeenCalledWith(1, expectedScroll);
|
||||
},
|
||||
);
|
||||
|
||||
it('respects scrollPageSize prop', () => {
|
||||
render(
|
||||
<ShellInputPrompt
|
||||
activeShellPtyId={1}
|
||||
focus={true}
|
||||
scrollPageSize={10}
|
||||
/>,
|
||||
);
|
||||
|
||||
const handler = mockUseKeypress.mock.calls[0][0];
|
||||
|
||||
// PageDown
|
||||
handler({
|
||||
name: 'pagedown',
|
||||
shift: false,
|
||||
alt: false,
|
||||
ctrl: false,
|
||||
cmd: false,
|
||||
});
|
||||
expect(mockScrollPty).toHaveBeenCalledWith(1, 10);
|
||||
|
||||
// PageUp
|
||||
handler({
|
||||
name: 'pageup',
|
||||
shift: false,
|
||||
alt: false,
|
||||
ctrl: false,
|
||||
cmd: false,
|
||||
});
|
||||
expect(mockScrollPty).toHaveBeenCalledWith(1, -10);
|
||||
});
|
||||
|
||||
it('does not handle input when not focused', () => {
|
||||
render(<ShellInputPrompt activeShellPtyId={1} focus={false} />);
|
||||
|
||||
@@ -111,4 +186,21 @@ describe('ShellInputPrompt', () => {
|
||||
|
||||
expect(mockWriteToPty).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ignores Command.UNFOCUS_SHELL (Shift+Tab) to allow focus navigation', () => {
|
||||
render(<ShellInputPrompt activeShellPtyId={1} focus={true} />);
|
||||
|
||||
const handler = mockUseKeypress.mock.calls[0][0];
|
||||
|
||||
const result = handler({
|
||||
name: 'tab',
|
||||
shift: true,
|
||||
alt: false,
|
||||
ctrl: false,
|
||||
cmd: false,
|
||||
});
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(mockWriteToPty).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,16 +9,19 @@ import type React from 'react';
|
||||
import { useKeypress } from '../hooks/useKeypress.js';
|
||||
import { ShellExecutionService } from '@google/gemini-cli-core';
|
||||
import { keyToAnsi, type Key } from '../hooks/keyToAnsi.js';
|
||||
import { ACTIVE_SHELL_MAX_LINES } from '../constants.js';
|
||||
import { Command, keyMatchers } from '../keyMatchers.js';
|
||||
|
||||
export interface ShellInputPromptProps {
|
||||
activeShellPtyId: number | null;
|
||||
focus?: boolean;
|
||||
scrollPageSize?: number;
|
||||
}
|
||||
|
||||
export const ShellInputPrompt: React.FC<ShellInputPromptProps> = ({
|
||||
activeShellPtyId,
|
||||
focus = true,
|
||||
scrollPageSize = ACTIVE_SHELL_MAX_LINES,
|
||||
}) => {
|
||||
const handleShellInputSubmit = useCallback(
|
||||
(input: string) => {
|
||||
@@ -34,21 +37,33 @@ export const ShellInputPrompt: React.FC<ShellInputPromptProps> = ({
|
||||
if (!focus || !activeShellPtyId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Allow background shell toggle to bubble up
|
||||
if (keyMatchers[Command.TOGGLE_BACKGROUND_SHELL](key)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (key.ctrl && key.shift && key.name === 'up') {
|
||||
// Allow Shift+Tab to bubble up for focus navigation
|
||||
if (keyMatchers[Command.UNFOCUS_SHELL_INPUT](key)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (keyMatchers[Command.SCROLL_UP](key)) {
|
||||
ShellExecutionService.scrollPty(activeShellPtyId, -1);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (key.ctrl && key.shift && key.name === 'down') {
|
||||
if (keyMatchers[Command.SCROLL_DOWN](key)) {
|
||||
ShellExecutionService.scrollPty(activeShellPtyId, 1);
|
||||
return true;
|
||||
}
|
||||
// TODO: Check pty service actually scrolls (request)[https://github.com/google-gemini/gemini-cli/pull/17438/changes/c9fdaf8967da0036bfef43592fcab5a69537df35#r2776479023].
|
||||
if (keyMatchers[Command.PAGE_UP](key)) {
|
||||
ShellExecutionService.scrollPty(activeShellPtyId, -scrollPageSize);
|
||||
return true;
|
||||
}
|
||||
if (keyMatchers[Command.PAGE_DOWN](key)) {
|
||||
ShellExecutionService.scrollPty(activeShellPtyId, scrollPageSize);
|
||||
return true;
|
||||
}
|
||||
|
||||
const ansiSequence = keyToAnsi(key);
|
||||
if (ansiSequence) {
|
||||
@@ -58,7 +73,7 @@ export const ShellInputPrompt: React.FC<ShellInputPromptProps> = ({
|
||||
|
||||
return false;
|
||||
},
|
||||
[focus, handleShellInputSubmit, activeShellPtyId],
|
||||
[focus, handleShellInputSubmit, activeShellPtyId, scrollPageSize],
|
||||
);
|
||||
|
||||
useKeypress(handleInput, { isActive: focus });
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, afterEach, vi } from 'vitest';
|
||||
import { renderWithProviders } from '../../test-utils/render.js';
|
||||
import { ShortcutsHelp } from './ShortcutsHelp.js';
|
||||
|
||||
describe('ShortcutsHelp', () => {
|
||||
const originalPlatform = process.platform;
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(process, 'platform', {
|
||||
value: originalPlatform,
|
||||
});
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
const testCases = [
|
||||
{ name: 'wide', width: 100 },
|
||||
{ name: 'narrow', width: 40 },
|
||||
];
|
||||
|
||||
const platforms = [
|
||||
{ name: 'mac', value: 'darwin' },
|
||||
{ name: 'linux', value: 'linux' },
|
||||
] as const;
|
||||
|
||||
it.each(
|
||||
platforms.flatMap((platform) =>
|
||||
testCases.map((testCase) => ({ ...testCase, platform })),
|
||||
),
|
||||
)(
|
||||
'renders correctly in $name mode on $platform.name',
|
||||
({ width, platform }) => {
|
||||
Object.defineProperty(process, 'platform', {
|
||||
value: platform.value,
|
||||
});
|
||||
|
||||
const { lastFrame } = renderWithProviders(<ShortcutsHelp />, {
|
||||
width,
|
||||
});
|
||||
expect(lastFrame()).toContain('shell mode');
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import { isNarrowWidth } from '../utils/isNarrowWidth.js';
|
||||
import { SectionHeader } from './shared/SectionHeader.js';
|
||||
import { useUIState } from '../contexts/UIStateContext.js';
|
||||
|
||||
type ShortcutItem = {
|
||||
key: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
const buildShortcutItems = (): ShortcutItem[] => {
|
||||
const isMac = process.platform === 'darwin';
|
||||
const altLabel = isMac ? 'Option' : 'Alt';
|
||||
|
||||
return [
|
||||
{ key: '!', description: 'shell mode' },
|
||||
{ key: 'Shift+Tab', description: 'cycle mode' },
|
||||
{ key: 'Ctrl+V', description: 'paste images' },
|
||||
{ key: '@', description: 'select file or folder' },
|
||||
{ key: 'Ctrl+Y', description: 'YOLO mode' },
|
||||
{ key: 'Ctrl+R', description: 'reverse-search history' },
|
||||
{ key: 'Esc Esc', description: 'clear prompt / rewind' },
|
||||
{ key: `${altLabel}+M`, description: 'raw markdown mode' },
|
||||
{ key: 'Ctrl+X', description: 'open external editor' },
|
||||
];
|
||||
};
|
||||
|
||||
const Shortcut: React.FC<{ item: ShortcutItem }> = ({ item }) => (
|
||||
<Box flexDirection="row">
|
||||
<Box flexShrink={0} marginRight={1}>
|
||||
<Text color={theme.text.accent}>{item.key}</Text>
|
||||
</Box>
|
||||
<Box flexGrow={1}>
|
||||
<Text color={theme.text.primary}>{item.description}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
|
||||
export const ShortcutsHelp: React.FC = () => {
|
||||
const { terminalWidth } = useUIState();
|
||||
const items = buildShortcutItems();
|
||||
|
||||
const isNarrow = isNarrowWidth(terminalWidth);
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" width="100%">
|
||||
<SectionHeader title="Shortcuts (for more, see /help)" />
|
||||
<Box flexDirection="row" flexWrap="wrap" paddingLeft={1} paddingRight={2}>
|
||||
{items.map((item, index) => (
|
||||
<Box
|
||||
key={`${item.key}-${index}`}
|
||||
width={isNarrow ? '100%' : '33%'}
|
||||
paddingRight={isNarrow ? 0 : 2}
|
||||
>
|
||||
<Shortcut item={item} />
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { Text } from 'ink';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import { useUIState } from '../contexts/UIStateContext.js';
|
||||
|
||||
export const ShortcutsHint: React.FC = () => {
|
||||
const { shortcutsHelpVisible } = useUIState();
|
||||
const highlightColor = shortcutsHelpVisible
|
||||
? theme.text.accent
|
||||
: theme.text.secondary;
|
||||
|
||||
return <Text color={highlightColor}> ? for shortcuts </Text>;
|
||||
};
|
||||
@@ -43,6 +43,7 @@ const createMockUIState = (overrides: UIStateOverrides = {}): UIState =>
|
||||
warningMessage: null,
|
||||
ctrlDPressedOnce: false,
|
||||
showEscapePrompt: false,
|
||||
shortcutsHelpVisible: false,
|
||||
queueErrorMessage: null,
|
||||
activeHooks: [],
|
||||
ideContextState: null,
|
||||
|
||||
+16
-16
@@ -39,14 +39,14 @@ Tips for getting started:
|
||||
2. Be specific for the best results.
|
||||
3. Create GEMINI.md files to customize your interactions with Gemini.
|
||||
4. /help for more information.
|
||||
╭──────────────────────────────────────────────────────────────────────────────╮
|
||||
│ ✓ tool1 Description for tool 1 │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────╯
|
||||
╭──────────────────────────────────────────────────────────────────────────────╮
|
||||
│ ✓ tool2 Description for tool 2 │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────╯"
|
||||
╭──────────────────────────────────────────────────────────────────────────╮
|
||||
│ ✓ tool1 Description for tool 1 │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────╯
|
||||
╭──────────────────────────────────────────────────────────────────────────╮
|
||||
│ ✓ tool2 Description for tool 2 │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
|
||||
exports[`AlternateBufferQuittingDisplay > renders with empty history and no pending items > empty 1`] = `
|
||||
@@ -83,14 +83,14 @@ Tips for getting started:
|
||||
2. Be specific for the best results.
|
||||
3. Create GEMINI.md files to customize your interactions with Gemini.
|
||||
4. /help for more information.
|
||||
╭──────────────────────────────────────────────────────────────────────────────╮
|
||||
│ ✓ tool1 Description for tool 1 │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────╯
|
||||
╭──────────────────────────────────────────────────────────────────────────────╮
|
||||
│ ✓ tool2 Description for tool 2 │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────╯"
|
||||
╭──────────────────────────────────────────────────────────────────────────╮
|
||||
│ ✓ tool1 Description for tool 1 │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────╯
|
||||
╭──────────────────────────────────────────────────────────────────────────╮
|
||||
│ ✓ tool2 Description for tool 2 │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
|
||||
exports[`AlternateBufferQuittingDisplay > renders with pending items but no history > with_pending_no_history 1`] = `
|
||||
|
||||
@@ -18,24 +18,6 @@ Tips for getting started:
|
||||
4. /help for more information."
|
||||
`;
|
||||
|
||||
exports[`<AppHeader /> > should not render the banner when previewFeatures is enabled 1`] = `
|
||||
"
|
||||
███ █████████
|
||||
░░░███ ███░░░░░███
|
||||
░░░███ ███ ░░░
|
||||
░░░███░███
|
||||
███░ ░███ █████
|
||||
███░ ░░███ ░░███
|
||||
███░ ░░█████████
|
||||
░░░ ░░░░░░░░░
|
||||
|
||||
Tips for getting started:
|
||||
1. Ask questions, edit files, or run commands.
|
||||
2. Be specific for the best results.
|
||||
3. Create GEMINI.md files to customize your interactions with Gemini.
|
||||
4. /help for more information."
|
||||
`;
|
||||
|
||||
exports[`<AppHeader /> > should not render the default banner if shown count is 5 or more 1`] = `
|
||||
"
|
||||
███ █████████
|
||||
@@ -54,27 +36,6 @@ Tips for getting started:
|
||||
4. /help for more information."
|
||||
`;
|
||||
|
||||
exports[`<AppHeader /> > should render the banner when previewFeatures is disabled 1`] = `
|
||||
"
|
||||
███ █████████
|
||||
░░░███ ███░░░░░███
|
||||
░░░███ ███ ░░░
|
||||
░░░███░███
|
||||
███░ ░███ █████
|
||||
███░ ░░███ ░░███
|
||||
███░ ░░█████████
|
||||
░░░ ░░░░░░░░░
|
||||
|
||||
╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ This is the default banner │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
|
||||
Tips for getting started:
|
||||
1. Ask questions, edit files, or run commands.
|
||||
2. Be specific for the best results.
|
||||
3. Create GEMINI.md files to customize your interactions with Gemini.
|
||||
4. /help for more information."
|
||||
`;
|
||||
|
||||
exports[`<AppHeader /> > should render the banner with default text 1`] = `
|
||||
"
|
||||
███ █████████
|
||||
|
||||
@@ -2,16 +2,16 @@
|
||||
|
||||
exports[`<BackgroundShellDisplay /> > highlights the focused state 1`] = `
|
||||
"┌──────────────────────────────────────────────────────────────────────────────────────────────────┐
|
||||
│ 1: npm star... (PID: 1001) (Focused) Ctrl+B Hide | Ctrl+K Kill | Ctrl+L List │
|
||||
│ 1: npm sta... (PID: 1001) (Focused) Close (Ctrl+B) | Kill (Ctrl+K) | List (Ctrl+L) │
|
||||
│ Starting server... │
|
||||
└──────────────────────────────────────────────────────────────────────────────────────────────────┘"
|
||||
`;
|
||||
|
||||
exports[`<BackgroundShellDisplay /> > keeps exit code status color even when selected 1`] = `
|
||||
"┌──────────────────────────────────────────────────────────────────────────────────────────────────┐
|
||||
│ 1: npm star... (PID: 1003) (Focused) Ctrl+B Hide | Ctrl+K Kill | Ctrl+L List │
|
||||
│ 1: npm sta... (PID: 1003) (Focused) Close (Ctrl+B) | Kill (Ctrl+K) | List (Ctrl+L) │
|
||||
│ │
|
||||
│ Select Process (Enter to select, Esc to cancel): │
|
||||
│ Select Process (Enter to select, Ctrl+K to kill, Esc to cancel): │
|
||||
│ │
|
||||
│ 1. npm start (PID: 1001) │
|
||||
│ 2. tail -f log.txt (PID: 1002) │
|
||||
@@ -21,23 +21,23 @@ exports[`<BackgroundShellDisplay /> > keeps exit code status color even when sel
|
||||
|
||||
exports[`<BackgroundShellDisplay /> > renders tabs for multiple shells 1`] = `
|
||||
"┌──────────────────────────────────────────────────────────────────────────────────────────────────┐
|
||||
│ 1: npm start 2: tail -f log.txt (PID: 1001) Ctrl+B Hide | Ctrl+K Kill | Ctrl+L List │
|
||||
│ 1: npm start 2: tail -f lo... (PID: 1001) Close (Ctrl+B) | Kill (Ctrl+K) | List (Ctrl+L) │
|
||||
│ Starting server... │
|
||||
└──────────────────────────────────────────────────────────────────────────────────────────────────┘"
|
||||
`;
|
||||
|
||||
exports[`<BackgroundShellDisplay /> > renders the output of the active shell 1`] = `
|
||||
"┌──────────────────────────────────────────────────────────────────────────────────────────────────┐
|
||||
│ 1: npm ... 2: tail... (PID: 1001) Ctrl+B Hide | Ctrl+K Kill | Ctrl+L List │
|
||||
│ 1: ... 2: ... (PID: 1001) Close (Ctrl+B) | Kill (Ctrl+K) | List (Ctrl+L) │
|
||||
│ Starting server... │
|
||||
└──────────────────────────────────────────────────────────────────────────────────────────────────┘"
|
||||
`;
|
||||
|
||||
exports[`<BackgroundShellDisplay /> > renders the process list when isListOpenProp is true 1`] = `
|
||||
"┌──────────────────────────────────────────────────────────────────────────────────────────────────┐
|
||||
│ 1: npm star... (PID: 1001) (Focused) Ctrl+B Hide | Ctrl+K Kill | Ctrl+L List │
|
||||
│ 1: npm sta... (PID: 1001) (Focused) Close (Ctrl+B) | Kill (Ctrl+K) | List (Ctrl+L) │
|
||||
│ │
|
||||
│ Select Process (Enter to select, Esc to cancel): │
|
||||
│ Select Process (Enter to select, Ctrl+K to kill, Esc to cancel): │
|
||||
│ │
|
||||
│ ● 1. npm start (PID: 1001) │
|
||||
│ 2. tail -f log.txt (PID: 1002) │
|
||||
@@ -46,9 +46,9 @@ exports[`<BackgroundShellDisplay /> > renders the process list when isListOpenPr
|
||||
|
||||
exports[`<BackgroundShellDisplay /> > scrolls to active shell when list opens 1`] = `
|
||||
"┌──────────────────────────────────────────────────────────────────────────────────────────────────┐
|
||||
│ 1: npm star... (PID: 1002) (Focused) Ctrl+B Hide | Ctrl+K Kill | Ctrl+L List │
|
||||
│ 1: npm sta... (PID: 1002) (Focused) Close (Ctrl+B) | Kill (Ctrl+K) | List (Ctrl+L) │
|
||||
│ │
|
||||
│ Select Process (Enter to select, Esc to cancel): │
|
||||
│ Select Process (Enter to select, Ctrl+K to kill, Esc to cancel): │
|
||||
│ │
|
||||
│ 1. npm start (PID: 1001) │
|
||||
│ ● 2. tail -f log.txt (PID: 1002) │
|
||||
|
||||
@@ -1,8 +1,116 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`MainContent > MainContent Tool Output Height Logic > 'ASB mode - Focused shell should expand' 1`] = `
|
||||
"ScrollableList
|
||||
AppHeader
|
||||
╭──────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ ⊷ Shell Command Running a long command... │
|
||||
│ │
|
||||
│ Line 1 │
|
||||
│ Line 2 │
|
||||
│ Line 3 │
|
||||
│ Line 4 │
|
||||
│ Line 5 │
|
||||
│ Line 6 │
|
||||
│ Line 7 │
|
||||
│ Line 8 │
|
||||
│ Line 9 │
|
||||
│ Line 10 │
|
||||
│ Line 11 │
|
||||
│ Line 12 │
|
||||
│ Line 13 │
|
||||
│ Line 14 │
|
||||
│ Line 15 │
|
||||
│ Line 16 │
|
||||
│ Line 17 │
|
||||
│ Line 18 │
|
||||
│ Line 19 │
|
||||
│ Line 20 │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────╯
|
||||
ShowMoreLines"
|
||||
`;
|
||||
|
||||
exports[`MainContent > MainContent Tool Output Height Logic > 'ASB mode - Unfocused shell' 1`] = `
|
||||
"ScrollableList
|
||||
AppHeader
|
||||
╭──────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ ⊷ Shell Command Running a long command... │
|
||||
│ │
|
||||
│ Line 6 │
|
||||
│ Line 7 │
|
||||
│ Line 8 │
|
||||
│ Line 9 ▄ │
|
||||
│ Line 10 █ │
|
||||
│ Line 11 █ │
|
||||
│ Line 12 █ │
|
||||
│ Line 13 █ │
|
||||
│ Line 14 █ │
|
||||
│ Line 15 █ │
|
||||
│ Line 16 █ │
|
||||
│ Line 17 █ │
|
||||
│ Line 18 █ │
|
||||
│ Line 19 █ │
|
||||
│ Line 20 █ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────╯
|
||||
ShowMoreLines"
|
||||
`;
|
||||
|
||||
exports[`MainContent > MainContent Tool Output Height Logic > 'Normal mode - Constrained height' 1`] = `
|
||||
"AppHeader
|
||||
╭──────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ ⊷ Shell Command Running a long command... │
|
||||
│ │
|
||||
│ Line 6 │
|
||||
│ Line 7 │
|
||||
│ Line 8 │
|
||||
│ Line 9 │
|
||||
│ Line 10 │
|
||||
│ Line 11 │
|
||||
│ Line 12 │
|
||||
│ Line 13 │
|
||||
│ Line 14 │
|
||||
│ Line 15 │
|
||||
│ Line 16 │
|
||||
│ Line 17 │
|
||||
│ Line 18 │
|
||||
│ Line 19 │
|
||||
│ Line 20 │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────╯
|
||||
ShowMoreLines"
|
||||
`;
|
||||
|
||||
exports[`MainContent > MainContent Tool Output Height Logic > 'Normal mode - Unconstrained height' 1`] = `
|
||||
"AppHeader
|
||||
╭──────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ ⊷ Shell Command Running a long command... │
|
||||
│ │
|
||||
│ Line 6 │
|
||||
│ Line 7 │
|
||||
│ Line 8 │
|
||||
│ Line 9 │
|
||||
│ Line 10 │
|
||||
│ Line 11 │
|
||||
│ Line 12 │
|
||||
│ Line 13 │
|
||||
│ Line 14 │
|
||||
│ Line 15 │
|
||||
│ Line 16 │
|
||||
│ Line 17 │
|
||||
│ Line 18 │
|
||||
│ Line 19 │
|
||||
│ Line 20 │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────╯
|
||||
ShowMoreLines"
|
||||
`;
|
||||
|
||||
exports[`MainContent > does not constrain height in alternate buffer mode 1`] = `
|
||||
"ScrollableList
|
||||
AppHeader
|
||||
HistoryItem: Hello (height: undefined)
|
||||
HistoryItem: Hi there (height: undefined)"
|
||||
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
|
||||
> Hello
|
||||
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
|
||||
✦ Hi there
|
||||
ShowMoreLines
|
||||
"
|
||||
`;
|
||||
|
||||
@@ -10,10 +10,7 @@ exports[`SettingsDialog > Initial Rendering > should render settings list with v
|
||||
│ ╰──────────────────────────────────────────────────────────────────────────────────────────────╯ │
|
||||
│ │
|
||||
│ ▲ │
|
||||
│ ● Preview Features (e.g., models) false │
|
||||
│ Enable preview features (e.g., preview models). │
|
||||
│ │
|
||||
│ Vim Mode false │
|
||||
│ ● Vim Mode false │
|
||||
│ Enable Vim keybindings │
|
||||
│ │
|
||||
│ Enable Auto Update true │
|
||||
@@ -34,6 +31,9 @@ exports[`SettingsDialog > Initial Rendering > should render settings list with v
|
||||
│ Auto Theme Switching true │
|
||||
│ Automatically switch between default light and dark themes based on terminal backgro… │
|
||||
│ │
|
||||
│ Terminal Background Polling Interval 60 │
|
||||
│ Interval in seconds to poll the terminal background color. │
|
||||
│ │
|
||||
│ ▼ │
|
||||
│ │
|
||||
│ Apply To │
|
||||
@@ -56,10 +56,7 @@ exports[`SettingsDialog > Snapshot Tests > should render 'accessibility settings
|
||||
│ ╰──────────────────────────────────────────────────────────────────────────────────────────────╯ │
|
||||
│ │
|
||||
│ ▲ │
|
||||
│ ● Preview Features (e.g., models) false │
|
||||
│ Enable preview features (e.g., preview models). │
|
||||
│ │
|
||||
│ Vim Mode true* │
|
||||
│ ● Vim Mode true* │
|
||||
│ Enable Vim keybindings │
|
||||
│ │
|
||||
│ Enable Auto Update true │
|
||||
@@ -80,6 +77,9 @@ exports[`SettingsDialog > Snapshot Tests > should render 'accessibility settings
|
||||
│ Auto Theme Switching true │
|
||||
│ Automatically switch between default light and dark themes based on terminal backgro… │
|
||||
│ │
|
||||
│ Terminal Background Polling Interval 60 │
|
||||
│ Interval in seconds to poll the terminal background color. │
|
||||
│ │
|
||||
│ ▼ │
|
||||
│ │
|
||||
│ Apply To │
|
||||
@@ -102,10 +102,7 @@ exports[`SettingsDialog > Snapshot Tests > should render 'all boolean settings d
|
||||
│ ╰──────────────────────────────────────────────────────────────────────────────────────────────╯ │
|
||||
│ │
|
||||
│ ▲ │
|
||||
│ ● Preview Features (e.g., models) false │
|
||||
│ Enable preview features (e.g., preview models). │
|
||||
│ │
|
||||
│ Vim Mode false* │
|
||||
│ ● Vim Mode false* │
|
||||
│ Enable Vim keybindings │
|
||||
│ │
|
||||
│ Enable Auto Update true* │
|
||||
@@ -126,6 +123,9 @@ exports[`SettingsDialog > Snapshot Tests > should render 'all boolean settings d
|
||||
│ Auto Theme Switching true │
|
||||
│ Automatically switch between default light and dark themes based on terminal backgro… │
|
||||
│ │
|
||||
│ Terminal Background Polling Interval 60 │
|
||||
│ Interval in seconds to poll the terminal background color. │
|
||||
│ │
|
||||
│ ▼ │
|
||||
│ │
|
||||
│ Apply To │
|
||||
@@ -148,10 +148,7 @@ exports[`SettingsDialog > Snapshot Tests > should render 'default state' correct
|
||||
│ ╰──────────────────────────────────────────────────────────────────────────────────────────────╯ │
|
||||
│ │
|
||||
│ ▲ │
|
||||
│ ● Preview Features (e.g., models) false │
|
||||
│ Enable preview features (e.g., preview models). │
|
||||
│ │
|
||||
│ Vim Mode false │
|
||||
│ ● Vim Mode false │
|
||||
│ Enable Vim keybindings │
|
||||
│ │
|
||||
│ Enable Auto Update true │
|
||||
@@ -172,6 +169,9 @@ exports[`SettingsDialog > Snapshot Tests > should render 'default state' correct
|
||||
│ Auto Theme Switching true │
|
||||
│ Automatically switch between default light and dark themes based on terminal backgro… │
|
||||
│ │
|
||||
│ Terminal Background Polling Interval 60 │
|
||||
│ Interval in seconds to poll the terminal background color. │
|
||||
│ │
|
||||
│ ▼ │
|
||||
│ │
|
||||
│ Apply To │
|
||||
@@ -194,10 +194,7 @@ exports[`SettingsDialog > Snapshot Tests > should render 'file filtering setting
|
||||
│ ╰──────────────────────────────────────────────────────────────────────────────────────────────╯ │
|
||||
│ │
|
||||
│ ▲ │
|
||||
│ ● Preview Features (e.g., models) false │
|
||||
│ Enable preview features (e.g., preview models). │
|
||||
│ │
|
||||
│ Vim Mode false │
|
||||
│ ● Vim Mode false │
|
||||
│ Enable Vim keybindings │
|
||||
│ │
|
||||
│ Enable Auto Update true │
|
||||
@@ -218,6 +215,9 @@ exports[`SettingsDialog > Snapshot Tests > should render 'file filtering setting
|
||||
│ Auto Theme Switching true │
|
||||
│ Automatically switch between default light and dark themes based on terminal backgro… │
|
||||
│ │
|
||||
│ Terminal Background Polling Interval 60 │
|
||||
│ Interval in seconds to poll the terminal background color. │
|
||||
│ │
|
||||
│ ▼ │
|
||||
│ │
|
||||
│ Apply To │
|
||||
@@ -240,9 +240,6 @@ exports[`SettingsDialog > Snapshot Tests > should render 'focused on scope selec
|
||||
│ ╰──────────────────────────────────────────────────────────────────────────────────────────────╯ │
|
||||
│ │
|
||||
│ ▲ │
|
||||
│ Preview Features (e.g., models) false │
|
||||
│ Enable preview features (e.g., preview models). │
|
||||
│ │
|
||||
│ Vim Mode false │
|
||||
│ Enable Vim keybindings │
|
||||
│ │
|
||||
@@ -264,6 +261,9 @@ exports[`SettingsDialog > Snapshot Tests > should render 'focused on scope selec
|
||||
│ Auto Theme Switching true │
|
||||
│ Automatically switch between default light and dark themes based on terminal backgro… │
|
||||
│ │
|
||||
│ Terminal Background Polling Interval 60 │
|
||||
│ Interval in seconds to poll the terminal background color. │
|
||||
│ │
|
||||
│ ▼ │
|
||||
│ │
|
||||
│ > Apply To │
|
||||
@@ -286,10 +286,7 @@ exports[`SettingsDialog > Snapshot Tests > should render 'mixed boolean and numb
|
||||
│ ╰──────────────────────────────────────────────────────────────────────────────────────────────╯ │
|
||||
│ │
|
||||
│ ▲ │
|
||||
│ ● Preview Features (e.g., models) false │
|
||||
│ Enable preview features (e.g., preview models). │
|
||||
│ │
|
||||
│ Vim Mode false* │
|
||||
│ ● Vim Mode false* │
|
||||
│ Enable Vim keybindings │
|
||||
│ │
|
||||
│ Enable Auto Update false* │
|
||||
@@ -310,6 +307,9 @@ exports[`SettingsDialog > Snapshot Tests > should render 'mixed boolean and numb
|
||||
│ Auto Theme Switching true │
|
||||
│ Automatically switch between default light and dark themes based on terminal backgro… │
|
||||
│ │
|
||||
│ Terminal Background Polling Interval 60 │
|
||||
│ Interval in seconds to poll the terminal background color. │
|
||||
│ │
|
||||
│ ▼ │
|
||||
│ │
|
||||
│ Apply To │
|
||||
@@ -332,10 +332,7 @@ exports[`SettingsDialog > Snapshot Tests > should render 'tools and security set
|
||||
│ ╰──────────────────────────────────────────────────────────────────────────────────────────────╯ │
|
||||
│ │
|
||||
│ ▲ │
|
||||
│ ● Preview Features (e.g., models) false │
|
||||
│ Enable preview features (e.g., preview models). │
|
||||
│ │
|
||||
│ Vim Mode false │
|
||||
│ ● Vim Mode false │
|
||||
│ Enable Vim keybindings │
|
||||
│ │
|
||||
│ Enable Auto Update true │
|
||||
@@ -356,6 +353,9 @@ exports[`SettingsDialog > Snapshot Tests > should render 'tools and security set
|
||||
│ Auto Theme Switching true │
|
||||
│ Automatically switch between default light and dark themes based on terminal backgro… │
|
||||
│ │
|
||||
│ Terminal Background Polling Interval 60 │
|
||||
│ Interval in seconds to poll the terminal background color. │
|
||||
│ │
|
||||
│ ▼ │
|
||||
│ │
|
||||
│ Apply To │
|
||||
@@ -378,10 +378,7 @@ exports[`SettingsDialog > Snapshot Tests > should render 'various boolean settin
|
||||
│ ╰──────────────────────────────────────────────────────────────────────────────────────────────╯ │
|
||||
│ │
|
||||
│ ▲ │
|
||||
│ ● Preview Features (e.g., models) false │
|
||||
│ Enable preview features (e.g., preview models). │
|
||||
│ │
|
||||
│ Vim Mode true* │
|
||||
│ ● Vim Mode true* │
|
||||
│ Enable Vim keybindings │
|
||||
│ │
|
||||
│ Enable Auto Update false* │
|
||||
@@ -402,6 +399,9 @@ exports[`SettingsDialog > Snapshot Tests > should render 'various boolean settin
|
||||
│ Auto Theme Switching true │
|
||||
│ Automatically switch between default light and dark themes based on terminal backgro… │
|
||||
│ │
|
||||
│ Terminal Background Polling Interval 60 │
|
||||
│ Interval in seconds to poll the terminal background color. │
|
||||
│ │
|
||||
│ ▼ │
|
||||
│ │
|
||||
│ Apply To │
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`ShortcutsHelp > renders correctly in 'narrow' mode on 'linux' 1`] = `
|
||||
"── Shortcuts (for more, see /help) ─────
|
||||
! shell mode
|
||||
Shift+Tab cycle mode
|
||||
Ctrl+V paste images
|
||||
@ select file or folder
|
||||
Ctrl+Y YOLO mode
|
||||
Ctrl+R reverse-search history
|
||||
Esc Esc clear prompt / rewind
|
||||
Alt+M raw markdown mode
|
||||
Ctrl+X open external editor"
|
||||
`;
|
||||
|
||||
exports[`ShortcutsHelp > renders correctly in 'narrow' mode on 'mac' 1`] = `
|
||||
"── Shortcuts (for more, see /help) ─────
|
||||
! shell mode
|
||||
Shift+Tab cycle mode
|
||||
Ctrl+V paste images
|
||||
@ select file or folder
|
||||
Ctrl+Y YOLO mode
|
||||
Ctrl+R reverse-search history
|
||||
Esc Esc clear prompt / rewind
|
||||
Option+M raw markdown mode
|
||||
Ctrl+X open external editor"
|
||||
`;
|
||||
|
||||
exports[`ShortcutsHelp > renders correctly in 'wide' mode on 'linux' 1`] = `
|
||||
"── Shortcuts (for more, see /help) ─────────────────────────────────────────────────────────────────
|
||||
! shell mode Shift+Tab cycle mode Ctrl+V paste images
|
||||
@ select file or folder Ctrl+Y YOLO mode Ctrl+R reverse-search history
|
||||
Esc Esc clear prompt / rewind Alt+M raw markdown mode Ctrl+X open external editor"
|
||||
`;
|
||||
|
||||
exports[`ShortcutsHelp > renders correctly in 'wide' mode on 'mac' 1`] = `
|
||||
"── Shortcuts (for more, see /help) ─────────────────────────────────────────────────────────────────
|
||||
! shell mode Shift+Tab cycle mode Ctrl+V paste images
|
||||
@ select file or folder Ctrl+Y YOLO mode Ctrl+R reverse-search history
|
||||
Esc Esc clear prompt / rewind Option+M raw markdown mode Ctrl+X open external editor"
|
||||
`;
|
||||
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { renderWithProviders } from '../../../test-utils/render.js';
|
||||
import { RichDataDisplay } from './RichDataDisplay.js';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
describe('RichDataDisplay', () => {
|
||||
it('should render table visualization', () => {
|
||||
const data = {
|
||||
type: 'table' as const,
|
||||
data: [{ name: 'Test', value: 123 }],
|
||||
};
|
||||
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<RichDataDisplay data={data} availableWidth={80} />,
|
||||
);
|
||||
const output = lastFrame();
|
||||
|
||||
expect(output).toContain('name');
|
||||
expect(output).toContain('value');
|
||||
expect(output).toContain('Test');
|
||||
expect(output).toContain('123');
|
||||
});
|
||||
|
||||
it('should render bar chart visualization', () => {
|
||||
const data = {
|
||||
type: 'bar_chart' as const,
|
||||
title: 'Sales',
|
||||
data: [
|
||||
{ label: 'Q1', value: 10 },
|
||||
{ label: 'Q2', value: 20 },
|
||||
],
|
||||
};
|
||||
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<RichDataDisplay data={data} availableWidth={80} />,
|
||||
);
|
||||
const output = lastFrame();
|
||||
|
||||
expect(output).toContain('Sales');
|
||||
expect(output).toContain('Q1');
|
||||
expect(output).toContain('Q2');
|
||||
expect(output).toContain('█'); // Check for bar character
|
||||
});
|
||||
|
||||
it('should render line chart visualization', () => {
|
||||
const data = {
|
||||
type: 'line_chart' as const,
|
||||
title: 'Trends',
|
||||
data: [
|
||||
{ label: 'Jan', value: 10 },
|
||||
{ label: 'Feb', value: 20 },
|
||||
{ label: 'Mar', value: 15 },
|
||||
],
|
||||
};
|
||||
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<RichDataDisplay data={data} availableWidth={80} />,
|
||||
);
|
||||
const output = lastFrame();
|
||||
|
||||
expect(output).toContain('Trends');
|
||||
expect(output).toContain('Jan');
|
||||
expect(output).toContain('Feb');
|
||||
expect(output).toContain('Mar');
|
||||
expect(output).toContain('•'); // Check for plot point
|
||||
expect(output).toContain('│'); // Check for axis
|
||||
});
|
||||
|
||||
it('should render pie chart visualization', () => {
|
||||
const data = {
|
||||
type: 'pie_chart' as const,
|
||||
title: 'Market Share',
|
||||
data: [
|
||||
{ label: 'A', value: 50 },
|
||||
{ label: 'B', value: 50 },
|
||||
],
|
||||
};
|
||||
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<RichDataDisplay data={data} availableWidth={80} />,
|
||||
);
|
||||
const output = lastFrame();
|
||||
|
||||
expect(output).toContain('Market Share');
|
||||
expect(output).toContain('A');
|
||||
expect(output).toContain('B');
|
||||
expect(output).toContain('50.0%');
|
||||
expect(output).toContain('█'); // Check for proportional bar
|
||||
});
|
||||
|
||||
it('should show saved file path', () => {
|
||||
const data = {
|
||||
type: 'table' as const,
|
||||
data: [],
|
||||
savedFilePath: '/path/to/file.csv',
|
||||
};
|
||||
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<RichDataDisplay data={data} availableWidth={80} />,
|
||||
);
|
||||
const output = lastFrame();
|
||||
|
||||
expect(output).toContain('Saved to: /path/to/file.csv');
|
||||
});
|
||||
|
||||
it('should render diff visualization', () => {
|
||||
const data = {
|
||||
type: 'diff' as const,
|
||||
data: {
|
||||
fileDiff:
|
||||
'diff --git a/file.txt b/file.txt\nindex 123..456 100644\n--- a/file.txt\n+++ b/file.txt\n@@ -1 +1 @@\n-foo\n+bar',
|
||||
fileName: 'file.txt',
|
||||
},
|
||||
};
|
||||
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<RichDataDisplay data={data} availableWidth={80} />,
|
||||
);
|
||||
const output = lastFrame();
|
||||
|
||||
expect(output).toContain('foo');
|
||||
expect(output).toContain('bar');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,321 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { theme } from '../../semantic-colors.js';
|
||||
import { Table, type Column } from '../Table.js';
|
||||
import { DiffRenderer } from './DiffRenderer.js';
|
||||
import * as Diff from 'diff';
|
||||
import type { RichVisualization } from '@google/gemini-cli-core';
|
||||
import { getPlainTextLength } from '../../utils/InlineMarkdownRenderer.js';
|
||||
|
||||
interface RichDataDisplayProps {
|
||||
data: RichVisualization;
|
||||
availableWidth: number;
|
||||
}
|
||||
|
||||
export const RichDataDisplay: React.FC<RichDataDisplayProps> = ({
|
||||
data,
|
||||
availableWidth,
|
||||
}) => {
|
||||
const {
|
||||
type,
|
||||
title,
|
||||
data: rawData,
|
||||
columns: providedColumns,
|
||||
savedFilePath,
|
||||
} = data;
|
||||
|
||||
const normalizeData = (
|
||||
data: unknown[],
|
||||
providedCols: typeof providedColumns,
|
||||
) =>
|
||||
data.map((item) => {
|
||||
const record = item as Record<string, unknown>;
|
||||
let label = 'Unknown';
|
||||
let value = 0;
|
||||
|
||||
if (providedCols && providedCols.length >= 2) {
|
||||
label = String(record[providedCols[0].key]);
|
||||
value = Number(record[providedCols[1].key]);
|
||||
} else {
|
||||
// Auto-detect
|
||||
const keys = Object.keys(record);
|
||||
const labelKey =
|
||||
keys.find((k) => typeof record[k] === 'string') || keys[0];
|
||||
const valueKey = keys.find((k) => typeof record[k] === 'number');
|
||||
if (labelKey) label = String(record[labelKey]);
|
||||
if (valueKey) value = Number(record[valueKey]);
|
||||
}
|
||||
return { label, value };
|
||||
});
|
||||
|
||||
const renderContent = () => {
|
||||
if (type === 'table' && Array.isArray(rawData)) {
|
||||
const tableData = rawData as Array<Record<string, unknown>>;
|
||||
|
||||
// Infer columns if not provided
|
||||
let columns: Array<Column<Record<string, unknown>>> = [];
|
||||
if (providedColumns) {
|
||||
columns = providedColumns.map((col) => ({
|
||||
key: col.key,
|
||||
header: col.label,
|
||||
}));
|
||||
} else if (tableData.length > 0) {
|
||||
columns = Object.keys(tableData[0]).map((key) => ({
|
||||
key,
|
||||
header: key,
|
||||
}));
|
||||
}
|
||||
|
||||
// Calculate widths based on content
|
||||
const paddingPerCol = 2; // Extra buffer
|
||||
const columnContentWidths = columns.map((col) => {
|
||||
const headerWidth = getPlainTextLength(String(col.header));
|
||||
const maxDataWidth = Math.max(
|
||||
...tableData.map((row) =>
|
||||
getPlainTextLength(String(row[col.key] || '')),
|
||||
),
|
||||
0,
|
||||
);
|
||||
return Math.max(headerWidth, maxDataWidth) + paddingPerCol;
|
||||
});
|
||||
|
||||
const totalContentWidth = columnContentWidths.reduce((a, b) => a + b, 0);
|
||||
|
||||
if (totalContentWidth > availableWidth && columns.length > 0) {
|
||||
// Scale down if exceeds available width
|
||||
const scaleFactor = availableWidth / totalContentWidth;
|
||||
columns = columns.map((col, i) => ({
|
||||
...col,
|
||||
width: Math.max(4, Math.floor(columnContentWidths[i] * scaleFactor)),
|
||||
}));
|
||||
} else {
|
||||
// Use content widths or distribute remaining space
|
||||
columns = columns.map((col, i) => ({
|
||||
...col,
|
||||
width: columnContentWidths[i],
|
||||
}));
|
||||
}
|
||||
|
||||
return <Table data={tableData} columns={columns} />;
|
||||
} else if (type === 'bar_chart' && Array.isArray(rawData)) {
|
||||
const normalized = normalizeData(rawData as unknown[], providedColumns);
|
||||
|
||||
const maxValue = Math.max(...normalized.map((d) => d.value), 1);
|
||||
const maxLabelLen = Math.max(...normalized.map((d) => d.label.length), 1);
|
||||
const barAreaWidth = Math.max(10, availableWidth - maxLabelLen - 10);
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
{normalized.map((item, i) => {
|
||||
const barLen = Math.max(
|
||||
0,
|
||||
Math.floor((item.value / maxValue) * barAreaWidth),
|
||||
);
|
||||
const bar = '█'.repeat(barLen);
|
||||
return (
|
||||
<Box key={i}>
|
||||
<Text>{item.label.padEnd(maxLabelLen + 1)}</Text>
|
||||
<Text color={theme.text.accent}>{bar}</Text>
|
||||
<Text> {item.value}</Text>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
);
|
||||
} else if (type === 'line_chart' && Array.isArray(rawData)) {
|
||||
const normalized = normalizeData(rawData as unknown[], providedColumns);
|
||||
if (normalized.length === 0) return <Text>No data to display.</Text>;
|
||||
|
||||
const maxValue = Math.max(...normalized.map((d) => d.value), 0);
|
||||
const minValue = Math.min(...normalized.map((d) => d.value), 0);
|
||||
const range = Math.max(maxValue - minValue, 1);
|
||||
const chartHeight = 10;
|
||||
|
||||
// Plotting
|
||||
const rows: string[][] = Array.from({ length: chartHeight }, () =>
|
||||
Array.from({ length: normalized.length }, () => ' '),
|
||||
);
|
||||
|
||||
normalized.forEach((item, x) => {
|
||||
const y = Math.min(
|
||||
chartHeight - 1,
|
||||
Math.max(
|
||||
0,
|
||||
Math.floor(((item.value - minValue) / range) * (chartHeight - 1)),
|
||||
),
|
||||
);
|
||||
rows[chartHeight - 1 - y][x] = '•';
|
||||
});
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
{rows.map((row, i) => {
|
||||
const yValue =
|
||||
minValue + (range * (chartHeight - 1 - i)) / (chartHeight - 1);
|
||||
return (
|
||||
<Box key={i}>
|
||||
<Text color={theme.text.secondary} dimColor>
|
||||
{yValue.toFixed(1).padStart(8)} │
|
||||
</Text>
|
||||
<Text color={theme.text.accent}>{row.join(' ')}</Text>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
<Box marginLeft={10}>
|
||||
<Text color={theme.text.secondary} dimColor>
|
||||
└─{'──'.repeat(normalized.length)}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box marginLeft={10}>
|
||||
{normalized.map((item, i) => (
|
||||
<Box key={i} width={3}>
|
||||
<Text color={theme.text.secondary} dimColor wrap="truncate-end">
|
||||
{item.label}
|
||||
</Text>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
} else if (type === 'pie_chart' && Array.isArray(rawData)) {
|
||||
const normalized = normalizeData(rawData as unknown[], providedColumns);
|
||||
const total = normalized.reduce((sum, item) => sum + item.value, 0);
|
||||
|
||||
const colors = [
|
||||
theme.text.accent,
|
||||
theme.status.success,
|
||||
theme.status.warning,
|
||||
theme.status.info,
|
||||
'#FF6B6B',
|
||||
'#4D96FF',
|
||||
'#6BCB77',
|
||||
'#FFD93D',
|
||||
];
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
{/* Proportional Bar */}
|
||||
<Box height={1} marginBottom={1}>
|
||||
{normalized.map((item, i) => {
|
||||
const percent = total > 0 ? item.value / total : 0;
|
||||
const barWidth = Math.max(
|
||||
1,
|
||||
Math.floor(percent * availableWidth),
|
||||
);
|
||||
return (
|
||||
<Text key={i} color={colors[i % colors.length]}>
|
||||
{'█'.repeat(barWidth)}
|
||||
</Text>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
{/* Legend */}
|
||||
{normalized.map((item, i) => {
|
||||
const percent = total > 0 ? (item.value / total) * 100 : 0;
|
||||
return (
|
||||
<Box key={i}>
|
||||
<Text color={colors[i % colors.length]}>■ </Text>
|
||||
<Text bold>{item.label}: </Text>
|
||||
<Text>{item.value} </Text>
|
||||
<Text color={theme.text.secondary}>
|
||||
({percent.toFixed(1)}%)
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
);
|
||||
} else if (
|
||||
type === 'diff' &&
|
||||
(typeof rawData === 'object' || typeof rawData === 'string') &&
|
||||
rawData
|
||||
) {
|
||||
let diffContent: string | undefined;
|
||||
let filename = 'Diff';
|
||||
|
||||
if (typeof rawData === 'string') {
|
||||
diffContent = rawData;
|
||||
} else {
|
||||
const diffData = rawData as {
|
||||
fileDiff?: string;
|
||||
fileName?: string;
|
||||
old?: string;
|
||||
new?: string;
|
||||
oldContent?: string;
|
||||
newContent?: string;
|
||||
originalContent?: string;
|
||||
};
|
||||
|
||||
diffContent = diffData.fileDiff;
|
||||
filename = diffData.fileName || 'Diff';
|
||||
|
||||
if (!diffContent) {
|
||||
const oldVal =
|
||||
diffData.old ?? diffData.oldContent ?? diffData.originalContent;
|
||||
const newVal = diffData.new ?? diffData.newContent;
|
||||
if (oldVal !== undefined && newVal !== undefined) {
|
||||
diffContent = Diff.createPatch(
|
||||
filename,
|
||||
String(oldVal),
|
||||
String(newVal),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (diffContent) {
|
||||
return (
|
||||
<DiffRenderer
|
||||
diffContent={diffContent}
|
||||
filename={filename}
|
||||
availableTerminalHeight={20} // Reasonable default or pass from props
|
||||
terminalWidth={availableWidth}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text color={theme.status.error}>
|
||||
Error: Diff data missing 'fileDiff' property.
|
||||
</Text>
|
||||
<Text color={theme.text.secondary} dimColor>
|
||||
Expected data to be a string or an object with
|
||||
'fileDiff', or both 'old' and 'new'
|
||||
content.
|
||||
</Text>
|
||||
<Text color={theme.text.secondary} dimColor>
|
||||
Received keys:{' '}
|
||||
{typeof rawData === 'object'
|
||||
? Object.keys(rawData).join(', ')
|
||||
: 'none (string)'}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return <Text>Unknown visualization type: {type}</Text>;
|
||||
};
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" marginTop={1} marginBottom={1}>
|
||||
{title && (
|
||||
<Text bold color={theme.text.accent} underline>
|
||||
{title}
|
||||
</Text>
|
||||
)}
|
||||
{renderContent()}
|
||||
{savedFilePath && (
|
||||
<Text color={theme.status.success} dimColor>
|
||||
{`Saved to: ${savedFilePath}`}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -10,49 +10,12 @@ import {
|
||||
type ShellToolMessageProps,
|
||||
} from './ShellToolMessage.js';
|
||||
import { StreamingState, ToolCallStatus } from '../../types.js';
|
||||
import { Text } from 'ink';
|
||||
import type { Config } from '@google/gemini-cli-core';
|
||||
import { renderWithProviders } from '../../../test-utils/render.js';
|
||||
import { waitFor } from '../../../test-utils/async.js';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { SHELL_TOOL_NAME } from '@google/gemini-cli-core';
|
||||
import { SHELL_COMMAND_NAME } from '../../constants.js';
|
||||
import { StreamingContext } from '../../contexts/StreamingContext.js';
|
||||
|
||||
vi.mock('../TerminalOutput.js', () => ({
|
||||
TerminalOutput: function MockTerminalOutput({
|
||||
cursor,
|
||||
}: {
|
||||
cursor: { x: number; y: number } | null;
|
||||
}) {
|
||||
return (
|
||||
<Text>
|
||||
MockCursor:({cursor?.x},{cursor?.y})
|
||||
</Text>
|
||||
);
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock child components or utilities if they are complex or have side effects
|
||||
vi.mock('../GeminiRespondingSpinner.js', () => ({
|
||||
GeminiRespondingSpinner: ({
|
||||
nonRespondingDisplay,
|
||||
}: {
|
||||
nonRespondingDisplay?: string;
|
||||
}) => {
|
||||
const streamingState = React.useContext(StreamingContext)!;
|
||||
if (streamingState === StreamingState.Responding) {
|
||||
return <Text>MockRespondingSpinner</Text>;
|
||||
}
|
||||
return nonRespondingDisplay ? <Text>{nonRespondingDisplay}</Text> : null;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../utils/MarkdownDisplay.js', () => ({
|
||||
MarkdownDisplay: function MockMarkdownDisplay({ text }: { text: string }) {
|
||||
return <Text>MockMarkdown:{text}</Text>;
|
||||
},
|
||||
}));
|
||||
import { SHELL_COMMAND_NAME, ACTIVE_SHELL_MAX_LINES } from '../../constants.js';
|
||||
|
||||
describe('<ShellToolMessage />', () => {
|
||||
const baseProps: ShellToolMessageProps = {
|
||||
@@ -72,62 +35,36 @@ describe('<ShellToolMessage />', () => {
|
||||
} as unknown as Config,
|
||||
};
|
||||
|
||||
const LONG_OUTPUT = Array.from(
|
||||
{ length: 100 },
|
||||
(_, i) => `Line ${i + 1}`,
|
||||
).join('\n');
|
||||
|
||||
const mockSetEmbeddedShellFocused = vi.fn();
|
||||
const uiActions = {
|
||||
setEmbeddedShellFocused: mockSetEmbeddedShellFocused,
|
||||
};
|
||||
|
||||
// Helper to render with context
|
||||
const renderWithContext = (
|
||||
ui: React.ReactElement,
|
||||
streamingState: StreamingState,
|
||||
const renderShell = (
|
||||
props: Partial<ShellToolMessageProps> = {},
|
||||
options: Parameters<typeof renderWithProviders>[1] = {},
|
||||
) =>
|
||||
renderWithProviders(ui, {
|
||||
renderWithProviders(<ShellToolMessage {...baseProps} {...props} />, {
|
||||
uiActions,
|
||||
uiState: { streamingState },
|
||||
...options,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('interactive shell focus', () => {
|
||||
const shellProps: ShellToolMessageProps = {
|
||||
...baseProps,
|
||||
};
|
||||
|
||||
it('clicks inside the shell area sets focus to true', async () => {
|
||||
const { stdin, lastFrame, simulateClick } = renderWithProviders(
|
||||
<ShellToolMessage {...shellProps} />,
|
||||
{
|
||||
mouseEventsEnabled: true,
|
||||
uiActions,
|
||||
},
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain('A shell command'); // Wait for render
|
||||
});
|
||||
|
||||
await simulateClick(stdin, 2, 2); // Click at column 2, row 2 (1-based)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSetEmbeddedShellFocused).toHaveBeenCalledWith(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('handles focus for SHELL_TOOL_NAME (core shell tool)', async () => {
|
||||
const coreShellProps: ShellToolMessageProps = {
|
||||
...shellProps,
|
||||
name: SHELL_TOOL_NAME,
|
||||
};
|
||||
|
||||
const { stdin, lastFrame, simulateClick } = renderWithProviders(
|
||||
<ShellToolMessage {...coreShellProps} />,
|
||||
{
|
||||
mouseEventsEnabled: true,
|
||||
uiActions,
|
||||
},
|
||||
it.each([
|
||||
['SHELL_COMMAND_NAME', SHELL_COMMAND_NAME],
|
||||
['SHELL_TOOL_NAME', SHELL_TOOL_NAME],
|
||||
])('clicks inside the shell area sets focus for %s', async (_, name) => {
|
||||
const { stdin, lastFrame, simulateClick } = renderShell(
|
||||
{ name },
|
||||
{ mouseEventsEnabled: true },
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -140,7 +77,6 @@ describe('<ShellToolMessage />', () => {
|
||||
expect(mockSetEmbeddedShellFocused).toHaveBeenCalledWith(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('resets focus when shell finishes', async () => {
|
||||
let updateStatus: (s: ToolCallStatus) => void = () => {};
|
||||
|
||||
@@ -149,7 +85,7 @@ describe('<ShellToolMessage />', () => {
|
||||
updateStatus = setStatus;
|
||||
return (
|
||||
<ShellToolMessage
|
||||
{...shellProps}
|
||||
{...baseProps}
|
||||
status={status}
|
||||
embeddedShellFocused={true}
|
||||
activeShellPtyId={1}
|
||||
@@ -158,11 +94,14 @@ describe('<ShellToolMessage />', () => {
|
||||
);
|
||||
};
|
||||
|
||||
const { lastFrame } = renderWithContext(<Wrapper />, StreamingState.Idle);
|
||||
const { lastFrame } = renderWithProviders(<Wrapper />, {
|
||||
uiActions,
|
||||
uiState: { streamingState: StreamingState.Idle },
|
||||
});
|
||||
|
||||
// Verify it is initially focused
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain('(Focused)');
|
||||
expect(lastFrame()).toContain('(Shift+Tab to unfocus)');
|
||||
});
|
||||
|
||||
// Now update status to Success
|
||||
@@ -173,6 +112,100 @@ describe('<ShellToolMessage />', () => {
|
||||
// Should call setEmbeddedShellFocused(false) because isThisShellFocused became false
|
||||
await waitFor(() => {
|
||||
expect(mockSetEmbeddedShellFocused).toHaveBeenCalledWith(false);
|
||||
expect(lastFrame()).not.toContain('(Shift+Tab to unfocus)');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Snapshots', () => {
|
||||
it.each([
|
||||
[
|
||||
'renders in Executing state',
|
||||
{ status: ToolCallStatus.Executing },
|
||||
undefined,
|
||||
],
|
||||
[
|
||||
'renders in Success state (history mode)',
|
||||
{ status: ToolCallStatus.Success },
|
||||
undefined,
|
||||
],
|
||||
[
|
||||
'renders in Error state',
|
||||
{ status: ToolCallStatus.Error, resultDisplay: 'Error output' },
|
||||
undefined,
|
||||
],
|
||||
[
|
||||
'renders in Alternate Buffer mode while focused',
|
||||
{
|
||||
status: ToolCallStatus.Executing,
|
||||
embeddedShellFocused: true,
|
||||
activeShellPtyId: 1,
|
||||
ptyId: 1,
|
||||
},
|
||||
{ useAlternateBuffer: true },
|
||||
],
|
||||
[
|
||||
'renders in Alternate Buffer mode while unfocused',
|
||||
{
|
||||
status: ToolCallStatus.Executing,
|
||||
embeddedShellFocused: false,
|
||||
activeShellPtyId: 1,
|
||||
ptyId: 1,
|
||||
},
|
||||
{ useAlternateBuffer: true },
|
||||
],
|
||||
])('%s', async (_, props, options) => {
|
||||
const { lastFrame } = renderShell(props, options);
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Height Constraints', () => {
|
||||
it.each([
|
||||
[
|
||||
'respects availableTerminalHeight when it is smaller than ACTIVE_SHELL_MAX_LINES',
|
||||
10,
|
||||
8,
|
||||
false,
|
||||
],
|
||||
[
|
||||
'uses ACTIVE_SHELL_MAX_LINES when availableTerminalHeight is large',
|
||||
100,
|
||||
ACTIVE_SHELL_MAX_LINES,
|
||||
false,
|
||||
],
|
||||
[
|
||||
'uses full availableTerminalHeight when focused in alternate buffer mode',
|
||||
100,
|
||||
98, // 100 - 2
|
||||
true,
|
||||
],
|
||||
[
|
||||
'defaults to ACTIVE_SHELL_MAX_LINES when availableTerminalHeight is undefined',
|
||||
undefined,
|
||||
ACTIVE_SHELL_MAX_LINES,
|
||||
false,
|
||||
],
|
||||
])('%s', async (_, availableTerminalHeight, expectedMaxLines, focused) => {
|
||||
const { lastFrame } = renderShell(
|
||||
{
|
||||
resultDisplay: LONG_OUTPUT,
|
||||
renderOutputAsMarkdown: false,
|
||||
availableTerminalHeight,
|
||||
activeShellPtyId: 1,
|
||||
ptyId: focused ? 1 : 2,
|
||||
status: ToolCallStatus.Executing,
|
||||
embeddedShellFocused: focused,
|
||||
},
|
||||
{ useAlternateBuffer: true },
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
const frame = lastFrame();
|
||||
expect(frame!.match(/Line \d+/g)?.length).toBe(expectedMaxLines);
|
||||
expect(frame).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -22,6 +22,12 @@ import {
|
||||
FocusHint,
|
||||
} from './ToolShared.js';
|
||||
import type { ToolMessageProps } from './ToolMessage.js';
|
||||
import { ToolCallStatus } from '../../types.js';
|
||||
import {
|
||||
ACTIVE_SHELL_MAX_LINES,
|
||||
COMPLETED_SHELL_MAX_LINES,
|
||||
} from '../../constants.js';
|
||||
import { useAlternateBuffer } from '../../hooks/useAlternateBuffer.js';
|
||||
import type { Config } from '@google/gemini-cli-core';
|
||||
|
||||
export interface ShellToolMessageProps extends ToolMessageProps {
|
||||
@@ -61,6 +67,7 @@ export const ShellToolMessage: React.FC<ShellToolMessageProps> = ({
|
||||
|
||||
borderDimColor,
|
||||
}) => {
|
||||
const isAlternateBuffer = useAlternateBuffer();
|
||||
const isThisShellFocused = checkIsShellFocused(
|
||||
name,
|
||||
status,
|
||||
@@ -70,6 +77,18 @@ export const ShellToolMessage: React.FC<ShellToolMessageProps> = ({
|
||||
);
|
||||
|
||||
const { setEmbeddedShellFocused } = useUIActions();
|
||||
const wasFocusedRef = React.useRef(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isThisShellFocused) {
|
||||
wasFocusedRef.current = true;
|
||||
} else if (wasFocusedRef.current) {
|
||||
if (embeddedShellFocused) {
|
||||
setEmbeddedShellFocused(false);
|
||||
}
|
||||
wasFocusedRef.current = false;
|
||||
}
|
||||
}, [isThisShellFocused, embeddedShellFocused, setEmbeddedShellFocused]);
|
||||
|
||||
const headerRef = React.useRef<DOMElement>(null);
|
||||
|
||||
@@ -89,20 +108,6 @@ export const ShellToolMessage: React.FC<ShellToolMessageProps> = ({
|
||||
|
||||
useMouseClick(contentRef, handleFocus, { isActive: !!isThisShellFocusable });
|
||||
|
||||
const wasFocusedRef = React.useRef(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isThisShellFocused) {
|
||||
wasFocusedRef.current = true;
|
||||
} else if (wasFocusedRef.current) {
|
||||
if (embeddedShellFocused) {
|
||||
setEmbeddedShellFocused(false);
|
||||
}
|
||||
|
||||
wasFocusedRef.current = false;
|
||||
}
|
||||
}, [isThisShellFocused, embeddedShellFocused, setEmbeddedShellFocused]);
|
||||
|
||||
const { shouldShowFocusHint } = useFocusHint(
|
||||
isThisShellFocusable,
|
||||
isThisShellFocused,
|
||||
@@ -153,12 +158,20 @@ export const ShellToolMessage: React.FC<ShellToolMessageProps> = ({
|
||||
availableTerminalHeight={availableTerminalHeight}
|
||||
terminalWidth={terminalWidth}
|
||||
renderOutputAsMarkdown={renderOutputAsMarkdown}
|
||||
hasFocus={isThisShellFocused}
|
||||
maxLines={getShellMaxLines(
|
||||
status,
|
||||
isAlternateBuffer,
|
||||
isThisShellFocused,
|
||||
availableTerminalHeight,
|
||||
)}
|
||||
/>
|
||||
{isThisShellFocused && config && (
|
||||
<Box paddingLeft={STATUS_INDICATOR_WIDTH} marginTop={1}>
|
||||
<ShellInputPrompt
|
||||
activeShellPtyId={activeShellPtyId ?? null}
|
||||
focus={embeddedShellFocused}
|
||||
scrollPageSize={availableTerminalHeight ?? ACTIVE_SHELL_MAX_LINES}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
@@ -166,3 +179,39 @@ export const ShellToolMessage: React.FC<ShellToolMessageProps> = ({
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Calculates the maximum number of lines to display for shell output.
|
||||
*
|
||||
* For completed processes (Success, Error, Canceled), it returns COMPLETED_SHELL_MAX_LINES.
|
||||
* For active processes, it returns the available terminal height if in alternate buffer mode
|
||||
* and focused. Otherwise, it returns ACTIVE_SHELL_MAX_LINES.
|
||||
*
|
||||
* This function ensures a finite number of lines is always returned to prevent performance issues.
|
||||
*/
|
||||
function getShellMaxLines(
|
||||
status: ToolCallStatus,
|
||||
isAlternateBuffer: boolean,
|
||||
isThisShellFocused: boolean,
|
||||
availableTerminalHeight: number | undefined,
|
||||
): number {
|
||||
if (
|
||||
status === ToolCallStatus.Success ||
|
||||
status === ToolCallStatus.Error ||
|
||||
status === ToolCallStatus.Canceled
|
||||
) {
|
||||
return COMPLETED_SHELL_MAX_LINES;
|
||||
}
|
||||
|
||||
if (availableTerminalHeight === undefined) {
|
||||
return ACTIVE_SHELL_MAX_LINES;
|
||||
}
|
||||
|
||||
const maxLinesBasedOnHeight = Math.max(1, availableTerminalHeight - 2);
|
||||
|
||||
if (isAlternateBuffer && isThisShellFocused) {
|
||||
return maxLinesBasedOnHeight;
|
||||
}
|
||||
|
||||
return Math.min(maxLinesBasedOnHeight, ACTIVE_SHELL_MAX_LINES);
|
||||
}
|
||||
|
||||
@@ -45,7 +45,6 @@ describe('<ToolGroupMessage />', () => {
|
||||
folderTrust: false,
|
||||
ideMode: false,
|
||||
enableInteractiveShell: true,
|
||||
previewFeatures: false,
|
||||
enableEventDrivenScheduler: true,
|
||||
});
|
||||
|
||||
|
||||
@@ -42,6 +42,9 @@ const isAskUserInProgress = (t: IndividualToolCallDisplay): boolean =>
|
||||
].includes(t.status);
|
||||
|
||||
// Main component renders the border and maps the tools using ToolMessage
|
||||
const TOOL_MESSAGE_HORIZONTAL_MARGIN = 4;
|
||||
const TOOL_CONFIRMATION_INTERNAL_PADDING = 4;
|
||||
|
||||
export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
|
||||
toolCalls: allToolCalls,
|
||||
availableTerminalHeight,
|
||||
@@ -142,6 +145,8 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
|
||||
)
|
||||
: undefined;
|
||||
|
||||
const contentWidth = terminalWidth - TOOL_MESSAGE_HORIZONTAL_MARGIN;
|
||||
|
||||
return (
|
||||
// This box doesn't have a border even though it conceptually does because
|
||||
// we need to allow the sticky headers to render the borders themselves so
|
||||
@@ -155,6 +160,7 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
|
||||
cause tearing.
|
||||
*/
|
||||
width={terminalWidth}
|
||||
paddingRight={TOOL_MESSAGE_HORIZONTAL_MARGIN}
|
||||
>
|
||||
{visibleToolCalls.map((tool, index) => {
|
||||
const isConfirming = toolAwaitingApproval?.callId === tool.callId;
|
||||
@@ -164,7 +170,7 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
|
||||
const commonProps = {
|
||||
...tool,
|
||||
availableTerminalHeight: availableTerminalHeightPerToolMessage,
|
||||
terminalWidth,
|
||||
terminalWidth: contentWidth,
|
||||
emphasis: isConfirming
|
||||
? ('high' as const)
|
||||
: toolAwaitingApproval
|
||||
@@ -183,7 +189,7 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
|
||||
key={tool.callId}
|
||||
flexDirection="column"
|
||||
minHeight={1}
|
||||
width={terminalWidth}
|
||||
width={contentWidth}
|
||||
>
|
||||
{isShellToolCall ? (
|
||||
<ShellToolMessage
|
||||
@@ -218,7 +224,9 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
|
||||
availableTerminalHeight={
|
||||
availableTerminalHeightPerToolMessage
|
||||
}
|
||||
terminalWidth={terminalWidth - 4}
|
||||
terminalWidth={
|
||||
contentWidth - TOOL_CONFIRMATION_INTERNAL_PADDING
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{tool.outputFile && (
|
||||
@@ -240,7 +248,7 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
|
||||
(visibleToolCalls.length > 0 || borderBottomOverride !== undefined) && (
|
||||
<Box
|
||||
height={0}
|
||||
width={terminalWidth}
|
||||
width={contentWidth}
|
||||
borderLeft={true}
|
||||
borderRight={true}
|
||||
borderTop={false}
|
||||
|
||||
@@ -4,13 +4,11 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import type { ToolMessageProps } from './ToolMessage.js';
|
||||
import type React from 'react';
|
||||
import { ToolMessage, type ToolMessageProps } from './ToolMessage.js';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { ToolMessage } from './ToolMessage.js';
|
||||
import { StreamingState, ToolCallStatus } from '../../types.js';
|
||||
import { Text } from 'ink';
|
||||
import { StreamingContext } from '../../contexts/StreamingContext.js';
|
||||
import type { AnsiOutput } from '@google/gemini-cli-core';
|
||||
import { renderWithProviders } from '../../../test-utils/render.js';
|
||||
import { tryParseJSON } from '../../../utils/jsonoutput.js';
|
||||
@@ -29,45 +27,6 @@ vi.mock('../TerminalOutput.js', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../AnsiOutput.js', () => ({
|
||||
AnsiOutputText: function MockAnsiOutputText({ data }: { data: AnsiOutput }) {
|
||||
// Simple serialization for snapshot stability
|
||||
const serialized = data
|
||||
.map((line) => line.map((token) => token.text || '').join(''))
|
||||
.join('\n');
|
||||
return <Text>MockAnsiOutput:{serialized}</Text>;
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock child components or utilities if they are complex or have side effects
|
||||
vi.mock('../GeminiRespondingSpinner.js', () => ({
|
||||
GeminiRespondingSpinner: ({
|
||||
nonRespondingDisplay,
|
||||
}: {
|
||||
nonRespondingDisplay?: string;
|
||||
}) => {
|
||||
const streamingState = React.useContext(StreamingContext)!;
|
||||
if (streamingState === StreamingState.Responding) {
|
||||
return <Text>MockRespondingSpinner</Text>;
|
||||
}
|
||||
return nonRespondingDisplay ? <Text>{nonRespondingDisplay}</Text> : null;
|
||||
},
|
||||
}));
|
||||
vi.mock('./DiffRenderer.js', () => ({
|
||||
DiffRenderer: function MockDiffRenderer({
|
||||
diffContent,
|
||||
}: {
|
||||
diffContent: string;
|
||||
}) {
|
||||
return <Text>MockDiff:{diffContent}</Text>;
|
||||
},
|
||||
}));
|
||||
vi.mock('../../utils/MarkdownDisplay.js', () => ({
|
||||
MarkdownDisplay: function MockMarkdownDisplay({ text }: { text: string }) {
|
||||
return <Text>MockMarkdown:{text}</Text>;
|
||||
},
|
||||
}));
|
||||
|
||||
describe('<ToolMessage />', () => {
|
||||
const baseProps: ToolMessageProps = {
|
||||
callId: 'tool-123',
|
||||
@@ -131,7 +90,6 @@ describe('<ToolMessage />', () => {
|
||||
expect(output).toContain('"a": 1');
|
||||
expect(output).toContain('"b": [');
|
||||
// Should not use markdown renderer for JSON
|
||||
expect(output).not.toContain('MockMarkdown:');
|
||||
});
|
||||
|
||||
it('renders pretty JSON in ink frame', () => {
|
||||
@@ -143,9 +101,6 @@ describe('<ToolMessage />', () => {
|
||||
const frame = lastFrame();
|
||||
|
||||
expect(frame).toMatchSnapshot();
|
||||
expect(frame).not.toContain('MockMarkdown:');
|
||||
expect(frame).not.toContain('MockAnsiOutput:');
|
||||
expect(frame).not.toMatch(/MockDiff:/);
|
||||
});
|
||||
|
||||
it('uses JSON renderer even when renderOutputAsMarkdown=true is true', () => {
|
||||
@@ -167,7 +122,6 @@ describe('<ToolMessage />', () => {
|
||||
expect(output).toContain('"a": 1');
|
||||
expect(output).toContain('"b": [');
|
||||
// Should not use markdown renderer for JSON even when renderOutputAsMarkdown=true
|
||||
expect(output).not.toContain('MockMarkdown:');
|
||||
});
|
||||
it('falls back to plain text for malformed JSON', () => {
|
||||
const testJSONstring = 'a": 1, "b": [2, 3]}';
|
||||
|
||||
@@ -113,6 +113,7 @@ export const ToolMessage: React.FC<ToolMessageProps> = ({
|
||||
availableTerminalHeight={availableTerminalHeight}
|
||||
terminalWidth={terminalWidth}
|
||||
renderOutputAsMarkdown={renderOutputAsMarkdown}
|
||||
hasFocus={isThisShellFocused}
|
||||
/>
|
||||
{isThisShellFocused && config && (
|
||||
<Box paddingLeft={STATUS_INDICATOR_WIDTH} marginTop={1}>
|
||||
|
||||
@@ -77,7 +77,7 @@ describe('Focus Hint', () => {
|
||||
|
||||
// Now it SHOULD contain the focus hint
|
||||
expect(lastFrame()).toMatchSnapshot('after-delay-no-output');
|
||||
expect(lastFrame()).toContain('(tab to focus)');
|
||||
expect(lastFrame()).toContain('(Tab to focus)');
|
||||
});
|
||||
|
||||
it('shows focus hint after delay with output', async () => {
|
||||
@@ -95,7 +95,7 @@ describe('Focus Hint', () => {
|
||||
});
|
||||
|
||||
expect(lastFrame()).toMatchSnapshot('after-delay-with-output');
|
||||
expect(lastFrame()).toContain('(tab to focus)');
|
||||
expect(lastFrame()).toContain('(Tab to focus)');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -116,7 +116,7 @@ describe('Focus Hint', () => {
|
||||
|
||||
// The focus hint should be visible
|
||||
expect(lastFrame()).toMatchSnapshot('long-description');
|
||||
expect(lastFrame()).toContain('(tab to focus)');
|
||||
expect(lastFrame()).toContain('(Tab to focus)');
|
||||
// The name should still be visible
|
||||
expect(lastFrame()).toContain(SHELL_COMMAND_NAME);
|
||||
});
|
||||
|
||||
@@ -4,34 +4,21 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { render } from '../../../test-utils/render.js';
|
||||
import { renderWithProviders } from '../../../test-utils/render.js';
|
||||
import { ToolResultDisplay } from './ToolResultDisplay.js';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { Box, Text } from 'ink';
|
||||
import type { AnsiOutput } from '@google/gemini-cli-core';
|
||||
|
||||
// Mock child components to simplify testing
|
||||
vi.mock('./DiffRenderer.js', () => ({
|
||||
DiffRenderer: ({
|
||||
diffContent,
|
||||
filename,
|
||||
}: {
|
||||
diffContent: string;
|
||||
filename: string;
|
||||
}) => (
|
||||
<Box>
|
||||
<Text>
|
||||
DiffRenderer: {filename} - {diffContent}
|
||||
</Text>
|
||||
</Box>
|
||||
),
|
||||
}));
|
||||
|
||||
// Mock UIStateContext
|
||||
// Mock UIStateContext partially
|
||||
const mockUseUIState = vi.fn();
|
||||
vi.mock('../../contexts/UIStateContext.js', () => ({
|
||||
useUIState: () => mockUseUIState(),
|
||||
}));
|
||||
vi.mock('../../contexts/UIStateContext.js', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('../../contexts/UIStateContext.js')>();
|
||||
return {
|
||||
...actual,
|
||||
useUIState: () => mockUseUIState(),
|
||||
};
|
||||
});
|
||||
|
||||
// Mock useAlternateBuffer
|
||||
const mockUseAlternateBuffer = vi.fn();
|
||||
@@ -39,28 +26,6 @@ vi.mock('../../hooks/useAlternateBuffer.js', () => ({
|
||||
useAlternateBuffer: () => mockUseAlternateBuffer(),
|
||||
}));
|
||||
|
||||
// Mock useSettings
|
||||
vi.mock('../../contexts/SettingsContext.js', () => ({
|
||||
useSettings: () => ({
|
||||
merged: {
|
||||
ui: {
|
||||
useAlternateBuffer: false,
|
||||
},
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock useOverflowActions
|
||||
vi.mock('../../contexts/OverflowContext.js', () => ({
|
||||
useOverflowActions: () => ({
|
||||
addOverflowingId: vi.fn(),
|
||||
removeOverflowingId: vi.fn(),
|
||||
}),
|
||||
useOverflowState: () => ({
|
||||
overflowingIds: new Set(),
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('ToolResultDisplay', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
@@ -68,6 +33,66 @@ describe('ToolResultDisplay', () => {
|
||||
mockUseAlternateBuffer.mockReturnValue(false);
|
||||
});
|
||||
|
||||
// Helper to use renderWithProviders
|
||||
const render = (ui: React.ReactElement) => renderWithProviders(ui);
|
||||
|
||||
it('uses ScrollableList for ANSI output in alternate buffer mode', () => {
|
||||
mockUseAlternateBuffer.mockReturnValue(true);
|
||||
const content = 'ansi content';
|
||||
const ansiResult: AnsiOutput = [
|
||||
[
|
||||
{
|
||||
text: content,
|
||||
fg: 'red',
|
||||
bg: 'black',
|
||||
bold: false,
|
||||
italic: false,
|
||||
underline: false,
|
||||
dim: false,
|
||||
inverse: false,
|
||||
},
|
||||
],
|
||||
];
|
||||
const { lastFrame } = render(
|
||||
<ToolResultDisplay
|
||||
resultDisplay={ansiResult}
|
||||
terminalWidth={80}
|
||||
maxLines={10}
|
||||
/>,
|
||||
);
|
||||
const output = lastFrame();
|
||||
|
||||
expect(output).toContain(content);
|
||||
});
|
||||
|
||||
it('uses Scrollable for non-ANSI output in alternate buffer mode', () => {
|
||||
mockUseAlternateBuffer.mockReturnValue(true);
|
||||
const { lastFrame } = render(
|
||||
<ToolResultDisplay
|
||||
resultDisplay="**Markdown content**"
|
||||
terminalWidth={80}
|
||||
maxLines={10}
|
||||
/>,
|
||||
);
|
||||
const output = lastFrame();
|
||||
|
||||
// With real components, we check for the content itself
|
||||
expect(output).toContain('Markdown content');
|
||||
});
|
||||
|
||||
it('passes hasFocus prop to scrollable components', () => {
|
||||
mockUseAlternateBuffer.mockReturnValue(true);
|
||||
const { lastFrame } = render(
|
||||
<ToolResultDisplay
|
||||
resultDisplay="Some result"
|
||||
terminalWidth={80}
|
||||
hasFocus={true}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(lastFrame()).toContain('Some result');
|
||||
});
|
||||
|
||||
it('renders string result as markdown by default', () => {
|
||||
const { lastFrame } = render(
|
||||
<ToolResultDisplay resultDisplay="**Some result**" terminalWidth={80} />,
|
||||
@@ -194,4 +219,126 @@ describe('ToolResultDisplay', () => {
|
||||
|
||||
expect(output).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('truncates ANSI output when maxLines is provided', () => {
|
||||
const ansiResult: AnsiOutput = [
|
||||
[
|
||||
{
|
||||
text: 'Line 1',
|
||||
fg: '',
|
||||
bg: '',
|
||||
bold: false,
|
||||
italic: false,
|
||||
underline: false,
|
||||
dim: false,
|
||||
inverse: false,
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
text: 'Line 2',
|
||||
fg: '',
|
||||
bg: '',
|
||||
bold: false,
|
||||
italic: false,
|
||||
underline: false,
|
||||
dim: false,
|
||||
inverse: false,
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
text: 'Line 3',
|
||||
fg: '',
|
||||
bg: '',
|
||||
bold: false,
|
||||
italic: false,
|
||||
underline: false,
|
||||
dim: false,
|
||||
inverse: false,
|
||||
},
|
||||
],
|
||||
];
|
||||
const { lastFrame } = render(
|
||||
<ToolResultDisplay
|
||||
resultDisplay={ansiResult}
|
||||
terminalWidth={80}
|
||||
availableTerminalHeight={20}
|
||||
maxLines={2}
|
||||
/>,
|
||||
);
|
||||
const output = lastFrame();
|
||||
|
||||
expect(output).not.toContain('Line 1');
|
||||
expect(output).toContain('Line 2');
|
||||
expect(output).toContain('Line 3');
|
||||
});
|
||||
|
||||
it('truncates ANSI output when maxLines is provided, even if availableTerminalHeight is undefined', () => {
|
||||
const ansiResult: AnsiOutput = Array.from({ length: 50 }, (_, i) => [
|
||||
{
|
||||
text: `Line ${i + 1}`,
|
||||
fg: '',
|
||||
bg: '',
|
||||
bold: false,
|
||||
italic: false,
|
||||
underline: false,
|
||||
dim: false,
|
||||
inverse: false,
|
||||
},
|
||||
]);
|
||||
const { lastFrame } = render(
|
||||
<ToolResultDisplay
|
||||
resultDisplay={ansiResult}
|
||||
terminalWidth={80}
|
||||
maxLines={25}
|
||||
availableTerminalHeight={undefined}
|
||||
/>,
|
||||
);
|
||||
const output = lastFrame();
|
||||
|
||||
// It SHOULD truncate to 25 lines because maxLines is provided
|
||||
expect(output).not.toContain('Line 1');
|
||||
expect(output).toContain('Line 50');
|
||||
});
|
||||
|
||||
it('renders rich visualization result (diff)', () => {
|
||||
const richResult = {
|
||||
type: 'diff' as const,
|
||||
data: {
|
||||
fileDiff:
|
||||
'diff --git a/test.ts b/test.ts\n--- a/test.ts\n+++ b/test.ts\n@@ -1 +1 @@\n-old\n+new',
|
||||
fileName: 'test.ts',
|
||||
},
|
||||
};
|
||||
const { lastFrame } = render(
|
||||
<ToolResultDisplay
|
||||
resultDisplay={richResult}
|
||||
terminalWidth={80}
|
||||
availableTerminalHeight={20}
|
||||
/>,
|
||||
);
|
||||
const output = lastFrame();
|
||||
|
||||
expect(output).toContain('old');
|
||||
expect(output).toContain('new');
|
||||
});
|
||||
|
||||
it('renders rich visualization result (table)', () => {
|
||||
const richResult = {
|
||||
type: 'table' as const,
|
||||
data: [{ name: 'Test', value: 123 }],
|
||||
};
|
||||
const { lastFrame } = render(
|
||||
<ToolResultDisplay
|
||||
resultDisplay={richResult}
|
||||
terminalWidth={80}
|
||||
availableTerminalHeight={20}
|
||||
/>,
|
||||
);
|
||||
const output = lastFrame();
|
||||
|
||||
expect(output).toContain('Test');
|
||||
expect(output).toContain('123');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,12 +8,22 @@ import React from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { DiffRenderer } from './DiffRenderer.js';
|
||||
import { MarkdownDisplay } from '../../utils/MarkdownDisplay.js';
|
||||
import { AnsiOutputText } from '../AnsiOutput.js';
|
||||
import { AnsiOutputText, AnsiLineText } from '../AnsiOutput.js';
|
||||
import { MaxSizedBox } from '../shared/MaxSizedBox.js';
|
||||
import { theme } from '../../semantic-colors.js';
|
||||
import type { AnsiOutput } from '@google/gemini-cli-core';
|
||||
import type {
|
||||
AnsiOutput,
|
||||
AnsiLine,
|
||||
RichVisualization,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { useUIState } from '../../contexts/UIStateContext.js';
|
||||
import { tryParseJSON } from '../../../utils/jsonoutput.js';
|
||||
import { useAlternateBuffer } from '../../hooks/useAlternateBuffer.js';
|
||||
import { Scrollable } from '../shared/Scrollable.js';
|
||||
import { ScrollableList } from '../shared/ScrollableList.js';
|
||||
import { SCROLL_TO_ITEM_END } from '../shared/VirtualizedList.js';
|
||||
import { ACTIVE_SHELL_MAX_LINES } from '../../constants.js';
|
||||
import { RichDataDisplay } from './RichDataDisplay.js';
|
||||
|
||||
const STATIC_HEIGHT = 1;
|
||||
const RESERVED_LINE_COUNT = 6; // for tool name, status, padding, and 'ShowMoreLines' hint
|
||||
@@ -28,6 +38,8 @@ export interface ToolResultDisplayProps {
|
||||
availableTerminalHeight?: number;
|
||||
terminalWidth: number;
|
||||
renderOutputAsMarkdown?: boolean;
|
||||
maxLines?: number;
|
||||
hasFocus?: boolean;
|
||||
}
|
||||
|
||||
interface FileDiffResult {
|
||||
@@ -40,30 +52,100 @@ export const ToolResultDisplay: React.FC<ToolResultDisplayProps> = ({
|
||||
availableTerminalHeight,
|
||||
terminalWidth,
|
||||
renderOutputAsMarkdown = true,
|
||||
maxLines,
|
||||
hasFocus = false,
|
||||
}) => {
|
||||
const { renderMarkdown } = useUIState();
|
||||
const isAlternateBuffer = useAlternateBuffer();
|
||||
|
||||
const availableHeight = availableTerminalHeight
|
||||
let availableHeight = availableTerminalHeight
|
||||
? Math.max(
|
||||
availableTerminalHeight - STATIC_HEIGHT - RESERVED_LINE_COUNT,
|
||||
MIN_LINES_SHOWN + 1, // enforce minimum lines shown
|
||||
)
|
||||
: undefined;
|
||||
|
||||
if (maxLines && availableHeight) {
|
||||
availableHeight = Math.min(availableHeight, maxLines);
|
||||
}
|
||||
|
||||
const combinedPaddingAndBorderWidth = 4;
|
||||
const childWidth = terminalWidth - combinedPaddingAndBorderWidth;
|
||||
|
||||
const keyExtractor = React.useCallback(
|
||||
(_: AnsiLine, index: number) => index.toString(),
|
||||
[],
|
||||
);
|
||||
|
||||
const renderVirtualizedAnsiLine = React.useCallback(
|
||||
({ item }: { item: AnsiLine }) => (
|
||||
<Box height={1} overflow="hidden">
|
||||
<AnsiLineText line={item} />
|
||||
</Box>
|
||||
),
|
||||
[],
|
||||
);
|
||||
|
||||
const truncatedResultDisplay = React.useMemo(() => {
|
||||
if (typeof resultDisplay === 'string') {
|
||||
if (resultDisplay.length > MAXIMUM_RESULT_DISPLAY_CHARACTERS) {
|
||||
return '...' + resultDisplay.slice(-MAXIMUM_RESULT_DISPLAY_CHARACTERS);
|
||||
// Only truncate string output if not in alternate buffer mode to ensure
|
||||
// we can scroll through the full output.
|
||||
if (typeof resultDisplay === 'string' && !isAlternateBuffer) {
|
||||
let text = resultDisplay;
|
||||
if (text.length > MAXIMUM_RESULT_DISPLAY_CHARACTERS) {
|
||||
text = '...' + text.slice(-MAXIMUM_RESULT_DISPLAY_CHARACTERS);
|
||||
}
|
||||
if (maxLines) {
|
||||
const hasTrailingNewline = text.endsWith('\n');
|
||||
const contentText = hasTrailingNewline ? text.slice(0, -1) : text;
|
||||
const lines = contentText.split('\n');
|
||||
if (lines.length > maxLines) {
|
||||
text =
|
||||
lines.slice(-maxLines).join('\n') +
|
||||
(hasTrailingNewline ? '\n' : '');
|
||||
}
|
||||
}
|
||||
return text;
|
||||
}
|
||||
return resultDisplay;
|
||||
}, [resultDisplay]);
|
||||
}, [resultDisplay, isAlternateBuffer, maxLines]);
|
||||
|
||||
if (!truncatedResultDisplay) return null;
|
||||
|
||||
// 1. Early return for background tools (Todos)
|
||||
if (
|
||||
typeof truncatedResultDisplay === 'object' &&
|
||||
'todos' in truncatedResultDisplay
|
||||
) {
|
||||
// display nothing, as the TodoTray will handle rendering todos
|
||||
return null;
|
||||
}
|
||||
|
||||
// 2. High-performance path: Virtualized ANSI in interactive mode
|
||||
if (isAlternateBuffer && Array.isArray(truncatedResultDisplay)) {
|
||||
// If availableHeight is undefined, fallback to a safe default to prevents infinite loop
|
||||
// where Container grows -> List renders more -> Container grows.
|
||||
const limit = maxLines ?? availableHeight ?? ACTIVE_SHELL_MAX_LINES;
|
||||
const listHeight = Math.min(
|
||||
(truncatedResultDisplay as AnsiOutput).length,
|
||||
limit,
|
||||
);
|
||||
|
||||
return (
|
||||
<Box width={childWidth} flexDirection="column" maxHeight={listHeight}>
|
||||
<ScrollableList
|
||||
width={childWidth}
|
||||
data={truncatedResultDisplay as AnsiOutput}
|
||||
renderItem={renderVirtualizedAnsiLine}
|
||||
estimatedItemHeight={() => 1}
|
||||
keyExtractor={keyExtractor}
|
||||
initialScrollIndex={SCROLL_TO_ITEM_END}
|
||||
hasFocus={hasFocus}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// 3. Compute content node for non-virtualized paths
|
||||
// Check if string content is valid JSON and pretty-print it
|
||||
const prettyJSON =
|
||||
typeof truncatedResultDisplay === 'string'
|
||||
@@ -115,20 +197,50 @@ export const ToolResultDisplay: React.FC<ToolResultDisplayProps> = ({
|
||||
);
|
||||
} else if (
|
||||
typeof truncatedResultDisplay === 'object' &&
|
||||
'todos' in truncatedResultDisplay
|
||||
'type' in truncatedResultDisplay &&
|
||||
'data' in truncatedResultDisplay &&
|
||||
['table', 'bar_chart', 'pie_chart', 'line_chart', 'diff'].includes(
|
||||
(truncatedResultDisplay as RichVisualization).type,
|
||||
)
|
||||
) {
|
||||
// display nothing, as the TodoTray will handle rendering todos
|
||||
return null;
|
||||
content = (
|
||||
<RichDataDisplay
|
||||
data={truncatedResultDisplay as RichVisualization}
|
||||
availableWidth={childWidth}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
const shouldDisableTruncation =
|
||||
isAlternateBuffer ||
|
||||
(availableTerminalHeight === undefined && maxLines === undefined);
|
||||
|
||||
content = (
|
||||
<AnsiOutputText
|
||||
data={truncatedResultDisplay as AnsiOutput}
|
||||
availableTerminalHeight={availableHeight}
|
||||
availableTerminalHeight={
|
||||
isAlternateBuffer ? undefined : availableHeight
|
||||
}
|
||||
width={childWidth}
|
||||
maxLines={isAlternateBuffer ? undefined : maxLines}
|
||||
disableTruncation={shouldDisableTruncation}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// 4. Final render based on session mode
|
||||
if (isAlternateBuffer) {
|
||||
return (
|
||||
<Scrollable
|
||||
width={childWidth}
|
||||
maxHeight={maxLines ?? availableHeight}
|
||||
hasFocus={hasFocus} // Allow scrolling via keyboard (Shift+Up/Down)
|
||||
scrollToBottom={true}
|
||||
>
|
||||
{content}
|
||||
</Scrollable>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box width={childWidth} flexDirection="column">
|
||||
<MaxSizedBox maxHeight={availableHeight} maxWidth={childWidth}>
|
||||
|
||||
@@ -49,6 +49,7 @@ describe('ToolResultDisplay Overflow', () => {
|
||||
streamingState: StreamingState.Idle,
|
||||
constrainHeight: true,
|
||||
},
|
||||
useAlternateBuffer: false,
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -22,6 +22,8 @@ import {
|
||||
type ToolResultDisplay,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { useInactivityTimer } from '../../hooks/useInactivityTimer.js';
|
||||
import { formatCommand } from '../../utils/keybindingUtils.js';
|
||||
import { Command } from '../../../config/keyBindings.js';
|
||||
|
||||
export const STATUS_INDICATOR_WIDTH = 3;
|
||||
|
||||
@@ -117,7 +119,9 @@ export const FocusHint: React.FC<{
|
||||
return (
|
||||
<Box marginLeft={1} flexShrink={0}>
|
||||
<Text color={theme.text.accent}>
|
||||
{isThisShellFocused ? '(Focused)' : '(tab to focus)'}
|
||||
{isThisShellFocused
|
||||
? `(${formatCommand(Command.UNFOCUS_SHELL_INPUT)} to unfocus)`
|
||||
: `(${formatCommand(Command.FOCUS_SHELL_INPUT)} to focus)`}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`<ShellToolMessage /> > Height Constraints > defaults to ACTIVE_SHELL_MAX_LINES when availableTerminalHeight is undefined 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────────╮
|
||||
│ ⊷ Shell Command A shell command │
|
||||
│ │
|
||||
│ Line 86 │
|
||||
│ Line 87 │
|
||||
│ Line 88 │
|
||||
│ Line 89 │
|
||||
│ Line 90 │
|
||||
│ Line 91 │
|
||||
│ Line 92 │
|
||||
│ Line 93 │
|
||||
│ Line 94 │
|
||||
│ Line 95 │
|
||||
│ Line 96 │
|
||||
│ Line 97 │
|
||||
│ Line 98 ▄ │
|
||||
│ Line 99 █ │
|
||||
│ Line 100 █ │"
|
||||
`;
|
||||
|
||||
exports[`<ShellToolMessage /> > Height Constraints > respects availableTerminalHeight when it is smaller than ACTIVE_SHELL_MAX_LINES 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────────╮
|
||||
│ ⊷ Shell Command A shell command │
|
||||
│ │
|
||||
│ Line 93 │
|
||||
│ Line 94 │
|
||||
│ Line 95 │
|
||||
│ Line 96 │
|
||||
│ Line 97 │
|
||||
│ Line 98 │
|
||||
│ Line 99 │
|
||||
│ Line 100 █ │"
|
||||
`;
|
||||
|
||||
exports[`<ShellToolMessage /> > Height Constraints > uses ACTIVE_SHELL_MAX_LINES when availableTerminalHeight is large 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────────╮
|
||||
│ ⊷ Shell Command A shell command │
|
||||
│ │
|
||||
│ Line 86 │
|
||||
│ Line 87 │
|
||||
│ Line 88 │
|
||||
│ Line 89 │
|
||||
│ Line 90 │
|
||||
│ Line 91 │
|
||||
│ Line 92 │
|
||||
│ Line 93 │
|
||||
│ Line 94 │
|
||||
│ Line 95 │
|
||||
│ Line 96 │
|
||||
│ Line 97 │
|
||||
│ Line 98 ▄ │
|
||||
│ Line 99 █ │
|
||||
│ Line 100 █ │"
|
||||
`;
|
||||
|
||||
exports[`<ShellToolMessage /> > Height Constraints > uses full availableTerminalHeight when focused in alternate buffer mode 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────────╮
|
||||
│ ⊷ Shell Command A shell command (Shift+Tab to unfocus) │
|
||||
│ │
|
||||
│ Line 3 │
|
||||
│ Line 4 │
|
||||
│ Line 5 █ │
|
||||
│ Line 6 █ │
|
||||
│ Line 7 █ │
|
||||
│ Line 8 █ │
|
||||
│ Line 9 █ │
|
||||
│ Line 10 █ │
|
||||
│ Line 11 █ │
|
||||
│ Line 12 █ │
|
||||
│ Line 13 █ │
|
||||
│ Line 14 █ │
|
||||
│ Line 15 █ │
|
||||
│ Line 16 █ │
|
||||
│ Line 17 █ │
|
||||
│ Line 18 █ │
|
||||
│ Line 19 █ │
|
||||
│ Line 20 █ │
|
||||
│ Line 21 █ │
|
||||
│ Line 22 █ │
|
||||
│ Line 23 █ │
|
||||
│ Line 24 █ │
|
||||
│ Line 25 █ │
|
||||
│ Line 26 █ │
|
||||
│ Line 27 █ │
|
||||
│ Line 28 █ │
|
||||
│ Line 29 █ │
|
||||
│ Line 30 █ │
|
||||
│ Line 31 █ │
|
||||
│ Line 32 █ │
|
||||
│ Line 33 █ │
|
||||
│ Line 34 █ │
|
||||
│ Line 35 █ │
|
||||
│ Line 36 █ │
|
||||
│ Line 37 █ │
|
||||
│ Line 38 █ │
|
||||
│ Line 39 █ │
|
||||
│ Line 40 █ │
|
||||
│ Line 41 █ │
|
||||
│ Line 42 █ │
|
||||
│ Line 43 █ │
|
||||
│ Line 44 █ │
|
||||
│ Line 45 █ │
|
||||
│ Line 46 █ │
|
||||
│ Line 47 █ │
|
||||
│ Line 48 █ │
|
||||
│ Line 49 █ │
|
||||
│ Line 50 █ │
|
||||
│ Line 51 █ │
|
||||
│ Line 52 █ │
|
||||
│ Line 53 █ │
|
||||
│ Line 54 █ │
|
||||
│ Line 55 █ │
|
||||
│ Line 56 █ │
|
||||
│ Line 57 █ │
|
||||
│ Line 58 █ │
|
||||
│ Line 59 █ │
|
||||
│ Line 60 █ │
|
||||
│ Line 61 █ │
|
||||
│ Line 62 █ │
|
||||
│ Line 63 █ │
|
||||
│ Line 64 █ │
|
||||
│ Line 65 █ │
|
||||
│ Line 66 █ │
|
||||
│ Line 67 █ │
|
||||
│ Line 68 █ │
|
||||
│ Line 69 █ │
|
||||
│ Line 70 █ │
|
||||
│ Line 71 █ │
|
||||
│ Line 72 █ │
|
||||
│ Line 73 █ │
|
||||
│ Line 74 █ │
|
||||
│ Line 75 █ │
|
||||
│ Line 76 █ │
|
||||
│ Line 77 █ │
|
||||
│ Line 78 █ │
|
||||
│ Line 79 █ │
|
||||
│ Line 80 █ │
|
||||
│ Line 81 █ │
|
||||
│ Line 82 █ │
|
||||
│ Line 83 █ │
|
||||
│ Line 84 █ │
|
||||
│ Line 85 █ │
|
||||
│ Line 86 █ │
|
||||
│ Line 87 █ │
|
||||
│ Line 88 █ │
|
||||
│ Line 89 █ │
|
||||
│ Line 90 █ │
|
||||
│ Line 91 █ │
|
||||
│ Line 92 █ │
|
||||
│ Line 93 █ │
|
||||
│ Line 94 █ │
|
||||
│ Line 95 █ │
|
||||
│ Line 96 █ │
|
||||
│ Line 97 █ │
|
||||
│ Line 98 █ │
|
||||
│ Line 99 █ │
|
||||
│ Line 100 █ │
|
||||
│ │"
|
||||
`;
|
||||
|
||||
exports[`<ShellToolMessage /> > Snapshots > renders in Alternate Buffer mode while focused 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────────╮
|
||||
│ ⊷ Shell Command A shell command (Shift+Tab to unfocus) │
|
||||
│ │
|
||||
│ Test result │
|
||||
│ │"
|
||||
`;
|
||||
|
||||
exports[`<ShellToolMessage /> > Snapshots > renders in Alternate Buffer mode while unfocused 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────────╮
|
||||
│ ⊷ Shell Command A shell command │
|
||||
│ │
|
||||
│ Test result │"
|
||||
`;
|
||||
|
||||
exports[`<ShellToolMessage /> > Snapshots > renders in Error state 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────────╮
|
||||
│ x Shell Command A shell command │
|
||||
│ │
|
||||
│ Error output │"
|
||||
`;
|
||||
|
||||
exports[`<ShellToolMessage /> > Snapshots > renders in Executing state 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────────╮
|
||||
│ ⊷ Shell Command A shell command │
|
||||
│ │
|
||||
│ Test result │"
|
||||
`;
|
||||
|
||||
exports[`<ShellToolMessage /> > Snapshots > renders in Success state (history mode) 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────────╮
|
||||
│ ✓ Shell Command A shell command │
|
||||
│ │
|
||||
│ Test result │"
|
||||
`;
|
||||
+13
-13
@@ -1,18 +1,18 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`ToolConfirmationMessage Overflow > should display "press ctrl-o" hint when content overflows in ToolGroupMessage 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────────╮
|
||||
│ ? test-tool a test tool ← │
|
||||
│ │
|
||||
│ ... first 49 lines hidden ... │
|
||||
│ 50 line 50 │
|
||||
│ Apply this change? │
|
||||
│ │
|
||||
│ ● 1. Allow once │
|
||||
│ 2. Allow for this session │
|
||||
│ 3. Modify with external editor │
|
||||
│ 4. No, suggest changes (esc) │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────────╯
|
||||
"╭──────────────────────────────────────────────────────────────────────────╮
|
||||
│ ? test-tool a test tool ← │
|
||||
│ │
|
||||
│ ... first 49 lines hidden ... │
|
||||
│ 50 line 50 │
|
||||
│ Apply this change? │
|
||||
│ │
|
||||
│ ● 1. Allow once │
|
||||
│ 2. Allow for this session │
|
||||
│ 3. Modify with external editor │
|
||||
│ 4. No, suggest changes (esc) │
|
||||
│ │
|
||||
╰──────────────────────────────────────────────────────────────────────────╯
|
||||
Press ctrl-o to show more lines"
|
||||
`;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user