Compare commits
53 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c618fcb2be | |||
| f518fd6004 | |||
| 91400c5b0b | |||
| d5da95eb5c | |||
| cb25b5f7c3 | |||
| cdd0814bdf | |||
| bda7a9a625 | |||
| 554a5a36a3 | |||
| 7e117ac0ac | |||
| 6f053dd8b9 | |||
| 25af91e4df | |||
| 1edc542b55 | |||
| 119dff3b73 | |||
| 7c4b497a84 | |||
| 65ee6171e7 | |||
| 9cb09ccf8e | |||
| a79f2f81ae | |||
| 140c2b9914 | |||
| 6805e818f7 | |||
| 07e2053e12 | |||
| f9a93a1337 | |||
| 3982a252bb | |||
| e293424bb4 | |||
| 21ad42f677 | |||
| 561418c554 | |||
| d0d3639e16 | |||
| 2f7f967189 | |||
| 46d6b119b6 | |||
| 80929c48c5 | |||
| 35efdfc409 | |||
| bea57a2f3d | |||
| 1df5c98b33 | |||
| 3e95b8ec59 | |||
| 5b5f87abc7 | |||
| 991dca4c40 | |||
| dfba0e91e2 | |||
| 117a2d3844 | |||
| 4b20d93e1d | |||
| 44cdb3e376 | |||
| 44f6a0fafa | |||
| 326edee741 | |||
| bfb13b4fc3 | |||
| 27a56f4f7a | |||
| 90bdba9059 | |||
| e1febb6849 | |||
| 6a4f022a4f | |||
| 4204813895 | |||
| 3de096b444 | |||
| 8faed58289 | |||
| 96cd5b7c5a | |||
| 480ac50204 | |||
| 7ff4402dbd | |||
| 2b96590e94 |
@@ -1,9 +1,9 @@
|
||||
{
|
||||
"experimental": {
|
||||
"plan": true,
|
||||
"extensionReloading": true,
|
||||
"modelSteering": true,
|
||||
"memoryManager": true
|
||||
"memoryManager": true,
|
||||
"topicUpdateNarration": true
|
||||
},
|
||||
"general": {
|
||||
"devtools": true
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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 }}'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.** |
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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[]) =>
|
||||
|
||||
@@ -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 @@
|
||||
{"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"}]}]}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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">></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">></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">></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">></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 "exception TypeError: fetch failed sending request" 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 "exception TypeError: fetch failed sending request" 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 "exception TypeError: fetch failed sending request" 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 "exception TypeError: fetch failed sending request" 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 "exception TypeError: fetch failed sending request" 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 "exception TypeError: fetch failed sending request" 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 "exception TypeError: fetch failed sending request" 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 "exception TypeError: fetch failed sending request" 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 "exception TypeError: fetch failed sending request" 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 "exception TypeError: fetch failed sending request" 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 "exception TypeError: fetch failed sending request" 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 "exception TypeError: fetch failed sending request" 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 "exception TypeError: fetch failed sending request" 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 "exception TypeError: fetch failed sending request" 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 "exception TypeError: fetch failed sending request" 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 "exception TypeError: fetch failed sending request" 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 "exception TypeError: fetch failed sending request" 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 "exception TypeError: fetch failed sending request" 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);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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... │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────╯
|
||||
"
|
||||
`;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||