Compare commits

..

53 Commits

Author SHA1 Message Date
Jack Wotherspoon c618fcb2be Merge branch 'main' into gemini-cli-policies-ui 2026-03-31 16:00:48 -04:00
Jack Wotherspoon f518fd6004 fix(cli): fix toolName type and resolve '*' to 'all tools' in policies dialog 2026-03-31 16:00:02 -04:00
Tommaso Sciortino 91400c5b0b don't try to remove tags in dry run (#24356) 2026-03-31 12:54:53 -07:00
Jack Wotherspoon d5da95eb5c chore: update license year for policiesCommand.tsx 2026-03-31 15:54:47 -04:00
Jack Wotherspoon cb25b5f7c3 Merge branch 'main' into gemini-cli-policies-ui 2026-03-31 15:54:38 -04:00
Jack Wotherspoon cdd0814bdf Merge branch 'main' into gemini-cli-policies-ui 2026-03-31 15:52:21 -04:00
Jack Wotherspoon bda7a9a625 Merge remote-tracking branch 'origin/main' into gemini-cli-policies-ui 2026-03-31 15:51:30 -04:00
Gal Zahavi 554a5a36a3 fix(core): implement __read and __write commands in sandbox managers (#24283) 2026-03-31 19:39:51 +00:00
Abhi 7e117ac0ac docs: update subagents docs to not be experimental (#24343) 2026-03-31 18:58:41 +00:00
Jerop Kipruto 6f053dd8b9 Revert "Add security settings for tool sandboxing" (#24357) 2026-03-31 18:39:28 +00:00
Sehoon Shon 25af91e4df chore(release): allow bundling for all builds, including stable (#24305) 2026-03-31 18:37:14 +00:00
Emily Hedlund 1edc542b55 ci: install bubblewrap on Linux for release workflows (#24347) 2026-03-31 18:19:48 +00:00
Sehoon Shon 119dff3b73 perf(build): optimize build scripts for parallel execution and remove redundant checks (#24307) 2026-03-31 17:46:36 +00:00
cynthialong0-0 7c4b497a84 fix(core): fix race condition between browser agent and main closing process (#24340) 2026-03-31 17:43:37 +00:00
Emily Hedlund 65ee6171e7 fix(core): batch macOS seatbelt rules into a profile file to prevent ARG_MAX errors (#24255) 2026-03-31 17:35:13 +00:00
Yifan Ruan 9cb09ccf8e docs: Add 'plan' option to approval mode in CLI reference (#24134) 2026-03-31 17:34:59 +00:00
cynthialong0-0 a79f2f81ae Fix(browser): terminate immediately for "browser is already running" error (#24233)
Co-authored-by: Gaurav <39389231+gsquared94@users.noreply.github.com>
2026-03-31 17:07:58 +00:00
ruomeng 140c2b9914 feat(cli): add UI to update extensions (#23682) 2026-03-31 17:05:08 +00:00
Gaurav 6805e818f7 fix(browser): terminate subagent immediately on domain restriction violations (#24313) 2026-03-31 16:51:27 +00:00
ruomeng 07e2053e12 feat(plan): promote planning feature to stable (#24282) 2026-03-31 16:10:13 +00:00
Sehoon Shon f9a93a1337 feat(cli): add auth info to footer (#24042) 2026-03-31 16:10:13 +00:00
cynthialong0-0 3982a252bb fix(browser): reset action counter for each agent session and let it ignore internal actions (#24228) 2026-03-31 15:23:19 +00:00
gemini-cli-robot e293424bb4 Changelog for v0.35.3 (#24083)
Co-authored-by: gemini-cli-robot <224641728+gemini-cli-robot@users.noreply.github.com>
2026-03-31 06:35:30 +00:00
gemini-cli-robot 21ad42f677 Changelog for v0.36.0-preview.6 (#24082)
Co-authored-by: gemini-cli-robot <224641728+gemini-cli-robot@users.noreply.github.com>
2026-03-31 06:35:26 +00:00
Sehoon Shon 561418c554 fix(core): enable mid-stream retries for all models and re-enable compression test (#24302) 2026-03-31 06:25:21 +00:00
Adib234 d0d3639e16 feat(plan): support Plan mode in untrusted folders (#17586) 2026-03-31 03:33:55 +00:00
Sehoon Shon 2f7f967189 test(integration): fix plan mode write denial test false positive (#24299) 2026-03-31 03:17:36 +00:00
Sandy Tao 46d6b119b6 feat(core): add project-level memory scope to save_memory tool (#24161) 2026-03-31 01:32:15 +00:00
Abhijit Balaji 80929c48c5 feat(core): enable topic update narration for legacy models (#24241)
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-03-31 00:57:46 +00:00
Sehoon Shon 35efdfc409 chore(test-utils): switch integration tests to use PREVIEW_GEMINI_MODEL (#24276)
Co-authored-by: Tommaso Sciortino <sciortino@gmail.com>
2026-03-31 00:31:04 +00:00
Gal Zahavi bea57a2f3d Add security settings for tool sandboxing (#23923) 2026-03-30 23:49:53 +00:00
Jarrod Whelan 1df5c98b33 feat(cli): implement compact tool output (#20974) 2026-03-30 23:43:29 +00:00
Sehoon Shon 3e95b8ec59 feat(cli): show Flash Lite Preview model regardless of user tier (#23904) 2026-03-30 23:30:33 +00:00
Abhi 5b5f87abc7 fix(core): ensure default agents provide tools and use model-specific schemas (#24268) 2026-03-30 23:10:57 +00:00
Christian Gunderman 991dca4c40 Default enable narration for the team. (#24224) 2026-03-30 22:30:55 +00:00
joshualitt dfba0e91e2 feat(core): Unified Context Management and Tool Distillation. (#24157) 2026-03-30 22:29:59 +00:00
Christian Gunderman 117a2d3844 fix(evals): add update_topic behavioral eval (#24223) 2026-03-30 22:02:53 +00:00
Tommaso Sciortino 4b20d93e1d fix broken tests (#24279) 2026-03-30 15:25:56 -07:00
Tommaso Sciortino 44cdb3e376 fix(cli): resolve missing F12 logs via global console store (#24235) 2026-03-30 13:15:10 -07:00
Jack Wotherspoon 44f6a0fafa Merge branch 'main' into gemini-cli-policies-ui 2026-03-20 09:55:56 -04:00
Jack Wotherspoon 326edee741 Merge remote-tracking branch 'origin/main' into gemini-cli-policies-ui
# Conflicts:
#	packages/cli/src/ui/commands/policiesCommand.test.ts
#	packages/cli/src/ui/commands/policiesCommand.ts
#	packages/core/src/policy/config.ts
#	packages/core/src/policy/toml-loader.ts
2026-03-20 09:22:37 -04:00
Jack Wotherspoon bfb13b4fc3 chore: remove one line 2026-03-14 21:50:55 +01:00
Jack Wotherspoon 27a56f4f7a chore: use reducer 2026-03-14 21:45:03 +01:00
Jack Wotherspoon 90bdba9059 Merge branch 'main' into gemini-cli-policies-ui 2026-03-14 21:30:40 +01:00
Jack Wotherspoon e1febb6849 chore: minor nits 2026-03-14 21:29:43 +01:00
Jack Wotherspoon 6a4f022a4f chore: update tests 2026-03-14 20:46:48 +01:00
Jack Wotherspoon 4204813895 chore: remove reverse engineering of arg pattern 2026-03-14 20:44:42 +01:00
Jack Wotherspoon 3de096b444 chore: update test 2026-03-14 20:18:50 +01:00
Jack Wotherspoon 8faed58289 chore: update test 2026-03-14 20:02:35 +01:00
Jack Wotherspoon 96cd5b7c5a chore: update 2026-03-14 19:53:04 +01:00
Jack Wotherspoon 480ac50204 Merge branch 'main' into gemini-cli-policies-ui 2026-03-14 19:13:11 +01:00
Jack Wotherspoon 7ff4402dbd chore: add tests 2026-03-14 18:35:15 +01:00
Jack Wotherspoon 2b96590e94 feat(ui): add policies dialog 2026-03-14 18:21:25 +01:00
182 changed files with 8173 additions and 2398 deletions
+2 -2
View File
@@ -1,9 +1,9 @@
{
"experimental": {
"plan": true,
"extensionReloading": true,
"modelSteering": true,
"memoryManager": true
"memoryManager": true,
"topicUpdateNarration": true
},
"general": {
"devtools": true
+7 -3
View File
@@ -175,7 +175,9 @@ runs:
--dry-run="${INPUTS_DRY_RUN}" \
--workspace="${INPUTS_CORE_PACKAGE_NAME}" \
--no-tag
npm dist-tag rm ${INPUTS_CORE_PACKAGE_NAME} false
if [[ "${INPUTS_DRY_RUN}" == "false" ]]; then
npm dist-tag rm ${INPUTS_CORE_PACKAGE_NAME} false
fi
- name: '🔗 Install latest core package'
working-directory: '${{ inputs.working-directory }}'
@@ -193,7 +195,7 @@ runs:
INPUTS_A2A_PACKAGE_NAME: '${{ inputs.a2a-package-name }}'
- name: '📦 Prepare bundled CLI for npm release'
if: "inputs.npm-registry-url != 'https://npm.pkg.github.com/' && inputs.npm-tag != 'latest'"
if: "inputs.npm-registry-url != 'https://npm.pkg.github.com/'"
working-directory: '${{ inputs.working-directory }}'
shell: 'bash'
run: |
@@ -248,7 +250,9 @@ runs:
--dry-run="${INPUTS_DRY_RUN}" \
--workspace="${INPUTS_A2A_PACKAGE_NAME}" \
--no-tag
npm dist-tag rm ${INPUTS_A2A_PACKAGE_NAME} false
if [[ "${INPUTS_DRY_RUN}" == "false" ]]; then
npm dist-tag rm ${INPUTS_A2A_PACKAGE_NAME} false
fi
- name: '🔬 Verify NPM release by version'
uses: './.github/actions/verify-release'
+7
View File
@@ -18,6 +18,13 @@ runs:
env:
JSON_INPUTS: '${{ toJSON(inputs) }}'
run: 'echo "$JSON_INPUTS"'
- name: 'Install system dependencies'
if: "runner.os == 'Linux'"
run: |
sudo apt-get update -qq && sudo DEBIAN_FRONTEND=noninteractive apt-get install -y -qq bubblewrap
# Ubuntu 24.04+ requires this to allow bwrap to function in CI
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 || true
shell: 'bash'
- name: 'Run Tests'
env:
GEMINI_API_KEY: '${{ inputs.gemini_api_key }}'
+6 -3
View File
@@ -1,6 +1,6 @@
# Latest stable release: v0.35.2
# Latest stable release: v0.35.3
Released: March 26, 2026
Released: March 28, 2026
For most users, our latest stable release is the recommended release. Install
the latest stable version with:
@@ -29,6 +29,9 @@ npm install -g @google/gemini-cli
## What's Changed
- fix(patch): cherry-pick 765fb67 to release/v0.35.2-pr-24055 [CONFLICTS] by
@gemini-cli-robot in
[#24063](https://github.com/google-gemini/gemini-cli/pull/24063)
- fix(core): allow disabling environment variable redaction by @galz10 in
[#23927](https://github.com/google-gemini/gemini-cli/pull/23927)
- fix(a2a-server): A2A server should execute ask policies in interactive mode by
@@ -385,4 +388,4 @@ npm install -g @google/gemini-cli
[#23585](https://github.com/google-gemini/gemini-cli/pull/23585)
**Full Changelog**:
https://github.com/google-gemini/gemini-cli/compare/v0.34.0...v0.35.2
https://github.com/google-gemini/gemini-cli/compare/v0.34.0...v0.35.3
+7 -3
View File
@@ -1,6 +1,6 @@
# Preview release: v0.36.0-preview.5
# Preview release: v0.36.0-preview.6
Released: March 27, 2026
Released: March 28, 2026
Our preview release includes the latest, new, and experimental features. This
release may not be as stable as our [latest weekly release](latest.md).
@@ -31,6 +31,10 @@ npm install -g @google/gemini-cli@preview
## What's Changed
- fix(patch): cherry-pick 765fb67 to release/v0.36.0-preview.5-pr-24055 to patch
version v0.36.0-preview.5 and create version 0.36.0-preview.6 by
@gemini-cli-robot in
[#24061](https://github.com/google-gemini/gemini-cli/pull/24061)
- fix(a2a-server): A2A server should execute ask policies in interactive mode by
@kschaab in [#23831](https://github.com/google-gemini/gemini-cli/pull/23831)
- docs(core): document agent_card_json string literal options for remote agents
@@ -386,4 +390,4 @@ npm install -g @google/gemini-cli@preview
[#23666](https://github.com/google-gemini/gemini-cli/pull/23666)
**Full Changelog**:
https://github.com/google-gemini/gemini-cli/compare/v0.35.0-preview.5...v0.36.0-preview.5
https://github.com/google-gemini/gemini-cli/compare/v0.35.0-preview.5...v0.36.0-preview.6
+1 -1
View File
@@ -52,7 +52,7 @@ These commands are available within the interactive REPL.
| `--prompt-interactive` | `-i` | string | - | Execute prompt and continue in interactive mode |
| `--worktree` | `-w` | string | - | Start Gemini in a new git worktree. If no name is provided, one is generated automatically. Requires `experimental.worktrees: true` in settings. |
| `--sandbox` | `-s` | boolean | `false` | Run in a sandboxed environment for safer execution |
| `--approval-mode` | - | string | `default` | Approval mode for tool execution. Choices: `default`, `auto_edit`, `yolo` |
| `--approval-mode` | - | string | `default` | Approval mode for tool execution. Choices: `default`, `auto_edit`, `yolo`, `plan` |
| `--yolo` | `-y` | boolean | `false` | **Deprecated.** Auto-approve all actions. Use `--approval-mode=yolo` instead. |
| `--experimental-acp` | - | boolean | - | Start in ACP (Agent Code Pilot) mode. **Experimental feature.** |
| `--experimental-zed-integration` | - | boolean | - | Run in Zed editor integration mode. **Experimental feature.** |
+13 -15
View File
@@ -30,6 +30,7 @@ they appear in the UI.
| Default Approval Mode | `general.defaultApprovalMode` | The default approval mode for tool execution. 'default' prompts for approval, 'auto_edit' auto-approves edit tools, and 'plan' is read-only mode. YOLO mode (auto-approve all actions) can only be enabled via command line (--yolo or --approval-mode=yolo). | `"default"` |
| Enable Auto Update | `general.enableAutoUpdate` | Enable automatic updates. | `true` |
| Enable Notifications | `general.enableNotifications` | Enable run-event notifications for action-required prompts and session completion. | `false` |
| Enable Plan Mode | `general.plan.enabled` | Enable Plan Mode for read-only safety during planning. | `true` |
| Plan Directory | `general.plan.directory` | The directory where planning artifacts are stored. If not specified, defaults to the system temporary directory. A custom directory requires a policy to allow write access in Plan Mode. | `undefined` |
| Plan Model Routing | `general.plan.modelRouting` | Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pro for the planning phase and Flash for the implementation phase. | `true` |
| Retry Fetch Errors | `general.retryFetchErrors` | Retry on "exception TypeError: fetch failed sending request" errors. | `true` |
@@ -59,6 +60,7 @@ they appear in the UI.
| Hide Tips | `ui.hideTips` | Hide helpful tips in the UI | `false` |
| Escape Pasted @ Symbols | `ui.escapePastedAtSymbols` | When enabled, @ symbols in pasted text are escaped to prevent unintended @path expansion. | `false` |
| Show Shortcuts Hint | `ui.showShortcutsHint` | Show the "? for shortcuts" hint above the input. | `true` |
| Compact Tool Output | `ui.compactToolOutput` | Display tool outputs (like directory listings and file reads) in a compact, structured format. | `false` |
| Hide Banner | `ui.hideBanner` | Hide the application banner | `false` |
| Hide Context Summary | `ui.hideContextSummary` | Hide the context summary (GEMINI.md, MCP servers) above the input. | `false` |
| Hide CWD | `ui.footer.hideCWD` | Hide the current working directory in the footer. | `false` |
@@ -155,21 +157,17 @@ they appear in the UI.
### Experimental
| UI Label | Setting | Description | Default |
| ---------------------------------- | ---------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- |
| Enable Tool Output Masking | `experimental.toolOutputMasking.enabled` | Enables tool output masking to save tokens. | `true` |
| Enable Git Worktrees | `experimental.worktrees` | Enable automated Git worktree management for parallel work. | `false` |
| Use OSC 52 Paste | `experimental.useOSC52Paste` | Use OSC 52 for pasting. This may be more robust than the default system when using remote terminal sessions (if your terminal is configured to allow it). | `false` |
| Use OSC 52 Copy | `experimental.useOSC52Copy` | Use OSC 52 for copying. This may be more robust than the default system when using remote terminal sessions (if your terminal is configured to allow it). | `false` |
| Plan | `experimental.plan` | Enable Plan Mode. | `true` |
| Model Steering | `experimental.modelSteering` | Enable model steering (user hints) to guide the model during tool execution. | `false` |
| Direct Web Fetch | `experimental.directWebFetch` | Enable web fetch behavior that bypasses LLM summarization. | `false` |
| Memory Manager Agent | `experimental.memoryManager` | Replace the built-in save_memory tool with a memory manager subagent that supports adding, removing, de-duplicating, and organizing memories. | `false` |
| Agent History Truncation | `experimental.agentHistoryTruncation` | Enable truncation window logic for the Agent History Provider. | `false` |
| Agent History Truncation Threshold | `experimental.agentHistoryTruncationThreshold` | The maximum number of messages before history is truncated. | `30` |
| Agent History Retained Messages | `experimental.agentHistoryRetainedMessages` | The number of recent messages to retain after truncation. | `15` |
| Agent History Summarization | `experimental.agentHistorySummarization` | Enable summarization of truncated content via a small model for the Agent History Provider. | `false` |
| Topic & Update Narration | `experimental.topicUpdateNarration` | Enable the experimental Topic & Update communication model for reduced chattiness and structured progress reporting. | `false` |
| UI Label | Setting | Description | Default |
| -------------------------- | ---------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- |
| Enable Tool Output Masking | `experimental.toolOutputMasking.enabled` | Enables tool output masking to save tokens. | `true` |
| Enable Git Worktrees | `experimental.worktrees` | Enable automated Git worktree management for parallel work. | `false` |
| Use OSC 52 Paste | `experimental.useOSC52Paste` | Use OSC 52 for pasting. This may be more robust than the default system when using remote terminal sessions (if your terminal is configured to allow it). | `false` |
| Use OSC 52 Copy | `experimental.useOSC52Copy` | Use OSC 52 for copying. This may be more robust than the default system when using remote terminal sessions (if your terminal is configured to allow it). | `false` |
| Model Steering | `experimental.modelSteering` | Enable model steering (user hints) to guide the model during tool execution. | `false` |
| Direct Web Fetch | `experimental.directWebFetch` | Enable web fetch behavior that bypasses LLM summarization. | `false` |
| Memory Manager Agent | `experimental.memoryManager` | Replace the built-in save_memory tool with a memory manager subagent that supports adding, removing, de-duplicating, and organizing memories. | `false` |
| Enable Context Management | `experimental.contextManagement` | Enable logic for context management. | `false` |
| Topic & Update Narration | `experimental.topicUpdateNarration` | Enable the experimental Topic & Update communication model for reduced chattiness and structured progress reporting. | `false` |
### Skills
+2 -2
View File
@@ -7,8 +7,8 @@ requests sent from `packages/cli`. For a general overview of Gemini CLI, see the
## Navigating this section
- **[Sub-agents (experimental)](./subagents.md):** Learn how to create and use
specialized sub-agents for complex tasks.
- **[Sub-agents](./subagents.md):** Learn how to create and use specialized
sub-agents for complex tasks.
- **[Core tools reference](../reference/tools.md):** Information on how tools
are defined, registered, and used by the core.
- **[Memory Import Processor](../reference/memport.md):** Documentation for the
+6 -23
View File
@@ -1,20 +1,16 @@
# Subagents (experimental)
# Subagents
Subagents are specialized agents that operate within your main Gemini CLI
session. They are designed to handle specific, complex tasks—like deep codebase
analysis, documentation lookup, or domain-specific reasoning—without cluttering
the main agent's context or toolset.
<!-- prettier-ignore -->
> [!NOTE]
> Subagents are currently an experimental feature.
>
To use custom subagents, you must ensure they are enabled in your
`settings.json` (enabled by default):
Subagents are enabled by default. To disable them, set `enableAgents` to `false`
in your `settings.json`:
```json
{
"experimental": { "enableAgents": true }
"experimental": { "enableAgents": false }
}
```
@@ -229,16 +225,7 @@ the `click_at` tool for precise, coordinate-based interactions.
## Creating custom subagents
You can create your own subagents to automate specific workflows or enforce
specific personas. To use custom subagents, you must enable them in your
`settings.json`:
```json
{
"experimental": {
"enableAgents": true
}
}
```
specific personas.
### Agent definition files
@@ -406,15 +393,11 @@ If you need to further tune your subagent, you can do so by selecting the model
to optimize for with `/model` and then asking the model why it does not think
that your subagent was called with a specific prompt and the given description.
## Remote subagents (Agent2Agent) (experimental)
## Remote subagents (Agent2Agent)
Gemini CLI can also delegate tasks to remote subagents using the Agent-to-Agent
(A2A) protocol.
<!-- prettier-ignore -->
> [!NOTE]
> Remote subagents are currently an experimental feature.
See the [Remote Subagents documentation](remote-agents) for detailed
configuration, authentication, and usage instructions.
+1 -3
View File
@@ -17,8 +17,6 @@ Slash commands provide meta-level control over the CLI itself.
### `/agents`
- **Description:** Manage local and remote subagents.
- **Note:** This command is experimental and requires
`experimental.enableAgents: true` in your `settings.json`.
- **Sub-commands:**
- **`list`**:
- **Description:** Lists all discovered agents, including built-in, local,
@@ -305,7 +303,7 @@ Slash commands provide meta-level control over the CLI itself.
- **Description:** Switch to Plan Mode (read-only) and view the current plan if
one has been generated.
- **Note:** This feature is enabled by default. It can be disabled via the
`experimental.plan` setting in your configuration.
`general.plan.enabled` setting in your configuration.
- **Sub-commands:**
- **`copy`**:
- **Description:** Copy the currently approved plan to your clipboard.
+55 -24
View File
@@ -141,6 +141,11 @@ their corresponding top-level category object in your `settings.json` file.
- **Default:** `false`
- **Requires restart:** Yes
- **`general.plan.enabled`** (boolean):
- **Description:** Enable Plan Mode for read-only safety during planning.
- **Default:** `true`
- **Requires restart:** Yes
- **`general.plan.directory`** (string):
- **Description:** The directory where planning artifacts are stored. If not
specified, defaults to the system temporary directory. A custom directory
@@ -257,6 +262,11 @@ their corresponding top-level category object in your `settings.json` file.
- **Description:** Show the "? for shortcuts" hint above the input.
- **Default:** `true`
- **`ui.compactToolOutput`** (boolean):
- **Description:** Display tool outputs (like directory listings and file
reads) in a compact, structured format.
- **Default:** `false`
- **`ui.hideBanner`** (boolean):
- **Description:** Hide the application banner
- **Default:** `false`
@@ -1652,11 +1662,6 @@ their corresponding top-level category object in your `settings.json` file.
configured to allow it).
- **Default:** `false`
- **`experimental.plan`** (boolean):
- **Description:** Enable Plan Mode.
- **Default:** `true`
- **Requires restart:** Yes
- **`experimental.taskTracker`** (boolean):
- **Description:** Enable task tracker tools.
- **Default:** `false`
@@ -1702,25 +1707,8 @@ their corresponding top-level category object in your `settings.json` file.
- **Default:** `false`
- **Requires restart:** Yes
- **`experimental.agentHistoryTruncation`** (boolean):
- **Description:** Enable truncation window logic for the Agent History
Provider.
- **Default:** `false`
- **Requires restart:** Yes
- **`experimental.agentHistoryTruncationThreshold`** (number):
- **Description:** The maximum number of messages before history is truncated.
- **Default:** `30`
- **Requires restart:** Yes
- **`experimental.agentHistoryRetainedMessages`** (number):
- **Description:** The number of recent messages to retain after truncation.
- **Default:** `15`
- **Requires restart:** Yes
- **`experimental.agentHistorySummarization`** (boolean):
- **Description:** Enable summarization of truncated content via a small model
for the Agent History Provider.
- **`experimental.contextManagement`** (boolean):
- **Description:** Enable logic for context management.
- **Default:** `false`
- **Requires restart:** Yes
@@ -1815,6 +1803,49 @@ their corresponding top-level category object in your `settings.json` file.
prioritize available tools dynamically.
- **Default:** `[]`
#### `contextManagement`
- **`contextManagement.historyWindow.maxTokens`** (number):
- **Description:** The number of tokens to allow before triggering
compression.
- **Default:** `150000`
- **Requires restart:** Yes
- **`contextManagement.historyWindow.retainedTokens`** (number):
- **Description:** The number of tokens to always retain.
- **Default:** `40000`
- **Requires restart:** Yes
- **`contextManagement.messageLimits.normalMaxTokens`** (number):
- **Description:** The target number of tokens to budget for a normal
conversation turn.
- **Default:** `2500`
- **Requires restart:** Yes
- **`contextManagement.messageLimits.retainedMaxTokens`** (number):
- **Description:** The maximum number of tokens a single conversation turn can
consume before truncation.
- **Default:** `12000`
- **Requires restart:** Yes
- **`contextManagement.messageLimits.normalizationHeadRatio`** (number):
- **Description:** The ratio of tokens to retain from the beginning of a
truncated message (0.0 to 1.0).
- **Default:** `0.25`
- **Requires restart:** Yes
- **`contextManagement.toolDistillation.maxOutputTokens`** (number):
- **Description:** Maximum tokens to show when truncating large tool outputs.
- **Default:** `10000`
- **Requires restart:** Yes
- **`contextManagement.toolDistillation.summarizationThresholdTokens`**
(number):
- **Description:** Threshold above which truncated tool outputs will be
summarized by an LLM.
- **Default:** `20000`
- **Requires restart:** Yes
#### `admin`
- **`admin.secureModeEnabled`** (boolean):
+7
View File
@@ -127,6 +127,13 @@ available combinations.
| `background.unfocusList` | Move focus from background shell list to Gemini. | `Tab` |
| `background.unfocusWarning` | Show warning when trying to move focus away from background shell. | `Tab` |
#### Extension Controls
| Command | Action | Keys |
| ------------------ | ------------------------------------------- | ---- |
| `extension.update` | Update the current extension if available. | `I` |
| `extension.link` | Link the current extension to a local path. | `L` |
<!-- KEYBINDINGS-AUTOGEN:END -->
## Customizing Keybindings
-2
View File
@@ -138,12 +138,10 @@
{ "label": "Plan mode", "slug": "docs/cli/plan-mode" },
{
"label": "Subagents",
"badge": "🔬",
"slug": "docs/core/subagents"
},
{
"label": "Remote subagents",
"badge": "🔬",
"slug": "docs/core/remote-agents"
},
{ "label": "Rewind", "slug": "docs/cli/rewind" },
+3 -1
View File
@@ -15,7 +15,9 @@ import {
describe('plan_mode', () => {
const TEST_PREFIX = 'Plan Mode: ';
const settings = {
experimental: { plan: true },
general: {
plan: { enabled: true },
},
};
const getWriteTargets = (logs: any[]) =>
+116
View File
@@ -0,0 +1,116 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, expect } from 'vitest';
import { evalTest } from './test-helper.js';
describe('update_topic_behavior', () => {
// Constants for tool names and params for robustness
const UPDATE_TOPIC_TOOL_NAME = 'update_topic';
/**
* Verifies the desired behavior of the update_topic tool. update_topic is used by the
* agent to share periodic, concise updates about what the agent is working on, independent
* of the regular model output and/or thoughts. This tool is expected to be called at least
* at the start and end of the session, and typically at least once in the middle, but no
* more than 1/4 turns.
*/
evalTest('USUALLY_PASSES', {
name: 'update_topic should be used at start, end and middle for complex tasks',
prompt: `Create a simple users REST API using Express.
1. Initialize a new npm project and install express.
2. Create src/app.ts as the main entry point.
3. Create src/routes/userRoutes.ts for user routes.
4. Create src/controllers/userController.ts for user logic.
5. Implement GET /users, POST /users, and GET /users/:id using an in-memory array.
6. Add a 'start' script to package.json.
7. Finally, run a quick grep to verify the routes are in src/app.ts.`,
files: {
'package.json': JSON.stringify(
{
name: 'users-api',
version: '1.0.0',
private: true,
},
null,
2,
),
'.gemini/settings.json': JSON.stringify({
experimental: {
topicUpdateNarration: true,
},
}),
},
assert: async (rig, result) => {
const toolLogs = rig.readToolLogs();
const topicCalls = toolLogs.filter(
(l) => l.toolRequest.name === UPDATE_TOPIC_TOOL_NAME,
);
// 1. Assert that update_topic is called at least 3 times (start, middle, end)
expect(
topicCalls.length,
`Expected at least 3 update_topic calls, but found ${topicCalls.length}`,
).toBeGreaterThanOrEqual(3);
// 2. Assert update_topic is called at the very beginning (first tool call)
expect(
toolLogs[0].toolRequest.name,
'First tool call should be update_topic',
).toBe(UPDATE_TOPIC_TOOL_NAME);
// 3. Assert update_topic is called near the end
const lastTopicCallIndex = toolLogs
.map((l) => l.toolRequest.name)
.lastIndexOf(UPDATE_TOPIC_TOOL_NAME);
expect(
lastTopicCallIndex,
'Expected update_topic to be used near the end of the task',
).toBeGreaterThanOrEqual(toolLogs.length * 0.7);
// 4. Assert there is at least one update_topic call in the middle (between start and end phases)
const middleTopicCalls = topicCalls.slice(1, -1);
expect(
middleTopicCalls.length,
'Expected at least one update_topic call in the middle of the task',
).toBeGreaterThanOrEqual(1);
// 5. Turn Ratio Assertion: update_topic should be <= 1/2 of total turns.
// We only enforce this for tasks that take more than 5 turns, as shorter tasks
// naturally have a higher ratio when following the "start, middle, end" rule.
const uniquePromptIds = new Set(
toolLogs
.map((l) => l.toolRequest.prompt_id)
.filter((id) => id !== undefined),
);
const totalTurns = uniquePromptIds.size;
if (totalTurns > 5) {
const topicTurns = new Set(
topicCalls
.map((l) => l.toolRequest.prompt_id)
.filter((id) => id !== undefined),
);
const topicTurnCount = topicTurns.size;
const ratio = topicTurnCount / totalTurns;
expect(
ratio,
`update_topic was used in ${topicTurnCount} out of ${totalTurns} turns (${(ratio * 100).toFixed(1)}%). Expected <= 50%.`,
).toBeLessThanOrEqual(0.5);
// Ideal ratio is closer to 1/5 (20%). We log high usage as a warning.
if (ratio > 0.25) {
console.warn(
`[Efficiency Warning] update_topic usage is high: ${(ratio * 100).toFixed(1)}% (Goal: ~20%)`,
);
}
}
},
});
});
+1 -1
View File
@@ -1 +1 @@
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"Part 1. "}],"role":"model"},"index":0}]},{"usageMetadata":{"promptTokenCount":100,"candidatesTokenCount":10,"totalTokenCount":110}},{"candidates":[{"content":{"parts":[{"text":"Part 2."}],"role":"model"},"index":0}],"finishReason":"STOP"}]}
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"Part 1. "}],"role":"model"},"index":0}]},{"usageMetadata":{"promptTokenCount":100,"candidatesTokenCount":10,"totalTokenCount":110}},{"candidates":[{"content":{"parts":[{"text":"Part 2."}],"role":"model"},"index":0,"finishReason":"STOP"}]}]}
+10 -15
View File
@@ -23,7 +23,9 @@ describe('Plan Mode', () => {
'should allow read-only tools but deny write tools in plan mode',
{
settings: {
experimental: { plan: true },
general: {
plan: { enabled: true },
},
tools: {
core: [
'run_shell_command',
@@ -67,15 +69,12 @@ describe('Plan Mode', () => {
await rig.setup(testName, {
settings: {
experimental: { plan: true },
tools: {
core: ['write_file', 'read_file', 'list_directory'],
},
general: {
plan: { enabled: true, directory: plansDir },
defaultApprovalMode: 'plan',
plan: {
directory: plansDir,
},
},
},
});
@@ -120,22 +119,19 @@ describe('Plan Mode', () => {
await rig.setup(testName, {
settings: {
experimental: { plan: true },
tools: {
core: ['write_file', 'read_file', 'list_directory'],
},
general: {
plan: { enabled: true, directory: plansDir },
defaultApprovalMode: 'plan',
plan: {
directory: plansDir,
},
},
},
});
await rig.run({
approvalMode: 'plan',
args: 'Create a file called hello.txt in the current directory.',
args: 'Attempt to create a file named "hello.txt" in the current directory. Do not create a plan file, try to write hello.txt directly.',
});
const toolLogs = rig.readToolLogs();
@@ -156,7 +152,9 @@ describe('Plan Mode', () => {
it('should be able to enter plan mode from default mode', async () => {
await rig.setup('should be able to enter plan mode from default mode', {
settings: {
experimental: { plan: true },
general: {
plan: { enabled: true },
},
tools: {
core: ['enter_plan_mode'],
allowed: ['enter_plan_mode'],
@@ -184,15 +182,12 @@ describe('Plan Mode', () => {
await rig.setup(testName, {
settings: {
experimental: { plan: true },
tools: {
core: ['write_file', 'read_file', 'list_directory'],
},
general: {
plan: { enabled: true, directory: plansDir },
defaultApprovalMode: 'plan',
plan: {
directory: plansDir,
},
},
},
});
@@ -109,12 +109,8 @@ export function createMockConfig(
enableEnvironmentVariableRedaction: false,
},
}),
isExperimentalAgentHistoryTruncationEnabled: vi.fn().mockReturnValue(false),
getExperimentalAgentHistoryTruncationThreshold: vi.fn().mockReturnValue(50),
getExperimentalAgentHistoryRetainedMessages: vi.fn().mockReturnValue(30),
isExperimentalAgentHistorySummarizationEnabled: vi
.fn()
.mockReturnValue(false),
isAutoDistillationEnabled: vi.fn().mockReturnValue(false),
getContextManagementConfig: vi.fn().mockReturnValue({ enabled: false }),
...overrides,
} as unknown as Config;
+34 -26
View File
@@ -1364,8 +1364,8 @@ describe('Approval mode tool exclusion logic', () => {
'test',
];
const settings = createTestMergedSettings({
experimental: {
plan: true,
general: {
plan: { enabled: true },
},
});
const argv = await parseArguments(createTestMergedSettings());
@@ -1479,9 +1479,7 @@ describe('Approval mode tool exclusion logic', () => {
const settings = createTestMergedSettings({
general: {
defaultApprovalMode: 'plan',
},
experimental: {
plan: false,
plan: { enabled: false },
},
});
const argv = await parseArguments(settings);
@@ -1489,14 +1487,12 @@ describe('Approval mode tool exclusion logic', () => {
expect(config.getApprovalMode()).toBe(ApprovalMode.DEFAULT);
});
it('should allow plan approval mode if experimental plan is enabled', async () => {
it('should allow plan approval mode if plan is enabled', async () => {
process.argv = ['node', 'script.js'];
const settings = createTestMergedSettings({
general: {
defaultApprovalMode: 'plan',
},
experimental: {
plan: true,
plan: { enabled: true },
},
});
const argv = await parseArguments(settings);
@@ -2742,12 +2738,12 @@ describe('loadCliConfig approval mode', () => {
expect(config.getApprovalMode()).toBe(ServerConfig.ApprovalMode.YOLO);
});
it('should set Plan approval mode when --approval-mode=plan is used and experimental.plan is enabled', async () => {
it('should set Plan approval mode when --approval-mode=plan is used and plan is enabled', async () => {
process.argv = ['node', 'script.js', '--approval-mode', 'plan'];
const argv = await parseArguments(createTestMergedSettings());
const settings = createTestMergedSettings({
experimental: {
plan: true,
general: {
plan: { enabled: true },
},
});
const config = await loadCliConfig(settings, 'test-session', argv);
@@ -2767,12 +2763,12 @@ describe('loadCliConfig approval mode', () => {
expect(config.getApprovalMode()).toBe(ServerConfig.ApprovalMode.DEFAULT);
});
it('should throw error when --approval-mode=plan is used but experimental.plan is disabled', async () => {
it('should throw error when --approval-mode=plan is used but plan is disabled', async () => {
process.argv = ['node', 'script.js', '--approval-mode', 'plan'];
const argv = await parseArguments(createTestMergedSettings());
const settings = createTestMergedSettings({
experimental: {
plan: false,
general: {
plan: { enabled: false },
},
});
@@ -2893,22 +2889,26 @@ describe('loadCliConfig approval mode', () => {
expect(config.getApprovalMode()).toBe(ServerConfig.ApprovalMode.YOLO);
});
it('should respect plan mode from settings when experimental.plan is enabled', async () => {
it('should respect plan mode from settings when plan is enabled', async () => {
process.argv = ['node', 'script.js'];
const settings = createTestMergedSettings({
general: { defaultApprovalMode: 'plan' },
experimental: { plan: true },
general: {
defaultApprovalMode: 'plan',
plan: { enabled: true },
},
});
const argv = await parseArguments(settings);
const config = await loadCliConfig(settings, 'test-session', argv);
expect(config.getApprovalMode()).toBe(ServerConfig.ApprovalMode.PLAN);
});
it('should throw error if plan mode is in settings but experimental.plan is disabled', async () => {
it('should fall back to default if plan mode is in settings but disabled', async () => {
process.argv = ['node', 'script.js'];
const settings = createTestMergedSettings({
general: { defaultApprovalMode: 'plan' },
experimental: { plan: false },
general: {
defaultApprovalMode: 'plan',
plan: { enabled: false },
},
});
const argv = await parseArguments(settings);
const config = await loadCliConfig(settings, 'test-session', argv);
@@ -3696,7 +3696,9 @@ describe('loadCliConfig mcpEnabled', () => {
it('should use plan directory from active extension when user has not specified one', async () => {
process.argv = ['node', 'script.js'];
const settings = createTestMergedSettings({
experimental: { plan: true },
general: {
plan: { enabled: true },
},
});
const argv = await parseArguments(settings);
@@ -3715,9 +3717,11 @@ describe('loadCliConfig mcpEnabled', () => {
it('should NOT use plan directory from active extension when user has specified one', async () => {
process.argv = ['node', 'script.js'];
const settings = createTestMergedSettings({
experimental: { plan: true },
general: {
plan: { directory: 'user-plans-dir' },
plan: {
enabled: true,
directory: 'user-plans-dir',
},
},
});
const argv = await parseArguments(settings);
@@ -3738,7 +3742,9 @@ describe('loadCliConfig mcpEnabled', () => {
it('should NOT use plan directory from inactive extension', async () => {
process.argv = ['node', 'script.js'];
const settings = createTestMergedSettings({
experimental: { plan: true },
general: {
plan: { enabled: true },
},
});
const argv = await parseArguments(settings);
@@ -3759,7 +3765,9 @@ describe('loadCliConfig mcpEnabled', () => {
it('should use default path if neither user nor extension settings provide a plan directory', async () => {
process.argv = ['node', 'script.js'];
const settings = createTestMergedSettings({
experimental: { plan: true },
general: {
plan: { enabled: true },
},
});
const argv = await parseArguments(settings);
+7 -11
View File
@@ -669,9 +669,9 @@ export async function loadCliConfig(
approvalMode = ApprovalMode.AUTO_EDIT;
break;
case 'plan':
if (!(settings.experimental?.plan ?? false)) {
if (!(settings.general?.plan?.enabled ?? true)) {
debugLogger.warn(
'Approval mode "plan" is only available when experimental.plan is enabled. Falling back to "default".',
'Approval mode "plan" is disabled in your settings. Falling back to "default".',
);
approvalMode = ApprovalMode.DEFAULT;
} else {
@@ -966,7 +966,7 @@ export async function loadCliConfig(
extensionRegistryURI,
enableExtensionReloading: settings.experimental?.extensionReloading,
enableAgents: settings.experimental?.enableAgents,
plan: settings.experimental?.plan,
plan: settings.general?.plan?.enabled ?? true,
tracker: settings.experimental?.taskTracker,
directWebFetch: settings.experimental?.directWebFetch,
planSettings: settings.general?.plan?.directory
@@ -977,14 +977,10 @@ export async function loadCliConfig(
disabledSkills: settings.skills?.disabled,
experimentalJitContext: settings.experimental?.jitContext,
experimentalMemoryManager: settings.experimental?.memoryManager,
experimentalAgentHistoryTruncation:
settings.experimental?.agentHistoryTruncation,
experimentalAgentHistoryTruncationThreshold:
settings.experimental?.agentHistoryTruncationThreshold,
experimentalAgentHistoryRetainedMessages:
settings.experimental?.agentHistoryRetainedMessages,
experimentalAgentHistorySummarization:
settings.experimental?.agentHistorySummarization,
contextManagement: {
enabled: settings.experimental?.contextManagement,
...settings?.contextManagement,
},
modelSteering: settings.experimental?.modelSteering,
topicUpdateNarration: settings.experimental?.topicUpdateNarration,
toolOutputMasking: settings.experimental?.toolOutputMasking,
+143 -77
View File
@@ -5,87 +5,153 @@
*/
import { describe, it, expect } from 'vitest';
import { deriveItemsFromLegacySettings } from './footerItems.js';
import {
deriveItemsFromLegacySettings,
resolveFooterState,
} from './footerItems.js';
import { createMockSettings } from '../test-utils/settings.js';
describe('deriveItemsFromLegacySettings', () => {
it('returns defaults when no legacy settings are customized', () => {
const settings = createMockSettings({
ui: { footer: { hideContextPercentage: true } },
}).merged;
const items = deriveItemsFromLegacySettings(settings);
expect(items).toEqual([
'workspace',
'git-branch',
'sandbox',
'model-name',
'quota',
]);
});
describe('footerItems', () => {
describe('deriveItemsFromLegacySettings', () => {
it('returns defaults when no legacy settings are customized', () => {
const settings = createMockSettings({
ui: { footer: { hideContextPercentage: true } },
}).merged;
const items = deriveItemsFromLegacySettings(settings);
expect(items).toEqual([
'workspace',
'git-branch',
'sandbox',
'model-name',
'quota',
]);
});
it('removes workspace when hideCWD is true', () => {
const settings = createMockSettings({
ui: { footer: { hideCWD: true, hideContextPercentage: true } },
}).merged;
const items = deriveItemsFromLegacySettings(settings);
expect(items).not.toContain('workspace');
});
it('removes workspace when hideCWD is true', () => {
const settings = createMockSettings({
ui: { footer: { hideCWD: true, hideContextPercentage: true } },
}).merged;
const items = deriveItemsFromLegacySettings(settings);
expect(items).not.toContain('workspace');
});
it('removes sandbox when hideSandboxStatus is true', () => {
const settings = createMockSettings({
ui: { footer: { hideSandboxStatus: true, hideContextPercentage: true } },
}).merged;
const items = deriveItemsFromLegacySettings(settings);
expect(items).not.toContain('sandbox');
});
it('removes model-name, context-used, and quota when hideModelInfo is true', () => {
const settings = createMockSettings({
ui: { footer: { hideModelInfo: true, hideContextPercentage: true } },
}).merged;
const items = deriveItemsFromLegacySettings(settings);
expect(items).not.toContain('model-name');
expect(items).not.toContain('context-used');
expect(items).not.toContain('quota');
});
it('includes context-used when hideContextPercentage is false', () => {
const settings = createMockSettings({
ui: { footer: { hideContextPercentage: false } },
}).merged;
const items = deriveItemsFromLegacySettings(settings);
expect(items).toContain('context-used');
// Should be after model-name
const modelIdx = items.indexOf('model-name');
const contextIdx = items.indexOf('context-used');
expect(contextIdx).toBe(modelIdx + 1);
});
it('includes memory-usage when showMemoryUsage is true', () => {
const settings = createMockSettings({
ui: { showMemoryUsage: true, footer: { hideContextPercentage: true } },
}).merged;
const items = deriveItemsFromLegacySettings(settings);
expect(items).toContain('memory-usage');
});
it('handles combination of settings', () => {
const settings = createMockSettings({
ui: {
showMemoryUsage: true,
footer: {
hideCWD: true,
hideModelInfo: true,
hideContextPercentage: false,
it('removes sandbox when hideSandboxStatus is true', () => {
const settings = createMockSettings({
ui: {
footer: { hideSandboxStatus: true, hideContextPercentage: true },
},
},
}).merged;
const items = deriveItemsFromLegacySettings(settings);
expect(items).toEqual([
'git-branch',
'sandbox',
'context-used',
'memory-usage',
]);
}).merged;
const items = deriveItemsFromLegacySettings(settings);
expect(items).not.toContain('sandbox');
});
it('removes model-name, context-used, and quota when hideModelInfo is true', () => {
const settings = createMockSettings({
ui: { footer: { hideModelInfo: true, hideContextPercentage: true } },
}).merged;
const items = deriveItemsFromLegacySettings(settings);
expect(items).not.toContain('model-name');
expect(items).not.toContain('context-used');
expect(items).not.toContain('quota');
});
it('includes context-used when hideContextPercentage is false', () => {
const settings = createMockSettings({
ui: { footer: { hideContextPercentage: false } },
}).merged;
const items = deriveItemsFromLegacySettings(settings);
expect(items).toContain('context-used');
// Should be after model-name
const modelIdx = items.indexOf('model-name');
const contextIdx = items.indexOf('context-used');
expect(contextIdx).toBe(modelIdx + 1);
});
it('includes memory-usage when showMemoryUsage is true', () => {
const settings = createMockSettings({
ui: { showMemoryUsage: true, footer: { hideContextPercentage: true } },
}).merged;
const items = deriveItemsFromLegacySettings(settings);
expect(items).toContain('memory-usage');
});
it('handles combination of settings', () => {
const settings = createMockSettings({
ui: {
showMemoryUsage: true,
footer: {
hideCWD: true,
hideModelInfo: true,
hideContextPercentage: false,
},
},
}).merged;
const items = deriveItemsFromLegacySettings(settings);
expect(items).toEqual([
'git-branch',
'sandbox',
'context-used',
'memory-usage',
]);
});
});
describe('resolveFooterState', () => {
it('filters out auth item when showUserIdentity is false', () => {
const settings = createMockSettings({
ui: {
showUserIdentity: false,
footer: {
items: ['workspace', 'auth', 'model-name'],
},
},
}).merged;
const state = resolveFooterState(settings);
expect(state.orderedIds).not.toContain('auth');
expect(state.selectedIds.has('auth')).toBe(false);
// It should also not be in the 'others' part of orderedIds
expect(state.orderedIds).toEqual([
'workspace',
'model-name',
'git-branch',
'sandbox',
'context-used',
'quota',
'memory-usage',
'session-id',
'code-changes',
'token-count',
]);
});
it('includes auth item when showUserIdentity is true', () => {
const settings = createMockSettings({
ui: {
showUserIdentity: true,
footer: {
items: ['workspace', 'auth', 'model-name'],
},
},
}).merged;
const state = resolveFooterState(settings);
expect(state.orderedIds).toContain('auth');
expect(state.selectedIds.has('auth')).toBe(true);
});
it('includes auth item by default when showUserIdentity is undefined (defaults to true)', () => {
const settings = createMockSettings({
ui: {
footer: {
items: ['workspace', 'auth', 'model-name'],
},
},
}).merged;
const state = resolveFooterState(settings);
expect(state.orderedIds).toContain('auth');
expect(state.selectedIds.has('auth')).toBe(true);
});
});
});
+17 -2
View File
@@ -47,6 +47,11 @@ export const ALL_ITEMS = [
header: 'session',
description: 'Unique identifier for the current session',
},
{
id: 'auth',
header: '/auth',
description: 'Current authentication info',
},
{
id: 'code-changes',
header: 'diff',
@@ -70,6 +75,7 @@ export const DEFAULT_ORDER = [
'quota',
'memory-usage',
'session-id',
'auth',
'code-changes',
'token-count',
];
@@ -121,10 +127,19 @@ export function resolveFooterState(settings: MergedSettings): {
orderedIds: string[];
selectedIds: Set<string>;
} {
const showUserIdentity = settings.ui?.showUserIdentity !== false;
const filteredValidIds = showUserIdentity
? VALID_IDS
: new Set([...VALID_IDS].filter((id) => id !== 'auth'));
const source = (
settings.ui?.footer?.items ?? deriveItemsFromLegacySettings(settings)
).filter((id: string) => VALID_IDS.has(id));
const others = DEFAULT_ORDER.filter((id) => !source.includes(id));
).filter((id: string) => filteredValidIds.has(id));
const others = DEFAULT_ORDER.filter(
(id) => !source.includes(id) && filteredValidIds.has(id),
);
return {
orderedIds: [...source, ...others],
selectedIds: new Set(source),
+23 -4
View File
@@ -1124,15 +1124,15 @@ function migrateExperimentalSettings(
};
let modified = false;
const migrateExperimental = (
const migrateExperimental = <T = Record<string, unknown>>(
oldKey: string,
migrateFn: (oldValue: Record<string, unknown>) => void,
migrateFn: (oldValue: T) => void,
) => {
const old = experimentalSettings[oldKey];
if (old) {
if (old !== undefined) {
foundDeprecated?.push(`experimental.${oldKey}`);
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
migrateFn(old as Record<string, unknown>);
migrateFn(old as T);
modified = true;
}
};
@@ -1197,6 +1197,24 @@ function migrateExperimentalSettings(
agentsOverrides['cli_help'] = override;
});
// Migrate experimental.plan -> general.plan.enabled
migrateExperimental<boolean>('plan', (planValue) => {
const generalSettings =
(settings.general as Record<string, unknown> | undefined) || {};
const newGeneral = { ...generalSettings };
const planSettings =
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
(newGeneral['plan'] as Record<string, unknown> | undefined) || {};
const newPlan = { ...planSettings };
if (newPlan['enabled'] === undefined) {
newPlan['enabled'] = planValue;
newGeneral['plan'] = newPlan;
loadedSettings.setValue(scope, 'general', newGeneral);
modified = true;
}
});
if (modified) {
agentsSettings['overrides'] = agentsOverrides;
loadedSettings.setValue(scope, 'agents', agentsSettings);
@@ -1205,6 +1223,7 @@ function migrateExperimentalSettings(
const newExperimental = { ...experimentalSettings };
delete newExperimental['codebaseInvestigatorSettings'];
delete newExperimental['cliHelpAgentSettings'];
delete newExperimental['plan'];
loadedSettings.setValue(scope, 'experimental', newExperimental);
}
return true;
@@ -418,14 +418,17 @@ describe('SettingsSchema', () => {
});
it('should have plan setting in schema', () => {
const setting = getSettingsSchema().experimental.properties.plan;
const setting =
getSettingsSchema().general.properties.plan.properties.enabled;
expect(setting).toBeDefined();
expect(setting.type).toBe('boolean');
expect(setting.category).toBe('Experimental');
expect(setting.category).toBe('General');
expect(setting.default).toBe(true);
expect(setting.requiresRestart).toBe(true);
expect(setting.showInDialog).toBe(true);
expect(setting.description).toBe('Enable Plan Mode.');
expect(setting.description).toBe(
'Enable Plan Mode for read-only safety during planning.',
);
});
it('should have hooksConfig.notifications setting in schema', () => {
+135 -43
View File
@@ -293,6 +293,16 @@ const SETTINGS_SCHEMA = {
description: 'Planning features configuration.',
showInDialog: false,
properties: {
enabled: {
type: 'boolean',
label: 'Enable Plan Mode',
category: 'General',
requiresRestart: true,
default: true,
description:
'Enable Plan Mode for read-only safety during planning.',
showInDialog: true,
},
directory: {
type: 'string',
label: 'Plan Directory',
@@ -561,6 +571,16 @@ const SETTINGS_SCHEMA = {
description: 'Show the "? for shortcuts" hint above the input.',
showInDialog: true,
},
compactToolOutput: {
type: 'boolean',
label: 'Compact Tool Output',
category: 'UI',
requiresRestart: false,
default: false,
description:
'Display tool outputs (like directory listings and file reads) in a compact, structured format.',
showInDialog: true,
},
hideBanner: {
type: 'boolean',
label: 'Hide Banner',
@@ -2060,15 +2080,6 @@ const SETTINGS_SCHEMA = {
'Use OSC 52 for copying. This may be more robust than the default system when using remote terminal sessions (if your terminal is configured to allow it).',
showInDialog: true,
},
plan: {
type: 'boolean',
label: 'Plan',
category: 'Experimental',
requiresRestart: true,
default: true,
description: 'Enable Plan Mode.',
showInDialog: true,
},
taskTracker: {
type: 'boolean',
label: 'Task Tracker',
@@ -2169,44 +2180,13 @@ const SETTINGS_SCHEMA = {
'Replace the built-in save_memory tool with a memory manager subagent that supports adding, removing, de-duplicating, and organizing memories.',
showInDialog: true,
},
agentHistoryTruncation: {
contextManagement: {
type: 'boolean',
label: 'Agent History Truncation',
label: 'Enable Context Management',
category: 'Experimental',
requiresRestart: true,
default: false,
description:
'Enable truncation window logic for the Agent History Provider.',
showInDialog: true,
},
agentHistoryTruncationThreshold: {
type: 'number',
label: 'Agent History Truncation Threshold',
category: 'Experimental',
requiresRestart: true,
default: 30,
description:
'The maximum number of messages before history is truncated.',
showInDialog: true,
},
agentHistoryRetainedMessages: {
type: 'number',
label: 'Agent History Retained Messages',
category: 'Experimental',
requiresRestart: true,
default: 15,
description:
'The number of recent messages to retain after truncation.',
showInDialog: true,
},
agentHistorySummarization: {
type: 'boolean',
label: 'Agent History Summarization',
category: 'Experimental',
requiresRestart: true,
default: false,
description:
'Enable summarization of truncated content via a small model for the Agent History Provider.',
description: 'Enable logic for context management.',
showInDialog: true,
},
topicUpdateNarration: {
@@ -2485,6 +2465,118 @@ const SETTINGS_SCHEMA = {
},
},
contextManagement: {
type: 'object',
label: 'Context Management',
category: 'Experimental',
requiresRestart: true,
default: {},
description:
'Settings for agent history and tool distillation context management.',
showInDialog: false,
properties: {
historyWindow: {
type: 'object',
label: 'History Window Settings',
category: 'Context Management',
requiresRestart: true,
default: {},
showInDialog: false,
properties: {
maxTokens: {
type: 'number',
label: 'Max Tokens',
category: 'Context Management',
requiresRestart: true,
default: 150_000,
description:
'The number of tokens to allow before triggering compression.',
showInDialog: false,
},
retainedTokens: {
type: 'number',
label: 'Retained Tokens',
category: 'Context Management',
requiresRestart: true,
default: 40_000,
description: 'The number of tokens to always retain.',
showInDialog: false,
},
},
},
messageLimits: {
type: 'object',
label: 'Message Limits',
category: 'Context Management',
requiresRestart: true,
default: {},
showInDialog: false,
properties: {
normalMaxTokens: {
type: 'number',
label: 'Normal Maximum Tokens',
category: 'Context Management',
requiresRestart: true,
default: 2500,
description:
'The target number of tokens to budget for a normal conversation turn.',
showInDialog: false,
},
retainedMaxTokens: {
type: 'number',
label: 'Retained Maximum Tokens',
category: 'Context Management',
requiresRestart: true,
default: 12000,
description:
'The maximum number of tokens a single conversation turn can consume before truncation.',
showInDialog: false,
},
normalizationHeadRatio: {
type: 'number',
label: 'Normalization Head Ratio',
category: 'Context Management',
requiresRestart: true,
default: 0.25,
description:
'The ratio of tokens to retain from the beginning of a truncated message (0.0 to 1.0).',
showInDialog: false,
},
},
},
toolDistillation: {
type: 'object',
label: 'Tool Distillation',
category: 'Context Management',
requiresRestart: true,
default: {},
showInDialog: false,
properties: {
maxOutputTokens: {
type: 'number',
label: 'Max Output Tokens',
category: 'Context Management',
requiresRestart: true,
default: 10_000,
description:
'Maximum tokens to show when truncating large tool outputs.',
showInDialog: false,
},
summarizationThresholdTokens: {
type: 'number',
label: 'Tool Summarization Threshold',
category: 'Context Management',
requiresRestart: true,
default: 20_000,
description:
'Threshold above which truncated tool outputs will be summarized by an LLM.',
showInDialog: false,
},
},
},
},
},
admin: {
type: 'object',
label: 'Admin',
+2
View File
@@ -46,6 +46,7 @@ import { TerminalProvider } from './ui/contexts/TerminalContext.js';
import { isAlternateBufferEnabled } from './ui/hooks/useAlternateBuffer.js';
import { OverflowProvider } from './ui/contexts/OverflowContext.js';
import { profiler } from './ui/components/DebugProfiler.js';
import { initializeConsoleStore } from './ui/hooks/useConsoleMessages.js';
const SLOW_RENDER_MS = 200;
@@ -57,6 +58,7 @@ export async function startInteractiveUI(
resumedSessionData: ResumedSessionData | undefined,
initializationResult: InitializationResult,
) {
initializeConsoleStore();
// Never enter Ink alternate buffer mode when screen reader mode is enabled
// as there is no benefit of alternate buffer mode when using a screen reader
// and the Ink alternate buffer mode requires line wrapping harmful to
+11
View File
@@ -194,6 +194,17 @@ export function createMockSettings(
user: { settings: {} },
workspace: { settings: {} },
errors: [],
subscribe: vi.fn().mockReturnValue(() => {}),
getSnapshot: vi.fn().mockReturnValue({
system: { settings: {} },
systemDefaults: { settings: {} },
user: { settings: {} },
workspace: { settings: {} },
isTrusted: true,
errors: [],
merged,
}),
setValue: vi.fn(),
...overrides,
merged,
} as unknown as LoadedSettings;
+16 -1
View File
@@ -534,7 +534,6 @@ export const mockAppState: AppState = {
};
const mockUIActions: UIActions = {
toggleAlternateBuffer: vi.fn(),
handleThemeSelect: vi.fn(),
closeThemeDialog: vi.fn(),
handleThemeHighlight: vi.fn(),
@@ -614,6 +613,7 @@ export const renderWithProviders = async (
mouseEventsEnabled = false,
config,
uiActions,
toolActions,
persistentState,
appState = mockAppState,
}: {
@@ -624,6 +624,11 @@ export const renderWithProviders = async (
mouseEventsEnabled?: boolean;
config?: Config;
uiActions?: Partial<UIActions>;
toolActions?: Partial<{
isExpanded: (callId: string) => boolean;
toggleExpansion: (callId: string) => void;
toggleAllExpansion: (callIds: string[]) => void;
}>;
persistentState?: {
get?: typeof persistentStateMock.get;
set?: typeof persistentStateMock.set;
@@ -711,6 +716,16 @@ export const renderWithProviders = async (
<ToolActionsProvider
config={config}
toolCalls={allToolCalls}
isExpanded={
toolActions?.isExpanded ??
vi.fn().mockReturnValue(false)
}
toggleExpansion={
toolActions?.toggleExpansion ?? vi.fn()
}
toggleAllExpansion={
toolActions?.toggleAllExpansion ?? vi.fn()
}
>
<AskUserActionsProvider
request={null}
+67 -71
View File
@@ -68,10 +68,8 @@ import {
writeToStdout,
disableMouseEvents,
enterAlternateScreen,
exitAlternateScreen,
enableMouseEvents,
disableLineWrapping,
enableLineWrapping,
shouldEnterAlternateScreen,
startupProfiler,
SessionStartSource,
@@ -170,6 +168,7 @@ import { useSuspend } from './hooks/useSuspend.js';
import { useRunEventNotifications } from './hooks/useRunEventNotifications.js';
import { isNotificationsEnabled } from '../utils/terminalNotifications.js';
import {
getLastTurnToolCallIds,
isToolExecuting,
isToolAwaitingConfirmation,
getAllToolCalls,
@@ -215,7 +214,7 @@ export const AppContainer = (props: AppContainerProps) => {
});
useMemoryMonitor(historyManager);
const [isAlternateBuffer, setIsAlternateBuffer] = useState(config.getUseAlternateBuffer());
const isAlternateBuffer = config.getUseAlternateBuffer();
const [corgiMode, setCorgiMode] = useState(false);
const [forceRerenderKey, setForceRerenderKey] = useState(0);
const [debugMessage, setDebugMessage] = useState<string>('');
@@ -240,6 +239,39 @@ export const AppContainer = (props: AppContainerProps) => {
const [adminSettingsChanged, setAdminSettingsChanged] = useState(false);
const [expandedTools, setExpandedTools] = useState<Set<string>>(new Set());
const toggleExpansion = useCallback((callId: string) => {
setExpandedTools((prev) => {
const next = new Set(prev);
if (next.has(callId)) {
next.delete(callId);
} else {
next.add(callId);
}
return next;
});
}, []);
const toggleAllExpansion = useCallback((callIds: string[]) => {
setExpandedTools((prev) => {
const next = new Set(prev);
const anyCollapsed = callIds.some((id) => !next.has(id));
if (anyCollapsed) {
callIds.forEach((id) => next.add(id));
} else {
callIds.forEach((id) => next.delete(id));
}
return next;
});
}, []);
const isExpanded = useCallback(
(callId: string) => expandedTools.has(callId),
[expandedTools],
);
const [shellModeActive, setShellModeActive] = useState(false);
const [modelSwitchedFromQuotaError, setModelSwitchedFromQuotaError] =
useState<boolean>(false);
@@ -995,6 +1027,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
if (config.isJitContextEnabled()) {
await config.getContextManager()?.refresh();
config.updateSystemInstructionIfInitialized();
flattenedMemory = flattenMemory(config.getUserMemory());
fileCount = config.getGeminiMdFileCount();
} else {
@@ -1139,11 +1172,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
[pendingSlashCommandHistoryItems, pendingGeminiHistoryItems],
);
const hasPendingToolConfirmation = useMemo(
() => isToolAwaitingConfirmation(pendingHistoryItems),
[pendingHistoryItems],
);
toggleBackgroundTasksRef.current = toggleBackgroundTasks;
isBackgroundTaskVisibleRef.current = isBackgroundTaskVisible;
backgroundTasksRef.current = backgroundTasks;
@@ -1552,23 +1580,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
type: TransientMessageType;
}>(WARNING_PROMPT_DURATION_MS);
const [shownBufferToggleHint, setShownBufferToggleHint] = useState(false);
useEffect(() => {
if (isAlternateBuffer) return;
const isLongHistory = historyManager.history.length > 15;
const isComplexPrompt = buffer.text.length > 200 || buffer.text.includes('\n');
if ((isLongHistory || isComplexPrompt) && !shownBufferToggleHint) {
showTransientMessage({
text: 'Tip: Press Alt+T to toggle full-screen mode for better scrolling/editing',
type: TransientMessageType.Hint
});
setShownBufferToggleHint(true);
}
}, [historyManager.history.length, buffer.text, isAlternateBuffer, shownBufferToggleHint, showTransientMessage]);
const {
isFolderTrustDialogOpen,
discoveryResults: folderDiscoveryResults,
@@ -1719,11 +1730,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
return true;
}
if (keyMatchers[Command.TOGGLE_BUFFER_MODE](key)) {
toggleAlternateBuffer();
return true;
}
if (keyMatchers[Command.QUIT](key)) {
// If the user presses Ctrl+C, we want to cancel any ongoing requests.
// This should happen regardless of the count.
@@ -1751,13 +1757,25 @@ Logging in with Google... Restarting Gemini CLI to continue.
return true;
}
const toggleLastTurnTools = () => {
triggerExpandHint(true);
const targetToolCallIds = getLastTurnToolCallIds(
historyManager.history,
pendingHistoryItems,
);
if (targetToolCallIds.length > 0) {
toggleAllExpansion(targetToolCallIds);
}
};
let enteringConstrainHeightMode = false;
if (!constrainHeight) {
enteringConstrainHeightMode = true;
setConstrainHeight(true);
if (keyMatchers[Command.SHOW_MORE_LINES](key)) {
// If the user manually collapses the view, show the hint and reset the x-second timer.
triggerExpandHint(true);
toggleLastTurnTools();
}
if (!isAlternateBuffer) {
refreshStatic();
@@ -1805,11 +1823,8 @@ Logging in with Google... Restarting Gemini CLI to continue.
!enteringConstrainHeightMode
) {
setConstrainHeight(false);
// If the user manually expands the view, show the hint and reset the x-second timer.
triggerExpandHint(true);
if (!isAlternateBuffer) {
refreshStatic();
}
toggleLastTurnTools();
refreshStatic();
return true;
} else if (
(keyMatchers[Command.FOCUS_SHELL_INPUT](key) ||
@@ -1914,6 +1929,9 @@ Logging in with Google... Restarting Gemini CLI to continue.
triggerExpandHint,
keyMatchers,
isHelpDismissKey,
historyManager.history,
pendingHistoryItems,
toggleAllExpansion,
],
);
@@ -2057,6 +2075,11 @@ Logging in with Google... Restarting Gemini CLI to continue.
authState === AuthState.AwaitingApiKeyInput ||
!!newAgents;
const hasPendingToolConfirmation = useMemo(
() => isToolAwaitingConfirmation(pendingHistoryItems),
[pendingHistoryItems],
);
const hasConfirmUpdateExtensionRequests =
confirmUpdateExtensionRequests.length > 0;
const hasLoopDetectionConfirmationRequest =
@@ -2228,11 +2251,8 @@ Logging in with Google... Restarting Gemini CLI to continue.
};
}, [config, refreshStatic]);
const showIsAlternateBufferHint = (historyManager.history.length > 15 || buffer.text.length > 200 || buffer.text.includes('\n')) && !isAlternateBuffer;
const uiState: UIState = useMemo(
() => ({
isAlternateBuffer,
history: historyManager.history,
historyManager,
isThemeDialogOpen,
@@ -2358,7 +2378,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
adminSettingsChanged,
newAgents,
showIsExpandableHint,
showIsAlternateBufferHint,
hintMode:
config.isModelSteeringEnabled() && isToolExecuting(pendingHistoryItems),
hintBuffer: '',
@@ -2485,8 +2504,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
adminSettingsChanged,
newAgents,
showIsExpandableHint,
showIsAlternateBufferHint,
isAlternateBuffer,
],
);
@@ -2495,31 +2512,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
[setShowPrivacyNotice],
);
const toggleAlternateBuffer = useCallback(() => {
setIsAlternateBuffer(prev => {
const next = !prev;
if (next) {
enterAlternateScreen();
enableMouseEvents();
disableLineWrapping();
} else {
exitAlternateScreen();
disableMouseEvents();
enableLineWrapping();
writeToStdout('\x1b[2J\x1b[H');
}
process.stdout.emit('resize');
// Give a tick for resize to process, then trigger remount to force full redraw
setImmediate(() => {
refreshStatic();
setForceRerenderKey((prev) => prev + 1);
});
return next;
});
}, [setIsAlternateBuffer, refreshStatic, setForceRerenderKey]);
const uiActions: UIActions = useMemo(
() => ({
handleThemeSelect,
@@ -2531,7 +2523,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
handleEditorSelect,
exitEditorDialog,
exitPrivacyNotice,
toggleAlternateBuffer,
closeSettingsDialog,
closeModelDialog,
openAgentConfigDialog,
@@ -2670,7 +2661,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
config,
historyManager,
getPreferredEditor,
toggleAlternateBuffer,
],
);
@@ -2696,7 +2686,13 @@ Logging in with Google... Restarting Gemini CLI to continue.
startupWarnings: props.startupWarnings || [],
}}
>
<ToolActionsProvider config={config} toolCalls={allToolCalls}>
<ToolActionsProvider
config={config}
toolCalls={allToolCalls}
isExpanded={isExpanded}
toggleExpansion={toggleExpansion}
toggleAllExpansion={toggleAllExpansion}
>
<ShellFocusContext.Provider value={isFocused}>
<App key={`app-${forceRerenderKey}`} />
</ShellFocusContext.Provider>
@@ -9,11 +9,7 @@ import { policiesCommand } from './policiesCommand.js';
import { CommandKind } from './types.js';
import { MessageType } from '../types.js';
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
import {
type Config,
PolicyDecision,
ApprovalMode,
} from '@google/gemini-cli-core';
import { PolicyDecision, type AgentLoopContext } from '@google/gemini-cli-core';
describe('policiesCommand', () => {
let mockContext: ReturnType<typeof createMockCommandContext>;
@@ -27,15 +23,17 @@ describe('policiesCommand', () => {
expect(policiesCommand.description).toBe('Manage policies');
expect(policiesCommand.kind).toBe(CommandKind.BUILT_IN);
expect(policiesCommand.subCommands).toHaveLength(1);
expect(policiesCommand.subCommands![0].name).toBe('list');
expect(policiesCommand.subCommands?.[0].name).toBe('list');
});
describe('list subcommand', () => {
it('should show error if config is missing', async () => {
mockContext.services.agentContext = null;
const listCommand = policiesCommand.subCommands![0];
const listCommand = policiesCommand.subCommands?.[0];
if (!listCommand?.action)
throw new Error('list subcommand action missing');
await listCommand.action!(mockContext, '');
await listCommand.action(mockContext, '');
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({
@@ -50,26 +48,75 @@ describe('policiesCommand', () => {
const mockPolicyEngine = {
getRules: vi.fn().mockReturnValue([]),
};
mockContext.services.agentContext = {
const mockConfig = {
getPolicyEngine: vi.fn().mockReturnValue(mockPolicyEngine),
get config() {
return this;
},
} as unknown as Config;
getToolRegistry: vi.fn().mockReturnValue({
getTool: vi.fn().mockReturnValue(undefined),
}),
};
mockContext.services.agentContext = {
config: mockConfig,
} as unknown as AgentLoopContext;
const listCommand = policiesCommand.subCommands![0];
await listCommand.action!(mockContext, '');
const listCommand = policiesCommand.subCommands?.[0];
if (!listCommand?.action)
throw new Error('list subcommand action missing');
await listCommand.action(mockContext, '');
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageType.INFO,
text: 'No active policies.',
text: 'No custom policies configured.',
}),
expect.any(Number),
);
});
it('should list policies grouped by mode', async () => {
it('should show no-policies message when only default policies exist', async () => {
const mockRules = [
{
decision: PolicyDecision.ALLOW,
toolName: 'glob',
priority: 1.05,
source: 'Default: read-only.toml',
},
{
decision: PolicyDecision.ASK_USER,
toolName: 'run_shell_command',
priority: 1.01,
source: 'Default: write.toml',
},
];
const mockPolicyEngine = {
getRules: vi.fn().mockReturnValue(mockRules),
};
const mockConfig = {
getPolicyEngine: vi.fn().mockReturnValue(mockPolicyEngine),
getToolRegistry: vi.fn().mockReturnValue({
getTool: vi.fn().mockReturnValue(undefined),
}),
};
mockContext.services.agentContext = {
config: mockConfig,
} as unknown as AgentLoopContext;
const listCommand = policiesCommand.subCommands?.[0];
if (!listCommand?.action)
throw new Error('list subcommand action missing');
await listCommand.action(mockContext, '');
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageType.INFO,
text: 'No custom policies configured.',
}),
expect.any(Number),
);
});
it('should return custom_dialog when policies exist', async () => {
const mockRules = [
{
decision: PolicyDecision.DENY,
@@ -88,89 +135,125 @@ describe('policiesCommand', () => {
const mockPolicyEngine = {
getRules: vi.fn().mockReturnValue(mockRules),
};
mockContext.services.agentContext = {
const mockToolRegistry = {
getTool: vi.fn().mockReturnValue(undefined),
};
const mockConfig = {
getPolicyEngine: vi.fn().mockReturnValue(mockPolicyEngine),
get config() {
return this;
},
} as unknown as Config;
getToolRegistry: vi.fn().mockReturnValue(mockToolRegistry),
};
mockContext.services.agentContext = {
config: mockConfig,
} as unknown as AgentLoopContext;
const listCommand = policiesCommand.subCommands![0];
await listCommand.action!(mockContext, '');
const listCommand = policiesCommand.subCommands?.[0];
if (!listCommand?.action)
throw new Error('list subcommand action missing');
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageType.INFO,
text: expect.stringContaining('**Active Policies**'),
}),
expect.any(Number),
);
const result = await listCommand.action(mockContext, '');
const call = vi.mocked(mockContext.ui.addItem).mock.calls[0];
const content = (call[0] as { text: string }).text;
expect(content).toContain('### Normal Mode Policies');
expect(content).toContain(
'### Auto Edit Mode Policies (combined with normal mode policies)',
);
expect(content).toContain(
'### Yolo Mode Policies (combined with normal mode policies)',
);
expect(content).toContain(
'### Plan Mode Policies (combined with normal mode policies)',
);
expect(content).toContain(
'**DENY** tool: `dangerousTool` [Priority: 10]',
);
expect(content).toContain(
'**ALLOW** all tools (args match: `safe`) [Source: test.toml]',
);
expect(content).toContain('**ASK_USER** all tools');
expect(result).toMatchObject({
type: 'custom_dialog',
});
expect(result).toHaveProperty('component');
});
it('should show plan-only rules in plan mode section', async () => {
it('should populate toolDisplayNames from tool registry', async () => {
const mockRules = [
{
decision: PolicyDecision.ALLOW,
toolName: 'glob',
priority: 70,
modes: [ApprovalMode.PLAN],
},
{
decision: PolicyDecision.DENY,
priority: 60,
modes: [ApprovalMode.PLAN],
toolName: 'run_shell_command',
priority: 5,
},
{
decision: PolicyDecision.ALLOW,
toolName: 'shell',
priority: 50,
toolName: 'glob',
priority: 3,
},
];
const mockPolicyEngine = {
getRules: vi.fn().mockReturnValue(mockRules),
};
mockContext.services.agentContext = {
const mockToolRegistry = {
getTool: vi.fn().mockImplementation((name: string) => {
const displayNames: Record<string, string> = {
run_shell_command: 'Shell',
glob: 'FindFiles',
};
if (displayNames[name]) {
return { displayName: displayNames[name] };
}
return undefined;
}),
};
const mockConfig = {
getPolicyEngine: vi.fn().mockReturnValue(mockPolicyEngine),
get config() {
return this;
},
} as unknown as Config;
getToolRegistry: vi.fn().mockReturnValue(mockToolRegistry),
};
mockContext.services.agentContext = {
config: mockConfig,
} as unknown as AgentLoopContext;
const listCommand = policiesCommand.subCommands![0];
await listCommand.action!(mockContext, '');
const listCommand = policiesCommand.subCommands?.[0];
if (!listCommand?.action)
throw new Error('list subcommand action missing');
const call = vi.mocked(mockContext.ui.addItem).mock.calls[0];
const content = (call[0] as { text: string }).text;
const result = await listCommand.action(mockContext, '');
// Plan-only rules appear under Plan Mode section
expect(content).toContain(
'### Plan Mode Policies (combined with normal mode policies)',
expect(result).toMatchObject({ type: 'custom_dialog' });
// Verify getTool was called for each unique toolName
expect(mockToolRegistry.getTool).toHaveBeenCalledWith(
'run_shell_command',
);
expect(mockToolRegistry.getTool).toHaveBeenCalledWith('glob');
});
});
describe('parent command', () => {
it('should also return custom_dialog when policies exist', async () => {
const mockRules = [
{
decision: PolicyDecision.ALLOW,
toolName: 'glob',
priority: 5,
},
];
const mockPolicyEngine = {
getRules: vi.fn().mockReturnValue(mockRules),
};
const mockToolRegistry = {
getTool: vi.fn().mockReturnValue(undefined),
};
const mockConfig = {
getPolicyEngine: vi.fn().mockReturnValue(mockPolicyEngine),
getToolRegistry: vi.fn().mockReturnValue(mockToolRegistry),
};
mockContext.services.agentContext = {
config: mockConfig,
} as unknown as AgentLoopContext;
if (!policiesCommand.action)
throw new Error('policiesCommand action missing');
const result = await policiesCommand.action(mockContext, '');
expect(result).toMatchObject({ type: 'custom_dialog' });
expect(result).toHaveProperty('component');
});
it('should show error if config is missing', async () => {
mockContext.services.agentContext = null;
if (!policiesCommand.action)
throw new Error('policiesCommand action missing');
await policiesCommand.action(mockContext, '');
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageType.ERROR,
text: 'Error: Config not available.',
}),
expect.any(Number),
);
// glob ALLOW is plan-only, should appear in plan section
expect(content).toContain('**ALLOW** tool: `glob` [Priority: 70]');
// shell ALLOW has no modes (applies to all), appears in normal section
expect(content).toContain('**ALLOW** tool: `shell` [Priority: 50]');
});
});
});
@@ -1,124 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { ApprovalMode, type PolicyRule } from '@google/gemini-cli-core';
import { CommandKind, type SlashCommand } from './types.js';
import { MessageType } from '../types.js';
interface CategorizedRules {
normal: PolicyRule[];
autoEdit: PolicyRule[];
yolo: PolicyRule[];
plan: PolicyRule[];
}
const categorizeRulesByMode = (
rules: readonly PolicyRule[],
): CategorizedRules => {
const result: CategorizedRules = {
normal: [],
autoEdit: [],
yolo: [],
plan: [],
};
const ALL_MODES = Object.values(ApprovalMode);
rules.forEach((rule) => {
const modes = rule.modes?.length ? rule.modes : ALL_MODES;
const modeSet = new Set(modes);
if (modeSet.has(ApprovalMode.DEFAULT)) result.normal.push(rule);
if (modeSet.has(ApprovalMode.AUTO_EDIT)) result.autoEdit.push(rule);
if (modeSet.has(ApprovalMode.YOLO)) result.yolo.push(rule);
if (modeSet.has(ApprovalMode.PLAN)) result.plan.push(rule);
});
return result;
};
const formatRule = (rule: PolicyRule, i: number) =>
`${i + 1}. **${rule.decision.toUpperCase()}** ${rule.toolName ? `tool: \`${rule.toolName}\`` : 'all tools'}` +
(rule.argsPattern ? ` (args match: \`${rule.argsPattern.source}\`)` : '') +
(rule.priority !== undefined ? ` [Priority: ${rule.priority}]` : '') +
(rule.source ? ` [Source: ${rule.source}]` : '');
const formatSection = (title: string, rules: PolicyRule[]) =>
`### ${title}\n${rules.length ? rules.map(formatRule).join('\n') : '_No policies._'}\n\n`;
const listPoliciesCommand: SlashCommand = {
name: 'list',
description: 'List all active policies grouped by mode',
kind: CommandKind.BUILT_IN,
autoExecute: true,
action: async (context) => {
const agentContext = context.services.agentContext;
const config = agentContext?.config;
if (!config) {
context.ui.addItem(
{
type: MessageType.ERROR,
text: 'Error: Config not available.',
},
Date.now(),
);
return;
}
const policyEngine = config.getPolicyEngine();
const rules = policyEngine.getRules();
if (rules.length === 0) {
context.ui.addItem(
{
type: MessageType.INFO,
text: 'No active policies.',
},
Date.now(),
);
return;
}
const categorized = categorizeRulesByMode(rules);
const normalRulesSet = new Set(categorized.normal);
const uniqueAutoEdit = categorized.autoEdit.filter(
(rule) => !normalRulesSet.has(rule),
);
const uniqueYolo = categorized.yolo.filter(
(rule) => !normalRulesSet.has(rule),
);
const uniquePlan = categorized.plan.filter(
(rule) => !normalRulesSet.has(rule),
);
let content = '**Active Policies**\n\n';
content += formatSection('Normal Mode Policies', categorized.normal);
content += formatSection(
'Auto Edit Mode Policies (combined with normal mode policies)',
uniqueAutoEdit,
);
content += formatSection(
'Yolo Mode Policies (combined with normal mode policies)',
uniqueYolo,
);
content += formatSection(
'Plan Mode Policies (combined with normal mode policies)',
uniquePlan,
);
context.ui.addItem(
{
type: MessageType.INFO,
text: content,
},
Date.now(),
);
},
};
export const policiesCommand: SlashCommand = {
name: 'policies',
description: 'Manage policies',
kind: CommandKind.BUILT_IN,
autoExecute: false,
subCommands: [listPoliciesCommand],
};
@@ -0,0 +1,94 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type { Config, PolicyRule } from '@google/gemini-cli-core';
import { CommandKind, type SlashCommand } from './types.js';
import { MessageType } from '../types.js';
import { PoliciesDialog } from '../components/PoliciesDialog.js';
function buildToolDisplayNames(
rules: readonly PolicyRule[],
config: Config,
): Map<string, string> {
const toolDisplayNames = new Map<string, string>();
const toolRegistry = config.getToolRegistry();
for (const rule of rules) {
if (rule.toolName && !toolDisplayNames.has(rule.toolName)) {
const tool = toolRegistry.getTool(rule.toolName);
if (tool) {
toolDisplayNames.set(rule.toolName, tool.displayName);
}
}
}
return toolDisplayNames;
}
const policiesDialogAction: NonNullable<SlashCommand['action']> = async (
context,
) => {
const { agentContext } = context.services;
const config = agentContext?.config;
if (!config) {
context.ui.addItem(
{
type: MessageType.ERROR,
text: 'Error: Config not available.',
},
Date.now(),
);
return;
}
const policyEngine = config.getPolicyEngine();
const allRules = policyEngine.getRules();
// Filter out built-in default policies — users only care about rules they
// (or their team / admin / extensions) configured.
const rules = allRules.filter(
(rule) => !rule.source?.startsWith('Default: '),
);
if (rules.length === 0) {
context.ui.addItem(
{
type: MessageType.INFO,
text: 'No custom policies configured.',
},
Date.now(),
);
return;
}
const toolDisplayNames = buildToolDisplayNames(rules, config);
return {
type: 'custom_dialog' as const,
component: (
<PoliciesDialog
rules={rules}
toolDisplayNames={toolDisplayNames}
onClose={() => context.ui.removeComponent()}
/>
),
};
};
const listPoliciesCommand: SlashCommand = {
name: 'list',
description: 'List all active policies grouped by mode',
kind: CommandKind.BUILT_IN,
autoExecute: true,
action: policiesDialogAction,
};
export const policiesCommand: SlashCommand = {
name: 'policies',
description: 'Manage policies',
kind: CommandKind.BUILT_IN,
autoExecute: false,
action: policiesDialogAction,
subCommands: [listPoliciesCommand],
};
@@ -35,10 +35,7 @@ vi.mock('./shared/ScrollableList.js', () => ({
describe('DetailedMessagesDisplay', () => {
beforeEach(() => {
vi.mocked(useConsoleMessages).mockReturnValue({
consoleMessages: [],
clearConsoleMessages: vi.fn(),
});
vi.mocked(useConsoleMessages).mockReturnValue([]);
});
it('renders nothing when messages are empty', async () => {
const { lastFrame, unmount } = await renderWithProviders(
@@ -58,10 +55,7 @@ describe('DetailedMessagesDisplay', () => {
{ type: 'error', content: 'Error message', count: 1 },
{ type: 'debug', content: 'Debug message', count: 1 },
];
vi.mocked(useConsoleMessages).mockReturnValue({
consoleMessages: messages,
clearConsoleMessages: vi.fn(),
});
vi.mocked(useConsoleMessages).mockReturnValue(messages);
const { lastFrame, unmount } = await renderWithProviders(
<DetailedMessagesDisplay maxHeight={20} width={80} hasFocus={true} />,
@@ -79,10 +73,7 @@ describe('DetailedMessagesDisplay', () => {
const messages: ConsoleMessageItem[] = [
{ type: 'error', content: 'Error message', count: 1 },
];
vi.mocked(useConsoleMessages).mockReturnValue({
consoleMessages: messages,
clearConsoleMessages: vi.fn(),
});
vi.mocked(useConsoleMessages).mockReturnValue(messages);
const { lastFrame, unmount } = await renderWithProviders(
<DetailedMessagesDisplay maxHeight={20} width={80} hasFocus={true} />,
@@ -98,10 +89,7 @@ describe('DetailedMessagesDisplay', () => {
const messages: ConsoleMessageItem[] = [
{ type: 'error', content: 'Error message', count: 1 },
];
vi.mocked(useConsoleMessages).mockReturnValue({
consoleMessages: messages,
clearConsoleMessages: vi.fn(),
});
vi.mocked(useConsoleMessages).mockReturnValue(messages);
const { lastFrame, unmount } = await renderWithProviders(
<DetailedMessagesDisplay maxHeight={20} width={80} hasFocus={true} />,
@@ -117,10 +105,7 @@ describe('DetailedMessagesDisplay', () => {
const messages: ConsoleMessageItem[] = [
{ type: 'log', content: 'Repeated message', count: 5 },
];
vi.mocked(useConsoleMessages).mockReturnValue({
consoleMessages: messages,
clearConsoleMessages: vi.fn(),
});
vi.mocked(useConsoleMessages).mockReturnValue(messages);
const { lastFrame, unmount } = await renderWithProviders(
<DetailedMessagesDisplay maxHeight={10} width={80} hasFocus={false} />,
@@ -29,7 +29,7 @@ export const DetailedMessagesDisplay: React.FC<
> = ({ maxHeight, width, hasFocus }) => {
const scrollableListRef = useRef<ScrollableListRef<ConsoleMessageItem>>(null);
const { consoleMessages } = useConsoleMessages();
const consoleMessages = useConsoleMessages();
const config = useConfig();
const messages = useMemo(() => {
+79 -3
View File
@@ -8,7 +8,11 @@ import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
import { renderWithProviders } from '../../test-utils/render.js';
import { Footer } from './Footer.js';
import { createMockSettings } from '../../test-utils/settings.js';
import { type Config } from '@google/gemini-cli-core';
import {
type Config,
UserAccountManager,
AuthType,
} from '@google/gemini-cli-core';
import path from 'node:path';
// Normalize paths to POSIX slashes for stable cross-platform snapshots.
@@ -69,14 +73,17 @@ const defaultProps = {
branchName: 'main',
};
const mockConfig = {
const mockConfigPlain = {
getTargetDir: () => defaultProps.targetDir,
getDebugMode: () => false,
getModel: () => defaultProps.model,
getIdeMode: () => false,
isTrustedFolder: () => true,
getExtensionRegistryURI: () => undefined,
} as unknown as Config;
getContentGeneratorConfig: () => ({ authType: undefined }),
};
const mockConfig = mockConfigPlain as unknown as Config;
const mockSessionStats = {
sessionId: 'test-session-id',
@@ -675,6 +682,75 @@ describe('<Footer />', () => {
});
describe('Footer Custom Items', () => {
it('renders auth item with email', async () => {
const authConfig = {
...mockConfigPlain,
getContentGeneratorConfig: () => ({
authType: AuthType.LOGIN_WITH_GOOGLE,
}),
} as unknown as Config;
const getCachedAccountSpy = vi
.spyOn(UserAccountManager.prototype, 'getCachedGoogleAccount')
.mockReturnValue('test@example.com');
const { lastFrame, unmount } = await renderWithProviders(<Footer />, {
config: authConfig,
width: 120,
uiState: {
currentModel: 'gemini-pro',
sessionStats: mockSessionStats,
},
settings: createMockSettings({
ui: {
footer: {
items: ['auth'],
},
},
}),
});
expect(lastFrame()).toContain('auth');
expect(lastFrame()).toContain('test@example.com');
unmount();
getCachedAccountSpy.mockRestore();
});
it('does NOT render auth item when showUserIdentity is false', async () => {
const authConfig = {
...mockConfigPlain,
getContentGeneratorConfig: () => ({
authType: AuthType.LOGIN_WITH_GOOGLE,
}),
} as unknown as Config;
const getCachedAccountSpy = vi
.spyOn(UserAccountManager.prototype, 'getCachedGoogleAccount')
.mockReturnValue('test@example.com');
const { lastFrame, unmount } = await renderWithProviders(<Footer />, {
config: authConfig,
width: 120,
uiState: {
currentModel: 'gemini-pro',
sessionStats: mockSessionStats,
},
settings: createMockSettings({
ui: {
showUserIdentity: false,
footer: {
items: ['workspace', 'auth'],
},
},
}),
});
const output = lastFrame();
expect(output).toContain('workspace');
expect(output).not.toContain('auth');
expect(output).not.toContain('test@example.com');
unmount();
getCachedAccountSpy.mockRestore();
});
it('renders items in the specified order', async () => {
const { lastFrame, unmount } = await renderWithProviders(<Footer />, {
config: mockConfig,
+35
View File
@@ -5,6 +5,7 @@
*/
import type React from 'react';
import { useState, useEffect } from 'react';
import { Box, Text } from 'ink';
import { theme } from '../semantic-colors.js';
import {
@@ -12,6 +13,8 @@ import {
tildeifyPath,
getDisplayString,
checkExhaustive,
AuthType,
UserAccountManager,
} from '@google/gemini-cli-core';
import { ConsoleSummaryDisplay } from './ConsoleSummaryDisplay.js';
import process from 'node:process';
@@ -183,6 +186,18 @@ export const Footer: React.FC<{ copyModeEnabled?: boolean }> = ({
const settings = useSettings();
const { vimEnabled, vimMode } = useVimMode();
const authType = config.getContentGeneratorConfig()?.authType;
const [email, setEmail] = useState<string | undefined>();
useEffect(() => {
if (authType) {
const userAccountManager = new UserAccountManager();
setEmail(userAccountManager.getCachedGoogleAccount() ?? undefined);
} else {
setEmail(undefined);
}
}, [authType]);
if (copyModeEnabled) {
return <Box height={1} />;
}
@@ -221,6 +236,7 @@ export const Footer: React.FC<{ copyModeEnabled?: boolean }> = ({
errorCount > 0 &&
(isFullErrorVerbosity || debugMode || isDevelopment);
const displayVimMode = vimEnabled ? vimMode : undefined;
const items =
settings.merged.ui.footer.items ??
deriveItemsFromLegacySettings(settings.merged);
@@ -385,6 +401,25 @@ export const Footer: React.FC<{ copyModeEnabled?: boolean }> = ({
);
break;
}
case 'auth': {
if (!settings.merged.ui.showUserIdentity) break;
if (!authType) break;
const displayStr =
authType === AuthType.LOGIN_WITH_GOOGLE
? (email ?? 'google')
: authType;
addCol(
id,
header,
() => (
<Text color={itemColor} wrap="truncate-end">
{displayStr}
</Text>
),
displayStr.length,
);
break;
}
case 'code-changes': {
const added = uiState.sessionStats.metrics.files.totalLinesAdded;
const removed = uiState.sessionStats.metrics.files.totalLinesRemoved;
@@ -116,7 +116,7 @@ describe('<FooterConfigDialog />', () => {
expect(lastFrame()).toContain('~/project/path');
// Move focus down to 'code-changes' (which has colored elements)
for (let i = 0; i < 8; i++) {
for (let i = 0; i < 9; i++) {
act(() => {
stdin.write('\u001b[B'); // Down arrow
});
@@ -255,6 +255,7 @@ export const FooterConfigDialog: React.FC<FooterConfigDialogProps> = ({
<Text color={getColor('code-changes', theme.status.error)}>-4</Text>
</Box>
),
auth: <Text color={getColor('auth', itemColor)}>test@example.com</Text>,
'token-count': (
<Text color={getColor('token-count', itemColor)}>1.5k tokens</Text>
),
@@ -21,7 +21,6 @@ import {
PREVIEW_GEMINI_FLASH_MODEL,
PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL,
AuthType,
UserTierId,
} from '@google/gemini-cli-core';
import type { Config, ModelSlashCommandEvent } from '@google/gemini-cli-core';
@@ -56,7 +55,6 @@ describe('<ModelDialog />', () => {
const mockGetGemini31FlashLiteLaunchedSync = vi.fn();
const mockGetProModelNoAccess = vi.fn();
const mockGetProModelNoAccessSync = vi.fn();
const mockGetUserTier = vi.fn();
interface MockConfig extends Partial<Config> {
setModel: (model: string, isTemporary?: boolean) => void;
@@ -67,7 +65,6 @@ describe('<ModelDialog />', () => {
getGemini31FlashLiteLaunchedSync: () => boolean;
getProModelNoAccess: () => Promise<boolean>;
getProModelNoAccessSync: () => boolean;
getUserTier: () => UserTierId | undefined;
}
const mockConfig: MockConfig = {
@@ -79,7 +76,6 @@ describe('<ModelDialog />', () => {
getGemini31FlashLiteLaunchedSync: mockGetGemini31FlashLiteLaunchedSync,
getProModelNoAccess: mockGetProModelNoAccess,
getProModelNoAccessSync: mockGetProModelNoAccessSync,
getUserTier: mockGetUserTier,
};
beforeEach(() => {
@@ -90,7 +86,6 @@ describe('<ModelDialog />', () => {
mockGetGemini31FlashLiteLaunchedSync.mockReturnValue(false);
mockGetProModelNoAccess.mockResolvedValue(false);
mockGetProModelNoAccessSync.mockReturnValue(false);
mockGetUserTier.mockReturnValue(UserTierId.STANDARD);
// Default implementation for getDisplayString
mockGetDisplayString.mockImplementation((val: string) => {
@@ -136,7 +131,6 @@ describe('<ModelDialog />', () => {
mockGetProModelNoAccess.mockResolvedValue(true);
mockGetHasAccessToPreviewModel.mockReturnValue(true);
mockGetGemini31FlashLiteLaunchedSync.mockReturnValue(true);
mockGetUserTier.mockReturnValue(UserTierId.FREE);
mockGetDisplayString.mockImplementation((val: string) => val);
const { lastFrame, unmount } = await renderComponent();
@@ -442,34 +436,11 @@ describe('<ModelDialog />', () => {
unmount();
});
it('hides Flash Lite Preview model for users with pro access', async () => {
mockGetProModelNoAccessSync.mockReturnValue(false);
mockGetProModelNoAccess.mockResolvedValue(false);
mockGetHasAccessToPreviewModel.mockReturnValue(true);
const { lastFrame, stdin, waitUntilReady, unmount } =
await renderComponent();
// Go to manual view
await act(async () => {
stdin.write('\u001B[B'); // Manual
});
await waitUntilReady();
await act(async () => {
stdin.write('\r');
});
await waitUntilReady();
const output = lastFrame();
expect(output).not.toContain(PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL);
unmount();
});
it('shows Flash Lite Preview model for free tier users', async () => {
it('shows Flash Lite Preview model regardless of tier when flag is enabled', async () => {
mockGetProModelNoAccessSync.mockReturnValue(false);
mockGetProModelNoAccess.mockResolvedValue(false);
mockGetHasAccessToPreviewModel.mockReturnValue(true);
mockGetGemini31FlashLiteLaunchedSync.mockReturnValue(true);
mockGetUserTier.mockReturnValue(UserTierId.FREE);
const { lastFrame, stdin, waitUntilReady, unmount } =
await renderComponent();
@@ -23,7 +23,6 @@ import {
AuthType,
PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL,
isProModel,
UserTierId,
} from '@google/gemini-cli-core';
import { useKeypress } from '../hooks/useKeypress.js';
import { theme } from '../semantic-colors.js';
@@ -190,7 +189,6 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
}, [config, shouldShowPreviewModels, manualModelSelected, useGemini31]);
const manualOptions = useMemo(() => {
const isFreeTier = config?.getUserTier() === UserTierId.FREE;
// --- DYNAMIC PATH ---
if (
config?.getExperimentalDynamicModelConfiguration?.() === true &&
@@ -207,9 +205,6 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
if (m.tier === 'auto') return false;
// Pro models are shown for users with pro access
if (!hasAccessToProModel && m.tier === 'pro') return false;
// 3.1 Preview Flash-lite is only available on free tier
if (m.tier === 'flash-lite' && m.isPreview && !isFreeTier)
return false;
// Flag Guard: Versioned models only show if their flag is active.
if (id === PREVIEW_GEMINI_3_1_MODEL && !useGemini31) return false;
@@ -292,7 +287,7 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
},
];
if (isFreeTier && useGemini31FlashLite) {
if (useGemini31FlashLite) {
previewOptions.push({
value: PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL,
title: getDisplayString(PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL),
@@ -0,0 +1,374 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { act } from 'react';
import { renderWithProviders } from '../../test-utils/render.js';
import { waitFor } from '../../test-utils/async.js';
import { PoliciesDialog } from './PoliciesDialog.js';
import { PolicyDecision } from '@google/gemini-cli-core';
import type { PolicyRule } from '@google/gemini-cli-core';
function makeRule(
overrides: Partial<PolicyRule> & { decision: PolicyDecision },
): PolicyRule {
return {
toolName: undefined,
priority: 0,
source: undefined,
argsPattern: undefined,
constraintDisplay: undefined,
...overrides,
} as PolicyRule;
}
// Realistic user/workspace/extension policies (default rules are filtered
// out before reaching the dialog — see policiesCommand.ts).
const ALLOW_RULES: PolicyRule[] = [
makeRule({
decision: PolicyDecision.ALLOW,
toolName: 'run_shell_command',
priority: 4.1,
source: 'User: allowed-tools.toml',
constraintDisplay: 'git show*',
}),
makeRule({
decision: PolicyDecision.ALLOW,
toolName: 'run_shell_command',
priority: 4.1,
source: 'User: allowed-tools.toml',
constraintDisplay: 'git diff*',
}),
makeRule({
decision: PolicyDecision.ALLOW,
toolName: 'run_shell_command',
priority: 4.0,
source: 'Workspace: .gemini/settings.json',
constraintDisplay: 'npm test*',
}),
];
const ASK_RULES: PolicyRule[] = [
makeRule({
decision: PolicyDecision.ASK_USER,
toolName: 'write_file',
priority: 3,
source: 'Workspace: .gemini/policies/write-guard.toml',
}),
makeRule({
decision: PolicyDecision.ASK_USER,
toolName: 'run_shell_command',
priority: 2,
source: 'Workspace: .gemini/policies/shell.toml',
}),
];
const DENY_RULES: PolicyRule[] = [
makeRule({
decision: PolicyDecision.DENY,
toolName: 'run_shell_command',
priority: 10,
source: 'Admin: admin-policies.toml',
constraintDisplay: 'rm -rf*',
}),
];
const ALL_RULES = [...ALLOW_RULES, ...ASK_RULES, ...DENY_RULES];
const TOOL_DISPLAY_NAMES = new Map([
['run_shell_command', 'Shell'],
['write_file', 'WriteFile'],
]);
describe('PoliciesDialog', () => {
let onClose: () => void;
beforeEach(() => {
onClose = vi.fn();
});
it('renders with Allow tab active by default', async () => {
const { lastFrame, waitUntilReady } = await renderWithProviders(
<PoliciesDialog
rules={ALL_RULES}
toolDisplayNames={TOOL_DISPLAY_NAMES}
onClose={onClose}
/>,
{ width: 80 },
);
await waitUntilReady();
const output = lastFrame();
expect(output).toMatchSnapshot();
expect(output).toContain('Policies');
expect(output).toContain('Allow');
// Should show resolved display names, not internal names
expect(output).toContain('Shell');
expect(output).not.toContain('run_shell_command');
});
it('shows formatted shell command constraints', async () => {
const { lastFrame, waitUntilReady } = await renderWithProviders(
<PoliciesDialog
rules={ALL_RULES}
toolDisplayNames={TOOL_DISPLAY_NAMES}
onClose={onClose}
/>,
{ width: 80 },
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('git show*');
expect(output).toContain('git diff*');
expect(output).toContain('npm test*');
});
it('displays policy source on each item', async () => {
const { lastFrame, waitUntilReady } = await renderWithProviders(
<PoliciesDialog
rules={ALL_RULES}
toolDisplayNames={TOOL_DISPLAY_NAMES}
onClose={onClose}
/>,
{ width: 80 },
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('User: allowed-tools.toml');
expect(output).toContain('Workspace: .gemini/settings.json');
});
it('displays correct count for Allow tab', async () => {
const { lastFrame, waitUntilReady } = await renderWithProviders(
<PoliciesDialog
rules={ALL_RULES}
toolDisplayNames={TOOL_DISPLAY_NAMES}
onClose={onClose}
/>,
{ width: 80 },
);
await waitUntilReady();
expect(lastFrame()).toContain('3 Allow policies');
});
it('switches to Ask tab with right arrow', async () => {
const { stdin, lastFrame, waitUntilReady } = await renderWithProviders(
<PoliciesDialog
rules={ALL_RULES}
toolDisplayNames={TOOL_DISPLAY_NAMES}
onClose={onClose}
/>,
{ width: 80 },
);
await waitUntilReady();
act(() => {
stdin.write('\x1B[C'); // Right arrow
});
await waitFor(() => {
const output = lastFrame();
expect(output).toContain('WriteFile');
expect(output).toContain('2 Ask policies');
});
});
it('switches to Deny tab and shows deny rules', async () => {
const { stdin, lastFrame, waitUntilReady } = await renderWithProviders(
<PoliciesDialog
rules={ALL_RULES}
toolDisplayNames={TOOL_DISPLAY_NAMES}
onClose={onClose}
/>,
{ width: 80 },
);
await waitUntilReady();
// Right arrow twice: Allow → Ask → Deny
act(() => {
stdin.write('\x1B[C');
});
act(() => {
stdin.write('\x1B[C');
});
await waitFor(() => {
const output = lastFrame();
expect(output).toContain('rm -rf*');
expect(output).toContain('Admin: admin-policies.toml');
expect(output).toContain('1 Deny policy');
});
});
it('wraps tabs with left arrow from first tab', async () => {
const { stdin, lastFrame, waitUntilReady } = await renderWithProviders(
<PoliciesDialog
rules={ALL_RULES}
toolDisplayNames={TOOL_DISPLAY_NAMES}
onClose={onClose}
/>,
{ width: 80 },
);
await waitUntilReady();
// Left from Allow wraps to Deny
act(() => {
stdin.write('\x1B[D');
});
await waitFor(() => {
expect(lastFrame()).toContain('1 Deny policy');
});
});
it('shows empty state when a tab has no rules', async () => {
// Only allow rules — Ask and Deny tabs will be empty
const { stdin, lastFrame, waitUntilReady } = await renderWithProviders(
<PoliciesDialog
rules={ALLOW_RULES}
toolDisplayNames={TOOL_DISPLAY_NAMES}
onClose={onClose}
/>,
{ width: 80 },
);
await waitUntilReady();
// Navigate to Ask tab
act(() => {
stdin.write('\x1B[C');
});
await waitFor(() => {
expect(lastFrame()).toContain('No ask policies.');
});
});
it('navigates list items with up/down arrows', async () => {
const { stdin, lastFrame, waitUntilReady } = await renderWithProviders(
<PoliciesDialog
rules={ALL_RULES}
toolDisplayNames={TOOL_DISPLAY_NAMES}
onClose={onClose}
/>,
{ width: 80 },
);
await waitUntilReady();
// First item is active (highest priority first)
expect(lastFrame()).toContain('●');
// Move down
act(() => {
stdin.write('\x1B[B');
});
await waitFor(() => {
// Active indicator should still be present on a different item
expect(lastFrame()).toContain('●');
});
});
it('closes on Escape when search is empty', async () => {
const { stdin } = await renderWithProviders(
<PoliciesDialog
rules={ALL_RULES}
toolDisplayNames={TOOL_DISPLAY_NAMES}
onClose={onClose}
/>,
{ width: 80 },
);
act(() => {
stdin.write('\x1B');
});
await waitFor(() => {
expect(onClose).toHaveBeenCalled();
});
});
it('shows "all tools" for wildcard rules (no toolName)', async () => {
const wildcardRule = makeRule({
decision: PolicyDecision.ALLOW,
priority: 1,
source: 'Workspace: .gemini/policies/global.toml',
});
const { lastFrame, waitUntilReady } = await renderWithProviders(
<PoliciesDialog
rules={[wildcardRule]}
toolDisplayNames={TOOL_DISPLAY_NAMES}
onClose={onClose}
/>,
{ width: 80 },
);
await waitUntilReady();
expect(lastFrame()).toContain('all tools');
});
it('falls back to internal name for unmapped tools (e.g. MCP)', async () => {
const mcpRule = makeRule({
decision: PolicyDecision.ALLOW,
toolName: 'mcp_my-server_search',
priority: 5,
source: 'User: mcp-policies.toml',
});
const { lastFrame, waitUntilReady } = await renderWithProviders(
<PoliciesDialog
rules={[mcpRule]}
toolDisplayNames={TOOL_DISPLAY_NAMES}
onClose={onClose}
/>,
{ width: 80 },
);
await waitUntilReady();
// No display name mapping exists, so internal name is shown
expect(lastFrame()).toContain('mcp_my-server_search');
});
it('renders with no rules at all (empty allow tab)', async () => {
const { lastFrame, waitUntilReady } = await renderWithProviders(
<PoliciesDialog
rules={[]}
toolDisplayNames={new Map()}
onClose={onClose}
/>,
{ width: 80 },
);
await waitUntilReady();
expect(lastFrame()).toContain('No allow policies.');
});
it('sorts rules by priority descending within a tab', async () => {
// All three allow rules have priorities 4.1, 4.1, 4.0
// The 4.1 rules (git show, git diff) should appear before 4.0 (npm test)
const { lastFrame, waitUntilReady } = await renderWithProviders(
<PoliciesDialog
rules={ALL_RULES}
toolDisplayNames={TOOL_DISPLAY_NAMES}
onClose={onClose}
/>,
{ width: 80 },
);
await waitUntilReady();
const output = lastFrame();
const gitShowIdx = output.indexOf('git show*');
const npmTestIdx = output.indexOf('npm test*');
expect(gitShowIdx).toBeLessThan(npmTestIdx);
});
});
@@ -0,0 +1,468 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { useState, useMemo, useEffect, useCallback, useReducer } from 'react';
import { Box, Text } from 'ink';
import { AsyncFzf, type FzfResultItem } from 'fzf';
import { PolicyDecision, type PolicyRule } from '@google/gemini-cli-core';
import { theme } from '../semantic-colors.js';
import { useKeypress, type Key } from '../hooks/useKeypress.js';
import { useSearchBuffer } from '../hooks/useSearchBuffer.js';
import { TextInput } from './shared/TextInput.js';
import { TabHeader, type Tab } from './shared/TabHeader.js';
import { Command } from '../key/keyMatchers.js';
import { useKeyMatchers } from '../hooks/useKeyMatchers.js';
import {
buildPolicyListItems,
type PolicyListItem,
} from '../utils/policyUtils.js';
import { useUIState } from '../contexts/UIStateContext.js';
import { isNarrowWidth } from '../utils/isNarrowWidth.js';
const ITEM_HEIGHT = 2;
interface NavigationState {
activeTabIndex: number;
activeIndex: number;
scrollOffset: number;
}
type NavigationAction =
| { type: 'MOVE_UP'; maxItemsToShow: number }
| { type: 'MOVE_DOWN'; maxItemsToShow: number; totalItems: number }
| { type: 'CYCLE_TAB'; direction: number; numTabs: number }
| { type: 'RESET_SCROLL' };
function navigationReducer(
state: NavigationState,
action: NavigationAction,
): NavigationState {
switch (action.type) {
case 'MOVE_UP': {
if (state.activeIndex <= 0) return state;
const nextIndex = state.activeIndex - 1;
return {
...state,
activeIndex: nextIndex,
scrollOffset:
nextIndex < state.scrollOffset ? nextIndex : state.scrollOffset,
};
}
case 'MOVE_DOWN': {
if (state.activeIndex >= action.totalItems - 1) return state;
const nextIndex = state.activeIndex + 1;
return {
...state,
activeIndex: nextIndex,
scrollOffset:
nextIndex >= state.scrollOffset + action.maxItemsToShow
? nextIndex - action.maxItemsToShow + 1
: state.scrollOffset,
};
}
case 'CYCLE_TAB': {
return {
...state,
activeTabIndex:
(state.activeTabIndex + action.direction + action.numTabs) %
action.numTabs,
activeIndex: 0,
scrollOffset: 0,
};
}
case 'RESET_SCROLL': {
return {
...state,
activeIndex: 0,
scrollOffset: 0,
};
}
default:
return state;
}
}
interface PoliciesDialogProps {
rules: readonly PolicyRule[];
toolDisplayNames: Map<string, string>;
onClose: () => void;
}
const TABS = [
{
decision: PolicyDecision.ALLOW,
label: 'Allow',
description: 'Tools that run automatically without confirmation.',
},
{
decision: PolicyDecision.ASK_USER,
label: 'Ask',
description: 'Tools that require your approval before running.',
},
{
decision: PolicyDecision.DENY,
label: 'Deny',
description: 'Tools that are blocked from running.',
},
] as const;
export function PoliciesDialog({
rules,
toolDisplayNames,
onClose,
}: PoliciesDialogProps): React.JSX.Element {
const keyMatchers = useKeyMatchers();
const { terminalHeight, terminalWidth, staticExtraHeight, constrainHeight } =
useUIState();
const [{ activeTabIndex, activeIndex, scrollOffset }, dispatch] = useReducer(
navigationReducer,
{ activeTabIndex: 0, activeIndex: 0, scrollOffset: 0 },
);
const [searchQuery, setSearchQuery] = useState('');
const [filteredItems, setFilteredItems] = useState<PolicyListItem[] | null>(
null,
);
const activeTab = TABS[activeTabIndex];
// Compute available height independently of the dialog's own rendered height
// to break the circular dependency (dialog height -> controlsHeight ->
// availableTerminalHeight -> dialog height).
const dialogAvailableHeight = constrainHeight
? terminalHeight - staticExtraHeight
: undefined;
const { effectiveMaxItemsToShow, showTabDescription, showHelpText } =
useMemo(() => {
if (!dialogAvailableHeight) {
return {
effectiveMaxItemsToShow: 8,
showTabDescription: true,
showHelpText: true,
};
}
// On small terminals (< 20 lines), hide tab description and help text
// to reclaim 2 lines for content.
const tight = dialogAvailableHeight < 20;
const showDesc = !tight;
const showHelp = !tight;
// Fixed layout lines (full):
// 2 (border) + 2 (padding) + 1 (title/tabs) + 1 (tab description) +
// 3 (search) + 1 (list margin) + 2 (arrows) + 1 (footer margin) +
// 1 (count text) + 1 (help text) = 15
// When hiding tab description and help text: 13
let staticHeight = 13; // base without optional elements
if (showDesc) staticHeight += 1;
if (showHelp) staticHeight += 1;
const availableForItems = dialogAvailableHeight - staticHeight;
const maxItems = Math.max(1, Math.floor(availableForItems / ITEM_HEIGHT));
return {
effectiveMaxItemsToShow: maxItems,
showTabDescription: showDesc,
showHelpText: showHelp,
};
}, [dialogAvailableHeight]);
// Build items for all tabs
const itemsByDecision = useMemo(() => {
const map = new Map<PolicyDecision, PolicyListItem[]>();
for (const tab of TABS) {
map.set(
tab.decision,
buildPolicyListItems(rules, toolDisplayNames, tab.decision),
);
}
return map;
}, [rules, toolDisplayNames]);
// Build tab headers with optional counts
const showTabCounts = !isNarrowWidth(terminalWidth);
const policyTabs: Tab[] = useMemo(
() =>
TABS.map((tab) => {
const count = itemsByDecision.get(tab.decision)?.length ?? 0;
return {
key: tab.decision,
header: showTabCounts ? `${tab.label} (${count})` : tab.label,
};
}),
[itemsByDecision, showTabCounts],
);
// Get total count for current tab (unfiltered)
const allTabItems = itemsByDecision.get(activeTab.decision) ?? [];
// Build fzf instance per tab
const fzfInstances = useMemo(() => {
const map = new Map<PolicyDecision, AsyncFzf<PolicyListItem[]>>();
for (const tab of TABS) {
const items = itemsByDecision.get(tab.decision) ?? [];
map.set(
tab.decision,
new AsyncFzf(items, {
selector: (item: PolicyListItem) => item.searchText,
fuzzy: 'v2',
casing: 'case-insensitive',
}),
);
}
return map;
}, [itemsByDecision]);
// Perform search when query or tab changes
useEffect(() => {
let active = true;
if (!searchQuery.trim()) {
setFilteredItems(null);
return;
}
const fzf = fzfInstances.get(activeTab.decision);
if (!fzf) return;
const doSearch = async () => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const results = await fzf.find(searchQuery);
if (!active) return;
const matched: PolicyListItem[] = [];
results.forEach((r: FzfResultItem<PolicyListItem>) => {
matched.push(r.item);
});
setFilteredItems(matched);
};
void doSearch();
return () => {
active = false;
};
}, [searchQuery, activeTab.decision, fzfInstances]);
// Items to display (filtered or all)
const displayItems = filteredItems ?? allTabItems;
// Reset scroll when filtered items change
useEffect(() => {
dispatch({ type: 'RESET_SCROLL' });
}, [filteredItems]);
// Search buffer
const searchBuffer = useSearchBuffer({
initialText: '',
onChange: useCallback((text: string) => {
setSearchQuery(text);
}, []),
});
// Visible items
const visibleItems = displayItems.slice(
scrollOffset,
scrollOffset + effectiveMaxItemsToShow,
);
const hasOverflow = displayItems.length > effectiveMaxItemsToShow;
// Fixed height for the list area to prevent layout jumpiness in alternate
// buffer mode. Each item is ITEM_HEIGHT lines, plus 2 for scroll arrows.
const listAreaHeight = effectiveMaxItemsToShow * ITEM_HEIGHT + 2;
// Keyboard handling
useKeypress(
(key: Key) => {
// Tab cycling with left/right arrows or Tab
if (
keyMatchers[Command.MOVE_LEFT](key) ||
keyMatchers[Command.MOVE_RIGHT](key) ||
key.name === 'tab'
) {
const direction =
keyMatchers[Command.MOVE_LEFT](key) ||
(key.name === 'tab' && key.shift)
? -1
: 1;
dispatch({ type: 'CYCLE_TAB', direction, numTabs: TABS.length });
return;
}
// Up/Down navigation (no wrap-around)
if (keyMatchers[Command.DIALOG_NAVIGATION_UP](key)) {
dispatch({ type: 'MOVE_UP', maxItemsToShow: effectiveMaxItemsToShow });
return;
}
if (keyMatchers[Command.DIALOG_NAVIGATION_DOWN](key)) {
dispatch({
type: 'MOVE_DOWN',
maxItemsToShow: effectiveMaxItemsToShow,
totalItems: displayItems.length,
});
return;
}
// Escape - clear search first, then close
if (keyMatchers[Command.ESCAPE](key)) {
if (searchQuery) {
searchBuffer.setText('');
} else {
onClose();
}
return;
}
// Don't intercept other key matchers
},
{ isActive: true, priority: true },
);
// Tab label
const tabLabel = activeTab.label.toLowerCase();
const filteredCount = displayItems.length;
const totalCount = allTabItems.length;
const countText =
filteredItems !== null
? `${filteredCount} of ${totalCount} ${activeTab.label} policies`
: `${totalCount} ${activeTab.label} ${totalCount === 1 ? 'policy' : 'policies'}`;
return (
<Box
borderStyle="round"
borderColor={theme.border.default}
flexDirection="column"
padding={1}
width="100%"
>
{/* Title & Tabs */}
<Box marginX={1} flexDirection="row">
<Text bold>Policies: </Text>
<TabHeader
tabs={policyTabs}
currentIndex={activeTabIndex}
showStatusIcons={false}
/>
</Box>
{/* Tab description */}
{showTabDescription && (
<Box marginX={1}>
<Text color={theme.text.secondary}>{activeTab.description}</Text>
</Box>
)}
{/* Search box */}
<Box
borderStyle="round"
borderColor={theme.ui.focus}
paddingX={1}
height={3}
>
<TextInput
focus={true}
buffer={searchBuffer}
placeholder="Search to filter..."
/>
</Box>
{/* List — fixed height to prevent jumpiness in alternate buffer */}
<Box flexDirection="column" marginTop={1} height={listAreaHeight}>
<Box marginX={1}>
<Text color={theme.text.secondary}>
{hasOverflow && scrollOffset > 0 ? '▲' : ' '}
</Text>
</Box>
{displayItems.length === 0 ? (
<Box marginX={1} flexDirection="column">
<Text color={theme.text.secondary}>
{searchQuery ? 'No policies match.' : `No ${tabLabel} policies.`}
</Text>
{!searchQuery && (
<Text wrap="wrap">
Learn more:{' '}
<Text color={theme.text.link}>
https://geminicli.com/docs/reference/policy-engine/
</Text>
</Text>
)}
</Box>
) : (
visibleItems.map((item, idx) => {
const globalIndex = idx + scrollOffset;
const isActive = activeIndex === globalIndex;
// Line 1: Display name with optional constraint
// e.g. "Shell(git diff*)" or "all tools"
const toolPart =
item.toolDisplayName === 'all tools' ? (
<Text color={theme.text.secondary}>all tools</Text>
) : (
<Text>
{item.toolDisplayName}
{item.constraint !== undefined && (
<Text color={theme.text.secondary}>
({item.constraint})
</Text>
)}
</Text>
);
return (
<Box
key={item.key}
marginX={1}
flexDirection="row"
alignItems="flex-start"
backgroundColor={isActive ? theme.background.focus : undefined}
>
<Box minWidth={2} flexShrink={0}>
<Text
color={isActive ? theme.ui.focus : theme.text.secondary}
>
{isActive ? '●' : ''}
</Text>
</Box>
<Box flexDirection="column" minWidth={0}>
<Text
color={isActive ? theme.ui.focus : theme.text.primary}
wrap="truncate"
>
{toolPart}
</Text>
{item.source && (
<Text color={theme.text.secondary} wrap="truncate">
{item.source}
</Text>
)}
</Box>
</Box>
);
})
)}
<Box marginX={1} marginTop={0}>
<Text color={theme.text.secondary}>
{hasOverflow &&
scrollOffset + effectiveMaxItemsToShow < displayItems.length
? '▼'
: ' '}
</Text>
</Box>
</Box>
{/* Footer */}
<Box marginX={1} marginTop={1}>
<Text color={theme.text.secondary}>{countText}</Text>
</Box>
{showHelpText && (
<Box marginX={1}>
<Text color={theme.text.secondary}>
(Use to navigate, / or Tab to cycle, Esc to close)
</Text>
</Box>
)}
</Box>
);
}
@@ -142,38 +142,4 @@ describe('<StatusRow />', () => {
await waitUntilReady();
expect(lastFrame()).toContain('Tip: Test Tip');
});
it('renders buffer toggle hint when showIsAlternateBufferHint is true', async () => {
(useComposerStatus as Mock).mockReturnValue({
isInteractiveShellWaiting: false,
showLoadingIndicator: false,
showTips: false,
showWit: false,
modeContentObj: null,
showMinimalContext: false,
});
const uiState: Partial<UIState> = {
...defaultUiState,
showIsAlternateBufferHint: true,
};
const { lastFrame, waitUntilReady } = await renderWithProviders(
<StatusRow
showUiDetails={false}
isNarrow={false}
terminalWidth={100}
hideContextSummary={false}
hideUiDetailsForSuggestions={false}
hasPendingActionRequired={false}
/>,
{
width: 100,
uiState,
},
);
await waitUntilReady();
expect(lastFrame()).toContain('[Alt+T] Switch to Full Screen');
});
});
+1 -6
View File
@@ -206,12 +206,7 @@ export const StatusRow: React.FC<StatusRowProps> = ({
return uiState.currentTip;
}
// 2. Buffer Toggle Hint
if (uiState.showIsAlternateBufferHint) {
return '[Alt+T] Switch to Full Screen';
}
// 3. Shortcut Hint (Fallback)
// 2. Shortcut Hint (Fallback)
if (
settings.merged.ui.showShortcutsHint &&
!hideUiDetailsForSuggestions &&
@@ -5,7 +5,7 @@
*/
import type React from 'react';
import { useMemo } from 'react';
import { useMemo, useState, useEffect } from 'react';
import { Box, Text } from 'ink';
import { theme } from '../semantic-colors.js';
import {
@@ -21,12 +21,15 @@ interface UserIdentityProps {
export const UserIdentity: React.FC<UserIdentityProps> = ({ config }) => {
const authType = config.getContentGeneratorConfig()?.authType;
const email = useMemo(() => {
const [email, setEmail] = useState<string | undefined>();
useEffect(() => {
if (authType) {
const userAccountManager = new UserAccountManager();
return userAccountManager.getCachedGoogleAccount() ?? undefined;
setEmail(userAccountManager.getCachedGoogleAccount() ?? undefined);
} else {
setEmail(undefined);
}
return undefined;
}, [authType]);
const tierName = useMemo(
@@ -43,10 +43,12 @@ Tips for getting started:
│ ✓ tool1 Description for tool 1 │
│ │
╰──────────────────────────────────────────────────────────────────────────╯
╭──────────────────────────────────────────────────────────────────────────╮
│ ✓ tool2 Description for tool 2 │
│ │
╰──────────────────────────────────────────────────────────────────────────╯
╭──────────────────────────────────────────────────────────────────────────╮
│ o tool3 Description for tool 3 │
│ │
@@ -93,6 +95,7 @@ Tips for getting started:
│ ✓ tool1 Description for tool 1 │
│ │
╰──────────────────────────────────────────────────────────────────────────╯
╭──────────────────────────────────────────────────────────────────────────╮
│ ✓ tool2 Description for tool 2 │
│ │
@@ -1,8 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="700" viewBox="0 0 920 700">
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="734" viewBox="0 0 920 734">
<style>
text { font-family: Consolas, "Courier New", monospace; font-size: 14px; dominant-baseline: text-before-edge; white-space: pre; }
</style>
<rect width="920" height="700" fill="#000000" />
<rect width="920" height="734" fill="#000000" />
<g transform="translate(10, 10)">
<text x="0" y="2" fill="#333333" textLength="900" lengthAdjust="spacingAndGlyphs">╭──────────────────────────────────────────────────────────────────────────────────────────────────╮</text>
<text x="0" y="19" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
@@ -74,85 +74,92 @@
<text x="45" y="359" fill="#afafaf" textLength="378" lengthAdjust="spacingAndGlyphs"> Unique identifier for the current session</text>
<text x="891" y="359" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="376" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<rect x="27" y="374" width="9" height="17" fill="#001a00" />
<text x="27" y="376" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">&gt;</text>
<rect x="36" y="374" width="9" height="17" fill="#001a00" />
<rect x="45" y="374" width="27" height="17" fill="#001a00" />
<text x="45" y="376" fill="#d7ffd7" textLength="27" lengthAdjust="spacingAndGlyphs">[✓]</text>
<rect x="72" y="374" width="117" height="17" fill="#001a00" />
<text x="72" y="376" fill="#00cd00" textLength="117" lengthAdjust="spacingAndGlyphs"> code-changes</text>
<rect x="189" y="374" width="684" height="17" fill="#001a00" />
<text x="45" y="376" fill="#afafaf" textLength="27" lengthAdjust="spacingAndGlyphs">[ ]</text>
<text x="72" y="376" fill="#ffffff" textLength="45" lengthAdjust="spacingAndGlyphs"> auth</text>
<text x="891" y="376" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="393" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<rect x="27" y="391" width="18" height="17" fill="#001a00" />
<rect x="45" y="391" width="513" height="17" fill="#001a00" />
<text x="45" y="393" fill="#afafaf" textLength="513" lengthAdjust="spacingAndGlyphs"> Lines added/removed in the session (not shown when zero)</text>
<rect x="558" y="391" width="315" height="17" fill="#001a00" />
<text x="45" y="393" fill="#afafaf" textLength="252" lengthAdjust="spacingAndGlyphs"> Current authentication info</text>
<text x="891" y="393" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="410" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="410" fill="#afafaf" textLength="27" lengthAdjust="spacingAndGlyphs">[ ]</text>
<text x="72" y="410" fill="#ffffff" textLength="108" lengthAdjust="spacingAndGlyphs"> token-count</text>
<rect x="27" y="408" width="9" height="17" fill="#001a00" />
<text x="27" y="410" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">&gt;</text>
<rect x="36" y="408" width="9" height="17" fill="#001a00" />
<rect x="45" y="408" width="27" height="17" fill="#001a00" />
<text x="45" y="410" fill="#d7ffd7" textLength="27" lengthAdjust="spacingAndGlyphs">[✓]</text>
<rect x="72" y="408" width="117" height="17" fill="#001a00" />
<text x="72" y="410" fill="#00cd00" textLength="117" lengthAdjust="spacingAndGlyphs"> code-changes</text>
<rect x="189" y="408" width="684" height="17" fill="#001a00" />
<text x="891" y="410" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="427" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="427" fill="#afafaf" textLength="495" lengthAdjust="spacingAndGlyphs"> Total tokens used in the session (not shown when zero)</text>
<rect x="27" y="425" width="18" height="17" fill="#001a00" />
<rect x="45" y="425" width="513" height="17" fill="#001a00" />
<text x="45" y="427" fill="#afafaf" textLength="513" lengthAdjust="spacingAndGlyphs"> Lines added/removed in the session (not shown when zero)</text>
<rect x="558" y="425" width="315" height="17" fill="#001a00" />
<text x="891" y="427" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="444" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="444" fill="#d7ffd7" textLength="27" lengthAdjust="spacingAndGlyphs">[]</text>
<text x="72" y="444" fill="#ffffff" textLength="171" lengthAdjust="spacingAndGlyphs"> Show footer labels</text>
<text x="45" y="444" fill="#afafaf" textLength="27" lengthAdjust="spacingAndGlyphs">[ ]</text>
<text x="72" y="444" fill="#ffffff" textLength="108" lengthAdjust="spacingAndGlyphs"> token-count</text>
<text x="891" y="444" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="461" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="461" fill="#afafaf" textLength="495" lengthAdjust="spacingAndGlyphs"> Total tokens used in the session (not shown when zero)</text>
<text x="891" y="461" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="478" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="478" fill="#ffffff" textLength="207" lengthAdjust="spacingAndGlyphs">Reset to default footer</text>
<text x="45" y="478" fill="#d7ffd7" textLength="27" lengthAdjust="spacingAndGlyphs">[✓]</text>
<text x="72" y="478" fill="#ffffff" textLength="171" lengthAdjust="spacingAndGlyphs"> Show footer labels</text>
<text x="891" y="478" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="495" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="495" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="512" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="512" fill="#ffffff" textLength="207" lengthAdjust="spacingAndGlyphs">Reset to default footer</text>
<text x="891" y="512" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="529" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="27" y="529" fill="#afafaf" textLength="585" lengthAdjust="spacingAndGlyphs">Enter to select · ↑/↓ to navigate · ←/→ to reorder · Esc to close</text>
<text x="891" y="529" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="546" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="546" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="563" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="27" y="563" fill="#333333" textLength="846" lengthAdjust="spacingAndGlyphs">┌────────────────────────────────────────────────────────────────────────────────────────────┐</text>
<text x="27" y="563" fill="#afafaf" textLength="585" lengthAdjust="spacingAndGlyphs">Enter to select · ↑/↓ to navigate · ←/→ to reorder · Esc to close</text>
<text x="891" y="563" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="580" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="27" y="580" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="580" fill="#ffffff" textLength="72" lengthAdjust="spacingAndGlyphs" font-weight="bold">Preview:</text>
<text x="864" y="580" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="580" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="597" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="27" y="597" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="597" fill="#afafaf" textLength="198" lengthAdjust="spacingAndGlyphs">workspace (/directory)</text>
<text x="297" y="597" fill="#afafaf" textLength="54" lengthAdjust="spacingAndGlyphs">branch</text>
<text x="405" y="597" fill="#afafaf" textLength="63" lengthAdjust="spacingAndGlyphs">sandbox</text>
<text x="513" y="597" fill="#afafaf" textLength="54" lengthAdjust="spacingAndGlyphs">/model</text>
<text x="693" y="597" fill="#afafaf" textLength="54" lengthAdjust="spacingAndGlyphs">/stats</text>
<rect x="801" y="595" width="36" height="17" fill="#001a00" />
<text x="801" y="597" fill="#ffffff" textLength="36" lengthAdjust="spacingAndGlyphs">diff</text>
<rect x="837" y="595" width="18" height="17" fill="#001a00" />
<text x="864" y="597" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="27" y="597" fill="#333333" textLength="846" lengthAdjust="spacingAndGlyphs">┌────────────────────────────────────────────────────────────────────────────────────────────┐</text>
<text x="891" y="597" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="614" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="27" y="614" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="614" fill="#ffffff" textLength="126" lengthAdjust="spacingAndGlyphs">~/project/path</text>
<text x="297" y="614" fill="#ffffff" textLength="36" lengthAdjust="spacingAndGlyphs">main</text>
<text x="405" y="614" fill="#00cd00" textLength="54" lengthAdjust="spacingAndGlyphs">docker</text>
<text x="513" y="614" fill="#ffffff" textLength="126" lengthAdjust="spacingAndGlyphs">gemini-2.5-pro</text>
<text x="693" y="614" fill="#ffffff" textLength="27" lengthAdjust="spacingAndGlyphs">97%</text>
<rect x="801" y="612" width="27" height="17" fill="#001a00" />
<text x="801" y="614" fill="#d7ffd7" textLength="27" lengthAdjust="spacingAndGlyphs">+12</text>
<rect x="828" y="612" width="9" height="17" fill="#001a00" />
<rect x="837" y="612" width="18" height="17" fill="#001a00" />
<text x="837" y="614" fill="#ff87af" textLength="18" lengthAdjust="spacingAndGlyphs">-4</text>
<text x="45" y="614" fill="#ffffff" textLength="72" lengthAdjust="spacingAndGlyphs" font-weight="bold">Preview:</text>
<text x="864" y="614" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="614" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="631" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="27" y="631" fill="#333333" textLength="846" lengthAdjust="spacingAndGlyphs">└────────────────────────────────────────────────────────────────────────────────────────────┘</text>
<text x="27" y="631" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="631" fill="#afafaf" textLength="198" lengthAdjust="spacingAndGlyphs">workspace (/directory)</text>
<text x="297" y="631" fill="#afafaf" textLength="54" lengthAdjust="spacingAndGlyphs">branch</text>
<text x="405" y="631" fill="#afafaf" textLength="63" lengthAdjust="spacingAndGlyphs">sandbox</text>
<text x="513" y="631" fill="#afafaf" textLength="54" lengthAdjust="spacingAndGlyphs">/model</text>
<text x="693" y="631" fill="#afafaf" textLength="54" lengthAdjust="spacingAndGlyphs">/stats</text>
<rect x="801" y="629" width="36" height="17" fill="#001a00" />
<text x="801" y="631" fill="#ffffff" textLength="36" lengthAdjust="spacingAndGlyphs">diff</text>
<rect x="837" y="629" width="18" height="17" fill="#001a00" />
<text x="864" y="631" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="631" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="648" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="27" y="648" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="648" fill="#ffffff" textLength="126" lengthAdjust="spacingAndGlyphs">~/project/path</text>
<text x="297" y="648" fill="#ffffff" textLength="36" lengthAdjust="spacingAndGlyphs">main</text>
<text x="405" y="648" fill="#00cd00" textLength="54" lengthAdjust="spacingAndGlyphs">docker</text>
<text x="513" y="648" fill="#ffffff" textLength="126" lengthAdjust="spacingAndGlyphs">gemini-2.5-pro</text>
<text x="693" y="648" fill="#ffffff" textLength="27" lengthAdjust="spacingAndGlyphs">97%</text>
<rect x="801" y="646" width="27" height="17" fill="#001a00" />
<text x="801" y="648" fill="#d7ffd7" textLength="27" lengthAdjust="spacingAndGlyphs">+12</text>
<rect x="828" y="646" width="9" height="17" fill="#001a00" />
<rect x="837" y="646" width="18" height="17" fill="#001a00" />
<text x="837" y="648" fill="#ff87af" textLength="18" lengthAdjust="spacingAndGlyphs">-4</text>
<text x="864" y="648" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="648" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="665" fill="#333333" textLength="900" lengthAdjust="spacingAndGlyphs">╰──────────────────────────────────────────────────────────────────────────────────────────────────╯</text>
<text x="0" y="665" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="27" y="665" fill="#333333" textLength="846" lengthAdjust="spacingAndGlyphs">└────────────────────────────────────────────────────────────────────────────────────────────┘</text>
<text x="891" y="665" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="682" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="682" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="699" fill="#333333" textLength="900" lengthAdjust="spacingAndGlyphs">╰──────────────────────────────────────────────────────────────────────────────────────────────────╯</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 17 KiB

@@ -1,8 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="700" viewBox="0 0 920 700">
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="734" viewBox="0 0 920 734">
<style>
text { font-family: Consolas, "Courier New", monospace; font-size: 14px; dominant-baseline: text-before-edge; white-space: pre; }
</style>
<rect width="920" height="700" fill="#000000" />
<rect width="920" height="734" fill="#000000" />
<g transform="translate(10, 10)">
<text x="0" y="2" fill="#333333" textLength="900" lengthAdjust="spacingAndGlyphs">╭──────────────────────────────────────────────────────────────────────────────────────────────────╮</text>
<text x="0" y="19" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
@@ -84,70 +84,77 @@
<text x="891" y="359" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="376" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="376" fill="#afafaf" textLength="27" lengthAdjust="spacingAndGlyphs">[ ]</text>
<text x="72" y="376" fill="#ffffff" textLength="117" lengthAdjust="spacingAndGlyphs"> code-changes</text>
<text x="72" y="376" fill="#ffffff" textLength="45" lengthAdjust="spacingAndGlyphs"> auth</text>
<text x="891" y="376" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="393" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="393" fill="#afafaf" textLength="513" lengthAdjust="spacingAndGlyphs"> Lines added/removed in the session (not shown when zero)</text>
<text x="45" y="393" fill="#afafaf" textLength="252" lengthAdjust="spacingAndGlyphs"> Current authentication info</text>
<text x="891" y="393" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="410" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="410" fill="#afafaf" textLength="27" lengthAdjust="spacingAndGlyphs">[ ]</text>
<text x="72" y="410" fill="#ffffff" textLength="108" lengthAdjust="spacingAndGlyphs"> token-count</text>
<text x="72" y="410" fill="#ffffff" textLength="117" lengthAdjust="spacingAndGlyphs"> code-changes</text>
<text x="891" y="410" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="427" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="427" fill="#afafaf" textLength="495" lengthAdjust="spacingAndGlyphs"> Total tokens used in the session (not shown when zero)</text>
<text x="45" y="427" fill="#afafaf" textLength="513" lengthAdjust="spacingAndGlyphs"> Lines added/removed in the session (not shown when zero)</text>
<text x="891" y="427" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="444" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="444" fill="#d7ffd7" textLength="27" lengthAdjust="spacingAndGlyphs">[]</text>
<text x="72" y="444" fill="#ffffff" textLength="171" lengthAdjust="spacingAndGlyphs"> Show footer labels</text>
<text x="45" y="444" fill="#afafaf" textLength="27" lengthAdjust="spacingAndGlyphs">[ ]</text>
<text x="72" y="444" fill="#ffffff" textLength="108" lengthAdjust="spacingAndGlyphs"> token-count</text>
<text x="891" y="444" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="461" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="461" fill="#afafaf" textLength="495" lengthAdjust="spacingAndGlyphs"> Total tokens used in the session (not shown when zero)</text>
<text x="891" y="461" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="478" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="478" fill="#ffffff" textLength="207" lengthAdjust="spacingAndGlyphs">Reset to default footer</text>
<text x="45" y="478" fill="#d7ffd7" textLength="27" lengthAdjust="spacingAndGlyphs">[✓]</text>
<text x="72" y="478" fill="#ffffff" textLength="171" lengthAdjust="spacingAndGlyphs"> Show footer labels</text>
<text x="891" y="478" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="495" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="495" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="512" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="512" fill="#ffffff" textLength="207" lengthAdjust="spacingAndGlyphs">Reset to default footer</text>
<text x="891" y="512" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="529" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="27" y="529" fill="#afafaf" textLength="585" lengthAdjust="spacingAndGlyphs">Enter to select · ↑/↓ to navigate · ←/→ to reorder · Esc to close</text>
<text x="891" y="529" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="546" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="546" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="563" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="27" y="563" fill="#333333" textLength="846" lengthAdjust="spacingAndGlyphs">┌────────────────────────────────────────────────────────────────────────────────────────────┐</text>
<text x="27" y="563" fill="#afafaf" textLength="585" lengthAdjust="spacingAndGlyphs">Enter to select · ↑/↓ to navigate · ←/→ to reorder · Esc to close</text>
<text x="891" y="563" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="580" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="27" y="580" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="580" fill="#ffffff" textLength="72" lengthAdjust="spacingAndGlyphs" font-weight="bold">Preview:</text>
<text x="864" y="580" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="580" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="597" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="27" y="597" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<rect x="45" y="595" width="198" height="17" fill="#001a00" />
<text x="45" y="597" fill="#ffffff" textLength="198" lengthAdjust="spacingAndGlyphs">workspace (/directory)</text>
<text x="324" y="597" fill="#afafaf" textLength="54" lengthAdjust="spacingAndGlyphs">branch</text>
<text x="459" y="597" fill="#afafaf" textLength="63" lengthAdjust="spacingAndGlyphs">sandbox</text>
<text x="594" y="597" fill="#afafaf" textLength="54" lengthAdjust="spacingAndGlyphs">/model</text>
<text x="801" y="597" fill="#afafaf" textLength="54" lengthAdjust="spacingAndGlyphs">/stats</text>
<text x="864" y="597" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="27" y="597" fill="#333333" textLength="846" lengthAdjust="spacingAndGlyphs">┌────────────────────────────────────────────────────────────────────────────────────────────┐</text>
<text x="891" y="597" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="614" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="27" y="614" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<rect x="45" y="612" width="126" height="17" fill="#001a00" />
<text x="45" y="614" fill="#ffffff" textLength="126" lengthAdjust="spacingAndGlyphs">~/project/path</text>
<rect x="171" y="612" width="72" height="17" fill="#001a00" />
<text x="324" y="614" fill="#ffffff" textLength="36" lengthAdjust="spacingAndGlyphs">main</text>
<text x="459" y="614" fill="#00cd00" textLength="54" lengthAdjust="spacingAndGlyphs">docker</text>
<text x="594" y="614" fill="#ffffff" textLength="126" lengthAdjust="spacingAndGlyphs">gemini-2.5-pro</text>
<text x="801" y="614" fill="#ffffff" textLength="27" lengthAdjust="spacingAndGlyphs">97%</text>
<text x="45" y="614" fill="#ffffff" textLength="72" lengthAdjust="spacingAndGlyphs" font-weight="bold">Preview:</text>
<text x="864" y="614" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="614" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="631" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="27" y="631" fill="#333333" textLength="846" lengthAdjust="spacingAndGlyphs">└────────────────────────────────────────────────────────────────────────────────────────────┘</text>
<text x="27" y="631" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<rect x="45" y="629" width="198" height="17" fill="#001a00" />
<text x="45" y="631" fill="#ffffff" textLength="198" lengthAdjust="spacingAndGlyphs">workspace (/directory)</text>
<text x="324" y="631" fill="#afafaf" textLength="54" lengthAdjust="spacingAndGlyphs">branch</text>
<text x="459" y="631" fill="#afafaf" textLength="63" lengthAdjust="spacingAndGlyphs">sandbox</text>
<text x="594" y="631" fill="#afafaf" textLength="54" lengthAdjust="spacingAndGlyphs">/model</text>
<text x="801" y="631" fill="#afafaf" textLength="54" lengthAdjust="spacingAndGlyphs">/stats</text>
<text x="864" y="631" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="631" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="648" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="27" y="648" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<rect x="45" y="646" width="126" height="17" fill="#001a00" />
<text x="45" y="648" fill="#ffffff" textLength="126" lengthAdjust="spacingAndGlyphs">~/project/path</text>
<rect x="171" y="646" width="72" height="17" fill="#001a00" />
<text x="324" y="648" fill="#ffffff" textLength="36" lengthAdjust="spacingAndGlyphs">main</text>
<text x="459" y="648" fill="#00cd00" textLength="54" lengthAdjust="spacingAndGlyphs">docker</text>
<text x="594" y="648" fill="#ffffff" textLength="126" lengthAdjust="spacingAndGlyphs">gemini-2.5-pro</text>
<text x="801" y="648" fill="#ffffff" textLength="27" lengthAdjust="spacingAndGlyphs">97%</text>
<text x="864" y="648" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="648" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="665" fill="#333333" textLength="900" lengthAdjust="spacingAndGlyphs">╰──────────────────────────────────────────────────────────────────────────────────────────────────╯</text>
<text x="0" y="665" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="27" y="665" fill="#333333" textLength="846" lengthAdjust="spacingAndGlyphs">└────────────────────────────────────────────────────────────────────────────────────────────┘</text>
<text x="891" y="665" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="682" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="682" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="699" fill="#333333" textLength="900" lengthAdjust="spacingAndGlyphs">╰──────────────────────────────────────────────────────────────────────────────────────────────────╯</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 16 KiB

@@ -1,8 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="683" viewBox="0 0 920 683">
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="717" viewBox="0 0 920 717">
<style>
text { font-family: Consolas, "Courier New", monospace; font-size: 14px; dominant-baseline: text-before-edge; white-space: pre; }
</style>
<rect width="920" height="683" fill="#000000" />
<rect width="920" height="717" fill="#000000" />
<g transform="translate(10, 10)">
<text x="0" y="2" fill="#333333" textLength="900" lengthAdjust="spacingAndGlyphs">╭──────────────────────────────────────────────────────────────────────────────────────────────────╮</text>
<text x="0" y="19" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
@@ -75,69 +75,76 @@
<text x="891" y="359" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="376" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="376" fill="#afafaf" textLength="27" lengthAdjust="spacingAndGlyphs">[ ]</text>
<text x="72" y="376" fill="#ffffff" textLength="117" lengthAdjust="spacingAndGlyphs"> code-changes</text>
<text x="72" y="376" fill="#ffffff" textLength="45" lengthAdjust="spacingAndGlyphs"> auth</text>
<text x="891" y="376" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="393" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="393" fill="#afafaf" textLength="513" lengthAdjust="spacingAndGlyphs"> Lines added/removed in the session (not shown when zero)</text>
<text x="45" y="393" fill="#afafaf" textLength="252" lengthAdjust="spacingAndGlyphs"> Current authentication info</text>
<text x="891" y="393" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="410" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="410" fill="#afafaf" textLength="27" lengthAdjust="spacingAndGlyphs">[ ]</text>
<text x="72" y="410" fill="#ffffff" textLength="108" lengthAdjust="spacingAndGlyphs"> token-count</text>
<text x="72" y="410" fill="#ffffff" textLength="117" lengthAdjust="spacingAndGlyphs"> code-changes</text>
<text x="891" y="410" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="427" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="427" fill="#afafaf" textLength="495" lengthAdjust="spacingAndGlyphs"> Total tokens used in the session (not shown when zero)</text>
<text x="45" y="427" fill="#afafaf" textLength="513" lengthAdjust="spacingAndGlyphs"> Lines added/removed in the session (not shown when zero)</text>
<text x="891" y="427" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="444" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<rect x="27" y="442" width="9" height="17" fill="#001a00" />
<text x="27" y="444" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">&gt;</text>
<rect x="36" y="442" width="9" height="17" fill="#001a00" />
<rect x="45" y="442" width="27" height="17" fill="#001a00" />
<text x="45" y="444" fill="#afafaf" textLength="27" lengthAdjust="spacingAndGlyphs">[ ]</text>
<rect x="72" y="442" width="171" height="17" fill="#001a00" />
<text x="72" y="444" fill="#00cd00" textLength="171" lengthAdjust="spacingAndGlyphs"> Show footer labels</text>
<rect x="243" y="442" width="630" height="17" fill="#001a00" />
<text x="72" y="444" fill="#ffffff" textLength="108" lengthAdjust="spacingAndGlyphs"> token-count</text>
<text x="891" y="444" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="461" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<rect x="27" y="459" width="846" height="17" fill="#001a00" />
<text x="45" y="461" fill="#afafaf" textLength="495" lengthAdjust="spacingAndGlyphs"> Total tokens used in the session (not shown when zero)</text>
<text x="891" y="461" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="478" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="478" fill="#ffffff" textLength="207" lengthAdjust="spacingAndGlyphs">Reset to default footer</text>
<rect x="27" y="476" width="9" height="17" fill="#001a00" />
<text x="27" y="478" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">&gt;</text>
<rect x="36" y="476" width="9" height="17" fill="#001a00" />
<rect x="45" y="476" width="27" height="17" fill="#001a00" />
<text x="45" y="478" fill="#afafaf" textLength="27" lengthAdjust="spacingAndGlyphs">[ ]</text>
<rect x="72" y="476" width="171" height="17" fill="#001a00" />
<text x="72" y="478" fill="#00cd00" textLength="171" lengthAdjust="spacingAndGlyphs"> Show footer labels</text>
<rect x="243" y="476" width="630" height="17" fill="#001a00" />
<text x="891" y="478" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="495" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<rect x="27" y="493" width="846" height="17" fill="#001a00" />
<text x="891" y="495" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="512" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="512" fill="#ffffff" textLength="207" lengthAdjust="spacingAndGlyphs">Reset to default footer</text>
<text x="891" y="512" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="529" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="27" y="529" fill="#afafaf" textLength="585" lengthAdjust="spacingAndGlyphs">Enter to select · ↑/↓ to navigate · ←/→ to reorder · Esc to close</text>
<text x="891" y="529" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="546" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="546" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="563" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="27" y="563" fill="#333333" textLength="846" lengthAdjust="spacingAndGlyphs">┌────────────────────────────────────────────────────────────────────────────────────────────┐</text>
<text x="27" y="563" fill="#afafaf" textLength="585" lengthAdjust="spacingAndGlyphs">Enter to select · ↑/↓ to navigate · ←/→ to reorder · Esc to close</text>
<text x="891" y="563" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="580" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="27" y="580" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="580" fill="#ffffff" textLength="72" lengthAdjust="spacingAndGlyphs" font-weight="bold">Preview:</text>
<text x="864" y="580" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="580" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="597" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="27" y="597" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="597" fill="#afafaf" textLength="126" lengthAdjust="spacingAndGlyphs">~/project/path</text>
<text x="207" y="597" fill="#afafaf" textLength="27" lengthAdjust="spacingAndGlyphs"> · </text>
<text x="279" y="597" fill="#afafaf" textLength="36" lengthAdjust="spacingAndGlyphs">main</text>
<text x="351" y="597" fill="#afafaf" textLength="27" lengthAdjust="spacingAndGlyphs"> · </text>
<text x="432" y="597" fill="#00cd00" textLength="54" lengthAdjust="spacingAndGlyphs">docker</text>
<text x="522" y="597" fill="#afafaf" textLength="27" lengthAdjust="spacingAndGlyphs"> · </text>
<text x="594" y="597" fill="#afafaf" textLength="126" lengthAdjust="spacingAndGlyphs">gemini-2.5-pro</text>
<text x="756" y="597" fill="#afafaf" textLength="27" lengthAdjust="spacingAndGlyphs"> · </text>
<text x="828" y="597" fill="#afafaf" textLength="27" lengthAdjust="spacingAndGlyphs">97%</text>
<text x="864" y="597" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="27" y="597" fill="#333333" textLength="846" lengthAdjust="spacingAndGlyphs">┌────────────────────────────────────────────────────────────────────────────────────────────┐</text>
<text x="891" y="597" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="614" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="27" y="614" fill="#333333" textLength="846" lengthAdjust="spacingAndGlyphs">└────────────────────────────────────────────────────────────────────────────────────────────┘</text>
<text x="27" y="614" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="614" fill="#ffffff" textLength="72" lengthAdjust="spacingAndGlyphs" font-weight="bold">Preview:</text>
<text x="864" y="614" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="614" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="631" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="27" y="631" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="631" fill="#afafaf" textLength="126" lengthAdjust="spacingAndGlyphs">~/project/path</text>
<text x="207" y="631" fill="#afafaf" textLength="27" lengthAdjust="spacingAndGlyphs"> · </text>
<text x="279" y="631" fill="#afafaf" textLength="36" lengthAdjust="spacingAndGlyphs">main</text>
<text x="351" y="631" fill="#afafaf" textLength="27" lengthAdjust="spacingAndGlyphs"> · </text>
<text x="432" y="631" fill="#00cd00" textLength="54" lengthAdjust="spacingAndGlyphs">docker</text>
<text x="522" y="631" fill="#afafaf" textLength="27" lengthAdjust="spacingAndGlyphs"> · </text>
<text x="594" y="631" fill="#afafaf" textLength="126" lengthAdjust="spacingAndGlyphs">gemini-2.5-pro</text>
<text x="756" y="631" fill="#afafaf" textLength="27" lengthAdjust="spacingAndGlyphs"> · </text>
<text x="828" y="631" fill="#afafaf" textLength="27" lengthAdjust="spacingAndGlyphs">97%</text>
<text x="864" y="631" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="631" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="648" fill="#333333" textLength="900" lengthAdjust="spacingAndGlyphs">╰──────────────────────────────────────────────────────────────────────────────────────────────────╯</text>
<text x="0" y="648" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="27" y="648" fill="#333333" textLength="846" lengthAdjust="spacingAndGlyphs">└────────────────────────────────────────────────────────────────────────────────────────────┘</text>
<text x="891" y="648" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="665" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="665" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="682" fill="#333333" textLength="900" lengthAdjust="spacingAndGlyphs">╰──────────────────────────────────────────────────────────────────────────────────────────────────╯</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 16 KiB

@@ -23,6 +23,8 @@ exports[`<FooterConfigDialog /> > highlights the active item in the preview 1`]
│ Memory used by the application │
│ [ ] session-id │
│ Unique identifier for the current session │
│ [ ] auth │
│ Current authentication info │
│ > [✓] code-changes │
│ Lines added/removed in the session (not shown when zero) │
│ [ ] token-count │
@@ -66,6 +68,8 @@ exports[`<FooterConfigDialog /> > renders correctly with default settings 1`] =
│ Memory used by the application │
│ [ ] session-id │
│ Unique identifier for the current session │
│ [ ] auth │
│ Current authentication info │
│ [ ] code-changes │
│ Lines added/removed in the session (not shown when zero) │
│ [ ] token-count │
@@ -110,6 +114,8 @@ exports[`<FooterConfigDialog /> > renders correctly with default settings 2`] =
│ Memory used by the application │
│ [ ] session-id │
│ Unique identifier for the current session │
│ [ ] auth │
│ Current authentication info │
│ [ ] code-changes │
│ Lines added/removed in the session (not shown when zero) │
│ [ ] token-count │
@@ -153,6 +159,8 @@ exports[`<FooterConfigDialog /> > updates the preview when Show footer labels is
│ Memory used by the application │
│ [ ] session-id │
│ Unique identifier for the current session │
│ [ ] auth │
│ Current authentication info │
│ [ ] code-changes │
│ Lines added/removed in the session (not shown when zero) │
│ [ ] token-count │
@@ -6,12 +6,11 @@ AppHeader(full)
╭──────────────────────────────────────────────────────────────────────────────────────────────╮
│ ⊶ Shell Command Running a long command... │
│ │
│ Line 10 │
│ Line 11 │
│ Line 12 │
│ Line 13 │
│ Line 14 │
│ Line 15
│ Line 15
│ Line 16 █ │
│ Line 17 █ │
│ Line 18 █ │
@@ -27,12 +26,11 @@ AppHeader(full)
╭──────────────────────────────────────────────────────────────────────────────────────────────╮
│ ⊶ Shell Command Running a long command... │
│ │
│ Line 10 │
│ Line 11 │
│ Line 12 │
│ Line 13 │
│ Line 14 │
│ Line 15
│ Line 15
│ Line 16 █ │
│ Line 17 █ │
│ Line 18 █ │
@@ -47,8 +45,7 @@ exports[`MainContent > MainContent Tool Output Height Logic > 'Normal mode - Con
╭──────────────────────────────────────────────────────────────────────────────────────────────╮
│ ⊶ Shell Command Running a long command... │
│ │
│ ... first 10 lines hidden (Ctrl+O to show) ... │
│ Line 11 │
│ ... first 11 lines hidden (Ctrl+O to show) ... │
│ Line 12 │
│ Line 13 │
│ Line 14 │
@@ -0,0 +1,37 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`PoliciesDialog > renders with Allow tab active by default 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
│ │
│ Policies: ← Allow (3) │ Ask (2) │ Deny (1) → │
│ │
│ Tools that run automatically without confirmation. │
│ ╭──────────────────────────────────────────────────────────────────────────╮ │
│ │ Search to filter... │ │
│ ╰──────────────────────────────────────────────────────────────────────────╯ │
│ │
│ │
│ ● Shell(git show*) │
│ User: allowed-tools.toml │
│ Shell(git diff*) │
│ User: allowed-tools.toml │
│ Shell(npm test*) │
│ Workspace: .gemini/settings.json │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ 3 Allow policies │
│ (Use ↑↓ to navigate, ←/→ or Tab to cycle, Esc to close) │
│ │
╰──────────────────────────────────────────────────────────────────────────────╯
"
`;
@@ -76,38 +76,38 @@
<text x="0" y="342" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="342" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="359" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="359" fill="#ffffff" textLength="126" lengthAdjust="spacingAndGlyphs">Plan Directory</text>
<text x="792" y="359" fill="#afafaf" textLength="81" lengthAdjust="spacingAndGlyphs">undefined</text>
<text x="45" y="359" fill="#ffffff" textLength="144" lengthAdjust="spacingAndGlyphs">Enable Plan Mode</text>
<text x="837" y="359" fill="#afafaf" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
<text x="891" y="359" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="376" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="376" fill="#afafaf" textLength="720" lengthAdjust="spacingAndGlyphs">The directory where planning artifacts are stored. If not specified, defaults t…</text>
<text x="45" y="376" fill="#afafaf" textLength="486" lengthAdjust="spacingAndGlyphs">Enable Plan Mode for read-only safety during planning.</text>
<text x="891" y="376" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="393" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="393" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="410" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="410" fill="#ffffff" textLength="162" lengthAdjust="spacingAndGlyphs">Plan Model Routing</text>
<text x="837" y="410" fill="#afafaf" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
<text x="45" y="410" fill="#ffffff" textLength="126" lengthAdjust="spacingAndGlyphs">Plan Directory</text>
<text x="792" y="410" fill="#afafaf" textLength="81" lengthAdjust="spacingAndGlyphs">undefined</text>
<text x="891" y="410" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="427" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="427" fill="#afafaf" textLength="765" lengthAdjust="spacingAndGlyphs">Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr</text>
<text x="45" y="427" fill="#afafaf" textLength="720" lengthAdjust="spacingAndGlyphs">The directory where planning artifacts are stored. If not specified, defaults t</text>
<text x="891" y="427" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="444" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="444" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="461" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="461" fill="#ffffff" textLength="162" lengthAdjust="spacingAndGlyphs">Retry Fetch Errors</text>
<text x="45" y="461" fill="#ffffff" textLength="162" lengthAdjust="spacingAndGlyphs">Plan Model Routing</text>
<text x="837" y="461" fill="#afafaf" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
<text x="891" y="461" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="478" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="478" fill="#afafaf" textLength="612" lengthAdjust="spacingAndGlyphs">Retry on &quot;exception TypeError: fetch failed sending request&quot; errors.</text>
<text x="45" y="478" fill="#afafaf" textLength="765" lengthAdjust="spacingAndGlyphs">Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr…</text>
<text x="891" y="478" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="495" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="495" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="512" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="512" fill="#ffffff" textLength="207" lengthAdjust="spacingAndGlyphs">Max Chat Model Attempts</text>
<text x="855" y="512" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">10</text>
<text x="45" y="512" fill="#ffffff" textLength="162" lengthAdjust="spacingAndGlyphs">Retry Fetch Errors</text>
<text x="837" y="512" fill="#afafaf" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
<text x="891" y="512" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="529" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="529" fill="#afafaf" textLength="729" lengthAdjust="spacingAndGlyphs">Maximum number of attempts for requests to the main chat model. Cannot exceed 10.</text>
<text x="45" y="529" fill="#afafaf" textLength="612" lengthAdjust="spacingAndGlyphs">Retry on &quot;exception TypeError: fetch failed sending request&quot; errors.</text>
<text x="891" y="529" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="546" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="546" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

@@ -76,38 +76,38 @@
<text x="0" y="342" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="342" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="359" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="359" fill="#ffffff" textLength="126" lengthAdjust="spacingAndGlyphs">Plan Directory</text>
<text x="792" y="359" fill="#afafaf" textLength="81" lengthAdjust="spacingAndGlyphs">undefined</text>
<text x="45" y="359" fill="#ffffff" textLength="144" lengthAdjust="spacingAndGlyphs">Enable Plan Mode</text>
<text x="837" y="359" fill="#afafaf" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
<text x="891" y="359" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="376" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="376" fill="#afafaf" textLength="720" lengthAdjust="spacingAndGlyphs">The directory where planning artifacts are stored. If not specified, defaults t…</text>
<text x="45" y="376" fill="#afafaf" textLength="486" lengthAdjust="spacingAndGlyphs">Enable Plan Mode for read-only safety during planning.</text>
<text x="891" y="376" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="393" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="393" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="410" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="410" fill="#ffffff" textLength="162" lengthAdjust="spacingAndGlyphs">Plan Model Routing</text>
<text x="837" y="410" fill="#afafaf" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
<text x="45" y="410" fill="#ffffff" textLength="126" lengthAdjust="spacingAndGlyphs">Plan Directory</text>
<text x="792" y="410" fill="#afafaf" textLength="81" lengthAdjust="spacingAndGlyphs">undefined</text>
<text x="891" y="410" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="427" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="427" fill="#afafaf" textLength="765" lengthAdjust="spacingAndGlyphs">Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr</text>
<text x="45" y="427" fill="#afafaf" textLength="720" lengthAdjust="spacingAndGlyphs">The directory where planning artifacts are stored. If not specified, defaults t</text>
<text x="891" y="427" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="444" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="444" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="461" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="461" fill="#ffffff" textLength="162" lengthAdjust="spacingAndGlyphs">Retry Fetch Errors</text>
<text x="45" y="461" fill="#ffffff" textLength="162" lengthAdjust="spacingAndGlyphs">Plan Model Routing</text>
<text x="837" y="461" fill="#afafaf" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
<text x="891" y="461" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="478" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="478" fill="#afafaf" textLength="612" lengthAdjust="spacingAndGlyphs">Retry on &quot;exception TypeError: fetch failed sending request&quot; errors.</text>
<text x="45" y="478" fill="#afafaf" textLength="765" lengthAdjust="spacingAndGlyphs">Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr…</text>
<text x="891" y="478" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="495" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="495" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="512" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="512" fill="#ffffff" textLength="207" lengthAdjust="spacingAndGlyphs">Max Chat Model Attempts</text>
<text x="855" y="512" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">10</text>
<text x="45" y="512" fill="#ffffff" textLength="162" lengthAdjust="spacingAndGlyphs">Retry Fetch Errors</text>
<text x="837" y="512" fill="#afafaf" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
<text x="891" y="512" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="529" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="529" fill="#afafaf" textLength="729" lengthAdjust="spacingAndGlyphs">Maximum number of attempts for requests to the main chat model. Cannot exceed 10.</text>
<text x="45" y="529" fill="#afafaf" textLength="612" lengthAdjust="spacingAndGlyphs">Retry on &quot;exception TypeError: fetch failed sending request&quot; errors.</text>
<text x="891" y="529" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="546" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="546" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

@@ -76,38 +76,38 @@
<text x="0" y="342" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="342" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="359" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="359" fill="#ffffff" textLength="126" lengthAdjust="spacingAndGlyphs">Plan Directory</text>
<text x="792" y="359" fill="#afafaf" textLength="81" lengthAdjust="spacingAndGlyphs">undefined</text>
<text x="45" y="359" fill="#ffffff" textLength="144" lengthAdjust="spacingAndGlyphs">Enable Plan Mode</text>
<text x="837" y="359" fill="#afafaf" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
<text x="891" y="359" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="376" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="376" fill="#afafaf" textLength="720" lengthAdjust="spacingAndGlyphs">The directory where planning artifacts are stored. If not specified, defaults t…</text>
<text x="45" y="376" fill="#afafaf" textLength="486" lengthAdjust="spacingAndGlyphs">Enable Plan Mode for read-only safety during planning.</text>
<text x="891" y="376" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="393" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="393" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="410" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="410" fill="#ffffff" textLength="162" lengthAdjust="spacingAndGlyphs">Plan Model Routing</text>
<text x="837" y="410" fill="#afafaf" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
<text x="45" y="410" fill="#ffffff" textLength="126" lengthAdjust="spacingAndGlyphs">Plan Directory</text>
<text x="792" y="410" fill="#afafaf" textLength="81" lengthAdjust="spacingAndGlyphs">undefined</text>
<text x="891" y="410" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="427" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="427" fill="#afafaf" textLength="765" lengthAdjust="spacingAndGlyphs">Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr</text>
<text x="45" y="427" fill="#afafaf" textLength="720" lengthAdjust="spacingAndGlyphs">The directory where planning artifacts are stored. If not specified, defaults t</text>
<text x="891" y="427" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="444" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="444" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="461" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="461" fill="#ffffff" textLength="162" lengthAdjust="spacingAndGlyphs">Retry Fetch Errors</text>
<text x="45" y="461" fill="#ffffff" textLength="162" lengthAdjust="spacingAndGlyphs">Plan Model Routing</text>
<text x="837" y="461" fill="#afafaf" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
<text x="891" y="461" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="478" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="478" fill="#afafaf" textLength="612" lengthAdjust="spacingAndGlyphs">Retry on &quot;exception TypeError: fetch failed sending request&quot; errors.</text>
<text x="45" y="478" fill="#afafaf" textLength="765" lengthAdjust="spacingAndGlyphs">Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr…</text>
<text x="891" y="478" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="495" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="495" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="512" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="512" fill="#ffffff" textLength="207" lengthAdjust="spacingAndGlyphs">Max Chat Model Attempts</text>
<text x="855" y="512" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">10</text>
<text x="45" y="512" fill="#ffffff" textLength="162" lengthAdjust="spacingAndGlyphs">Retry Fetch Errors</text>
<text x="837" y="512" fill="#afafaf" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
<text x="891" y="512" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="529" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="529" fill="#afafaf" textLength="729" lengthAdjust="spacingAndGlyphs">Maximum number of attempts for requests to the main chat model. Cannot exceed 10.</text>
<text x="45" y="529" fill="#afafaf" textLength="612" lengthAdjust="spacingAndGlyphs">Retry on &quot;exception TypeError: fetch failed sending request&quot; errors.</text>
<text x="891" y="529" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="546" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="546" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

@@ -76,38 +76,38 @@
<text x="0" y="342" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="342" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="359" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="359" fill="#ffffff" textLength="126" lengthAdjust="spacingAndGlyphs">Plan Directory</text>
<text x="792" y="359" fill="#afafaf" textLength="81" lengthAdjust="spacingAndGlyphs">undefined</text>
<text x="45" y="359" fill="#ffffff" textLength="144" lengthAdjust="spacingAndGlyphs">Enable Plan Mode</text>
<text x="837" y="359" fill="#afafaf" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
<text x="891" y="359" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="376" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="376" fill="#afafaf" textLength="720" lengthAdjust="spacingAndGlyphs">The directory where planning artifacts are stored. If not specified, defaults t…</text>
<text x="45" y="376" fill="#afafaf" textLength="486" lengthAdjust="spacingAndGlyphs">Enable Plan Mode for read-only safety during planning.</text>
<text x="891" y="376" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="393" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="393" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="410" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="410" fill="#ffffff" textLength="162" lengthAdjust="spacingAndGlyphs">Plan Model Routing</text>
<text x="837" y="410" fill="#afafaf" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
<text x="45" y="410" fill="#ffffff" textLength="126" lengthAdjust="spacingAndGlyphs">Plan Directory</text>
<text x="792" y="410" fill="#afafaf" textLength="81" lengthAdjust="spacingAndGlyphs">undefined</text>
<text x="891" y="410" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="427" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="427" fill="#afafaf" textLength="765" lengthAdjust="spacingAndGlyphs">Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr</text>
<text x="45" y="427" fill="#afafaf" textLength="720" lengthAdjust="spacingAndGlyphs">The directory where planning artifacts are stored. If not specified, defaults t</text>
<text x="891" y="427" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="444" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="444" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="461" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="461" fill="#ffffff" textLength="162" lengthAdjust="spacingAndGlyphs">Retry Fetch Errors</text>
<text x="45" y="461" fill="#ffffff" textLength="162" lengthAdjust="spacingAndGlyphs">Plan Model Routing</text>
<text x="837" y="461" fill="#afafaf" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
<text x="891" y="461" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="478" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="478" fill="#afafaf" textLength="612" lengthAdjust="spacingAndGlyphs">Retry on &quot;exception TypeError: fetch failed sending request&quot; errors.</text>
<text x="45" y="478" fill="#afafaf" textLength="765" lengthAdjust="spacingAndGlyphs">Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr…</text>
<text x="891" y="478" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="495" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="495" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="512" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="512" fill="#ffffff" textLength="207" lengthAdjust="spacingAndGlyphs">Max Chat Model Attempts</text>
<text x="855" y="512" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">10</text>
<text x="45" y="512" fill="#ffffff" textLength="162" lengthAdjust="spacingAndGlyphs">Retry Fetch Errors</text>
<text x="837" y="512" fill="#afafaf" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
<text x="891" y="512" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="529" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="529" fill="#afafaf" textLength="729" lengthAdjust="spacingAndGlyphs">Maximum number of attempts for requests to the main chat model. Cannot exceed 10.</text>
<text x="45" y="529" fill="#afafaf" textLength="612" lengthAdjust="spacingAndGlyphs">Retry on &quot;exception TypeError: fetch failed sending request&quot; errors.</text>
<text x="891" y="529" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="546" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="546" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

@@ -76,38 +76,38 @@
<text x="0" y="342" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="342" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="359" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="359" fill="#ffffff" textLength="126" lengthAdjust="spacingAndGlyphs">Plan Directory</text>
<text x="792" y="359" fill="#afafaf" textLength="81" lengthAdjust="spacingAndGlyphs">undefined</text>
<text x="45" y="359" fill="#ffffff" textLength="144" lengthAdjust="spacingAndGlyphs">Enable Plan Mode</text>
<text x="837" y="359" fill="#afafaf" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
<text x="891" y="359" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="376" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="376" fill="#afafaf" textLength="720" lengthAdjust="spacingAndGlyphs">The directory where planning artifacts are stored. If not specified, defaults t…</text>
<text x="45" y="376" fill="#afafaf" textLength="486" lengthAdjust="spacingAndGlyphs">Enable Plan Mode for read-only safety during planning.</text>
<text x="891" y="376" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="393" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="393" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="410" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="410" fill="#ffffff" textLength="162" lengthAdjust="spacingAndGlyphs">Plan Model Routing</text>
<text x="837" y="410" fill="#afafaf" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
<text x="45" y="410" fill="#ffffff" textLength="126" lengthAdjust="spacingAndGlyphs">Plan Directory</text>
<text x="792" y="410" fill="#afafaf" textLength="81" lengthAdjust="spacingAndGlyphs">undefined</text>
<text x="891" y="410" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="427" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="427" fill="#afafaf" textLength="765" lengthAdjust="spacingAndGlyphs">Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr</text>
<text x="45" y="427" fill="#afafaf" textLength="720" lengthAdjust="spacingAndGlyphs">The directory where planning artifacts are stored. If not specified, defaults t</text>
<text x="891" y="427" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="444" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="444" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="461" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="461" fill="#ffffff" textLength="162" lengthAdjust="spacingAndGlyphs">Retry Fetch Errors</text>
<text x="45" y="461" fill="#ffffff" textLength="162" lengthAdjust="spacingAndGlyphs">Plan Model Routing</text>
<text x="837" y="461" fill="#afafaf" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
<text x="891" y="461" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="478" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="478" fill="#afafaf" textLength="612" lengthAdjust="spacingAndGlyphs">Retry on &quot;exception TypeError: fetch failed sending request&quot; errors.</text>
<text x="45" y="478" fill="#afafaf" textLength="765" lengthAdjust="spacingAndGlyphs">Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr…</text>
<text x="891" y="478" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="495" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="495" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="512" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="512" fill="#ffffff" textLength="207" lengthAdjust="spacingAndGlyphs">Max Chat Model Attempts</text>
<text x="855" y="512" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">10</text>
<text x="45" y="512" fill="#ffffff" textLength="162" lengthAdjust="spacingAndGlyphs">Retry Fetch Errors</text>
<text x="837" y="512" fill="#afafaf" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
<text x="891" y="512" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="529" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="529" fill="#afafaf" textLength="729" lengthAdjust="spacingAndGlyphs">Maximum number of attempts for requests to the main chat model. Cannot exceed 10.</text>
<text x="45" y="529" fill="#afafaf" textLength="612" lengthAdjust="spacingAndGlyphs">Retry on &quot;exception TypeError: fetch failed sending request&quot; errors.</text>
<text x="891" y="529" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="546" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="546" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

@@ -65,38 +65,38 @@
<text x="0" y="342" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="342" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="359" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="359" fill="#ffffff" textLength="126" lengthAdjust="spacingAndGlyphs">Plan Directory</text>
<text x="792" y="359" fill="#afafaf" textLength="81" lengthAdjust="spacingAndGlyphs">undefined</text>
<text x="45" y="359" fill="#ffffff" textLength="144" lengthAdjust="spacingAndGlyphs">Enable Plan Mode</text>
<text x="837" y="359" fill="#afafaf" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
<text x="891" y="359" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="376" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="376" fill="#afafaf" textLength="720" lengthAdjust="spacingAndGlyphs">The directory where planning artifacts are stored. If not specified, defaults t…</text>
<text x="45" y="376" fill="#afafaf" textLength="486" lengthAdjust="spacingAndGlyphs">Enable Plan Mode for read-only safety during planning.</text>
<text x="891" y="376" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="393" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="393" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="410" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="410" fill="#ffffff" textLength="162" lengthAdjust="spacingAndGlyphs">Plan Model Routing</text>
<text x="837" y="410" fill="#afafaf" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
<text x="45" y="410" fill="#ffffff" textLength="126" lengthAdjust="spacingAndGlyphs">Plan Directory</text>
<text x="792" y="410" fill="#afafaf" textLength="81" lengthAdjust="spacingAndGlyphs">undefined</text>
<text x="891" y="410" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="427" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="427" fill="#afafaf" textLength="765" lengthAdjust="spacingAndGlyphs">Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr</text>
<text x="45" y="427" fill="#afafaf" textLength="720" lengthAdjust="spacingAndGlyphs">The directory where planning artifacts are stored. If not specified, defaults t</text>
<text x="891" y="427" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="444" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="444" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="461" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="461" fill="#ffffff" textLength="162" lengthAdjust="spacingAndGlyphs">Retry Fetch Errors</text>
<text x="45" y="461" fill="#ffffff" textLength="162" lengthAdjust="spacingAndGlyphs">Plan Model Routing</text>
<text x="837" y="461" fill="#afafaf" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
<text x="891" y="461" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="478" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="478" fill="#afafaf" textLength="612" lengthAdjust="spacingAndGlyphs">Retry on &quot;exception TypeError: fetch failed sending request&quot; errors.</text>
<text x="45" y="478" fill="#afafaf" textLength="765" lengthAdjust="spacingAndGlyphs">Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr…</text>
<text x="891" y="478" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="495" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="495" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="512" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="512" fill="#ffffff" textLength="207" lengthAdjust="spacingAndGlyphs">Max Chat Model Attempts</text>
<text x="855" y="512" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">10</text>
<text x="45" y="512" fill="#ffffff" textLength="162" lengthAdjust="spacingAndGlyphs">Retry Fetch Errors</text>
<text x="837" y="512" fill="#afafaf" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
<text x="891" y="512" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="529" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="529" fill="#afafaf" textLength="729" lengthAdjust="spacingAndGlyphs">Maximum number of attempts for requests to the main chat model. Cannot exceed 10.</text>
<text x="45" y="529" fill="#afafaf" textLength="612" lengthAdjust="spacingAndGlyphs">Retry on &quot;exception TypeError: fetch failed sending request&quot; errors.</text>
<text x="891" y="529" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="546" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="546" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

@@ -76,38 +76,38 @@
<text x="0" y="342" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="342" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="359" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="359" fill="#ffffff" textLength="126" lengthAdjust="spacingAndGlyphs">Plan Directory</text>
<text x="792" y="359" fill="#afafaf" textLength="81" lengthAdjust="spacingAndGlyphs">undefined</text>
<text x="45" y="359" fill="#ffffff" textLength="144" lengthAdjust="spacingAndGlyphs">Enable Plan Mode</text>
<text x="837" y="359" fill="#afafaf" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
<text x="891" y="359" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="376" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="376" fill="#afafaf" textLength="720" lengthAdjust="spacingAndGlyphs">The directory where planning artifacts are stored. If not specified, defaults t…</text>
<text x="45" y="376" fill="#afafaf" textLength="486" lengthAdjust="spacingAndGlyphs">Enable Plan Mode for read-only safety during planning.</text>
<text x="891" y="376" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="393" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="393" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="410" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="410" fill="#ffffff" textLength="162" lengthAdjust="spacingAndGlyphs">Plan Model Routing</text>
<text x="837" y="410" fill="#afafaf" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
<text x="45" y="410" fill="#ffffff" textLength="126" lengthAdjust="spacingAndGlyphs">Plan Directory</text>
<text x="792" y="410" fill="#afafaf" textLength="81" lengthAdjust="spacingAndGlyphs">undefined</text>
<text x="891" y="410" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="427" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="427" fill="#afafaf" textLength="765" lengthAdjust="spacingAndGlyphs">Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr</text>
<text x="45" y="427" fill="#afafaf" textLength="720" lengthAdjust="spacingAndGlyphs">The directory where planning artifacts are stored. If not specified, defaults t</text>
<text x="891" y="427" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="444" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="444" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="461" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="461" fill="#ffffff" textLength="162" lengthAdjust="spacingAndGlyphs">Retry Fetch Errors</text>
<text x="45" y="461" fill="#ffffff" textLength="162" lengthAdjust="spacingAndGlyphs">Plan Model Routing</text>
<text x="837" y="461" fill="#afafaf" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
<text x="891" y="461" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="478" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="478" fill="#afafaf" textLength="612" lengthAdjust="spacingAndGlyphs">Retry on &quot;exception TypeError: fetch failed sending request&quot; errors.</text>
<text x="45" y="478" fill="#afafaf" textLength="765" lengthAdjust="spacingAndGlyphs">Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr…</text>
<text x="891" y="478" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="495" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="495" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="512" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="512" fill="#ffffff" textLength="207" lengthAdjust="spacingAndGlyphs">Max Chat Model Attempts</text>
<text x="855" y="512" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">10</text>
<text x="45" y="512" fill="#ffffff" textLength="162" lengthAdjust="spacingAndGlyphs">Retry Fetch Errors</text>
<text x="837" y="512" fill="#afafaf" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
<text x="891" y="512" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="529" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="529" fill="#afafaf" textLength="729" lengthAdjust="spacingAndGlyphs">Maximum number of attempts for requests to the main chat model. Cannot exceed 10.</text>
<text x="45" y="529" fill="#afafaf" textLength="612" lengthAdjust="spacingAndGlyphs">Retry on &quot;exception TypeError: fetch failed sending request&quot; errors.</text>
<text x="891" y="529" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="546" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="546" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

@@ -76,38 +76,38 @@
<text x="0" y="342" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="342" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="359" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="359" fill="#ffffff" textLength="126" lengthAdjust="spacingAndGlyphs">Plan Directory</text>
<text x="792" y="359" fill="#afafaf" textLength="81" lengthAdjust="spacingAndGlyphs">undefined</text>
<text x="45" y="359" fill="#ffffff" textLength="144" lengthAdjust="spacingAndGlyphs">Enable Plan Mode</text>
<text x="837" y="359" fill="#afafaf" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
<text x="891" y="359" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="376" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="376" fill="#afafaf" textLength="720" lengthAdjust="spacingAndGlyphs">The directory where planning artifacts are stored. If not specified, defaults t…</text>
<text x="45" y="376" fill="#afafaf" textLength="486" lengthAdjust="spacingAndGlyphs">Enable Plan Mode for read-only safety during planning.</text>
<text x="891" y="376" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="393" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="393" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="410" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="410" fill="#ffffff" textLength="162" lengthAdjust="spacingAndGlyphs">Plan Model Routing</text>
<text x="837" y="410" fill="#afafaf" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
<text x="45" y="410" fill="#ffffff" textLength="126" lengthAdjust="spacingAndGlyphs">Plan Directory</text>
<text x="792" y="410" fill="#afafaf" textLength="81" lengthAdjust="spacingAndGlyphs">undefined</text>
<text x="891" y="410" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="427" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="427" fill="#afafaf" textLength="765" lengthAdjust="spacingAndGlyphs">Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr</text>
<text x="45" y="427" fill="#afafaf" textLength="720" lengthAdjust="spacingAndGlyphs">The directory where planning artifacts are stored. If not specified, defaults t</text>
<text x="891" y="427" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="444" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="444" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="461" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="461" fill="#ffffff" textLength="162" lengthAdjust="spacingAndGlyphs">Retry Fetch Errors</text>
<text x="45" y="461" fill="#ffffff" textLength="162" lengthAdjust="spacingAndGlyphs">Plan Model Routing</text>
<text x="837" y="461" fill="#afafaf" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
<text x="891" y="461" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="478" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="478" fill="#afafaf" textLength="612" lengthAdjust="spacingAndGlyphs">Retry on &quot;exception TypeError: fetch failed sending request&quot; errors.</text>
<text x="45" y="478" fill="#afafaf" textLength="765" lengthAdjust="spacingAndGlyphs">Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr…</text>
<text x="891" y="478" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="495" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="495" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="512" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="512" fill="#ffffff" textLength="207" lengthAdjust="spacingAndGlyphs">Max Chat Model Attempts</text>
<text x="855" y="512" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">10</text>
<text x="45" y="512" fill="#ffffff" textLength="162" lengthAdjust="spacingAndGlyphs">Retry Fetch Errors</text>
<text x="837" y="512" fill="#afafaf" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
<text x="891" y="512" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="529" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="529" fill="#afafaf" textLength="729" lengthAdjust="spacingAndGlyphs">Maximum number of attempts for requests to the main chat model. Cannot exceed 10.</text>
<text x="45" y="529" fill="#afafaf" textLength="612" lengthAdjust="spacingAndGlyphs">Retry on &quot;exception TypeError: fetch failed sending request&quot; errors.</text>
<text x="891" y="529" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="546" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="546" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

@@ -76,38 +76,38 @@
<text x="0" y="342" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="342" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="359" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="359" fill="#ffffff" textLength="126" lengthAdjust="spacingAndGlyphs">Plan Directory</text>
<text x="792" y="359" fill="#afafaf" textLength="81" lengthAdjust="spacingAndGlyphs">undefined</text>
<text x="45" y="359" fill="#ffffff" textLength="144" lengthAdjust="spacingAndGlyphs">Enable Plan Mode</text>
<text x="837" y="359" fill="#afafaf" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
<text x="891" y="359" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="376" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="376" fill="#afafaf" textLength="720" lengthAdjust="spacingAndGlyphs">The directory where planning artifacts are stored. If not specified, defaults t…</text>
<text x="45" y="376" fill="#afafaf" textLength="486" lengthAdjust="spacingAndGlyphs">Enable Plan Mode for read-only safety during planning.</text>
<text x="891" y="376" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="393" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="393" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="410" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="410" fill="#ffffff" textLength="162" lengthAdjust="spacingAndGlyphs">Plan Model Routing</text>
<text x="837" y="410" fill="#afafaf" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
<text x="45" y="410" fill="#ffffff" textLength="126" lengthAdjust="spacingAndGlyphs">Plan Directory</text>
<text x="792" y="410" fill="#afafaf" textLength="81" lengthAdjust="spacingAndGlyphs">undefined</text>
<text x="891" y="410" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="427" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="427" fill="#afafaf" textLength="765" lengthAdjust="spacingAndGlyphs">Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr</text>
<text x="45" y="427" fill="#afafaf" textLength="720" lengthAdjust="spacingAndGlyphs">The directory where planning artifacts are stored. If not specified, defaults t</text>
<text x="891" y="427" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="444" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="444" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="461" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="461" fill="#ffffff" textLength="162" lengthAdjust="spacingAndGlyphs">Retry Fetch Errors</text>
<text x="45" y="461" fill="#ffffff" textLength="162" lengthAdjust="spacingAndGlyphs">Plan Model Routing</text>
<text x="837" y="461" fill="#afafaf" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
<text x="891" y="461" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="478" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="478" fill="#afafaf" textLength="612" lengthAdjust="spacingAndGlyphs">Retry on &quot;exception TypeError: fetch failed sending request&quot; errors.</text>
<text x="45" y="478" fill="#afafaf" textLength="765" lengthAdjust="spacingAndGlyphs">Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr…</text>
<text x="891" y="478" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="495" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="495" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="512" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="512" fill="#ffffff" textLength="207" lengthAdjust="spacingAndGlyphs">Max Chat Model Attempts</text>
<text x="855" y="512" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">10</text>
<text x="45" y="512" fill="#ffffff" textLength="162" lengthAdjust="spacingAndGlyphs">Retry Fetch Errors</text>
<text x="837" y="512" fill="#afafaf" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
<text x="891" y="512" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="529" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="529" fill="#afafaf" textLength="729" lengthAdjust="spacingAndGlyphs">Maximum number of attempts for requests to the main chat model. Cannot exceed 10.</text>
<text x="45" y="529" fill="#afafaf" textLength="612" lengthAdjust="spacingAndGlyphs">Retry on &quot;exception TypeError: fetch failed sending request&quot; errors.</text>
<text x="891" y="529" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="546" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="546" fill="#878787" textLength="9" lengthAdjust="spacingAndGlyphs"></text>

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

@@ -22,6 +22,9 @@ exports[`SettingsDialog > Initial Rendering > should render settings list with v
│ Enable Notifications false │
│ Enable run-event notifications for action-required prompts and session completion. │
│ │
│ Enable Plan Mode true │
│ Enable Plan Mode for read-only safety during planning. │
│ │
│ Plan Directory undefined │
│ The directory where planning artifacts are stored. If not specified, defaults t… │
│ │
@@ -31,9 +34,6 @@ exports[`SettingsDialog > Initial Rendering > should render settings list with v
│ Retry Fetch Errors true │
│ Retry on "exception TypeError: fetch failed sending request" errors. │
│ │
│ Max Chat Model Attempts 10 │
│ Maximum number of attempts for requests to the main chat model. Cannot exceed 10. │
│ │
│ ▼ │
│ │
│ Apply To │
@@ -68,6 +68,9 @@ exports[`SettingsDialog > Snapshot Tests > should render 'accessibility settings
│ Enable Notifications false │
│ Enable run-event notifications for action-required prompts and session completion. │
│ │
│ Enable Plan Mode true │
│ Enable Plan Mode for read-only safety during planning. │
│ │
│ Plan Directory undefined │
│ The directory where planning artifacts are stored. If not specified, defaults t… │
│ │
@@ -77,9 +80,6 @@ exports[`SettingsDialog > Snapshot Tests > should render 'accessibility settings
│ Retry Fetch Errors true │
│ Retry on "exception TypeError: fetch failed sending request" errors. │
│ │
│ Max Chat Model Attempts 10 │
│ Maximum number of attempts for requests to the main chat model. Cannot exceed 10. │
│ │
│ ▼ │
│ │
│ Apply To │
@@ -114,6 +114,9 @@ exports[`SettingsDialog > Snapshot Tests > should render 'all boolean settings d
│ Enable Notifications false │
│ Enable run-event notifications for action-required prompts and session completion. │
│ │
│ Enable Plan Mode true │
│ Enable Plan Mode for read-only safety during planning. │
│ │
│ Plan Directory undefined │
│ The directory where planning artifacts are stored. If not specified, defaults t… │
│ │
@@ -123,9 +126,6 @@ exports[`SettingsDialog > Snapshot Tests > should render 'all boolean settings d
│ Retry Fetch Errors true │
│ Retry on "exception TypeError: fetch failed sending request" errors. │
│ │
│ Max Chat Model Attempts 10 │
│ Maximum number of attempts for requests to the main chat model. Cannot exceed 10. │
│ │
│ ▼ │
│ │
│ Apply To │
@@ -160,6 +160,9 @@ exports[`SettingsDialog > Snapshot Tests > should render 'default state' correct
│ Enable Notifications false │
│ Enable run-event notifications for action-required prompts and session completion. │
│ │
│ Enable Plan Mode true │
│ Enable Plan Mode for read-only safety during planning. │
│ │
│ Plan Directory undefined │
│ The directory where planning artifacts are stored. If not specified, defaults t… │
│ │
@@ -169,9 +172,6 @@ exports[`SettingsDialog > Snapshot Tests > should render 'default state' correct
│ Retry Fetch Errors true │
│ Retry on "exception TypeError: fetch failed sending request" errors. │
│ │
│ Max Chat Model Attempts 10 │
│ Maximum number of attempts for requests to the main chat model. Cannot exceed 10. │
│ │
│ ▼ │
│ │
│ Apply To │
@@ -206,6 +206,9 @@ exports[`SettingsDialog > Snapshot Tests > should render 'file filtering setting
│ Enable Notifications false │
│ Enable run-event notifications for action-required prompts and session completion. │
│ │
│ Enable Plan Mode true │
│ Enable Plan Mode for read-only safety during planning. │
│ │
│ Plan Directory undefined │
│ The directory where planning artifacts are stored. If not specified, defaults t… │
│ │
@@ -215,9 +218,6 @@ exports[`SettingsDialog > Snapshot Tests > should render 'file filtering setting
│ Retry Fetch Errors true │
│ Retry on "exception TypeError: fetch failed sending request" errors. │
│ │
│ Max Chat Model Attempts 10 │
│ Maximum number of attempts for requests to the main chat model. Cannot exceed 10. │
│ │
│ ▼ │
│ │
│ Apply To │
@@ -252,6 +252,9 @@ exports[`SettingsDialog > Snapshot Tests > should render 'focused on scope selec
│ Enable Notifications false │
│ Enable run-event notifications for action-required prompts and session completion. │
│ │
│ Enable Plan Mode true │
│ Enable Plan Mode for read-only safety during planning. │
│ │
│ Plan Directory undefined │
│ The directory where planning artifacts are stored. If not specified, defaults t… │
│ │
@@ -261,9 +264,6 @@ exports[`SettingsDialog > Snapshot Tests > should render 'focused on scope selec
│ Retry Fetch Errors true │
│ Retry on "exception TypeError: fetch failed sending request" errors. │
│ │
│ Max Chat Model Attempts 10 │
│ Maximum number of attempts for requests to the main chat model. Cannot exceed 10. │
│ │
│ ▼ │
│ │
│ > Apply To │
@@ -298,6 +298,9 @@ exports[`SettingsDialog > Snapshot Tests > should render 'mixed boolean and numb
│ Enable Notifications false │
│ Enable run-event notifications for action-required prompts and session completion. │
│ │
│ Enable Plan Mode true │
│ Enable Plan Mode for read-only safety during planning. │
│ │
│ Plan Directory undefined │
│ The directory where planning artifacts are stored. If not specified, defaults t… │
│ │
@@ -307,9 +310,6 @@ exports[`SettingsDialog > Snapshot Tests > should render 'mixed boolean and numb
│ Retry Fetch Errors true │
│ Retry on "exception TypeError: fetch failed sending request" errors. │
│ │
│ Max Chat Model Attempts 10 │
│ Maximum number of attempts for requests to the main chat model. Cannot exceed 10. │
│ │
│ ▼ │
│ │
│ Apply To │
@@ -344,6 +344,9 @@ exports[`SettingsDialog > Snapshot Tests > should render 'tools and security set
│ Enable Notifications false │
│ Enable run-event notifications for action-required prompts and session completion. │
│ │
│ Enable Plan Mode true │
│ Enable Plan Mode for read-only safety during planning. │
│ │
│ Plan Directory undefined │
│ The directory where planning artifacts are stored. If not specified, defaults t… │
│ │
@@ -353,9 +356,6 @@ exports[`SettingsDialog > Snapshot Tests > should render 'tools and security set
│ Retry Fetch Errors true │
│ Retry on "exception TypeError: fetch failed sending request" errors. │
│ │
│ Max Chat Model Attempts 10 │
│ Maximum number of attempts for requests to the main chat model. Cannot exceed 10. │
│ │
│ ▼ │
│ │
│ Apply To │
@@ -390,6 +390,9 @@ exports[`SettingsDialog > Snapshot Tests > should render 'various boolean settin
│ Enable Notifications false │
│ Enable run-event notifications for action-required prompts and session completion. │
│ │
│ Enable Plan Mode true │
│ Enable Plan Mode for read-only safety during planning. │
│ │
│ Plan Directory undefined │
│ The directory where planning artifacts are stored. If not specified, defaults t… │
│ │
@@ -399,9 +402,6 @@ exports[`SettingsDialog > Snapshot Tests > should render 'various boolean settin
│ Retry Fetch Errors true │
│ Retry on "exception TypeError: fetch failed sending request" errors. │
│ │
│ Max Chat Model Attempts 10 │
│ Maximum number of attempts for requests to the main chat model. Cannot exceed 10. │
│ │
│ ▼ │
│ │
│ Apply To │
@@ -0,0 +1,577 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import { renderWithProviders } from '../../../test-utils/render.js';
import { DenseToolMessage } from './DenseToolMessage.js';
import {
CoreToolCallStatus,
type DiffStat,
type FileDiff,
type GrepResult,
type ListDirectoryResult,
type ReadManyFilesResult,
makeFakeConfig,
} from '@google/gemini-cli-core';
import type {
SerializableConfirmationDetails,
ToolResultDisplay,
} from '../../types.js';
import { createMockSettings } from '../../../test-utils/settings.js';
describe('DenseToolMessage', () => {
const defaultProps = {
callId: 'call-1',
name: 'test-tool',
description: 'Test description',
status: CoreToolCallStatus.Success,
resultDisplay: 'Success result' as ToolResultDisplay,
confirmationDetails: undefined,
terminalWidth: 80,
};
it('renders correctly for a successful string result', async () => {
const { lastFrame, waitUntilReady } = await renderWithProviders(
<DenseToolMessage {...defaultProps} />,
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('test-tool');
expect(output).toContain('Test description');
expect(output).toContain('→ Success result');
expect(output).toMatchSnapshot();
});
it('truncates long string results', async () => {
const longResult = 'A'.repeat(200);
const { lastFrame, waitUntilReady } = await renderWithProviders(
<DenseToolMessage
{...defaultProps}
resultDisplay={longResult as ToolResultDisplay}
/>,
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('…');
expect(lastFrame()).toMatchSnapshot();
});
it('flattens newlines in string results', async () => {
const multilineResult = 'Line 1\nLine 2';
const { lastFrame, waitUntilReady } = await renderWithProviders(
<DenseToolMessage
{...defaultProps}
resultDisplay={multilineResult as ToolResultDisplay}
/>,
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('→ Line 1 Line 2');
expect(output).toMatchSnapshot();
});
it('renders correctly for file diff results with stats', async () => {
const diffResult: FileDiff = {
fileDiff: '@@ -1,1 +1,1 @@\n-old line\n+diff content',
fileName: 'test.ts',
filePath: '/path/to/test.ts',
originalContent: 'old content',
newContent: 'new content',
diffStat: {
user_added_lines: 5,
user_removed_lines: 2,
user_added_chars: 50,
user_removed_chars: 20,
model_added_lines: 10,
model_removed_lines: 4,
model_added_chars: 100,
model_removed_chars: 40,
},
};
const { lastFrame, waitUntilReady } = await renderWithProviders(
<DenseToolMessage
{...defaultProps}
resultDisplay={diffResult as ToolResultDisplay}
/>,
{},
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('test.ts → Accepted (+15, -6)');
expect(output).toContain('diff content');
expect(output).toMatchSnapshot();
});
it('renders correctly for Edit tool using confirmationDetails', async () => {
const confirmationDetails = {
type: 'edit' as const,
title: 'Confirm Edit',
fileName: 'styles.scss',
filePath: '/path/to/styles.scss',
fileDiff:
'@@ -1,1 +1,1 @@\n-body { color: blue; }\n+body { color: red; }',
originalContent: 'body { color: blue; }',
newContent: 'body { color: red; }',
};
const { lastFrame, waitUntilReady } = await renderWithProviders(
<DenseToolMessage
{...defaultProps}
name="Edit"
status={CoreToolCallStatus.AwaitingApproval}
resultDisplay={undefined}
confirmationDetails={
confirmationDetails as SerializableConfirmationDetails
}
/>,
{},
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('Edit');
expect(output).toContain('styles.scss');
expect(output).toContain('→ Confirming');
expect(output).toContain('body { color: red; }');
expect(output).toMatchSnapshot();
});
it('renders correctly for Rejected Edit tool', async () => {
const diffResult: FileDiff = {
fileDiff: '@@ -1,1 +1,1 @@\n-old line\n+new line',
fileName: 'styles.scss',
filePath: '/path/to/styles.scss',
originalContent: 'old line',
newContent: 'new line',
diffStat: {
user_added_lines: 1,
user_removed_lines: 1,
user_added_chars: 0,
user_removed_chars: 0,
model_added_lines: 0,
model_removed_lines: 0,
model_added_chars: 0,
model_removed_chars: 0,
},
};
const { lastFrame, waitUntilReady } = await renderWithProviders(
<DenseToolMessage
{...defaultProps}
name="Edit"
status={CoreToolCallStatus.Cancelled}
resultDisplay={diffResult as ToolResultDisplay}
/>,
{},
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('Edit');
expect(output).toContain('styles.scss → Rejected (+1, -1)');
expect(output).toContain('- old line');
expect(output).toContain('+ new line');
expect(output).toMatchSnapshot();
});
it('renders correctly for Rejected Edit tool with confirmationDetails and diffStat', async () => {
const confirmationDetails = {
type: 'edit' as const,
title: 'Confirm Edit',
fileName: 'styles.scss',
filePath: '/path/to/styles.scss',
fileDiff:
'@@ -1,1 +1,1 @@\n-body { color: blue; }\n+body { color: red; }',
originalContent: 'body { color: blue; }',
newContent: 'body { color: red; }',
diffStat: {
user_added_lines: 1,
user_removed_lines: 1,
user_added_chars: 0,
user_removed_chars: 0,
model_added_lines: 0,
model_removed_lines: 0,
model_added_chars: 0,
model_removed_chars: 0,
} as DiffStat,
};
const { lastFrame, waitUntilReady } = await renderWithProviders(
<DenseToolMessage
{...defaultProps}
name="Edit"
status={CoreToolCallStatus.Cancelled}
resultDisplay={undefined}
confirmationDetails={
confirmationDetails as unknown as SerializableConfirmationDetails
}
/>,
{},
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('Edit');
expect(output).toContain('styles.scss → Rejected (+1, -1)');
expect(output).toMatchSnapshot();
});
it('renders correctly for WriteFile tool', async () => {
const diffResult: FileDiff = {
fileDiff: '@@ -1,1 +1,1 @@\n-old content\n+new content',
fileName: 'config.json',
filePath: '/path/to/config.json',
originalContent: 'old content',
newContent: 'new content',
diffStat: {
user_added_lines: 1,
user_removed_lines: 1,
user_added_chars: 0,
user_removed_chars: 0,
model_added_lines: 0,
model_removed_lines: 0,
model_added_chars: 0,
model_removed_chars: 0,
},
};
const { lastFrame, waitUntilReady } = await renderWithProviders(
<DenseToolMessage
{...defaultProps}
name="WriteFile"
status={CoreToolCallStatus.Success}
resultDisplay={diffResult as ToolResultDisplay}
/>,
{},
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('WriteFile');
expect(output).toContain('config.json → Accepted (+1, -1)');
expect(output).toContain('+ new content');
expect(output).toMatchSnapshot();
});
it('renders correctly for Rejected WriteFile tool', async () => {
const diffResult: FileDiff = {
fileDiff: '@@ -1,1 +1,1 @@\n-old content\n+new content',
fileName: 'config.json',
filePath: '/path/to/config.json',
originalContent: 'old content',
newContent: 'new content',
};
const { lastFrame, waitUntilReady } = await renderWithProviders(
<DenseToolMessage
{...defaultProps}
name="WriteFile"
status={CoreToolCallStatus.Cancelled}
resultDisplay={diffResult as ToolResultDisplay}
/>,
{},
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('WriteFile');
expect(output).toContain('config.json');
expect(output).toContain('→ Rejected');
expect(output).toContain('- old content');
expect(output).toContain('+ new content');
expect(output).toMatchSnapshot();
});
it('renders correctly for Errored Edit tool', async () => {
const diffResult: FileDiff = {
fileDiff: '@@ -1,1 +1,1 @@\n-old line\n+new line',
fileName: 'styles.scss',
filePath: '/path/to/styles.scss',
originalContent: 'old line',
newContent: 'new line',
diffStat: {
user_added_lines: 1,
user_removed_lines: 1,
user_added_chars: 0,
user_removed_chars: 0,
model_added_lines: 0,
model_removed_lines: 0,
model_added_chars: 0,
model_removed_chars: 0,
},
};
const { lastFrame, waitUntilReady } = await renderWithProviders(
<DenseToolMessage
{...defaultProps}
name="Edit"
status={CoreToolCallStatus.Error}
resultDisplay={diffResult as ToolResultDisplay}
/>,
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('Edit');
expect(output).toContain('styles.scss → Failed (+1, -1)');
expect(output).toMatchSnapshot();
});
it('renders correctly for grep results', async () => {
const grepResult: GrepResult = {
summary: 'Found 2 matches',
matches: [
{
filePath: 'file1.ts',
absolutePath: '/file1.ts',
lineNumber: 10,
line: 'match 1',
},
{
filePath: 'file2.ts',
absolutePath: '/file2.ts',
lineNumber: 20,
line: 'match 2',
},
],
};
const { lastFrame, waitUntilReady } = await renderWithProviders(
<DenseToolMessage
{...defaultProps}
resultDisplay={grepResult as unknown as ToolResultDisplay}
/>,
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('→ Found 2 matches');
// Matches are rendered in a secondary list for high-signal summaries
expect(output).toContain('file1.ts:10: match 1');
expect(output).toContain('file2.ts:20: match 2');
expect(output).toMatchSnapshot();
});
it('renders correctly for ls results', async () => {
const lsResult: ListDirectoryResult = {
summary: 'Listed 2 files. (1 ignored)',
files: ['file1.ts', 'dir1'],
};
const { lastFrame, waitUntilReady } = await renderWithProviders(
<DenseToolMessage
{...defaultProps}
resultDisplay={lsResult as unknown as ToolResultDisplay}
/>,
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('→ Listed 2 files. (1 ignored)');
// Directory listings should not have a payload in dense mode
expect(output).not.toContain('file1.ts');
expect(output).not.toContain('dir1');
expect(output).toMatchSnapshot();
});
it('renders correctly for ReadManyFiles results', async () => {
const rmfResult: ReadManyFilesResult = {
summary: 'Read 3 file(s)',
files: ['file1.ts', 'file2.ts', 'file3.ts'],
include: ['**/*.ts'],
skipped: [{ path: 'skipped.bin', reason: 'binary' }],
};
const { lastFrame, waitUntilReady } = await renderWithProviders(
<DenseToolMessage
{...defaultProps}
resultDisplay={rmfResult as unknown as ToolResultDisplay}
/>,
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('Attempting to read files from **/*.ts');
expect(output).toContain('→ Read 3 file(s) (1 ignored)');
expect(output).toContain('file1.ts');
expect(output).toContain('file2.ts');
expect(output).toContain('file3.ts');
expect(output).toMatchSnapshot();
});
it('renders correctly for todo updates', async () => {
const todoResult = {
todos: [],
};
const { lastFrame, waitUntilReady } = await renderWithProviders(
<DenseToolMessage
{...defaultProps}
resultDisplay={todoResult as ToolResultDisplay}
/>,
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('→ Todos updated');
expect(output).toMatchSnapshot();
});
it('renders generic output message for unknown object results', async () => {
const genericResult = {
some: 'data',
} as unknown as ToolResultDisplay;
const { lastFrame, waitUntilReady } = await renderWithProviders(
<DenseToolMessage {...defaultProps} resultDisplay={genericResult} />,
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('→ Returned (possible empty result)');
expect(output).toMatchSnapshot();
});
it('renders correctly for error status with string message', async () => {
const { lastFrame, waitUntilReady } = await renderWithProviders(
<DenseToolMessage
{...defaultProps}
status={CoreToolCallStatus.Error}
resultDisplay={'Error occurred' as ToolResultDisplay}
/>,
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('→ Error occurred');
expect(output).toMatchSnapshot();
});
it('renders generic failure message for error status without string message', async () => {
const { lastFrame, waitUntilReady } = await renderWithProviders(
<DenseToolMessage
{...defaultProps}
status={CoreToolCallStatus.Error}
resultDisplay={undefined}
/>,
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('→ Failed');
expect(output).toMatchSnapshot();
});
it('does not render result arrow if resultDisplay is missing', async () => {
const { lastFrame, waitUntilReady } = await renderWithProviders(
<DenseToolMessage
{...defaultProps}
status={CoreToolCallStatus.Scheduled}
resultDisplay={undefined}
/>,
);
await waitUntilReady();
const output = lastFrame();
expect(output).not.toContain('→');
expect(output).toMatchSnapshot();
});
describe('Toggleable Diff View (Alternate Buffer)', () => {
const diffResult: FileDiff = {
fileDiff: '@@ -1,1 +1,1 @@\n-old line\n+new line',
fileName: 'test.ts',
filePath: '/path/to/test.ts',
originalContent: 'old content',
newContent: 'new content',
};
it('hides diff content by default when in alternate buffer mode', async () => {
const { lastFrame, waitUntilReady } = await renderWithProviders(
<DenseToolMessage
{...defaultProps}
resultDisplay={diffResult as ToolResultDisplay}
status={CoreToolCallStatus.Success}
/>,
{
config: makeFakeConfig({ useAlternateBuffer: true }),
settings: createMockSettings({ ui: { useAlternateBuffer: true } }),
},
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('Accepted');
expect(output).not.toContain('new line');
expect(output).toMatchSnapshot();
});
it('shows diff content by default when NOT in alternate buffer mode', async () => {
const { lastFrame, waitUntilReady } = await renderWithProviders(
<DenseToolMessage
{...defaultProps}
resultDisplay={diffResult as ToolResultDisplay}
status={CoreToolCallStatus.Success}
/>,
{
config: makeFakeConfig({ useAlternateBuffer: false }),
settings: createMockSettings({ ui: { useAlternateBuffer: false } }),
},
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('Accepted');
expect(output).toContain('new line');
expect(output).toMatchSnapshot();
});
it('shows diff content when expanded via ToolActionsContext', async () => {
const { lastFrame, waitUntilReady } = await renderWithProviders(
<DenseToolMessage
{...defaultProps}
resultDisplay={diffResult as ToolResultDisplay}
status={CoreToolCallStatus.Success}
/>,
{
config: makeFakeConfig({ useAlternateBuffer: true }),
settings: createMockSettings({ ui: { useAlternateBuffer: true } }),
toolActions: {
isExpanded: () => true,
},
},
);
await waitUntilReady();
// Verify it shows the diff when expanded
expect(lastFrame()).toContain('new line');
});
});
describe('Visual Regression', () => {
it('matches SVG snapshot for an Accepted file edit with diff stats', async () => {
const diffResult: FileDiff = {
fileName: 'test.ts',
filePath: '/mock/test.ts',
fileDiff: '--- a/test.ts\n+++ b/test.ts\n@@ -1 +1 @@\n-old\n+new',
originalContent: 'old',
newContent: 'new',
diffStat: {
model_added_lines: 1,
model_removed_lines: 1,
model_added_chars: 3,
model_removed_chars: 3,
user_added_lines: 0,
user_removed_lines: 0,
user_added_chars: 0,
user_removed_chars: 0,
},
};
const renderResult = await renderWithProviders(
<DenseToolMessage
{...defaultProps}
name="edit"
description="Editing test.ts"
resultDisplay={diffResult as ToolResultDisplay}
status={CoreToolCallStatus.Success}
/>,
);
await renderResult.waitUntilReady();
await expect(renderResult).toMatchSvgSnapshot();
});
it('matches SVG snapshot for a Rejected tool call', async () => {
const renderResult = await renderWithProviders(
<DenseToolMessage
{...defaultProps}
name="read_file"
description="Reading important.txt"
resultDisplay="Rejected by user"
status={CoreToolCallStatus.Cancelled}
/>,
);
await renderResult.waitUntilReady();
await expect(renderResult).toMatchSvgSnapshot();
});
});
});
@@ -0,0 +1,563 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { useMemo, useState, useRef } from 'react';
import { Box, Text, type DOMElement } from 'ink';
import {
CoreToolCallStatus,
type FileDiff,
type ListDirectoryResult,
type ReadManyFilesResult,
isFileDiff,
hasSummary,
isGrepResult,
isListResult,
isReadManyFilesResult,
} from '@google/gemini-cli-core';
import {
type IndividualToolCallDisplay,
type ToolResultDisplay,
isTodoList,
} from '../../types.js';
import { useAlternateBuffer } from '../../hooks/useAlternateBuffer.js';
import { ToolStatusIndicator } from './ToolShared.js';
import { theme } from '../../semantic-colors.js';
import {
DiffRenderer,
renderDiffLines,
isNewFile,
parseDiffWithLineNumbers,
} from './DiffRenderer.js';
import { useMouseClick } from '../../hooks/useMouseClick.js';
import { ScrollableList } from '../shared/ScrollableList.js';
import { COMPACT_TOOL_SUBVIEW_MAX_LINES } from '../../constants.js';
import { useSettings } from '../../contexts/SettingsContext.js';
import { colorizeCode } from '../../utils/CodeColorizer.js';
import { useToolActions } from '../../contexts/ToolActionsContext.js';
import { getFileExtension } from '../../utils/fileUtils.js';
const PAYLOAD_MARGIN_LEFT = 6;
const PAYLOAD_BORDER_CHROME_WIDTH = 4; // paddingX=1 (2 cols) + borders (2 cols)
const PAYLOAD_SCROLL_GUTTER = 4;
const PAYLOAD_MAX_WIDTH = 120 + PAYLOAD_SCROLL_GUTTER;
interface DenseToolMessageProps extends IndividualToolCallDisplay {
terminalWidth: number;
availableTerminalHeight?: number;
}
interface ViewParts {
// brief description of action
description?: React.ReactNode;
// result summary or status text
summary?: React.ReactNode;
// detailed output, e.g. diff or command output
payload?: React.ReactNode;
}
interface PayloadResult {
summary: string;
payload: string;
}
const hasPayload = (res: unknown): res is PayloadResult => {
if (!hasSummary(res)) return false;
if (!('payload' in res)) return false;
const value = (res as { payload?: unknown }).payload;
return typeof value === 'string';
};
const RenderItemsList: React.FC<{
items?: string[];
maxVisible?: number;
}> = ({ items, maxVisible = 20 }) => {
if (!items || items.length === 0) return null;
return (
<Box flexDirection="column">
{items.slice(0, maxVisible).map((item, i) => (
<Text key={i} color={theme.text.secondary}>
{item}
</Text>
))}
{items.length > maxVisible && (
<Text color={theme.text.secondary}>
... and {items.length - maxVisible} more
</Text>
)}
</Box>
);
};
function getFileOpData(
diff: FileDiff,
status: CoreToolCallStatus,
resultDisplay: ToolResultDisplay | undefined,
terminalWidth: number,
availableTerminalHeight: number | undefined,
isClickable: boolean,
): ViewParts {
const added =
(diff.diffStat?.model_added_lines ?? 0) +
(diff.diffStat?.user_added_lines ?? 0);
const removed =
(diff.diffStat?.model_removed_lines ?? 0) +
(diff.diffStat?.user_removed_lines ?? 0);
const isAcceptedOrConfirming =
status === CoreToolCallStatus.Success ||
status === CoreToolCallStatus.Executing ||
status === CoreToolCallStatus.AwaitingApproval;
const addColor = isAcceptedOrConfirming
? theme.status.success
: theme.text.secondary;
const removeColor = isAcceptedOrConfirming
? theme.status.error
: theme.text.secondary;
// Always show diff stats if available, using neutral colors for rejected
const showDiffStat = !!diff.diffStat;
const description = (
<Box flexDirection="row">
<Text color={theme.text.secondary} wrap="truncate-end">
{diff.fileName}
</Text>
</Box>
);
let resultSummary = '';
let resultColor = theme.text.secondary;
if (status === CoreToolCallStatus.AwaitingApproval) {
resultSummary = 'Confirming';
} else if (
status === CoreToolCallStatus.Success ||
status === CoreToolCallStatus.Executing
) {
resultSummary = 'Accepted';
resultColor = theme.text.accent;
} else if (status === CoreToolCallStatus.Cancelled) {
resultSummary = 'Rejected';
resultColor = theme.status.error;
} else if (status === CoreToolCallStatus.Error) {
resultSummary =
typeof resultDisplay === 'string' ? resultDisplay : 'Failed';
resultColor = theme.status.error;
}
const summary = (
<Box flexDirection="row">
{resultSummary && (
<Text color={resultColor} wrap="truncate-end">
{' '}
<Text underline={isClickable}>
{resultSummary.replace(/\n/g, ' ')}
</Text>
</Text>
)}
{showDiffStat && (
<Box marginLeft={1} marginRight={2}>
<Text color={theme.text.secondary}>
{'('}
<Text color={addColor}>+{added}</Text>
{', '}
<Text color={removeColor}>-{removed}</Text>
{')'}
</Text>
</Box>
)}
</Box>
);
const payload = (
<DiffRenderer
diffContent={diff.fileDiff}
filename={diff.fileName}
terminalWidth={terminalWidth - PAYLOAD_MARGIN_LEFT}
availableTerminalHeight={availableTerminalHeight}
disableColor={status === CoreToolCallStatus.Cancelled}
/>
);
return { description, summary, payload };
}
function getReadManyFilesData(result: ReadManyFilesResult): ViewParts {
const items = result.files ?? [];
const maxVisible = 10;
const includePatterns = result.include?.join(', ') ?? '';
const description = (
<Text color={theme.text.secondary} wrap="truncate-end">
Attempting to read files from {includePatterns}
</Text>
);
const skippedCount = result.skipped?.length ?? 0;
const summaryStr = `Read ${items.length} file(s)${
skippedCount > 0 ? ` (${skippedCount} ignored)` : ''
}`;
const summary = <Text color={theme.text.accent}> {summaryStr}</Text>;
const hasItems = items.length > 0;
const payload = hasItems ? (
<Box flexDirection="column" marginLeft={2}>
{hasItems && <RenderItemsList items={items} maxVisible={maxVisible} />}
</Box>
) : undefined;
return { description, summary, payload };
}
function getListDirectoryData(
result: ListDirectoryResult,
originalDescription?: string,
): ViewParts {
const description = originalDescription ? (
<Text color={theme.text.secondary} wrap="truncate-end">
{originalDescription}
</Text>
) : undefined;
const summary = <Text color={theme.text.accent}> {result.summary}</Text>;
// For directory listings, we want NO payload in dense mode
return { description, summary, payload: undefined };
}
function getListResultData(
result: ListDirectoryResult | ReadManyFilesResult,
originalDescription?: string,
): ViewParts {
if (isReadManyFilesResult(result)) {
return getReadManyFilesData(result);
}
return getListDirectoryData(result, originalDescription);
}
function getGenericSuccessData(
resultDisplay: unknown,
originalDescription?: string,
): ViewParts {
let summary: React.ReactNode;
let payload: React.ReactNode;
const description = originalDescription ? (
<Text color={theme.text.secondary} wrap="truncate-end">
{originalDescription}
</Text>
) : undefined;
if (typeof resultDisplay === 'string') {
const flattened = resultDisplay.replace(/\n/g, ' ').trim();
summary = (
<Text color={theme.text.accent} wrap="truncate-end">
{flattened}
</Text>
);
} else if (isGrepResult(resultDisplay)) {
summary = <Text color={theme.text.accent}> {resultDisplay.summary}</Text>;
const matches = resultDisplay.matches;
if (matches.length > 0) {
payload = (
<Box flexDirection="column" marginLeft={2}>
<RenderItemsList
items={matches.map(
(m) => `${m.filePath}:${m.lineNumber}: ${m.line.trim()}`,
)}
maxVisible={10}
/>
</Box>
);
}
} else if (isTodoList(resultDisplay)) {
summary = (
<Text color={theme.text.accent} wrap="wrap">
Todos updated
</Text>
);
} else if (hasPayload(resultDisplay)) {
summary = <Text color={theme.text.accent}> {resultDisplay.summary}</Text>;
payload = (
<Box marginLeft={2}>
<Text color={theme.text.secondary}>{resultDisplay.payload}</Text>
</Box>
);
} else {
summary = (
<Text color={theme.text.accent} wrap="wrap">
Returned (possible empty result)
</Text>
);
}
return { description, summary, payload };
}
export const DenseToolMessage: React.FC<DenseToolMessageProps> = (props) => {
const {
callId,
name,
status,
resultDisplay,
confirmationDetails,
outputFile,
terminalWidth,
availableTerminalHeight,
description: originalDescription,
} = props;
const settings = useSettings();
const isAlternateBuffer = useAlternateBuffer();
const { isExpanded: isExpandedInContext, toggleExpansion } = useToolActions();
// Handle optional context members
const [localIsExpanded, setLocalIsExpanded] = useState(false);
const isExpanded = isExpandedInContext
? isExpandedInContext(callId)
: localIsExpanded;
const [isFocused, setIsFocused] = useState(false);
const toggleRef = useRef<DOMElement>(null);
// Unified File Data Extraction (Safely bridge resultDisplay and confirmationDetails)
const diff = useMemo((): FileDiff | undefined => {
if (isFileDiff(resultDisplay)) return resultDisplay;
if (confirmationDetails?.type === 'edit') {
const details = confirmationDetails;
return {
fileName: details.fileName,
fileDiff: details.fileDiff,
filePath: details.filePath,
originalContent: details.originalContent,
newContent: details.newContent,
diffStat: details.diffStat,
};
}
return undefined;
}, [resultDisplay, confirmationDetails]);
const handleToggle = () => {
const next = !isExpanded;
if (!next) {
setIsFocused(false);
} else {
setIsFocused(true);
}
if (toggleExpansion) {
toggleExpansion(callId);
} else {
setLocalIsExpanded(next);
}
};
useMouseClick(toggleRef, handleToggle, {
isActive: isAlternateBuffer && !!diff,
});
// State-to-View Coordination
const viewParts = useMemo((): ViewParts => {
if (diff) {
return getFileOpData(
diff,
status,
resultDisplay,
terminalWidth,
availableTerminalHeight,
isAlternateBuffer,
);
}
if (isListResult(resultDisplay)) {
return getListResultData(resultDisplay, originalDescription);
}
if (isGrepResult(resultDisplay)) {
return getGenericSuccessData(resultDisplay, originalDescription);
}
if (status === CoreToolCallStatus.Success && resultDisplay) {
return getGenericSuccessData(resultDisplay, originalDescription);
}
if (status === CoreToolCallStatus.Error) {
const text =
typeof resultDisplay === 'string'
? resultDisplay.replace(/\n/g, ' ')
: 'Failed';
const errorSummary = (
<Text color={theme.status.error} wrap="truncate-end">
{text}
</Text>
);
const descriptionText = originalDescription ? (
<Text color={theme.text.secondary} wrap="truncate-end">
{originalDescription}
</Text>
) : undefined;
return {
description: descriptionText,
summary: errorSummary,
payload: undefined,
};
}
const descriptionText = originalDescription ? (
<Text color={theme.text.secondary} wrap="truncate-end">
{originalDescription}
</Text>
) : undefined;
return {
description: descriptionText,
summary: undefined,
payload: undefined,
};
}, [
diff,
status,
resultDisplay,
terminalWidth,
availableTerminalHeight,
originalDescription,
isAlternateBuffer,
]);
const { description, summary } = viewParts;
const diffLines = useMemo(() => {
if (!diff || !isExpanded || !isAlternateBuffer) return [];
const parsedLines = parseDiffWithLineNumbers(diff.fileDiff);
const isNewFileResult = isNewFile(parsedLines);
if (isNewFileResult) {
const addedContent = parsedLines
.filter((line) => line.type === 'add')
.map((line) => line.content)
.join('\n');
const fileExtension = getFileExtension(diff.fileName);
return colorizeCode({
code: addedContent,
language: fileExtension,
maxWidth: terminalWidth - PAYLOAD_MARGIN_LEFT,
settings,
disableColor: status === CoreToolCallStatus.Cancelled,
returnLines: true,
});
} else {
return renderDiffLines({
parsedLines,
filename: diff.fileName,
terminalWidth: terminalWidth - PAYLOAD_MARGIN_LEFT,
disableColor: status === CoreToolCallStatus.Cancelled,
});
}
}, [diff, isExpanded, isAlternateBuffer, terminalWidth, settings, status]);
const showPayload = useMemo(() => {
const policy = !isAlternateBuffer || !diff || isExpanded;
if (!policy) return false;
if (diff) {
if (isAlternateBuffer) {
return isExpanded && diffLines.length > 0;
}
// In non-alternate buffer mode, we always show the diff.
return true;
}
return !!(viewParts.payload || outputFile);
}, [
isAlternateBuffer,
diff,
isExpanded,
diffLines.length,
viewParts.payload,
outputFile,
]);
const keyExtractor = (_item: React.ReactNode, index: number) =>
`diff-line-${index}`;
const renderItem = ({ item }: { item: React.ReactNode }) => (
<Box minHeight={1}>{item}</Box>
);
return (
<Box flexDirection="column">
<Box marginLeft={2} flexDirection="row" flexWrap="wrap">
<ToolStatusIndicator status={status} name={name} />
<Box maxWidth={25} flexShrink={1} flexGrow={0}>
<Text color={theme.text.primary} bold wrap="truncate-end">
{name}{' '}
</Text>
</Box>
<Box marginLeft={1} flexShrink={1} flexGrow={0}>
{description}
</Box>
{summary && (
<Box
key="tool-summary"
ref={isAlternateBuffer && diff ? toggleRef : undefined}
marginLeft={1}
flexGrow={0}
>
{summary}
</Box>
)}
</Box>
{showPayload && isAlternateBuffer && diffLines.length > 0 && (
<Box
marginLeft={PAYLOAD_MARGIN_LEFT}
marginTop={1}
marginBottom={1}
paddingX={1}
flexDirection="column"
height={
Math.min(diffLines.length, COMPACT_TOOL_SUBVIEW_MAX_LINES) + 2
}
maxHeight={COMPACT_TOOL_SUBVIEW_MAX_LINES + 2}
borderStyle="round"
borderColor={theme.border.default}
borderDimColor={true}
maxWidth={Math.min(
PAYLOAD_MAX_WIDTH,
terminalWidth - PAYLOAD_MARGIN_LEFT,
)}
>
<ScrollableList
data={diffLines}
renderItem={renderItem}
keyExtractor={keyExtractor}
estimatedItemHeight={() => 1}
hasFocus={isFocused}
width={Math.min(
PAYLOAD_MAX_WIDTH,
terminalWidth -
PAYLOAD_MARGIN_LEFT -
PAYLOAD_BORDER_CHROME_WIDTH -
PAYLOAD_SCROLL_GUTTER,
)}
/>
</Box>
)}
{showPayload && (!isAlternateBuffer || !diff) && viewParts.payload && (
<Box marginLeft={PAYLOAD_MARGIN_LEFT} marginTop={1} marginBottom={1}>
{viewParts.payload}
</Box>
)}
{showPayload && outputFile && (
<Box marginLeft={PAYLOAD_MARGIN_LEFT} marginTop={1} marginBottom={1}>
<Text color={theme.text.secondary}>
(Output saved to: {outputFile})
</Text>
</Box>
)}
</Box>
);
};
@@ -55,6 +55,7 @@ index 0000000..e69de29
maxWidth: 80,
theme: undefined,
settings: expect.anything(),
disableColor: false,
}),
);
});
@@ -89,6 +90,7 @@ index 0000000..e69de29
maxWidth: 80,
theme: undefined,
settings: expect.anything(),
disableColor: false,
}),
);
});
@@ -119,6 +121,7 @@ index 0000000..e69de29
maxWidth: 80,
theme: undefined,
settings: expect.anything(),
disableColor: false,
}),
);
});
@@ -7,21 +7,21 @@
import type React from 'react';
import { useMemo } from 'react';
import { Box, Text, useIsScreenReaderEnabled } from 'ink';
import crypto from 'node:crypto';
import { colorizeCode, colorizeLine } from '../../utils/CodeColorizer.js';
import { MaxSizedBox } from '../shared/MaxSizedBox.js';
import { theme as semanticTheme } from '../../semantic-colors.js';
import type { Theme } from '../../themes/theme.js';
import { useSettings } from '../../contexts/SettingsContext.js';
import { getFileExtension } from '../../utils/fileUtils.js';
interface DiffLine {
export interface DiffLine {
type: 'add' | 'del' | 'context' | 'hunk' | 'other';
oldLine?: number;
newLine?: number;
content: string;
}
function parseDiffWithLineNumbers(diffContent: string): DiffLine[] {
export function parseDiffWithLineNumbers(diffContent: string): DiffLine[] {
const lines = diffContent.split(/\r?\n/);
const result: DiffLine[] = [];
let currentOldLine = 0;
@@ -88,6 +88,7 @@ interface DiffRendererProps {
availableTerminalHeight?: number;
terminalWidth: number;
theme?: Theme;
disableColor?: boolean;
}
const DEFAULT_TAB_WIDTH = 4; // Spaces per tab for normalization
@@ -99,6 +100,7 @@ export const DiffRenderer: React.FC<DiffRendererProps> = ({
availableTerminalHeight,
terminalWidth,
theme,
disableColor = false,
}) => {
const settings = useSettings();
@@ -111,17 +113,7 @@ export const DiffRenderer: React.FC<DiffRendererProps> = ({
return parseDiffWithLineNumbers(diffContent);
}, [diffContent]);
const isNewFile = useMemo(() => {
if (parsedLines.length === 0) return false;
return parsedLines.every(
(line) =>
line.type === 'add' ||
line.type === 'hunk' ||
line.type === 'other' ||
line.content.startsWith('diff --git') ||
line.content.startsWith('new file mode'),
);
}, [parsedLines]);
const isNewFileResult = useMemo(() => isNewFile(parsedLines), [parsedLines]);
const renderedOutput = useMemo(() => {
if (!diffContent || typeof diffContent !== 'string') {
@@ -151,14 +143,14 @@ export const DiffRenderer: React.FC<DiffRendererProps> = ({
);
}
if (isNewFile) {
if (isNewFileResult) {
// Extract only the added lines' content
const addedContent = parsedLines
.filter((line) => line.type === 'add')
.map((line) => line.content)
.join('\n');
// Attempt to infer language from filename, default to plain text if no filename
const fileExtension = filename?.split('.').pop() || null;
const fileExtension = getFileExtension(filename);
const language = fileExtension
? getLanguageFromExtension(fileExtension)
: null;
@@ -169,39 +161,71 @@ export const DiffRenderer: React.FC<DiffRendererProps> = ({
maxWidth: terminalWidth,
theme,
settings,
disableColor,
});
} else {
return renderDiffContent(
parsedLines,
filename,
tabWidth,
availableTerminalHeight,
terminalWidth,
const key = filename ? `diff-box-${filename}` : undefined;
return (
<MaxSizedBox
maxHeight={availableTerminalHeight}
maxWidth={terminalWidth}
key={key}
>
{renderDiffLines({
parsedLines,
filename,
tabWidth,
terminalWidth,
disableColor,
})}
</MaxSizedBox>
);
}
}, [
diffContent,
parsedLines,
screenReaderEnabled,
isNewFile,
isNewFileResult,
filename,
availableTerminalHeight,
terminalWidth,
theme,
settings,
tabWidth,
disableColor,
]);
return renderedOutput;
};
const renderDiffContent = (
parsedLines: DiffLine[],
filename: string | undefined,
export const isNewFile = (parsedLines: DiffLine[]): boolean => {
if (parsedLines.length === 0) return false;
return parsedLines.every(
(line) =>
line.type === 'add' ||
line.type === 'hunk' ||
line.type === 'other' ||
line.content.startsWith('diff --git') ||
line.content.startsWith('new file mode'),
);
};
export interface RenderDiffLinesOptions {
parsedLines: DiffLine[];
filename?: string;
tabWidth?: number;
terminalWidth: number;
disableColor?: boolean;
}
export const renderDiffLines = ({
parsedLines,
filename,
tabWidth = DEFAULT_TAB_WIDTH,
availableTerminalHeight: number | undefined,
terminalWidth: number,
) => {
terminalWidth,
disableColor = false,
}: RenderDiffLinesOptions): React.ReactNode[] => {
// 1. Normalize whitespace (replace tabs with spaces) *before* further processing
const normalizedLines = parsedLines.map((line) => ({
...line,
@@ -214,15 +238,16 @@ const renderDiffContent = (
);
if (displayableLines.length === 0) {
return (
return [
<Box
key="no-changes"
borderStyle="round"
borderColor={semanticTheme.border.default}
padding={1}
>
<Text dimColor>No changes detected.</Text>
</Box>
);
</Box>,
];
}
const maxLineNumber = Math.max(
@@ -232,7 +257,7 @@ const renderDiffContent = (
);
const gutterWidth = Math.max(1, maxLineNumber.toString().length);
const fileExtension = filename?.split('.').pop() || null;
const fileExtension = getFileExtension(filename);
const language = fileExtension
? getLanguageFromExtension(fileExtension)
: null;
@@ -252,10 +277,6 @@ const renderDiffContent = (
baseIndentation = 0;
}
const key = filename
? `diff-box-${filename}`
: `diff-box-${crypto.createHash('sha1').update(JSON.stringify(parsedLines)).digest('hex')}`;
let lastLineNumber: number | null = null;
const MAX_CONTEXT_LINES_WITHOUT_GAP = 5;
@@ -321,12 +342,26 @@ const renderDiffContent = (
const displayContent = line.content.substring(baseIndentation);
const backgroundColor =
line.type === 'add'
const backgroundColor = disableColor
? undefined
: line.type === 'add'
? semanticTheme.background.diff.added
: line.type === 'del'
? semanticTheme.background.diff.removed
: undefined;
const gutterColor = disableColor
? undefined
: semanticTheme.text.secondary;
const symbolColor = disableColor
? undefined
: line.type === 'add'
? semanticTheme.status.success
: line.type === 'del'
? semanticTheme.status.error
: undefined;
acc.push(
<Box key={lineKey} flexDirection="row">
<Box
@@ -336,32 +371,24 @@ const renderDiffContent = (
backgroundColor={backgroundColor}
justifyContent="flex-end"
>
<Text color={semanticTheme.text.secondary}>{gutterNumStr}</Text>
<Text color={gutterColor}>{gutterNumStr}</Text>
</Box>
{line.type === 'context' ? (
<>
<Text>{prefixSymbol} </Text>
<Text wrap="wrap">{colorizeLine(displayContent, language)}</Text>
<Text wrap="wrap">
{colorizeLine(
displayContent,
language,
undefined,
disableColor,
)}
</Text>
</>
) : (
<Text
backgroundColor={
line.type === 'add'
? semanticTheme.background.diff.added
: semanticTheme.background.diff.removed
}
wrap="wrap"
>
<Text
color={
line.type === 'add'
? semanticTheme.status.success
: semanticTheme.status.error
}
>
{prefixSymbol}
</Text>{' '}
{colorizeLine(displayContent, language)}
<Text backgroundColor={backgroundColor} wrap="wrap">
<Text color={symbolColor}>{prefixSymbol}</Text>{' '}
{colorizeLine(displayContent, language, undefined, disableColor)}
</Text>
)}
</Box>,
@@ -371,15 +398,7 @@ const renderDiffContent = (
[],
);
return (
<MaxSizedBox
maxHeight={availableTerminalHeight}
maxWidth={terminalWidth}
key={key}
>
{content}
</MaxSizedBox>
);
return content;
};
const getLanguageFromExtension = (extension: string): string | null => {
@@ -19,8 +19,12 @@ import { renderWithProviders } from '../../../test-utils/render.js';
import { createMockSettings } from '../../../test-utils/settings.js';
import { makeFakeConfig } from '@google/gemini-cli-core';
import { waitFor } from '../../../test-utils/async.js';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { SHELL_COMMAND_NAME, ACTIVE_SHELL_MAX_LINES } from '../../constants.js';
import {
SHELL_CONTENT_OVERHEAD,
TOOL_RESULT_STANDARD_RESERVED_LINE_COUNT,
} from '../../utils/toolLayoutUtils.js';
describe('<ShellToolMessage />', () => {
const baseProps: ShellToolMessageProps = {
@@ -35,6 +39,7 @@ describe('<ShellToolMessage />', () => {
isFirst: true,
borderColor: 'green',
borderDimColor: false,
isExpandable: false,
config: {
getEnableInteractiveShell: () => true,
} as unknown as Config,
@@ -52,6 +57,11 @@ describe('<ShellToolMessage />', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
describe('interactive shell focus', () => {
@@ -59,14 +69,14 @@ describe('<ShellToolMessage />', () => {
['SHELL_COMMAND_NAME', SHELL_COMMAND_NAME],
['SHELL_TOOL_NAME', SHELL_TOOL_NAME],
])('clicks inside the shell area sets focus for %s', async (_, name) => {
const { lastFrame, simulateClick, unmount } = await renderWithProviders(
<ShellToolMessage {...baseProps} name={name} />,
{ uiActions, mouseEventsEnabled: true },
);
const { lastFrame, simulateClick, unmount, waitUntilReady } =
await renderWithProviders(
<ShellToolMessage {...baseProps} name={name} />,
{ uiActions, mouseEventsEnabled: true },
);
await waitFor(() => {
expect(lastFrame()).toContain('A shell command');
});
await waitUntilReady();
expect(lastFrame()).toContain('A shell command');
await simulateClick(2, 2);
@@ -75,6 +85,7 @@ describe('<ShellToolMessage />', () => {
});
unmount();
});
it('resets focus when shell finishes', async () => {
let updateStatus: (s: CoreToolCallStatus) => void = () => {};
@@ -86,19 +97,21 @@ describe('<ShellToolMessage />', () => {
return <ShellToolMessage {...baseProps} status={status} ptyId={1} />;
};
const { lastFrame, unmount } = await renderWithProviders(<Wrapper />, {
uiActions,
uiState: {
streamingState: StreamingState.Idle,
embeddedShellFocused: true,
activePtyId: 1,
const { lastFrame, unmount, waitUntilReady } = await renderWithProviders(
<Wrapper />,
{
uiActions,
uiState: {
streamingState: StreamingState.Idle,
embeddedShellFocused: true,
activePtyId: 1,
},
},
});
);
// Verify it is initially focused
await waitFor(() => {
expect(lastFrame()).toContain('(Shift+Tab to unfocus)');
});
await waitUntilReady();
expect(lastFrame()).toContain('(Shift+Tab to unfocus)');
// Now update status to Success
await act(async () => {
@@ -184,29 +197,33 @@ describe('<ShellToolMessage />', () => {
[
'respects availableTerminalHeight when it is smaller than ACTIVE_SHELL_MAX_LINES',
10,
7,
10 - TOOL_RESULT_STANDARD_RESERVED_LINE_COUNT, // 7 (Header height is 3, but calculation uses reserved=3)
false,
true,
false,
],
[
'uses ACTIVE_SHELL_MAX_LINES when availableTerminalHeight is large',
100,
ACTIVE_SHELL_MAX_LINES - 4,
ACTIVE_SHELL_MAX_LINES - SHELL_CONTENT_OVERHEAD, // 11
false,
true,
false,
],
[
'uses full availableTerminalHeight when focused in alternate buffer mode',
100,
97,
100 - TOOL_RESULT_STANDARD_RESERVED_LINE_COUNT, // 97
true,
false,
false,
],
[
'defaults to ACTIVE_SHELL_MAX_LINES in alternate buffer when availableTerminalHeight is undefined',
undefined,
ACTIVE_SHELL_MAX_LINES - 4,
ACTIVE_SHELL_MAX_LINES - SHELL_CONTENT_OVERHEAD, // 11
false,
true,
false,
],
])(
@@ -217,29 +234,34 @@ describe('<ShellToolMessage />', () => {
expectedMaxLines,
focused,
constrainHeight,
isExpandable,
) => {
const { lastFrame, unmount } = await renderWithProviders(
<ShellToolMessage
{...baseProps}
resultDisplay={LONG_OUTPUT}
renderOutputAsMarkdown={false}
availableTerminalHeight={availableTerminalHeight}
ptyId={1}
status={CoreToolCallStatus.Executing}
/>,
{
uiActions,
config: makeFakeConfig({ useAlternateBuffer: true }),
settings: createMockSettings({
ui: { useAlternateBuffer: true },
}),
uiState: {
activePtyId: focused ? 1 : 2,
embeddedShellFocused: focused,
constrainHeight,
const { lastFrame, waitUntilReady, unmount } =
await renderWithProviders(
<ShellToolMessage
{...baseProps}
resultDisplay={LONG_OUTPUT}
renderOutputAsMarkdown={false}
availableTerminalHeight={availableTerminalHeight}
ptyId={1}
status={CoreToolCallStatus.Executing}
isExpandable={isExpandable}
/>,
{
uiActions,
config: makeFakeConfig({ useAlternateBuffer: true }),
settings: createMockSettings({
ui: { useAlternateBuffer: true },
}),
uiState: {
activePtyId: focused ? 1 : 2,
embeddedShellFocused: focused,
constrainHeight,
},
},
},
);
);
await waitUntilReady();
const frame = lastFrame();
expect(frame.match(/Line \d+/g)?.length).toBe(expectedMaxLines);
@@ -249,7 +271,7 @@ describe('<ShellToolMessage />', () => {
);
it('fully expands in standard mode when availableTerminalHeight is undefined', async () => {
const { lastFrame, unmount } = await renderWithProviders(
const { lastFrame, unmount, waitUntilReady } = await renderWithProviders(
<ShellToolMessage
{...baseProps}
resultDisplay={LONG_OUTPUT}
@@ -264,16 +286,15 @@ describe('<ShellToolMessage />', () => {
},
);
await waitFor(() => {
const frame = lastFrame();
// Should show all 100 lines
expect(frame.match(/Line \d+/g)?.length).toBe(100);
});
await waitUntilReady();
const frame = lastFrame();
// Should show all 100 lines
expect(frame.match(/Line \d+/g)?.length).toBe(100);
unmount();
});
it('fully expands in alternate buffer mode when constrainHeight is false and isExpandable is true', async () => {
const { lastFrame, unmount } = await renderWithProviders(
const { lastFrame, unmount, waitUntilReady } = await renderWithProviders(
<ShellToolMessage
{...baseProps}
resultDisplay={LONG_OUTPUT}
@@ -292,17 +313,16 @@ describe('<ShellToolMessage />', () => {
},
);
await waitFor(() => {
const frame = lastFrame();
// Should show all 100 lines because constrainHeight is false and isExpandable is true
expect(frame.match(/Line \d+/g)?.length).toBe(100);
});
await waitUntilReady();
const frame = lastFrame();
// Should show all 100 lines because constrainHeight is false and isExpandable is true
expect(frame.match(/Line \d+/g)?.length).toBe(100);
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('stays constrained in alternate buffer mode when isExpandable is false even if constrainHeight is false', async () => {
const { lastFrame, unmount } = await renderWithProviders(
const { lastFrame, unmount, waitUntilReady } = await renderWithProviders(
<ShellToolMessage
{...baseProps}
resultDisplay={LONG_OUTPUT}
@@ -321,11 +341,12 @@ describe('<ShellToolMessage />', () => {
},
);
await waitFor(() => {
const frame = lastFrame();
// Should still be constrained to 11 (15 - 4) because isExpandable is false
expect(frame.match(/Line \d+/g)?.length).toBe(11);
});
await waitUntilReady();
const frame = lastFrame();
// Should still be constrained to 11 (15 - 4) because isExpandable is false
expect(frame.match(/Line \d+/g)?.length).toBe(
ACTIVE_SHELL_MAX_LINES - SHELL_CONTENT_OVERHEAD,
);
expect(lastFrame()).toMatchSnapshot();
unmount();
});
@@ -34,6 +34,9 @@ describe('ToolConfirmationMessage', () => {
confirm: mockConfirm,
cancel: vi.fn(),
isDiffingEnabled: false,
isExpanded: vi.fn().mockReturnValue(false),
toggleExpansion: vi.fn(),
toggleAllExpansion: vi.fn(),
});
const mockConfig = {
@@ -458,7 +461,11 @@ describe('ToolConfirmationMessage', () => {
confirm: vi.fn(),
cancel: vi.fn(),
isDiffingEnabled: false,
isExpanded: vi.fn().mockReturnValue(false),
toggleExpansion: vi.fn(),
toggleAllExpansion: vi.fn(),
});
const { lastFrame, unmount } = await renderWithProviders(
<ToolConfirmationMessage
callId="test-call-id"
@@ -485,7 +492,11 @@ describe('ToolConfirmationMessage', () => {
confirm: vi.fn(),
cancel: vi.fn(),
isDiffingEnabled: false,
isExpanded: vi.fn().mockReturnValue(false),
toggleExpansion: vi.fn(),
toggleAllExpansion: vi.fn(),
});
const { lastFrame, unmount } = await renderWithProviders(
<ToolConfirmationMessage
callId="test-call-id"
@@ -512,6 +523,9 @@ describe('ToolConfirmationMessage', () => {
confirm: vi.fn(),
cancel: vi.fn(),
isDiffingEnabled: true,
isExpanded: vi.fn().mockReturnValue(false),
toggleExpansion: vi.fn(),
toggleAllExpansion: vi.fn(),
});
const { lastFrame, unmount } = await renderWithProviders(
@@ -728,6 +742,9 @@ describe('ToolConfirmationMessage', () => {
confirm: mockConfirm,
cancel: vi.fn(),
isDiffingEnabled: false,
isExpanded: vi.fn().mockReturnValue(false),
toggleExpansion: vi.fn(),
toggleAllExpansion: vi.fn(),
});
const confirmationDetails: SerializableConfirmationDetails = {
type: 'info',
@@ -0,0 +1,178 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { renderWithProviders } from '../../../test-utils/render.js';
import { createMockSettings } from '../../../test-utils/mockConfig.js';
import { ToolGroupMessage } from './ToolGroupMessage.js';
import {
CoreToolCallStatus,
LS_DISPLAY_NAME,
READ_FILE_DISPLAY_NAME,
} from '@google/gemini-cli-core';
import { expect, it, describe } from 'vitest';
import type { IndividualToolCallDisplay } from '../../types.js';
describe('ToolGroupMessage Compact Rendering', () => {
const defaultProps = {
item: {
id: '1',
role: 'assistant',
content: '',
timestamp: new Date(),
type: 'help' as const, // Adding type property to satisfy HistoryItem type
},
terminalWidth: 80,
};
const compactSettings = createMockSettings({
merged: {
ui: {
compactToolOutput: true,
},
},
});
it('renders consecutive compact tools without empty lines between them', async () => {
const toolCalls: IndividualToolCallDisplay[] = [
{
callId: 'call1',
name: LS_DISPLAY_NAME,
status: CoreToolCallStatus.Success,
resultDisplay: 'file1.txt\nfile2.txt',
description: 'Listing files',
confirmationDetails: undefined,
isClientInitiated: true,
parentCallId: undefined,
},
{
callId: 'call2',
name: LS_DISPLAY_NAME,
status: CoreToolCallStatus.Success,
resultDisplay: 'file3.txt',
description: 'Listing files',
confirmationDetails: undefined,
isClientInitiated: true,
parentCallId: undefined,
},
];
const { lastFrame, waitUntilReady } = await renderWithProviders(
<ToolGroupMessage {...defaultProps} toolCalls={toolCalls} />,
{ settings: compactSettings },
);
await waitUntilReady();
const output = lastFrame();
expect(output).toMatchSnapshot();
});
it('does not add an extra empty line between a compact tool and a standard tool', async () => {
const toolCalls: IndividualToolCallDisplay[] = [
{
callId: 'call1',
name: LS_DISPLAY_NAME,
status: CoreToolCallStatus.Success,
resultDisplay: 'file1.txt',
description: 'Listing files',
confirmationDetails: undefined,
isClientInitiated: true,
parentCallId: undefined,
},
{
callId: 'call2',
name: 'non-compact-tool',
status: CoreToolCallStatus.Success,
resultDisplay: 'some large output',
description: 'Doing something',
confirmationDetails: undefined,
isClientInitiated: true,
parentCallId: undefined,
},
];
const { lastFrame, waitUntilReady } = await renderWithProviders(
<ToolGroupMessage {...defaultProps} toolCalls={toolCalls} />,
{ settings: compactSettings },
);
await waitUntilReady();
const output = lastFrame();
expect(output).toMatchSnapshot();
});
it('does not add an extra empty line if a compact tool has a dense payload', async () => {
const toolCalls: IndividualToolCallDisplay[] = [
{
callId: 'call1',
name: LS_DISPLAY_NAME,
status: CoreToolCallStatus.Success,
resultDisplay: 'file1.txt',
description: 'Listing files',
confirmationDetails: undefined,
isClientInitiated: true,
parentCallId: undefined,
},
{
callId: 'call2',
name: READ_FILE_DISPLAY_NAME,
status: CoreToolCallStatus.Success,
resultDisplay: {
summary: 'read file',
payload: 'file content',
files: ['file.txt'],
}, // Dense payload
description: 'Reading file',
confirmationDetails: undefined,
isClientInitiated: true,
parentCallId: undefined,
},
];
const { lastFrame, waitUntilReady } = await renderWithProviders(
<ToolGroupMessage {...defaultProps} toolCalls={toolCalls} />,
{ settings: compactSettings },
);
await waitUntilReady();
const output = lastFrame();
expect(output).toMatchSnapshot();
});
it('does not add an extra empty line between a standard tool and a compact tool', async () => {
const toolCalls: IndividualToolCallDisplay[] = [
{
callId: 'call1',
name: 'non-compact-tool',
status: CoreToolCallStatus.Success,
resultDisplay: 'some large output',
description: 'Doing something',
confirmationDetails: undefined,
isClientInitiated: true,
parentCallId: undefined,
},
{
callId: 'call2',
name: LS_DISPLAY_NAME,
status: CoreToolCallStatus.Success,
resultDisplay: 'file1.txt',
description: 'Listing files',
confirmationDetails: undefined,
isClientInitiated: true,
parentCallId: undefined,
},
];
const { lastFrame, waitUntilReady } = await renderWithProviders(
<ToolGroupMessage {...defaultProps} toolCalls={toolCalls} />,
{ settings: compactSettings },
);
await waitUntilReady();
const output = lastFrame();
expect(output).toMatchSnapshot();
});
});
@@ -481,7 +481,7 @@ describe('<ToolGroupMessage />', () => {
];
const item = createItem(toolCalls);
const { lastFrame, unmount } = await renderWithProviders(
<Scrollable height={10} hasFocus={true} scrollToBottom={true}>
<Scrollable height={12} hasFocus={true} scrollToBottom={true}>
<ToolGroupMessage {...baseProps} item={item} toolCalls={toolCalls} />
</Scrollable>,
{
@@ -5,7 +5,7 @@
*/
import type React from 'react';
import { useMemo } from 'react';
import { useMemo, Fragment } from 'react';
import { Box, Text } from 'ink';
import type {
HistoryItem,
@@ -17,6 +17,7 @@ import { ToolMessage } from './ToolMessage.js';
import { ShellToolMessage } from './ShellToolMessage.js';
import { TopicMessage, isTopicTool } from './TopicMessage.js';
import { SubagentGroupDisplay } from './SubagentGroupDisplay.js';
import { DenseToolMessage } from './DenseToolMessage.js';
import { theme } from '../../semantic-colors.js';
import { useConfig } from '../../contexts/ConfigContext.js';
import { isShellTool } from './ToolShared.js';
@@ -24,10 +25,84 @@ import {
shouldHideToolCall,
CoreToolCallStatus,
Kind,
EDIT_DISPLAY_NAME,
GLOB_DISPLAY_NAME,
WEB_SEARCH_DISPLAY_NAME,
READ_FILE_DISPLAY_NAME,
LS_DISPLAY_NAME,
GREP_DISPLAY_NAME,
WEB_FETCH_DISPLAY_NAME,
WRITE_FILE_DISPLAY_NAME,
READ_MANY_FILES_DISPLAY_NAME,
isFileDiff,
isGrepResult,
isListResult,
} from '@google/gemini-cli-core';
import { useUIState } from '../../contexts/UIStateContext.js';
import { getToolGroupBorderAppearance } from '../../utils/borderStyles.js';
import { useSettings } from '../../contexts/SettingsContext.js';
import {
TOOL_RESULT_STATIC_HEIGHT,
TOOL_RESULT_STANDARD_RESERVED_LINE_COUNT,
} from '../../utils/toolLayoutUtils.js';
const COMPACT_OUTPUT_ALLOWLIST = new Set([
EDIT_DISPLAY_NAME,
GLOB_DISPLAY_NAME,
WEB_SEARCH_DISPLAY_NAME,
READ_FILE_DISPLAY_NAME,
LS_DISPLAY_NAME,
GREP_DISPLAY_NAME,
WEB_FETCH_DISPLAY_NAME,
WRITE_FILE_DISPLAY_NAME,
READ_MANY_FILES_DISPLAY_NAME,
]);
// Helper to identify if a tool should use the compact view
export const isCompactTool = (
tool: IndividualToolCallDisplay,
isCompactModeEnabled: boolean,
): boolean => {
const hasCompactOutputSupport = COMPACT_OUTPUT_ALLOWLIST.has(tool.name);
const displayStatus = mapCoreStatusToDisplayStatus(tool.status);
return (
isCompactModeEnabled &&
hasCompactOutputSupport &&
displayStatus !== ToolCallStatus.Confirming
);
};
// Helper to identify if a compact tool has a payload (diff, list, etc.)
export const hasDensePayload = (tool: IndividualToolCallDisplay): boolean => {
if (tool.outputFile) return true;
const res = tool.resultDisplay;
if (!res) return false;
// TODO(24053): Usage of type guards makes this class too aware of internals
if (isFileDiff(res)) return true;
if (tool.confirmationDetails?.type === 'edit') return true;
if (isGrepResult(res) && res.matches.length > 0) return true;
// ReadManyFilesResult check (has 'include' and 'files')
if (isListResult(res) && 'include' in res) {
const includeProp = (res as { include?: unknown }).include;
if (Array.isArray(includeProp) && res.files.length > 0) {
return true;
}
}
// Generic summary/payload pattern
if (
typeof res === 'object' &&
res !== null &&
'summary' in res &&
'payload' in res
) {
return true;
}
return false;
};
interface ToolGroupMessageProps {
item: HistoryItem | HistoryItemWithoutId;
@@ -54,11 +129,13 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
}) => {
const settings = useSettings();
const isLowErrorVerbosity = settings.merged.ui?.errorVerbosity !== 'full';
const isCompactModeEnabled = settings.merged.ui?.compactToolOutput === true;
// Filter out tool calls that should be hidden (e.g. in-progress Ask User, or Plan Mode operations).
const toolCalls = useMemo(
const visibleToolCalls = useMemo(
() =>
allToolCalls.filter((t) => {
// Hide internal errors unless full verbosity
if (
isLowErrorVerbosity &&
t.status === CoreToolCallStatus.Error &&
@@ -66,19 +143,34 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
) {
return false;
}
// Standard hiding logic (e.g. Plan Mode internal edits)
if (
shouldHideToolCall({
displayName: t.name,
status: t.status,
approvalMode: t.approvalMode,
hasResultDisplay: !!t.resultDisplay,
parentCallId: t.parentCallId,
})
) {
return false;
}
return !shouldHideToolCall({
displayName: t.name,
status: t.status,
approvalMode: t.approvalMode,
hasResultDisplay: !!t.resultDisplay,
parentCallId: t.parentCallId,
});
// We HIDE tools that are still in pre-execution states (Confirming, Pending)
// from the History log. They live in the Global Queue or wait for their turn.
// Only show tools that are actually running or finished.
const displayStatus = mapCoreStatusToDisplayStatus(t.status);
// We hide Confirming tools from the history log because they are
// currently being rendered in the interactive ToolConfirmationQueue.
// We show everything else, including Pending (waiting to run) and
// Canceled (rejected by user), to ensure the history is complete
// and to avoid tools "vanishing" after approval.
return displayStatus !== ToolCallStatus.Confirming;
}),
[allToolCalls, isLowErrorVerbosity],
);
const config = useConfig();
const {
activePtyId,
embeddedShellFocused,
@@ -86,6 +178,8 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
pendingHistoryItems,
} = useUIState();
const config = useConfig();
const { borderColor, borderDimColor } = useMemo(
() =>
getToolGroupBorderAppearance(
@@ -104,41 +198,6 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
],
);
// We HIDE tools that are still in pre-execution states (Confirming, Pending)
// from the History log. They live in the Global Queue or wait for their turn.
// Only show tools that are actually running or finished.
// We explicitly exclude Pending and Confirming to ensure they only
// appear in the Global Queue until they are approved and start executing.
const visibleToolCalls = useMemo(
() =>
toolCalls.filter((t) => {
const displayStatus = mapCoreStatusToDisplayStatus(t.status);
// We hide Confirming tools from the history log because they are
// currently being rendered in the interactive ToolConfirmationQueue.
// We show everything else, including Pending (waiting to run) and
// Canceled (rejected by user), to ensure the history is complete
// and to avoid tools "vanishing" after approval.
return displayStatus !== ToolCallStatus.Confirming;
}),
[toolCalls],
);
const staticHeight = /* border */ 2;
let countToolCallsWithResults = 0;
for (const tool of visibleToolCalls) {
if (
tool.kind !== Kind.Agent &&
tool.resultDisplay !== undefined &&
tool.resultDisplay !== ''
) {
countToolCallsWithResults++;
}
}
const countOneLineToolCalls =
visibleToolCalls.filter((t) => t.kind !== Kind.Agent).length -
countToolCallsWithResults;
const groupedTools = useMemo(() => {
const groups: Array<
IndividualToolCallDisplay | IndividualToolCallDisplay[]
@@ -158,10 +217,81 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
return groups;
}, [visibleToolCalls]);
const staticHeight = useMemo(() => {
let height = 0;
for (let i = 0; i < groupedTools.length; i++) {
const group = groupedTools[i];
const isFirst = i === 0;
const isLast = i === groupedTools.length - 1;
const prevGroup = i > 0 ? groupedTools[i - 1] : null;
const prevIsCompact =
prevGroup &&
!Array.isArray(prevGroup) &&
isCompactTool(prevGroup, isCompactModeEnabled);
const nextGroup = !isLast ? groupedTools[i + 1] : null;
const nextIsCompact =
nextGroup &&
!Array.isArray(nextGroup) &&
isCompactTool(nextGroup, isCompactModeEnabled);
const isAgentGroup = Array.isArray(group);
const isCompact =
!isAgentGroup && isCompactTool(group, isCompactModeEnabled);
const showClosingBorder = !isCompact && (nextIsCompact || isLast);
if (isFirst) {
height += borderTopOverride ? 1 : 0;
} else if (isCompact !== prevIsCompact) {
// Add a 1-line gap when transitioning between compact and standard tools (or vice versa)
height += 1;
}
const isFirstProp = !!(isFirst
? (borderTopOverride ?? true)
: prevIsCompact);
if (isAgentGroup) {
// Agent group
height += 1; // Header
height += group.length; // 1 line per agent
if (isFirstProp) height += 1; // Top border
if (showClosingBorder) height += 1; // Bottom border
} else {
if (isCompact) {
height += 1; // Base height for compact tool
} else {
// Static overhead for standard tool header:
height +=
TOOL_RESULT_STATIC_HEIGHT +
TOOL_RESULT_STANDARD_RESERVED_LINE_COUNT;
}
}
}
return height;
}, [groupedTools, isCompactModeEnabled, borderTopOverride]);
let countToolCallsWithResults = 0;
for (const tool of visibleToolCalls) {
if (tool.kind !== Kind.Agent) {
if (isCompactTool(tool, isCompactModeEnabled)) {
if (hasDensePayload(tool)) {
countToolCallsWithResults++;
}
} else if (
tool.resultDisplay !== undefined &&
tool.resultDisplay !== ''
) {
countToolCallsWithResults++;
}
}
}
const availableTerminalHeightPerToolMessage = availableTerminalHeight
? Math.max(
Math.floor(
(availableTerminalHeight - staticHeight - countOneLineToolCalls) /
(availableTerminalHeight - staticHeight) /
Math.max(1, countToolCallsWithResults),
),
1,
@@ -176,7 +306,11 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
// explicit "closing slice" (tools: []) used to bridge static/pending sections,
// and only if it's actually continuing an open box from above.
const isExplicitClosingSlice = allToolCalls.length === 0;
if (visibleToolCalls.length === 0 && !isExplicitClosingSlice) {
const shouldShowGroup =
visibleToolCalls.length > 0 ||
(isExplicitClosingSlice && borderBottomOverride === true);
if (!shouldShowGroup) {
return null;
}
@@ -191,7 +325,24 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
*/
width={terminalWidth}
paddingRight={TOOL_MESSAGE_HORIZONTAL_MARGIN}
// When border will be present, add margin of 1 to create spacing from the
// previous message.
marginBottom={(borderBottomOverride ?? true) ? 1 : 0}
>
{visibleToolCalls.length === 0 &&
isExplicitClosingSlice &&
borderBottomOverride === true && (
<Box
width={contentWidth}
borderLeft={true}
borderRight={true}
borderTop={false}
borderBottom={true}
borderColor={borderColor}
borderDimColor={borderDimColor}
borderStyle="round"
/>
)}
{groupedTools.map((group, index) => {
let isFirst = index === 0;
if (!isFirst) {
@@ -207,98 +358,149 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
isFirst = allPreviousWereTopics;
}
const resolvedIsFirst =
borderTopOverride !== undefined
? borderTopOverride && isFirst
: isFirst;
const isLast = index === groupedTools.length - 1;
if (Array.isArray(group)) {
const prevGroup = index > 0 ? groupedTools[index - 1] : null;
const prevIsCompact =
prevGroup &&
!Array.isArray(prevGroup) &&
isCompactTool(prevGroup, isCompactModeEnabled);
const nextGroup = !isLast ? groupedTools[index + 1] : null;
const nextIsCompact =
nextGroup &&
!Array.isArray(nextGroup) &&
isCompactTool(nextGroup, isCompactModeEnabled);
const isAgentGroup = Array.isArray(group);
const isCompact =
!isAgentGroup && isCompactTool(group, isCompactModeEnabled);
const isTopicToolCall = !isAgentGroup && isTopicTool(group.name);
// When border is present, add margin of 1 to create spacing from the
// previous message.
let marginTop = 0;
if (isFirst) {
marginTop = (borderTopOverride ?? false) ? 1 : 0;
} else if (isCompact && prevIsCompact) {
marginTop = 0;
} else if (isCompact || prevIsCompact) {
marginTop = 1;
} else {
// For subsequent standard tools scenarios, the ToolMessage and
// ShellToolMessage components manage their own top spacing by passing
// `isFirst=false` to their internal StickyHeader which then applies
// a paddingTop=1 to create desired gap between standard tool outputs.
marginTop = 0;
}
const isFirstProp = !!(isFirst
? (borderTopOverride ?? true)
: prevIsCompact);
const showClosingBorder =
!isCompact && !isTopicToolCall && (nextIsCompact || isLast);
if (isAgentGroup) {
return (
<SubagentGroupDisplay
<Box
key={group[0].callId}
toolCalls={group}
availableTerminalHeight={availableTerminalHeight}
terminalWidth={contentWidth}
borderColor={borderColor}
borderDimColor={borderDimColor}
isFirst={resolvedIsFirst}
isExpandable={isExpandable}
/>
marginTop={marginTop}
flexDirection="column"
width={contentWidth}
>
<SubagentGroupDisplay
toolCalls={group}
availableTerminalHeight={availableTerminalHeight}
terminalWidth={contentWidth}
borderColor={borderColor}
borderDimColor={borderDimColor}
isFirst={isFirstProp}
isExpandable={isExpandable}
/>
{showClosingBorder && (
<Box
width={contentWidth}
borderLeft={true}
borderRight={true}
borderTop={false}
borderBottom={isLast ? (borderBottomOverride ?? true) : true}
borderColor={borderColor}
borderDimColor={borderDimColor}
borderStyle="round"
/>
)}
</Box>
);
}
const tool = group;
const isShellToolCall = isShellTool(tool.name);
const isTopicToolCall = isTopicTool(tool.name);
const commonProps = {
...tool,
availableTerminalHeight: availableTerminalHeightPerToolMessage,
terminalWidth: contentWidth,
emphasis: 'medium' as const,
isFirst: resolvedIsFirst,
isFirst: isCompact ? false : isFirstProp,
borderColor,
borderDimColor,
isExpandable,
};
return (
<Box
key={tool.callId}
flexDirection="column"
minHeight={1}
width={contentWidth}
>
{isTopicToolCall ? (
<TopicMessage {...commonProps} />
) : isShellToolCall ? (
<ShellToolMessage {...commonProps} config={config} />
) : (
<ToolMessage {...commonProps} />
)}
{tool.outputFile && (
<Fragment key={tool.callId}>
<Box
flexDirection="column"
minHeight={1}
width={contentWidth}
marginTop={marginTop}
>
{isCompact ? (
<DenseToolMessage {...commonProps} />
) : isTopicToolCall ? (
<TopicMessage {...commonProps} />
) : isShellToolCall ? (
<ShellToolMessage {...commonProps} config={config} />
) : (
<ToolMessage {...commonProps} />
)}
{!isCompact && tool.outputFile && (
<Box
borderLeft={true}
borderRight={true}
borderTop={false}
borderBottom={false}
borderColor={borderColor}
borderDimColor={borderDimColor}
flexDirection="column"
borderStyle="round"
paddingLeft={1}
paddingRight={1}
>
<Box>
<Text color={theme.text.primary}>
Output too long and was saved to: {tool.outputFile}
</Text>
</Box>
</Box>
)}
</Box>
{showClosingBorder && (
<Box
width={contentWidth}
borderLeft={true}
borderRight={true}
borderTop={false}
borderBottom={false}
borderBottom={isLast ? (borderBottomOverride ?? true) : true}
borderColor={borderColor}
borderDimColor={borderDimColor}
flexDirection="column"
borderStyle="round"
paddingLeft={1}
paddingRight={1}
>
<Box>
<Text color={theme.text.primary}>
Output too long and was saved to: {tool.outputFile}
</Text>
</Box>
</Box>
/>
)}
</Box>
</Fragment>
);
})}
{/*
We have to keep the bottom border separate so it doesn't get
drawn over by the sticky header directly inside it.
*/}
{(visibleToolCalls.length > 0 || borderBottomOverride !== undefined) &&
borderBottomOverride !== false &&
(visibleToolCalls.length === 0 ||
!visibleToolCalls.every((tool) => isTopicTool(tool.name))) && (
<Box
height={0}
width={contentWidth}
borderLeft={true}
borderRight={true}
borderTop={false}
borderBottom={borderBottomOverride ?? true}
borderColor={borderColor}
borderDimColor={borderDimColor}
borderStyle="round"
/>
)}
</Box>
);
@@ -15,6 +15,7 @@ import {
type AnsiOutput,
type AnsiLine,
isSubagentProgress,
isStructuredToolResult,
} from '@google/gemini-cli-core';
import { useUIState } from '../../contexts/UIStateContext.js';
import { tryParseJSON } from '../../../utils/jsonoutput.js';
@@ -123,7 +124,28 @@ export const ToolResultDisplay: React.FC<ToolResultDisplayProps> = ({
{contentData}
</Text>
);
} else if (typeof contentData === 'object' && 'fileDiff' in contentData) {
} else if (isStructuredToolResult(contentData)) {
if (renderOutputAsMarkdown) {
content = (
<MarkdownDisplay
text={contentData.summary}
terminalWidth={childWidth}
renderMarkdown={renderMarkdown}
isPending={false}
/>
);
} else {
content = (
<Text wrap="wrap" color={theme.text.primary}>
{contentData.summary}
</Text>
);
}
} else if (
typeof contentData === 'object' &&
contentData !== null &&
'fileDiff' in contentData
) {
content = (
<DiffRenderer
diffContent={
@@ -157,10 +179,13 @@ export const ToolResultDisplay: React.FC<ToolResultDisplayProps> = ({
// Final render based on session mode
if (isAlternateBuffer) {
// Use maxLines if provided, otherwise fall back to the calculated available height
const effectiveMaxHeight = maxLines ?? availableHeight;
return (
<Scrollable
width={childWidth}
maxHeight={maxLines ?? availableHeight}
maxHeight={effectiveMaxHeight}
hasFocus={hasFocus} // Allow scrolling via keyboard (Shift+Up/Down)
scrollToBottom={true}
reportOverflow={true}
@@ -120,10 +120,10 @@ describe('ToolMessage Sticky Header Regression', () => {
expect(lastFrame()).toContain('tool-1');
});
expect(lastFrame()).toContain('Description for tool-1');
// Content lines 1-4 should be scrolled off
// Content lines 1-5 should be scrolled off
expect(lastFrame()).not.toContain('c1-01');
expect(lastFrame()).not.toContain('c1-04');
// Line 6 and 7 should be visible (terminalHeight=5 means only 2 lines of content show below 3-line header)
expect(lastFrame()).not.toContain('c1-05');
// Line 6 and 7 should be visible (terminalHeight=5 means 2 lines of content show below 3-line header)
expect(lastFrame()).toContain('c1-06');
expect(lastFrame()).toContain('c1-07');
expect(lastFrame()).toMatchSnapshot();
@@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="37" viewBox="0 0 920 37">
<style>
text { font-family: Consolas, "Courier New", monospace; font-size: 14px; dominant-baseline: text-before-edge; white-space: pre; }
</style>
<rect width="920" height="37" fill="#000000" />
<g transform="translate(10, 10)">
<text x="18" y="2" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs" font-weight="bold">-</text>
<text x="45" y="2" fill="#ffffff" textLength="90" lengthAdjust="spacingAndGlyphs" font-weight="bold">read_file </text>
<text x="144" y="2" fill="#afafaf" textLength="189" lengthAdjust="spacingAndGlyphs">Reading important.txt</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 693 B

@@ -0,0 +1,33 @@
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="88" viewBox="0 0 920 88">
<style>
text { font-family: Consolas, "Courier New", monospace; font-size: 14px; dominant-baseline: text-before-edge; white-space: pre; }
</style>
<rect width="920" height="88" fill="#000000" />
<g transform="translate(10, 10)">
<text x="18" y="2" fill="#d7ffd7" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="2" fill="#ffffff" textLength="45" lengthAdjust="spacingAndGlyphs" font-weight="bold">edit </text>
<text x="99" y="2" fill="#afafaf" textLength="63" lengthAdjust="spacingAndGlyphs">test.ts</text>
<text x="171" y="2" fill="#d7afff" textLength="90" lengthAdjust="spacingAndGlyphs">→ Accepted</text>
<text x="270" y="2" fill="#afafaf" textLength="9" lengthAdjust="spacingAndGlyphs">(</text>
<text x="279" y="2" fill="#d7ffd7" textLength="18" lengthAdjust="spacingAndGlyphs">+1</text>
<text x="297" y="2" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">, </text>
<text x="315" y="2" fill="#ff87af" textLength="18" lengthAdjust="spacingAndGlyphs">-1</text>
<text x="333" y="2" fill="#afafaf" textLength="9" lengthAdjust="spacingAndGlyphs">)</text>
<rect x="54" y="34" width="9" height="17" fill="#5f0000" />
<text x="54" y="36" fill="#afafaf" textLength="9" lengthAdjust="spacingAndGlyphs">1</text>
<rect x="63" y="34" width="9" height="17" fill="#5f0000" />
<rect x="72" y="34" width="9" height="17" fill="#5f0000" />
<text x="72" y="36" fill="#ff87af" textLength="9" lengthAdjust="spacingAndGlyphs">-</text>
<rect x="81" y="34" width="9" height="17" fill="#5f0000" />
<rect x="90" y="34" width="27" height="17" fill="#5f0000" />
<text x="90" y="36" fill="#e5e5e5" textLength="27" lengthAdjust="spacingAndGlyphs">old</text>
<rect x="54" y="51" width="9" height="17" fill="#005f00" />
<text x="54" y="53" fill="#afafaf" textLength="9" lengthAdjust="spacingAndGlyphs">1</text>
<rect x="63" y="51" width="9" height="17" fill="#005f00" />
<rect x="72" y="51" width="9" height="17" fill="#005f00" />
<text x="72" y="53" fill="#d7ffd7" textLength="9" lengthAdjust="spacingAndGlyphs">+</text>
<rect x="81" y="51" width="9" height="17" fill="#005f00" />
<rect x="90" y="51" width="27" height="17" fill="#005f00" />
<text x="90" y="53" fill="#0000ee" textLength="27" lengthAdjust="spacingAndGlyphs">new</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.4 KiB

@@ -0,0 +1,143 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`DenseToolMessage > Toggleable Diff View (Alternate Buffer) > hides diff content by default when in alternate buffer mode 1`] = `
" ✓ test-tool test.ts → Accepted
"
`;
exports[`DenseToolMessage > Toggleable Diff View (Alternate Buffer) > shows diff content by default when NOT in alternate buffer mode 1`] = `
" ✓ test-tool test.ts → Accepted
1 - old line
1 + new line
"
`;
exports[`DenseToolMessage > Visual Regression > matches SVG snapshot for a Rejected tool call 1`] = `" - read_file Reading important.txt"`;
exports[`DenseToolMessage > Visual Regression > matches SVG snapshot for an Accepted file edit with diff stats 1`] = `
" ✓ edit test.ts → Accepted (+1, -1)
1 - old
1 + new
"
`;
exports[`DenseToolMessage > does not render result arrow if resultDisplay is missing 1`] = `
" o test-tool Test description
"
`;
exports[`DenseToolMessage > flattens newlines in string results 1`] = `
" ✓ test-tool Test description → Line 1 Line 2
"
`;
exports[`DenseToolMessage > renders correctly for Edit tool using confirmationDetails 1`] = `
" ? Edit styles.scss → Confirming
1 - body { color: blue; }
1 + body { color: red; }
"
`;
exports[`DenseToolMessage > renders correctly for Errored Edit tool 1`] = `
" x Edit styles.scss → Failed (+1, -1)
1 - old line
1 + new line
"
`;
exports[`DenseToolMessage > renders correctly for ReadManyFiles results 1`] = `
" ✓ test-tool Attempting to read files from **/*.ts → Read 3 file(s) (1 ignored)
file1.ts
file2.ts
file3.ts
"
`;
exports[`DenseToolMessage > renders correctly for Rejected Edit tool 1`] = `
" - Edit styles.scss → Rejected (+1, -1)
1 - old line
1 + new line
"
`;
exports[`DenseToolMessage > renders correctly for Rejected Edit tool with confirmationDetails and diffStat 1`] = `
" - Edit styles.scss → Rejected (+1, -1)
1 - body { color: blue; }
1 + body { color: red; }
"
`;
exports[`DenseToolMessage > renders correctly for Rejected WriteFile tool 1`] = `
" - WriteFile config.json → Rejected
1 - old content
1 + new content
"
`;
exports[`DenseToolMessage > renders correctly for WriteFile tool 1`] = `
" ✓ WriteFile config.json → Accepted (+1, -1)
1 - old content
1 + new content
"
`;
exports[`DenseToolMessage > renders correctly for a successful string result 1`] = `
" ✓ test-tool Test description → Success result
"
`;
exports[`DenseToolMessage > renders correctly for error status with string message 1`] = `
" x test-tool Test description → Error occurred
"
`;
exports[`DenseToolMessage > renders correctly for file diff results with stats 1`] = `
" ✓ test-tool test.ts → Accepted (+15, -6)
1 - old line
1 + diff content
"
`;
exports[`DenseToolMessage > renders correctly for grep results 1`] = `
" ✓ test-tool Test description → Found 2 matches
file1.ts:10: match 1
file2.ts:20: match 2
"
`;
exports[`DenseToolMessage > renders correctly for ls results 1`] = `
" ✓ test-tool Test description → Listed 2 files. (1 ignored)
"
`;
exports[`DenseToolMessage > renders correctly for todo updates 1`] = `
" ✓ test-tool Test description → Todos updated
"
`;
exports[`DenseToolMessage > renders generic failure message for error status without string message 1`] = `
" x test-tool Test description → Failed
"
`;
exports[`DenseToolMessage > renders generic output message for unknown object results 1`] = `
" ✓ test-tool Test description → Returned (possible empty result)
"
`;
exports[`DenseToolMessage > truncates long string results 1`] = `
" ✓ test-tool Test description
→ AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA…
"
`;
@@ -4,7 +4,6 @@ exports[`<ShellToolMessage /> > Height Constraints > defaults to ACTIVE_SHELL_MA
"╭──────────────────────────────────────────────────────────────────────────────╮
│ ⊶ Shell Command A shell command │
│ │
│ Line 90 │
│ Line 91 │
│ Line 92 │
│ Line 93 │
@@ -129,7 +128,6 @@ exports[`<ShellToolMessage /> > Height Constraints > respects availableTerminalH
"╭──────────────────────────────────────────────────────────────────────────────╮
│ ⊶ Shell Command A shell command │
│ │
│ Line 94 │
│ Line 95 │
│ Line 96 │
│ Line 97 │
@@ -143,7 +141,6 @@ exports[`<ShellToolMessage /> > Height Constraints > stays constrained in altern
"╭──────────────────────────────────────────────────────────────────────────────╮
│ ✓ Shell Command A shell command │
│ │
│ Line 90 │
│ Line 91 │
│ Line 92 │
│ Line 93 │
@@ -161,7 +158,6 @@ exports[`<ShellToolMessage /> > Height Constraints > uses ACTIVE_SHELL_MAX_LINES
"╭──────────────────────────────────────────────────────────────────────────────╮
│ ⊶ Shell Command A shell command │
│ │
│ Line 90 │
│ Line 91 │
│ Line 92 │
│ Line 93 │
@@ -179,11 +175,10 @@ exports[`<ShellToolMessage /> > Height Constraints > uses full availableTerminal
"╭──────────────────────────────────────────────────────────────────────────────╮
│ ⊶ Shell Command A shell command (Shift+Tab to unfocus) │
│ │
│ Line 4 │
│ Line 5 │
│ Line 6 │
│ Line 7
│ Line 8
│ Line 7
│ Line 8
│ Line 9 █ │
│ Line 10 █ │
│ Line 11 █ │
@@ -0,0 +1,35 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`ToolGroupMessage Compact Rendering > does not add an extra empty line between a compact tool and a standard tool 1`] = `
" ✓ ReadFolder Listing files → file1.txt
╭──────────────────────────────────────────────────────────────────────────╮
│ ✓ non-compact-tool Doing something │
│ │
│ some large output │
╰──────────────────────────────────────────────────────────────────────────╯
"
`;
exports[`ToolGroupMessage Compact Rendering > does not add an extra empty line between a standard tool and a compact tool 1`] = `
"╭──────────────────────────────────────────────────────────────────────────╮
│ ✓ non-compact-tool Doing something │
│ │
│ some large output │
╰──────────────────────────────────────────────────────────────────────────╯
✓ ReadFolder Listing files → file1.txt
"
`;
exports[`ToolGroupMessage Compact Rendering > does not add an extra empty line if a compact tool has a dense payload 1`] = `
" ✓ ReadFolder Listing files → file1.txt
✓ ReadFile Reading file → read file
"
`;
exports[`ToolGroupMessage Compact Rendering > renders consecutive compact tools without empty lines between them 1`] = `
" ✓ ReadFolder Listing files → file1.txt file2.txt
✓ ReadFolder Listing files → file3.txt
"
`;
@@ -63,7 +63,8 @@ exports[`<ToolGroupMessage /> > Golden Snapshots > renders empty tool calls arra
exports[`<ToolGroupMessage /> > Golden Snapshots > renders header when scrolled 1`] = `
"╭──────────────────────────────────────────────────────────────────────────╮
│ ✓ tool-1 Description 1. This is a long description that will need to b… │
│──────────────────────────────────────────────────────────────────────────│
│──────────────────────────────────────────────────────────────────────────│
│ line4 │ █
│ line5 │ █
│ │ █
│ ✓ tool-2 Description 2 │ █
@@ -71,6 +72,7 @@ exports[`<ToolGroupMessage /> > Golden Snapshots > renders header when scrolled
│ line1 │ █
│ line2 │ █
╰──────────────────────────────────────────────────────────────────────────╯ █
"
`;
@@ -129,12 +131,12 @@ exports[`<ToolGroupMessage /> > Golden Snapshots > renders tool call with output
`;
exports[`<ToolGroupMessage /> > Golden Snapshots > renders two tool groups where only the last line of the previous group is visible 1`] = `
"──────────────────────────────────────────────────────────────────────────
╭──────────────────────────────────────────────────────────────────────────╮
"──────────────────────────────────────────────────────────────────────────
│ ✓ tool-2 Description 2 │
│ │
│ line1 │
│ │
│ line1 │
╰──────────────────────────────────────────────────────────────────────────╯ █
"
`;
@@ -37,8 +37,7 @@ exports[`ToolResultDisplay > renders string result as plain text when renderOutp
`;
exports[`ToolResultDisplay > truncates very long string results 1`] = `
"... 249 hidden (Ctrl+O) ...
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
"... 250 hidden (Ctrl+O) ...
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
@@ -2,7 +2,7 @@
exports[`ToolMessage Sticky Header Regression > verifies that ShellToolMessage in a ToolGroupMessage in a ScrollableList has sticky headers 1`] = `
"╭────────────────────────────────────────────────────────────────────────╮ █
│ ✓ Shell Command Description for Shell Command │
│ ✓ Shell Command Description for Shell Command │
│ │
│ shell-01 │
│ shell-02 │
@@ -11,7 +11,7 @@ exports[`ToolMessage Sticky Header Regression > verifies that ShellToolMessage i
exports[`ToolMessage Sticky Header Regression > verifies that ShellToolMessage in a ToolGroupMessage in a ScrollableList has sticky headers 2`] = `
"╭────────────────────────────────────────────────────────────────────────╮
│ ✓ Shell Command Description for Shell Command │
│ ✓ Shell Command Description for Shell Command │
│────────────────────────────────────────────────────────────────────────│ █
│ shell-06 │ ▀
│ shell-07 │
@@ -10,6 +10,7 @@ import { waitFor } from '../../../test-utils/async.js';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { ExtensionDetails } from './ExtensionDetails.js';
import { type RegistryExtension } from '../../../config/extensionRegistryClient.js';
import { ExtensionUpdateState } from '../../state/extensions.js';
const mockExtension: RegistryExtension = {
id: 'ext1',
@@ -48,7 +49,11 @@ describe('ExtensionDetails', () => {
mockOnLink = vi.fn();
});
const renderDetails = async (isInstalled = false) =>
const renderDetails = async (
isInstalled = false,
updateState?: ExtensionUpdateState,
onUpdate = vi.fn(),
) =>
renderWithProviders(
<ExtensionDetails
extension={mockExtension}
@@ -56,6 +61,8 @@ describe('ExtensionDetails', () => {
onInstall={mockOnInstall}
onLink={mockOnLink}
isInstalled={isInstalled}
updateState={updateState}
onUpdate={onUpdate}
/>,
);
@@ -165,4 +172,40 @@ describe('ExtensionDetails', () => {
expect(lastFrame()).toContain('[L] Link');
});
});
it('should show update button when update is available', async () => {
const { lastFrame } = await renderDetails(
true,
ExtensionUpdateState.UPDATE_AVAILABLE,
);
await waitFor(() => {
expect(lastFrame()).toContain('[I] Update');
});
});
it('should call onUpdate when "i" is pressed', async () => {
const mockOnUpdate = vi.fn();
const { stdin } = await renderDetails(
true,
ExtensionUpdateState.UPDATE_AVAILABLE,
mockOnUpdate,
);
await React.act(async () => {
stdin.write('i');
});
await waitFor(() => {
expect(mockOnUpdate).toHaveBeenCalled();
});
});
it('should show [Updating...] and hide "Already Installed" when update is in progress', async () => {
const { lastFrame } = await renderDetails(
true,
ExtensionUpdateState.UPDATING,
);
await waitFor(() => {
expect(lastFrame()).toContain('[Updating...]');
expect(lastFrame()).not.toContain('Already Installed');
});
});
});
@@ -12,6 +12,7 @@ import { useKeypress } from '../../hooks/useKeypress.js';
import { Command } from '../../key/keyMatchers.js';
import { useKeyMatchers } from '../../hooks/useKeyMatchers.js';
import { theme } from '../../semantic-colors.js';
import { ExtensionUpdateState } from '../../state/extensions.js';
export interface ExtensionDetailsProps {
extension: RegistryExtension;
@@ -23,6 +24,8 @@ export interface ExtensionDetailsProps {
requestConsentOverride: (consent: string) => Promise<boolean>,
) => void | Promise<void>;
isInstalled: boolean;
updateState?: ExtensionUpdateState;
onUpdate?: () => void | Promise<void>;
}
export function ExtensionDetails({
@@ -31,6 +34,8 @@ export function ExtensionDetails({
onInstall,
onLink,
isInstalled,
updateState,
onUpdate,
}: ExtensionDetailsProps): React.JSX.Element {
const keyMatchers = useKeyMatchers();
const [consentRequest, setConsentRequest] = useState<{
@@ -76,7 +81,12 @@ export function ExtensionDetails({
);
return true;
}
if (key.name === 'l' && isLinkable && !isInstalled && !isInstalling) {
if (
keyMatchers[Command.LINK_EXTENSION](key) &&
isLinkable &&
!isInstalled &&
!isInstalling
) {
setIsInstalling(true);
void onLink(
(prompt: string) =>
@@ -86,6 +96,14 @@ export function ExtensionDetails({
);
return true;
}
if (
keyMatchers[Command.UPDATE_EXTENSION](key) &&
updateState === ExtensionUpdateState.UPDATE_AVAILABLE &&
!isInstalling
) {
void onUpdate?.();
return true;
}
return false;
},
{ isActive: true, priority: true },
@@ -150,6 +168,16 @@ export function ExtensionDetails({
<Text color={theme.text.primary} bold>
{extension.extensionName}
</Text>
{updateState === ExtensionUpdateState.UPDATE_AVAILABLE && (
<Box marginLeft={1}>
<Text color={theme.status.warning}>[I] Update</Text>
</Box>
)}
{updateState === ExtensionUpdateState.UPDATING && (
<Box marginLeft={1}>
<Text color={theme.text.secondary}>[Updating...]</Text>
</Box>
)}
</Box>
<Box flexDirection="row">
<Text color={theme.text.secondary}>
@@ -258,7 +286,7 @@ export function ExtensionDetails({
</Box>
</Box>
)}
{isInstalled && (
{isInstalled && updateState !== ExtensionUpdateState.UPDATING && (
<Box flexDirection="row" marginTop={1} justifyContent="center">
<Text color={theme.status.success}>Already Installed</Text>
</Box>
@@ -21,6 +21,8 @@ import {
type GenericListItem,
} from '../shared/SearchableList.js';
import { type TextBuffer } from '../shared/text-buffer.js';
import { type UseHistoryManagerReturn } from '../../hooks/useHistoryManager.js';
import { ExtensionUpdateState } from '../../state/extensions.js';
// Mocks
vi.mock('../../hooks/useExtensionRegistry.js');
@@ -97,6 +99,7 @@ describe('ExtensionRegistryView', () => {
vi.mocked(useExtensionUpdates).mockReturnValue({
extensionsUpdateState: new Map(),
dispatchExtensionStateUpdate: vi.fn(),
} as unknown as ReturnType<typeof useExtensionUpdates>);
// Mock useRegistrySearch implementation
@@ -134,6 +137,9 @@ describe('ExtensionRegistryView', () => {
uiState: {
staticExtraHeight: 5,
terminalHeight: 40,
historyManager: {
addItem: vi.fn(),
} as unknown as UseHistoryManagerReturn,
} as Partial<UIState>,
},
);
@@ -226,4 +232,42 @@ describe('ExtensionRegistryView', () => {
);
});
});
it('should show [Update available] and hide [Installed] when update is available', async () => {
mockExtensionManager.getExtensions = vi
.fn()
.mockReturnValue([{ name: 'Test Extension 1' }]);
vi.mocked(useExtensionUpdates).mockReturnValue({
extensionsUpdateState: new Map([
['Test Extension 1', ExtensionUpdateState.UPDATE_AVAILABLE],
]),
dispatchExtensionStateUpdate: vi.fn(),
} as unknown as ReturnType<typeof useExtensionUpdates>);
const { lastFrame } = await renderView();
await waitFor(() => {
expect(lastFrame()).toContain('[Update available]');
expect(lastFrame()).not.toContain('[Installed]');
});
});
it('should show [Updating...] and hide [Installed] when update is in progress', async () => {
mockExtensionManager.getExtensions = vi
.fn()
.mockReturnValue([{ name: 'Test Extension 1' }]);
vi.mocked(useExtensionUpdates).mockReturnValue({
extensionsUpdateState: new Map([
['Test Extension 1', ExtensionUpdateState.UPDATING],
]),
dispatchExtensionStateUpdate: vi.fn(),
} as unknown as ReturnType<typeof useExtensionUpdates>);
const { lastFrame } = await renderView();
await waitFor(() => {
expect(lastFrame()).toContain('[Updating...]');
expect(lastFrame()).not.toContain('[Installed]');
});
});
});
@@ -52,15 +52,16 @@ export function ExtensionRegistryView({
'',
config.getExtensionRegistryURI(),
);
const { terminalHeight, staticExtraHeight } = useUIState();
const { terminalHeight, staticExtraHeight, historyManager } = useUIState();
const [selectedExtension, setSelectedExtension] =
useState<RegistryExtension | null>(null);
const { extensionsUpdateState } = useExtensionUpdates(
extensionManager,
() => 0,
config.getEnableExtensionReloading(),
);
const { extensionsUpdateState, dispatchExtensionStateUpdate } =
useExtensionUpdates(
extensionManager,
historyManager.addItem,
config.getEnableExtensionReloading(),
);
const [installedExtensions, setInstalledExtensions] = useState(() =>
extensionManager.getExtensions(),
@@ -117,6 +118,23 @@ export function ExtensionRegistryView({
[onLink, extensionManager],
);
const handleUpdate = useCallback(
async (extension: RegistryExtension) => {
dispatchExtensionStateUpdate({
type: 'SCHEDULE_UPDATE',
payload: {
all: false,
names: [extension.extensionName],
onComplete: () => {
// Refresh installed extensions list if needed
setInstalledExtensions(extensionManager.getExtensions());
},
},
});
},
[dispatchExtensionStateUpdate, extensionManager],
);
const renderItem = useCallback(
(item: ExtensionItem, isActive: boolean, _labelWidth: number) => {
const isInstalled = installedExtensions.some(
@@ -125,7 +143,6 @@ export function ExtensionRegistryView({
const updateState = extensionsUpdateState.get(
item.extension.extensionName,
);
const hasUpdate = updateState === ExtensionUpdateState.UPDATE_AVAILABLE;
return (
<Box flexDirection="row" width="100%" justifyContent="space-between">
@@ -148,15 +165,20 @@ export function ExtensionRegistryView({
<Box flexShrink={0} marginX={1}>
<Text color={theme.text.secondary}>|</Text>
</Box>
{isInstalled && (
<Box marginRight={1} flexShrink={0}>
<Text color={theme.status.success}>[Installed]</Text>
</Box>
)}
{hasUpdate && (
{updateState === ExtensionUpdateState.UPDATE_AVAILABLE ? (
<Box marginRight={1} flexShrink={0}>
<Text color={theme.status.warning}>[Update available]</Text>
</Box>
) : updateState === ExtensionUpdateState.UPDATING ? (
<Box marginRight={1} flexShrink={0}>
<Text color={theme.text.secondary}>[Updating...]</Text>
</Box>
) : (
isInstalled && (
<Box marginRight={1} flexShrink={0}>
<Text color={theme.status.success}>[Installed]</Text>
</Box>
)
)}
<Box flexShrink={1} minWidth={0}>
<Text color={theme.text.secondary} wrap="truncate-end">
@@ -287,6 +309,12 @@ export function ExtensionRegistryView({
isInstalled={installedExtensions.some(
(e) => e.name === selectedExtension.extensionName,
)}
updateState={extensionsUpdateState.get(
selectedExtension.extensionName,
)}
onUpdate={async () => {
await handleUpdate(selectedExtension);
}}
/>
)}
</>
+3
View File
@@ -62,3 +62,6 @@ export const DEFAULT_COMPRESSION_THRESHOLD = 0.5;
/** Documentation URL for skills setup and configuration */
export const SKILLS_DOCS_URL =
'https://github.com/google-gemini/gemini-cli/blob/main/docs/cli/skills.md';
/** Max lines to show for a compact tool subview (e.g. diff) */
export const COMPACT_TOOL_SUBVIEW_MAX_LINES = 15;
@@ -205,8 +205,6 @@ const MAC_ALT_KEY_CHARACTER_MAP: Record<string, string> = {
'\u03A9': 'z', // "Ω" Option+z
'\u00B8': 'Z', // "¸" Option+Shift+z
'\u2202': 'd', // "∂" delete word forward
'\u2020': 't', // "†" toggle full screen buffer
'\u00E5': 'a', // "å" Option+a for alternate buffer
};
function nonKeyboardEventFilter(
@@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { act } from 'react';
import { act, useState, useCallback } from 'react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { renderHook } from '../../test-utils/render.js';
import { ToolActionsProvider, useToolActions } from './ToolActionsContext.js';
@@ -71,16 +71,61 @@ describe('ToolActionsContext', () => {
beforeEach(() => {
vi.clearAllMocks();
// Default to a pending promise to avoid unwanted async state updates in tests
// that don't specifically test the IdeClient initialization.
vi.mocked(IdeClient.getInstance).mockReturnValue(new Promise(() => {}));
});
const wrapper = ({ children }: { children: React.ReactNode }) => (
<ToolActionsProvider config={mockConfig} toolCalls={mockToolCalls}>
{children}
</ToolActionsProvider>
);
const WrapperReactComp = ({ children }: { children: React.ReactNode }) => {
const [expandedTools, setExpandedTools] = useState<Set<string>>(new Set());
const isExpanded = useCallback(
(callId: string) => expandedTools.has(callId),
[expandedTools],
);
const toggleExpansion = useCallback((callId: string) => {
setExpandedTools((prev) => {
const next = new Set(prev);
if (next.has(callId)) {
next.delete(callId);
} else {
next.add(callId);
}
return next;
});
}, []);
const toggleAllExpansion = useCallback((callIds: string[]) => {
setExpandedTools((prev) => {
const next = new Set(prev);
const anyCollapsed = callIds.some((id) => !next.has(id));
if (anyCollapsed) {
callIds.forEach((id) => next.add(id));
} else {
callIds.forEach((id) => next.delete(id));
}
return next;
});
}, []);
return (
<ToolActionsProvider
config={mockConfig}
toolCalls={mockToolCalls}
isExpanded={isExpanded}
toggleExpansion={toggleExpansion}
toggleAllExpansion={toggleAllExpansion}
>
{children}
</ToolActionsProvider>
);
};
it('publishes to MessageBus for tools with correlationId', async () => {
const { result } = await renderHook(() => useToolActions(), { wrapper });
const { result } = await renderHook(() => useToolActions(), {
wrapper: WrapperReactComp,
});
await result.current.confirm(
'modern-call',
@@ -98,7 +143,9 @@ describe('ToolActionsContext', () => {
});
it('handles cancel by calling confirm with Cancel outcome', async () => {
const { result } = await renderHook(() => useToolActions(), { wrapper });
const { result } = await renderHook(() => useToolActions(), {
wrapper: WrapperReactComp,
});
await result.current.cancel('modern-call');
@@ -127,7 +174,9 @@ describe('ToolActionsContext', () => {
);
vi.mocked(mockConfig.getIdeMode).mockReturnValue(true);
const { result } = await renderHook(() => useToolActions(), { wrapper });
const { result } = await renderHook(() => useToolActions(), {
wrapper: WrapperReactComp,
});
await act(async () => {
deferredIdeClient.resolve(mockIdeClient);
@@ -169,7 +218,9 @@ describe('ToolActionsContext', () => {
);
vi.mocked(mockConfig.getIdeMode).mockReturnValue(true);
const { result } = await renderHook(() => useToolActions(), { wrapper });
const { result } = await renderHook(() => useToolActions(), {
wrapper: WrapperReactComp,
});
await act(async () => {
deferredIdeClient.resolve(mockIdeClient);
@@ -214,7 +265,13 @@ describe('ToolActionsContext', () => {
const { result } = await renderHook(() => useToolActions(), {
wrapper: ({ children }) => (
<ToolActionsProvider config={mockConfig} toolCalls={[legacyTool]}>
<ToolActionsProvider
config={mockConfig}
toolCalls={[legacyTool]}
isExpanded={vi.fn().mockReturnValue(false)}
toggleExpansion={vi.fn()}
toggleAllExpansion={vi.fn()}
>
{children}
</ToolActionsProvider>
),
@@ -233,4 +290,58 @@ describe('ToolActionsContext', () => {
);
expect(mockMessageBus.publish).not.toHaveBeenCalled();
});
describe('toggleAllExpansion', () => {
it('expands all when none are expanded', async () => {
const { result } = await renderHook(() => useToolActions(), {
wrapper: WrapperReactComp,
});
act(() => {
result.current.toggleAllExpansion(['modern-call', 'edit-call']);
});
expect(result.current.isExpanded('modern-call')).toBe(true);
expect(result.current.isExpanded('edit-call')).toBe(true);
});
it('expands all when some are expanded', async () => {
const { result } = await renderHook(() => useToolActions(), {
wrapper: WrapperReactComp,
});
act(() => {
result.current.toggleExpansion('modern-call');
});
expect(result.current.isExpanded('modern-call')).toBe(true);
expect(result.current.isExpanded('edit-call')).toBe(false);
act(() => {
result.current.toggleAllExpansion(['modern-call', 'edit-call']);
});
expect(result.current.isExpanded('modern-call')).toBe(true);
expect(result.current.isExpanded('edit-call')).toBe(true);
});
it('collapses all when all are expanded', async () => {
const { result } = await renderHook(() => useToolActions(), {
wrapper: WrapperReactComp,
});
act(() => {
result.current.toggleExpansion('modern-call');
result.current.toggleExpansion('edit-call');
});
expect(result.current.isExpanded('modern-call')).toBe(true);
expect(result.current.isExpanded('edit-call')).toBe(true);
act(() => {
result.current.toggleAllExpansion(['modern-call', 'edit-call']);
});
expect(result.current.isExpanded('modern-call')).toBe(false);
expect(result.current.isExpanded('edit-call')).toBe(false);
});
});
});
@@ -48,11 +48,14 @@ interface ToolActionsContextValue {
) => Promise<void>;
cancel: (callId: string) => Promise<void>;
isDiffingEnabled: boolean;
isExpanded: (callId: string) => boolean;
toggleExpansion: (callId: string) => void;
toggleAllExpansion: (callIds: string[]) => void;
}
const ToolActionsContext = createContext<ToolActionsContextValue | null>(null);
export const useToolActions = () => {
export const useToolActions = (): ToolActionsContextValue => {
const context = useContext(ToolActionsContext);
if (!context) {
throw new Error('useToolActions must be used within a ToolActionsProvider');
@@ -64,12 +67,22 @@ interface ToolActionsProviderProps {
children: React.ReactNode;
config: Config;
toolCalls: IndividualToolCallDisplay[];
isExpanded: (callId: string) => boolean;
toggleExpansion: (callId: string) => void;
toggleAllExpansion: (callIds: string[]) => void;
}
export const ToolActionsProvider: React.FC<ToolActionsProviderProps> = (
props: ToolActionsProviderProps,
) => {
const { children, config, toolCalls } = props;
const {
children,
config,
toolCalls,
isExpanded,
toggleExpansion,
toggleAllExpansion,
} = props;
// Hoist IdeClient logic here to keep UI pure
const [ideClient, setIdeClient] = useState<IdeClient | null>(null);
@@ -77,24 +90,23 @@ export const ToolActionsProvider: React.FC<ToolActionsProviderProps> = (
useEffect(() => {
let isMounted = true;
let activeClient: IdeClient | null = null;
const handleStatusChange = () => {
if (isMounted && activeClient) {
setIsDiffingEnabled(activeClient.isDiffingEnabled());
}
};
if (config.getIdeMode()) {
IdeClient.getInstance()
.then((client) => {
if (!isMounted) return;
activeClient = client;
setIdeClient(client);
setIsDiffingEnabled(client.isDiffingEnabled());
const handleStatusChange = () => {
if (isMounted) {
setIsDiffingEnabled(client.isDiffingEnabled());
}
};
client.addStatusChangeListener(handleStatusChange);
// Return a cleanup function for the listener
return () => {
client.removeStatusChangeListener(handleStatusChange);
};
})
.catch((error) => {
debugLogger.error('Failed to get IdeClient instance:', error);
@@ -102,6 +114,9 @@ export const ToolActionsProvider: React.FC<ToolActionsProviderProps> = (
}
return () => {
isMounted = false;
if (activeClient) {
activeClient.removeStatusChangeListener(handleStatusChange);
}
};
}, [config]);
@@ -164,7 +179,16 @@ export const ToolActionsProvider: React.FC<ToolActionsProviderProps> = (
);
return (
<ToolActionsContext.Provider value={{ confirm, cancel, isDiffingEnabled }}>
<ToolActionsContext.Provider
value={{
confirm,
cancel,
isDiffingEnabled,
isExpanded,
toggleExpansion,
toggleAllExpansion,
}}
>
{children}
</ToolActionsContext.Provider>
);
@@ -78,7 +78,6 @@ export interface UIActions {
setShortcutsHelpVisible: (visible: boolean) => void;
setCleanUiDetailsVisible: (visible: boolean) => void;
toggleCleanUiDetailsVisible: () => void;
toggleAlternateBuffer: () => void;
revealCleanUiDetailsTemporarily: (durationMs?: number) => void;
handleWarning: (message: string) => void;
setEmbeddedShellFocused: (value: boolean) => void;
@@ -118,8 +118,6 @@ export interface UIState {
isEditorDialogOpen: boolean;
showPrivacyNotice: boolean;
corgiMode: boolean;
isAlternateBuffer: boolean;
showIsAlternateBufferHint: boolean;
debugMessage: string;
quittingMessages: HistoryItem[] | null;
isSettingsDialogOpen: boolean;
@@ -11,47 +11,49 @@ import {
isAlternateBufferEnabled,
} from './useAlternateBuffer.js';
import type { Config } from '@google/gemini-cli-core';
import { useUIState } from '../contexts/UIStateContext.js';
vi.mock('../contexts/UIStateContext.js');
vi.mock('../contexts/ConfigContext.js', () => ({
useConfig: vi.fn(),
}));
const mockUseUIState = vi.mocked(useUIState);
const mockUseConfig = vi.mocked(
await import('../contexts/ConfigContext.js').then((m) => m.useConfig),
);
describe('useAlternateBuffer', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('should return false when uiState.isAlternateBuffer is false', async () => {
mockUseUIState.mockReturnValue({
isAlternateBuffer: false,
} as unknown as ReturnType<typeof mockUseUIState>);
it('should return false when config.getUseAlternateBuffer returns false', async () => {
mockUseConfig.mockReturnValue({
getUseAlternateBuffer: () => false,
} as unknown as ReturnType<typeof mockUseConfig>);
const { result } = await renderHook(() => useAlternateBuffer());
expect(result.current).toBe(false);
});
it('should return true when uiState.isAlternateBuffer is true', async () => {
mockUseUIState.mockReturnValue({
isAlternateBuffer: true,
} as unknown as ReturnType<typeof mockUseUIState>);
it('should return true when config.getUseAlternateBuffer returns true', async () => {
mockUseConfig.mockReturnValue({
getUseAlternateBuffer: () => true,
} as unknown as ReturnType<typeof mockUseConfig>);
const { result } = await renderHook(() => useAlternateBuffer());
expect(result.current).toBe(true);
});
it('should react to state changes', async () => {
mockUseUIState.mockReturnValue({
isAlternateBuffer: false,
} as unknown as ReturnType<typeof mockUseUIState>);
it('should return the immutable config value, not react to settings changes', async () => {
const mockConfig = {
getUseAlternateBuffer: () => true,
} as unknown as ReturnType<typeof mockUseConfig>;
mockUseConfig.mockReturnValue(mockConfig);
const { result, rerender } = await renderHook(() => useAlternateBuffer());
expect(result.current).toBe(false);
mockUseUIState.mockReturnValue({
isAlternateBuffer: true,
} as unknown as ReturnType<typeof mockUseUIState>);
// Value should remain true even after rerender
expect(result.current).toBe(true);
rerender();
@@ -4,14 +4,14 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { useUIState } from '../contexts/UIStateContext.js';
import { useConfig } from '../contexts/ConfigContext.js';
import type { Config } from '@google/gemini-cli-core';
export const isAlternateBufferEnabled = (config: Config): boolean =>
config.getUseAlternateBuffer();
// This is read from UIState so that the UI can toggle dynamically
// This is read from Config so that the UI reads the same value per application session
export const useAlternateBuffer = (): boolean => {
const uiState = useUIState();
return uiState.isAlternateBuffer;
const config = useConfig();
return isAlternateBufferEnabled(config);
};
@@ -7,76 +7,93 @@
import { act, useCallback } from 'react';
import { vi } from 'vitest';
import { render } from '../../test-utils/render.js';
import { useConsoleMessages } from './useConsoleMessages.js';
import { CoreEvent, type ConsoleLogPayload } from '@google/gemini-cli-core';
// Mock coreEvents
let consoleLogHandler: ((payload: ConsoleLogPayload) => void) | undefined;
import {
useConsoleMessages,
useErrorCount,
initializeConsoleStore,
} from './useConsoleMessages.js';
import { coreEvents } from '@google/gemini-cli-core';
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const actual = (await importOriginal()) as any;
const actual = await importOriginal();
const handlers = new Map<string, (payload: unknown) => void>();
return {
...actual,
...(actual as Record<string, unknown>),
coreEvents: {
on: vi.fn((event, handler) => {
if (event === CoreEvent.ConsoleLog) {
consoleLogHandler = handler;
}
...((actual as Record<string, unknown>)['coreEvents'] as Record<
string,
unknown
>),
on: vi.fn((event: string, handler: (payload: unknown) => void) => {
handlers.set(event, handler);
}),
off: vi.fn((event) => {
if (event === CoreEvent.ConsoleLog) {
consoleLogHandler = undefined;
}
off: vi.fn((event: string) => {
handlers.delete(event);
}),
emitConsoleLog: vi.fn(),
// Helper for testing to trigger the handlers
_trigger: (event: string, payload: unknown) => {
handlers.get(event)?.(payload);
},
},
};
});
describe('useConsoleMessages', () => {
let unmounts: Array<() => void> = [];
beforeEach(() => {
vi.useFakeTimers();
consoleLogHandler = undefined;
initializeConsoleStore();
});
afterEach(() => {
for (const unmount of unmounts) {
try {
unmount();
} catch (_e) {
// Ignore unmount errors
}
}
unmounts = [];
vi.runOnlyPendingTimers();
vi.useRealTimers();
vi.restoreAllMocks();
});
const useTestableConsoleMessages = () => {
const { ...rest } = useConsoleMessages();
const consoleMessages = useConsoleMessages();
const log = useCallback((content: string) => {
if (consoleLogHandler) {
consoleLogHandler({ type: 'log', content });
}
// @ts-expect-error - internal testing helper
coreEvents._trigger('console-log', { type: 'log', content });
}, []);
const error = useCallback((content: string) => {
if (consoleLogHandler) {
consoleLogHandler({ type: 'error', content });
}
// @ts-expect-error - internal testing helper
coreEvents._trigger('console-log', { type: 'error', content });
}, []);
const clearConsoleMessages = useCallback(() => {
initializeConsoleStore();
}, []);
return {
...rest,
consoleMessages,
log,
error,
clearConsoleMessages: rest.clearConsoleMessages,
clearConsoleMessages,
};
};
const renderConsoleMessagesHook = async () => {
let hookResult: ReturnType<typeof useTestableConsoleMessages>;
let hookResult: ReturnType<typeof useTestableConsoleMessages> | undefined;
function TestComponent() {
hookResult = useTestableConsoleMessages();
return null;
}
const { unmount } = await render(<TestComponent />);
unmounts.push(unmount);
return {
result: {
get current() {
return hookResult;
return hookResult!;
},
},
unmount,
@@ -93,10 +110,7 @@ describe('useConsoleMessages', () => {
act(() => {
result.current.log('Test message');
});
await act(async () => {
await vi.advanceTimersByTimeAsync(60);
vi.runAllTimers();
});
expect(result.current.consoleMessages).toEqual([
@@ -111,10 +125,7 @@ describe('useConsoleMessages', () => {
result.current.log('Test message');
result.current.log('Test message');
result.current.log('Test message');
});
await act(async () => {
await vi.advanceTimersByTimeAsync(60);
vi.runAllTimers();
});
expect(result.current.consoleMessages).toEqual([
@@ -128,10 +139,7 @@ describe('useConsoleMessages', () => {
act(() => {
result.current.log('First message');
result.current.error('Second message');
});
await act(async () => {
await vi.advanceTimersByTimeAsync(60);
vi.runAllTimers();
});
expect(result.current.consoleMessages).toEqual([
@@ -139,53 +147,85 @@ describe('useConsoleMessages', () => {
{ type: 'error', content: 'Second message', count: 1 },
]);
});
});
it('should clear all messages when clearConsoleMessages is called', async () => {
const { result } = await renderConsoleMessagesHook();
describe('useErrorCount', () => {
let unmounts: Array<() => void> = [];
act(() => {
result.current.log('A message');
});
await act(async () => {
await vi.advanceTimersByTimeAsync(60);
});
expect(result.current.consoleMessages).toHaveLength(1);
act(() => {
result.current.clearConsoleMessages();
});
expect(result.current.consoleMessages).toHaveLength(0);
beforeEach(() => {
vi.useFakeTimers();
initializeConsoleStore();
});
it('should clear the pending timeout when clearConsoleMessages is called', async () => {
const { result } = await renderConsoleMessagesHook();
const clearTimeoutSpy = vi.spyOn(global, 'clearTimeout');
act(() => {
result.current.log('A message');
});
act(() => {
result.current.clearConsoleMessages();
});
expect(clearTimeoutSpy).toHaveBeenCalled();
// clearTimeoutSpy.mockRestore() is handled by afterEach restoreAllMocks
afterEach(() => {
for (const unmount of unmounts) {
try {
unmount();
} catch (_e) {
// Ignore unmount errors
}
}
unmounts = [];
vi.runOnlyPendingTimers();
vi.useRealTimers();
vi.restoreAllMocks();
});
it('should clean up the timeout on unmount', async () => {
const { result, unmount } = await renderConsoleMessagesHook();
const clearTimeoutSpy = vi.spyOn(global, 'clearTimeout');
const renderErrorCountHook = async () => {
let hookResult: ReturnType<typeof useErrorCount>;
function TestComponent() {
hookResult = useErrorCount();
return null;
}
const { unmount } = await render(<TestComponent />);
unmounts.push(unmount);
return {
result: {
get current() {
return hookResult;
},
},
unmount,
};
};
it('should initialize with an error count of 0', async () => {
const { result } = await renderErrorCountHook();
expect(result.current.errorCount).toBe(0);
});
it('should increment error count when an error is logged', async () => {
const { result } = await renderErrorCountHook();
act(() => {
// @ts-expect-error - internal testing helper
coreEvents._trigger('console-log', { type: 'error', content: 'error' });
vi.runAllTimers();
});
expect(result.current.errorCount).toBe(1);
});
it('should not increment error count for non-error logs', async () => {
const { result } = await renderErrorCountHook();
act(() => {
// @ts-expect-error - internal testing helper
coreEvents._trigger('console-log', { type: 'log', content: 'log' });
vi.runAllTimers();
});
expect(result.current.errorCount).toBe(0);
});
it('should clear the error count', async () => {
const { result } = await renderErrorCountHook();
act(() => {
// @ts-expect-error - internal testing helper
coreEvents._trigger('console-log', { type: 'error', content: 'error' });
vi.runAllTimers();
});
expect(result.current.errorCount).toBe(1);
act(() => {
result.current.log('A message');
result.current.clearErrorCount();
});
unmount();
expect(clearTimeoutSpy).toHaveBeenCalled();
expect(result.current.errorCount).toBe(0);
});
});
+157 -200
View File
@@ -4,13 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import {
useCallback,
useEffect,
useReducer,
useRef,
startTransition,
} from 'react';
import { useCallback, useSyncExternalStore } from 'react';
import type { ConsoleMessageItem } from '../types.js';
import {
coreEvents,
@@ -18,207 +12,170 @@ import {
type ConsoleLogPayload,
} from '@google/gemini-cli-core';
export interface UseConsoleMessagesReturn {
consoleMessages: ConsoleMessageItem[];
clearConsoleMessages: () => void;
}
type Action =
| { type: 'ADD_MESSAGES'; payload: ConsoleMessageItem[] }
| { type: 'CLEAR' };
function consoleMessagesReducer(
state: ConsoleMessageItem[],
action: Action,
): ConsoleMessageItem[] {
const MAX_CONSOLE_MESSAGES = 1000;
switch (action.type) {
case 'ADD_MESSAGES': {
const newMessages = [...state];
for (const queuedMessage of action.payload) {
const lastMessage = newMessages[newMessages.length - 1];
if (
lastMessage &&
lastMessage.type === queuedMessage.type &&
lastMessage.content === queuedMessage.content
) {
// Create a new object for the last message to ensure React detects
// the change, preventing mutation of the existing state object.
newMessages[newMessages.length - 1] = {
...lastMessage,
count: lastMessage.count + 1,
};
} else {
newMessages.push({ ...queuedMessage, count: 1 });
}
}
// Limit the number of messages to prevent memory issues
if (newMessages.length > MAX_CONSOLE_MESSAGES) {
return newMessages.slice(newMessages.length - MAX_CONSOLE_MESSAGES);
}
return newMessages;
}
case 'CLEAR':
return [];
default:
return state;
}
}
export function useConsoleMessages(): UseConsoleMessagesReturn {
const [consoleMessages, dispatch] = useReducer(consoleMessagesReducer, []);
const messageQueueRef = useRef<ConsoleMessageItem[]>([]);
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
const isProcessingRef = useRef(false);
const processQueue = useCallback(() => {
if (messageQueueRef.current.length > 0) {
isProcessingRef.current = true;
const messagesToProcess = messageQueueRef.current;
messageQueueRef.current = [];
startTransition(() => {
dispatch({ type: 'ADD_MESSAGES', payload: messagesToProcess });
});
}
timeoutRef.current = null;
}, []);
const handleNewMessage = useCallback(
(message: ConsoleMessageItem) => {
messageQueueRef.current.push(message);
if (!isProcessingRef.current && !timeoutRef.current) {
// Batch updates using a timeout. 50ms is a reasonable delay to batch
// rapid-fire messages without noticeable lag while avoiding React update
// queue flooding.
timeoutRef.current = setTimeout(processQueue, 50);
}
},
[processQueue],
);
// Once the updated consoleMessages have been committed to the screen,
// we can safely process the next batch of queued messages if any exist.
// This completely eliminates overlapping concurrent updates to this state.
useEffect(() => {
isProcessingRef.current = false;
if (messageQueueRef.current.length > 0 && !timeoutRef.current) {
timeoutRef.current = setTimeout(processQueue, 50);
}
}, [consoleMessages, processQueue]);
useEffect(() => {
const handleConsoleLog = (payload: ConsoleLogPayload) => {
let content = payload.content;
const MAX_CONSOLE_MSG_LENGTH = 10000;
if (content.length > MAX_CONSOLE_MSG_LENGTH) {
content =
content.slice(0, MAX_CONSOLE_MSG_LENGTH) +
`... [Truncated ${content.length - MAX_CONSOLE_MSG_LENGTH} characters]`;
}
handleNewMessage({
type: payload.type,
content,
count: 1,
});
};
const handleOutput = (payload: {
isStderr: boolean;
chunk: Uint8Array | string;
}) => {
let content =
typeof payload.chunk === 'string'
? payload.chunk
: new TextDecoder().decode(payload.chunk);
const MAX_OUTPUT_CHUNK_LENGTH = 10000;
if (content.length > MAX_OUTPUT_CHUNK_LENGTH) {
content =
content.slice(0, MAX_OUTPUT_CHUNK_LENGTH) +
`... [Truncated ${content.length - MAX_OUTPUT_CHUNK_LENGTH} characters]`;
}
// It would be nice if we could show stderr as 'warn' but unfortunately
// we log non warning info to stderr before the app starts so that would
// be misleading.
handleNewMessage({ type: 'log', content, count: 1 });
};
coreEvents.on(CoreEvent.ConsoleLog, handleConsoleLog);
coreEvents.on(CoreEvent.Output, handleOutput);
return () => {
coreEvents.off(CoreEvent.ConsoleLog, handleConsoleLog);
coreEvents.off(CoreEvent.Output, handleOutput);
};
}, [handleNewMessage]);
const clearConsoleMessages = useCallback(() => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
messageQueueRef.current = [];
isProcessingRef.current = true;
startTransition(() => {
dispatch({ type: 'CLEAR' });
});
}, []);
// Cleanup on unmount
useEffect(
() => () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
},
[],
);
return { consoleMessages, clearConsoleMessages };
}
export interface UseErrorCountReturn {
errorCount: number;
clearErrorCount: () => void;
}
// --- Global Console Store ---
const MAX_CONSOLE_MESSAGES = 1000;
let globalConsoleMessages: ConsoleMessageItem[] = [];
let globalErrorCount = 0;
const listeners = new Set<() => void>();
let messageQueue: ConsoleMessageItem[] = [];
let timeoutId: NodeJS.Timeout | null = null;
/**
* Initializes the global console store and subscribes to coreEvents.
* Acts as a safe reset function, making it idempotent and useful for test isolation.
* Must be called during application startup.
*/
export function initializeConsoleStore() {
if (timeoutId) {
clearTimeout(timeoutId);
timeoutId = null;
}
messageQueue = [];
globalConsoleMessages = [];
globalErrorCount = 0;
notifyListeners();
// Safely detach first to ensure idempotency and prevent listener leaks
coreEvents.off(CoreEvent.ConsoleLog, handleConsoleLog);
coreEvents.off(CoreEvent.Output, handleOutput);
coreEvents.on(CoreEvent.ConsoleLog, handleConsoleLog);
coreEvents.on(CoreEvent.Output, handleOutput);
}
function notifyListeners() {
for (const listener of listeners) {
listener();
}
}
function processQueue() {
if (messageQueue.length === 0) return;
// Create a new array to trigger React updates
const newMessages = [...globalConsoleMessages];
for (const queuedMessage of messageQueue) {
if (queuedMessage.type === 'error') {
globalErrorCount++;
}
// Coalesce consecutive identical messages
const prev = newMessages[newMessages.length - 1];
if (
prev &&
prev.type === queuedMessage.type &&
prev.content === queuedMessage.content
) {
newMessages[newMessages.length - 1] = {
...prev,
count: prev.count + 1,
};
} else {
newMessages.push({ ...queuedMessage, count: 1 });
}
}
globalConsoleMessages =
newMessages.length > MAX_CONSOLE_MESSAGES
? newMessages.slice(-MAX_CONSOLE_MESSAGES)
: newMessages;
messageQueue = [];
timeoutId = null;
notifyListeners();
}
function handleNewMessage(message: ConsoleMessageItem) {
messageQueue.push(message);
if (!timeoutId) {
// Batch updates using a timeout. 50ms is a reasonable delay to batch
// rapid-fire messages without noticeable lag while avoiding React update
// queue flooding.
timeoutId = setTimeout(processQueue, 50);
}
}
// --- Subscription API for useSyncExternalStore ---
function subscribe(listener: () => void) {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
}
function getConsoleMessagesSnapshot() {
return globalConsoleMessages;
}
function getErrorCountSnapshot() {
return globalErrorCount;
}
// --- Core Event Listeners (Always active at module level) ---
const handleConsoleLog = (payload: ConsoleLogPayload) => {
let content = payload.content;
const MAX_CONSOLE_MSG_LENGTH = 10000;
if (content.length > MAX_CONSOLE_MSG_LENGTH) {
content =
content.slice(0, MAX_CONSOLE_MSG_LENGTH) +
`... [Truncated ${content.length - MAX_CONSOLE_MSG_LENGTH} characters]`;
}
handleNewMessage({
type: payload.type,
content,
count: 1,
});
};
const handleOutput = (payload: {
isStderr: boolean;
chunk: Uint8Array | string;
}) => {
let content =
typeof payload.chunk === 'string'
? payload.chunk
: new TextDecoder().decode(payload.chunk);
const MAX_OUTPUT_CHUNK_LENGTH = 10000;
if (content.length > MAX_OUTPUT_CHUNK_LENGTH) {
content =
content.slice(0, MAX_OUTPUT_CHUNK_LENGTH) +
`... [Truncated ${content.length - MAX_OUTPUT_CHUNK_LENGTH} characters]`;
}
handleNewMessage({ type: 'log', content, count: 1 });
};
/**
* Hook to access the global console message history.
* Decoupled from any component lifecycle to ensure history is preserved even
* when the UI is unmounted.
*/
export function useConsoleMessages(): ConsoleMessageItem[] {
return useSyncExternalStore(subscribe, getConsoleMessagesSnapshot);
}
/**
* Hook to access the global error count.
* Uses the same external store as useConsoleMessages for consistency.
*/
export function useErrorCount(): UseErrorCountReturn {
const [errorCount, dispatch] = useReducer(
(state: number, action: 'INCREMENT' | 'CLEAR') => {
switch (action) {
case 'INCREMENT':
return state + 1;
case 'CLEAR':
return 0;
default:
return state;
}
},
0,
);
useEffect(() => {
const handleConsoleLog = (payload: ConsoleLogPayload) => {
if (payload.type === 'error') {
startTransition(() => {
dispatch('INCREMENT');
});
}
};
coreEvents.on(CoreEvent.ConsoleLog, handleConsoleLog);
return () => {
coreEvents.off(CoreEvent.ConsoleLog, handleConsoleLog);
};
}, []);
const errorCount = useSyncExternalStore(subscribe, getErrorCountSnapshot);
const clearErrorCount = useCallback(() => {
startTransition(() => {
dispatch('CLEAR');
});
globalErrorCount = 0;
notifyListeners();
}, []);
return { errorCount, clearErrorCount };
+117 -22
View File
@@ -84,6 +84,7 @@ import type { UseHistoryManagerReturn } from './useHistoryManager.js';
import { useLogger } from './useLogger.js';
import { SHELL_COMMAND_NAME } from '../constants.js';
import { mapToDisplay as mapTrackedToolCallsToDisplay } from './toolMapping.js';
import { isCompactTool } from '../components/messages/ToolGroupMessage.js';
import {
useToolScheduler,
type TrackedToolCall,
@@ -303,9 +304,32 @@ export const useGeminiStream = (
(tc) => !pushedToolCallIdsRef.current.has(tc.request.callId),
);
if (toolsToPush.length > 0) {
const isCompactModeEnabled =
settings.merged.ui?.compactToolOutput === true;
const firstToolToPush = toolsToPush[0];
const tcIndex = toolCalls.indexOf(firstToolToPush);
const prevTool = tcIndex > 0 ? toolCalls[tcIndex - 1] : null;
let borderTop = isFirstToolInGroupRef.current;
if (!borderTop && prevTool) {
// If the first tool in this push is non-compact but follows a compact tool,
// we must start a new border group.
const currentIsCompact = isCompactTool(
mapTrackedToolCallsToDisplay(firstToolToPush).tools[0],
isCompactModeEnabled,
);
const prevWasCompact = isCompactTool(
mapTrackedToolCallsToDisplay(prevTool).tools[0],
isCompactModeEnabled,
);
if (!currentIsCompact && prevWasCompact) {
borderTop = true;
}
}
addItem(
mapTrackedToolCallsToDisplay(toolsToPush as TrackedToolCall[], {
borderTop: isFirstToolInGroupRef.current,
borderTop,
borderBottom: true,
borderColor: theme.border.default,
borderDimColor: false,
@@ -340,9 +364,7 @@ export const useGeminiStream = (
}
// Handle tool response submission immediately when tools complete
await handleCompletedTools(
completedToolCallsFromScheduler as TrackedToolCall[],
);
await handleCompletedTools(completedToolCallsFromScheduler);
}
},
config,
@@ -472,26 +494,85 @@ export const useGeminiStream = (
if (toolsToPush.length > 0) {
const newPushed = new Set(pushedToolCallIdsRef.current);
const isFirstInThisPush = isFirstToolInGroupRef.current;
const isCompactModeEnabled =
settings.merged.ui?.compactToolOutput === true;
const groups: TrackedToolCall[][] = [];
let currentGroup: TrackedToolCall[] = [];
for (const tc of toolsToPush) {
newPushed.add(tc.request.callId);
if (tc.tool?.kind === Kind.Agent) {
currentGroup.push(tc);
} else {
if (currentGroup.length > 0) {
groups.push(currentGroup);
currentGroup = [];
}
groups.push([tc]);
}
}
if (currentGroup.length > 0) {
groups.push(currentGroup);
}
const isLastInBatch =
toolsToPush[toolsToPush.length - 1] === toolCalls[toolCalls.length - 1];
for (let i = 0; i < groups.length; i++) {
const group = groups[i];
const isFirstInBatch = i === 0 && isFirstInThisPush;
const lastTcInGroup = group[group.length - 1];
const tcIndexInBatch = toolCalls.indexOf(lastTcInGroup);
const isLastInBatch = tcIndexInBatch === toolCalls.length - 1;
const historyItem = mapTrackedToolCallsToDisplay(toolsToPush, {
borderTop: isFirstToolInGroupRef.current,
borderBottom: isLastInBatch,
...getToolGroupBorderAppearance(
{ type: 'tool_group', tools: toolCalls },
activeShellPtyId,
!!isShellFocused,
[],
backgroundTasks,
),
});
addItem(historyItem);
const nextTcInBatch =
tcIndexInBatch < toolCalls.length - 1
? toolCalls[tcIndexInBatch + 1]
: null;
const prevTcInBatch =
toolCalls.indexOf(group[0]) > 0
? toolCalls[toolCalls.indexOf(group[0]) - 1]
: null;
const historyItem = mapTrackedToolCallsToDisplay(group, {
...getToolGroupBorderAppearance(
{ type: 'tool_group', tools: toolCalls },
activeShellPtyId,
!!isShellFocused,
[],
backgroundTasks,
),
});
// Determine if this group starts with a compact tool
const currentIsCompact =
historyItem.tools.length === 1 &&
isCompactTool(historyItem.tools[0], isCompactModeEnabled);
let nextIsCompact = false;
if (nextTcInBatch) {
const nextHistoryItem = mapTrackedToolCallsToDisplay(nextTcInBatch);
nextIsCompact =
nextHistoryItem.tools.length === 1 &&
isCompactTool(nextHistoryItem.tools[0], isCompactModeEnabled);
}
let prevWasCompact = false;
if (prevTcInBatch) {
const prevHistoryItem = mapTrackedToolCallsToDisplay(prevTcInBatch);
prevWasCompact =
prevHistoryItem.tools.length === 1 &&
isCompactTool(prevHistoryItem.tools[0], isCompactModeEnabled);
}
historyItem.borderTop =
isFirstInBatch || (!currentIsCompact && prevWasCompact);
historyItem.borderBottom = currentIsCompact
? isLastInBatch && !nextIsCompact
: isLastInBatch || nextIsCompact;
addItem(historyItem);
}
setPushedToolCallIds(newPushed);
@@ -516,6 +597,7 @@ export const useGeminiStream = (
activeShellPtyId,
isShellFocused,
backgroundTasks,
settings.merged.ui?.compactToolOutput,
]);
const pendingToolGroupItems = useMemo((): HistoryItemWithoutId[] => {
const remainingTools = toolCalls.filter(
@@ -569,8 +651,7 @@ export const useGeminiStream = (
toolCalls.length > 0 &&
toolCalls.every((tc) => pushedToolCallIds.has(tc.request.callId));
const anyVisibleInHistory = pushedToolCallIds.size > 0;
const anyVisibleInPending = remainingTools.some((tc) => {
const isToolVisible = (tc: TrackedToolCall) => {
const displayName = tc.tool?.displayName ?? tc.request.name;
let hasResultDisplay = false;
@@ -607,12 +688,25 @@ export const useGeminiStream = (
// ToolGroupMessage now shows all non-canceled tools, so they are visible
// in pending and we need to draw the closing border for them.
return true;
});
};
let lastVisibleIsCompact = false;
const isCompactModeEnabled = settings.merged.ui?.compactToolOutput === true;
for (let i = toolCalls.length - 1; i >= 0; i--) {
if (isToolVisible(toolCalls[i])) {
const mapped = mapTrackedToolCallsToDisplay(toolCalls[i]);
lastVisibleIsCompact = mapped.tools[0]
? isCompactTool(mapped.tools[0], isCompactModeEnabled)
: false;
break;
}
}
if (
toolCalls.length > 0 &&
!(allTerminal && allPushed) &&
(anyVisibleInHistory || anyVisibleInPending)
toolCalls.some(isToolVisible) &&
!lastVisibleIsCompact
) {
items.push({
type: 'tool_group' as const,
@@ -630,6 +724,7 @@ export const useGeminiStream = (
activeShellPtyId,
isShellFocused,
backgroundTasks,
settings.merged.ui?.compactToolOutput,
]);
const lastQueryRef = useRef<PartListUnion | null>(null);
+16 -3
View File
@@ -95,7 +95,6 @@ export enum Command {
RESTART_APP = 'app.restart',
SUSPEND_APP = 'app.suspend',
SHOW_SHELL_INPUT_UNFOCUS_WARNING = 'app.showShellUnfocusWarning',
TOGGLE_BUFFER_MODE = 'app.toggleBufferMode',
// Background Shell Controls
BACKGROUND_SHELL_ESCAPE = 'background.escape',
@@ -106,6 +105,10 @@ export enum Command {
UNFOCUS_BACKGROUND_SHELL = 'background.unfocus',
UNFOCUS_BACKGROUND_SHELL_LIST = 'background.unfocusList',
SHOW_BACKGROUND_SHELL_UNFOCUS_WARNING = 'background.unfocusWarning',
// Extension Controls
UPDATE_EXTENSION = 'extension.update',
LINK_EXTENSION = 'extension.link',
}
/**
@@ -393,7 +396,6 @@ export const defaultKeyBindingConfig: KeyBindingConfig = new Map([
[Command.RESTART_APP, [new KeyBinding('r'), new KeyBinding('shift+r')]],
[Command.SUSPEND_APP, [new KeyBinding('ctrl+z')]],
[Command.SHOW_SHELL_INPUT_UNFOCUS_WARNING, [new KeyBinding('tab')]],
[Command.TOGGLE_BUFFER_MODE, [new KeyBinding('alt+a')]],
// Background Shell Controls
[Command.BACKGROUND_SHELL_ESCAPE, [new KeyBinding('escape')]],
@@ -404,6 +406,10 @@ export const defaultKeyBindingConfig: KeyBindingConfig = new Map([
[Command.UNFOCUS_BACKGROUND_SHELL, [new KeyBinding('shift+tab')]],
[Command.UNFOCUS_BACKGROUND_SHELL_LIST, [new KeyBinding('tab')]],
[Command.SHOW_BACKGROUND_SHELL_UNFOCUS_WARNING, [new KeyBinding('tab')]],
// Extension Controls
[Command.UPDATE_EXTENSION, [new KeyBinding('i')]],
[Command.LINK_EXTENSION, [new KeyBinding('l')]],
]);
interface CommandCategory {
@@ -531,6 +537,10 @@ export const commandCategories: readonly CommandCategory[] = [
Command.SHOW_BACKGROUND_SHELL_UNFOCUS_WARNING,
],
},
{
title: 'Extension Controls',
commands: [Command.UPDATE_EXTENSION, Command.LINK_EXTENSION],
},
];
/**
@@ -611,7 +621,6 @@ export const commandDescriptions: Readonly<Record<Command, string>> = {
[Command.SHOW_IDE_CONTEXT_DETAIL]: 'Show IDE context details.',
[Command.TOGGLE_MARKDOWN]: 'Toggle Markdown rendering.',
[Command.TOGGLE_COPY_MODE]: 'Toggle copy mode when in alternate buffer mode.',
[Command.TOGGLE_BUFFER_MODE]: 'Toggle between regular and full screen (alternate buffer) mode.',
[Command.TOGGLE_YOLO]: 'Toggle YOLO (auto-approval) mode for tool calls.',
[Command.CYCLE_APPROVAL_MODE]:
'Cycle through approval modes: default (prompt), auto_edit (auto-approve edits), and plan (read-only). Plan mode is skipped when the agent is busy.',
@@ -641,6 +650,10 @@ export const commandDescriptions: Readonly<Record<Command, string>> = {
'Move focus from background shell list to Gemini.',
[Command.SHOW_BACKGROUND_SHELL_UNFOCUS_WARNING]:
'Show warning when trying to move focus away from background shell.',
// Extension Controls
[Command.UPDATE_EXTENSION]: 'Update the current extension if available.',
[Command.LINK_EXTENSION]: 'Link the current extension to a local path.',
};
const keybindingsSchema = z.array(
+1
View File
@@ -370,6 +370,7 @@ export type HistoryItemMcpStatus = HistoryItemBase & {
showSchema: boolean;
};
// Individually exported types extending HistoryItemBase
export type HistoryItemWithoutId =
| HistoryItemUser
| HistoryItemUserShell
@@ -19,7 +19,8 @@ Tips for getting started:
│ ⊶ google_web_search │
│ │
│ Searching... │
╰──────────────────────────────────────────────────────────────────────────────────────────────╯"
╰──────────────────────────────────────────────────────────────────────────────────────────────╯
"
`;
exports[`MainContent tool group border SVG snapshots > should render SVG snapshot for a shell tool 1`] = `
@@ -41,7 +42,8 @@ Tips for getting started:
│ ⊶ run_shell_command │
│ │
│ Running command... │
╰──────────────────────────────────────────────────────────────────────────────────────────────╯"
╰──────────────────────────────────────────────────────────────────────────────────────────────╯
"
`;
exports[`MainContent tool group border SVG snapshots > should render SVG snapshot for an empty slice following a search tool 1`] = `
@@ -63,5 +65,6 @@ Tips for getting started:
│ ⊶ google_web_search │
│ │
│ Searching... │
╰──────────────────────────────────────────────────────────────────────────────────────────────╯"
╰──────────────────────────────────────────────────────────────────────────────────────────────╯
"
`;
+19
View File
@@ -0,0 +1,19 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import * as path from 'node:path';
/**
* Gets the file extension from a filename or path, excluding the leading dot.
* Returns null if no extension is found.
*/
export function getFileExtension(
filename: string | null | undefined,
): string | null {
if (!filename) return null;
const ext = path.extname(filename);
return ext ? ext.slice(1) : null;
}

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