Compare commits

..

1 Commits

Author SHA1 Message Date
mkorwel 32b098697b WIP 2026-03-29 12:10:03 -07:00
315 changed files with 3076 additions and 23184 deletions
+2 -2
View File
@@ -1,9 +1,9 @@
{
"experimental": {
"plan": true,
"extensionReloading": true,
"modelSteering": true,
"memoryManager": false,
"topicUpdateNarration": true
"memoryManager": true
},
"general": {
"devtools": true
+3 -7
View File
@@ -175,9 +175,7 @@ runs:
--dry-run="${INPUTS_DRY_RUN}" \
--workspace="${INPUTS_CORE_PACKAGE_NAME}" \
--no-tag
if [[ "${INPUTS_DRY_RUN}" == "false" ]]; then
npm dist-tag rm ${INPUTS_CORE_PACKAGE_NAME} false
fi
npm dist-tag rm ${INPUTS_CORE_PACKAGE_NAME} false
- name: '🔗 Install latest core package'
working-directory: '${{ inputs.working-directory }}'
@@ -195,7 +193,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/'"
if: "inputs.npm-registry-url != 'https://npm.pkg.github.com/' && inputs.npm-tag != 'latest'"
working-directory: '${{ inputs.working-directory }}'
shell: 'bash'
run: |
@@ -250,9 +248,7 @@ runs:
--dry-run="${INPUTS_DRY_RUN}" \
--workspace="${INPUTS_A2A_PACKAGE_NAME}" \
--no-tag
if [[ "${INPUTS_DRY_RUN}" == "false" ]]; then
npm dist-tag rm ${INPUTS_A2A_PACKAGE_NAME} false
fi
npm dist-tag rm ${INPUTS_A2A_PACKAGE_NAME} false
- name: '🔬 Verify NPM release by version'
uses: './.github/actions/verify-release'
-7
View File
@@ -18,13 +18,6 @@ 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 }}'
+5 -8
View File
@@ -346,11 +346,9 @@ npm run lint
- Please adhere to the coding style, patterns, and conventions used throughout
the existing codebase.
- Consult
[GEMINI.md](https://github.com/google-gemini/gemini-cli/blob/main/GEMINI.md)
(typically found in the project root) for specific instructions related to
AI-assisted development, including conventions for React, comments, and Git
usage.
- Consult [GEMINI.md](../GEMINI.md) (typically found in the project root) for
specific instructions related to AI-assisted development, including
conventions for React, comments, and Git usage.
- **Imports:** Pay special attention to import paths. The project uses ESLint to
enforce restrictions on relative imports between packages.
@@ -507,9 +505,8 @@ code.
### Documentation structure
Our documentation is organized using
[sidebar.json](https://github.com/google-gemini/gemini-cli/blob/main/docs/sidebar.json)
as the table of contents. When adding new documentation:
Our documentation is organized using [sidebar.json](/docs/sidebar.json) as the
table of contents. When adding new documentation:
1. Create your markdown file **in the appropriate directory** under `/docs`.
2. Add an entry to `sidebar.json` in the relevant section.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

+3 -6
View File
@@ -1,6 +1,6 @@
# Latest stable release: v0.35.3
# Latest stable release: v0.35.2
Released: March 28, 2026
Released: March 26, 2026
For most users, our latest stable release is the recommended release. Install
the latest stable version with:
@@ -29,9 +29,6 @@ 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
@@ -388,4 +385,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.3
https://github.com/google-gemini/gemini-cli/compare/v0.34.0...v0.35.2
+3 -7
View File
@@ -1,6 +1,6 @@
# Preview release: v0.36.0-preview.6
# Preview release: v0.36.0-preview.5
Released: March 28, 2026
Released: March 27, 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,10 +31,6 @@ 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
@@ -390,4 +386,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.6
https://github.com/google-gemini/gemini-cli/compare/v0.35.0-preview.5...v0.36.0-preview.5
+1 -1
View File
@@ -52,7 +52,7 @@ These commands are available within the interactive REPL.
| `--prompt-interactive` | `-i` | string | - | Execute prompt and continue in interactive mode |
| `--worktree` | `-w` | string | - | Start Gemini in a new git worktree. If no name is provided, one is generated automatically. Requires `experimental.worktrees: true` in settings. |
| `--sandbox` | `-s` | boolean | `false` | Run in a sandboxed environment for safer execution |
| `--approval-mode` | - | string | `default` | Approval mode for tool execution. Choices: `default`, `auto_edit`, `yolo`, `plan` |
| `--approval-mode` | - | string | `default` | Approval mode for tool execution. Choices: `default`, `auto_edit`, `yolo` |
| `--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.** |
+1 -3
View File
@@ -39,9 +39,7 @@ To start Plan Mode while using Gemini CLI:
the rotation when Gemini CLI is actively processing or showing confirmation
dialogs.
- **Command:** Type `/plan [goal]` in the input box. The `[goal]` is optional;
for example, `/plan implement authentication` will switch to Plan Mode and
immediately submit the prompt to the model.
- **Command:** Type `/plan` in the input box.
- **Natural Language:** Ask Gemini CLI to "start a plan for...". Gemini CLI
calls the
+15 -13
View File
@@ -30,7 +30,6 @@ 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` |
@@ -60,7 +59,6 @@ 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` |
@@ -157,17 +155,21 @@ 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` |
| 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` |
| 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` |
### Skills
-5
View File
@@ -19,7 +19,6 @@ using the `/theme` command within Gemini CLI:
- `Holiday`
- `Shades Of Purple`
- `Solarized Dark`
- `Tokyo Night`
- **Light themes:**
- `ANSI Light`
- `Ayu Light`
@@ -253,10 +252,6 @@ identify their source, for example: `shades-of-green (green-extension)`.
<img src="/docs/assets/theme-solarized-dark.png" alt="Solarized Dark theme" width="600">
### Tokyo Night
<img src="/docs/assets/theme-tokyonight-dark.png" alt="Tokyo Night theme" width="600">
## Light themes
### ANSI Light
+2 -2
View File
@@ -7,8 +7,8 @@ requests sent from `packages/cli`. For a general overview of Gemini CLI, see the
## Navigating this section
- **[Sub-agents](./subagents.md):** Learn how to create and use specialized
sub-agents for complex tasks.
- **[Sub-agents (experimental)](./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
+23 -6
View File
@@ -1,16 +1,20 @@
# Subagents
# Subagents (experimental)
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.
Subagents are enabled by default. To disable them, set `enableAgents` to `false`
in your `settings.json`:
<!-- 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):
```json
{
"experimental": { "enableAgents": false }
"experimental": { "enableAgents": true }
}
```
@@ -225,7 +229,16 @@ 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.
specific personas. To use custom subagents, you must enable them in your
`settings.json`:
```json
{
"experimental": {
"enableAgents": true
}
}
```
### Agent definition files
@@ -393,11 +406,15 @@ 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)
## Remote subagents (Agent2Agent) (experimental)
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.
+3 -1
View File
@@ -17,6 +17,8 @@ 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,
@@ -303,7 +305,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
`general.plan.enabled` setting in your configuration.
`experimental.plan` setting in your configuration.
- **Sub-commands:**
- **`copy`**:
- **Description:** Copy the currently approved plan to your clipboard.
+25 -56
View File
@@ -141,11 +141,6 @@ 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
@@ -262,11 +257,6 @@ 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`
@@ -1647,7 +1637,7 @@ their corresponding top-level category object in your `settings.json` file.
- **`experimental.jitContext`** (boolean):
- **Description:** Enable Just-In-Time (JIT) context loading.
- **Default:** `false`
- **Default:** `true`
- **Requires restart:** Yes
- **`experimental.useOSC52Paste`** (boolean):
@@ -1662,6 +1652,11 @@ 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`
@@ -1707,8 +1702,25 @@ their corresponding top-level category object in your `settings.json` file.
- **Default:** `false`
- **Requires restart:** Yes
- **`experimental.contextManagement`** (boolean):
- **Description:** Enable logic for context management.
- **`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.
- **Default:** `false`
- **Requires restart:** Yes
@@ -1803,49 +1815,6 @@ their corresponding top-level category object in your `settings.json` file.
prioritize available tools dynamically.
- **Default:** `[]`
#### `contextManagement`
- **`contextManagement.historyWindow.maxTokens`** (number):
- **Description:** The number of tokens to allow before triggering
compression.
- **Default:** `150000`
- **Requires restart:** Yes
- **`contextManagement.historyWindow.retainedTokens`** (number):
- **Description:** The number of tokens to always retain.
- **Default:** `40000`
- **Requires restart:** Yes
- **`contextManagement.messageLimits.normalMaxTokens`** (number):
- **Description:** The target number of tokens to budget for a normal
conversation turn.
- **Default:** `2500`
- **Requires restart:** Yes
- **`contextManagement.messageLimits.retainedMaxTokens`** (number):
- **Description:** The maximum number of tokens a single conversation turn can
consume before truncation.
- **Default:** `12000`
- **Requires restart:** Yes
- **`contextManagement.messageLimits.normalizationHeadRatio`** (number):
- **Description:** The ratio of tokens to retain from the beginning of a
truncated message (0.0 to 1.0).
- **Default:** `0.25`
- **Requires restart:** Yes
- **`contextManagement.toolDistillation.maxOutputTokens`** (number):
- **Description:** Maximum tokens to show when truncating large tool outputs.
- **Default:** `10000`
- **Requires restart:** Yes
- **`contextManagement.toolDistillation.summarizationThresholdTokens`**
(number):
- **Description:** Threshold above which truncated tool outputs will be
summarized by an LLM.
- **Default:** `20000`
- **Requires restart:** Yes
#### `admin`
- **`admin.secureModeEnabled`** (boolean):
-7
View File
@@ -127,13 +127,6 @@ 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
+14
View File
@@ -419,6 +419,20 @@ decision = "ask_user"
priority = 10
```
**4. Targeting a tool name across all servers**
Use `mcpName = "*"` with a specific `toolName` to target that operation
regardless of which server provides it.
```toml
# Allow the `search` tool across all connected MCP servers
[[rule]]
mcpName = "*"
toolName = "search"
decision = "allow"
priority = 50
```
## Default policies
The Gemini CLI ships with a set of default policies to provide a safe
+2
View File
@@ -138,10 +138,12 @@
{ "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" },
+1 -46
View File
@@ -15,9 +15,7 @@ import {
describe('plan_mode', () => {
const TEST_PREFIX = 'Plan Mode: ';
const settings = {
general: {
plan: { enabled: true },
},
experimental: { plan: true },
};
const getWriteTargets = (logs: any[]) =>
@@ -283,47 +281,4 @@ describe('plan_mode', () => {
assertModelHasOutput(result);
},
});
evalTest('ALWAYS_PASSES', {
name: 'should transition from plan mode to normal execution and create a plan file from scratch',
params: {
settings,
},
prompt:
'Enter plan mode and plan to create a new module called foo. The plan should be saved as foo-plan.md. Then, exit plan mode.',
assert: async (rig, result) => {
const enterPlanCalled = await rig.waitForToolCall('enter_plan_mode');
expect(
enterPlanCalled,
'Expected enter_plan_mode tool to be called',
).toBe(true);
const exitPlanCalled = await rig.waitForToolCall('exit_plan_mode');
expect(exitPlanCalled, 'Expected exit_plan_mode tool to be called').toBe(
true,
);
await rig.waitForTelemetryReady();
const toolLogs = rig.readToolLogs();
// Check if the plan file was written successfully
const planWrite = toolLogs.find(
(log) =>
log.toolRequest.name === 'write_file' &&
log.toolRequest.args.includes('foo-plan.md'),
);
expect(
planWrite,
'Expected write_file to be called for foo-plan.md',
).toBeDefined();
expect(
planWrite?.toolRequest.success,
`Expected write_file to succeed, but got error: ${planWrite?.toolRequest.error}`,
).toBe(true);
assertModelHasOutput(result);
},
});
});
+19 -117
View File
@@ -13,21 +13,8 @@ import { evalTest, TEST_AGENTS } from './test-helper.js';
const INDEX_TS = 'export const add = (a: number, b: number) => a + b;\n';
// A minimal package.json is used to provide a realistic workspace anchor.
// This prevents the agent from making incorrect assumptions about the environment
// and helps it properly navigate or act as if it is in a standard Node.js project.
const MOCK_PACKAGE_JSON = JSON.stringify(
{
name: 'subagent-eval-project',
version: '1.0.0',
type: 'module',
},
null,
2,
);
function readProjectFile(
rig: { testDir: string | null },
rig: { testDir?: string },
relativePath: string,
): string {
return fs.readFileSync(path.join(rig.testDir!, relativePath), 'utf8');
@@ -130,7 +117,15 @@ describe('subagent eval test cases', () => {
files: {
...TEST_AGENTS.TESTING_AGENT.asFile(),
'index.ts': INDEX_TS,
'package.json': MOCK_PACKAGE_JSON,
'package.json': JSON.stringify(
{
name: 'subagent-eval-project',
version: '1.0.0',
type: 'module',
},
null,
2,
),
},
assert: async (rig, _result) => {
const toolLogs = rig.readToolLogs() as Array<{
@@ -169,7 +164,15 @@ describe('subagent eval test cases', () => {
...TEST_AGENTS.TESTING_AGENT.asFile(),
'index.ts': INDEX_TS,
'README.md': 'TODO: update the README.\n',
'package.json': MOCK_PACKAGE_JSON,
'package.json': JSON.stringify(
{
name: 'subagent-eval-project',
version: '1.0.0',
type: 'module',
},
null,
2,
),
},
assert: async (rig, _result) => {
const toolLogs = rig.readToolLogs() as Array<{
@@ -187,105 +190,4 @@ describe('subagent eval test cases', () => {
);
},
});
/**
* Checks that the main agent can correctly select the appropriate subagent
* from a large pool of available subagents (10 total).
*/
evalTest('USUALLY_PASSES', {
name: 'should select the correct subagent from a pool of 10 different agents',
prompt: 'Please add a new SQL table migration for a user profile.',
files: {
...TEST_AGENTS.DOCS_AGENT.asFile(),
...TEST_AGENTS.TESTING_AGENT.asFile(),
...TEST_AGENTS.DATABASE_AGENT.asFile(),
...TEST_AGENTS.CSS_AGENT.asFile(),
...TEST_AGENTS.I18N_AGENT.asFile(),
...TEST_AGENTS.SECURITY_AGENT.asFile(),
...TEST_AGENTS.DEVOPS_AGENT.asFile(),
...TEST_AGENTS.ANALYTICS_AGENT.asFile(),
...TEST_AGENTS.ACCESSIBILITY_AGENT.asFile(),
...TEST_AGENTS.MOBILE_AGENT.asFile(),
'package.json': MOCK_PACKAGE_JSON,
},
assert: async (rig, _result) => {
const toolLogs = rig.readToolLogs() as Array<{
toolRequest: { name: string };
}>;
await rig.expectToolCallSuccess(['database-agent']);
// Ensure the generalist and other irrelevant specialists were not invoked
const uncalledAgents = [
'generalist',
TEST_AGENTS.DOCS_AGENT.name,
TEST_AGENTS.TESTING_AGENT.name,
TEST_AGENTS.CSS_AGENT.name,
TEST_AGENTS.I18N_AGENT.name,
TEST_AGENTS.SECURITY_AGENT.name,
TEST_AGENTS.DEVOPS_AGENT.name,
TEST_AGENTS.ANALYTICS_AGENT.name,
TEST_AGENTS.ACCESSIBILITY_AGENT.name,
TEST_AGENTS.MOBILE_AGENT.name,
];
for (const agentName of uncalledAgents) {
expect(toolLogs.some((l) => l.toolRequest.name === agentName)).toBe(
false,
);
}
},
});
/**
* Checks that the main agent can correctly select the appropriate subagent
* from a large pool of available subagents, even when many irrelevant MCP tools are present.
*
* This test includes stress tests the subagent delegation with ~80 tools.
*/
evalTest('USUALLY_PASSES', {
name: 'should select the correct subagent from a pool of 10 different agents with MCP tools present',
prompt: 'Please add a new SQL table migration for a user profile.',
setup: async (rig) => {
rig.addTestMcpServer('workspace-server', 'google-workspace');
},
files: {
...TEST_AGENTS.DOCS_AGENT.asFile(),
...TEST_AGENTS.TESTING_AGENT.asFile(),
...TEST_AGENTS.DATABASE_AGENT.asFile(),
...TEST_AGENTS.CSS_AGENT.asFile(),
...TEST_AGENTS.I18N_AGENT.asFile(),
...TEST_AGENTS.SECURITY_AGENT.asFile(),
...TEST_AGENTS.DEVOPS_AGENT.asFile(),
...TEST_AGENTS.ANALYTICS_AGENT.asFile(),
...TEST_AGENTS.ACCESSIBILITY_AGENT.asFile(),
...TEST_AGENTS.MOBILE_AGENT.asFile(),
'package.json': MOCK_PACKAGE_JSON,
},
assert: async (rig, _result) => {
const toolLogs = rig.readToolLogs() as Array<{
toolRequest: { name: string };
}>;
await rig.expectToolCallSuccess(['database-agent']);
// Ensure the generalist and other irrelevant specialists were not invoked
const uncalledAgents = [
'generalist',
TEST_AGENTS.DOCS_AGENT.name,
TEST_AGENTS.TESTING_AGENT.name,
TEST_AGENTS.CSS_AGENT.name,
TEST_AGENTS.I18N_AGENT.name,
TEST_AGENTS.SECURITY_AGENT.name,
TEST_AGENTS.DEVOPS_AGENT.name,
TEST_AGENTS.ANALYTICS_AGENT.name,
TEST_AGENTS.ACCESSIBILITY_AGENT.name,
TEST_AGENTS.MOBILE_AGENT.name,
];
for (const agentName of uncalledAgents) {
expect(toolLogs.some((l) => l.toolRequest.name === agentName)).toBe(
false,
);
}
},
});
});
-5
View File
@@ -61,10 +61,6 @@ export async function internalEvalTest(evalCase: EvalCase) {
try {
rig.setup(evalCase.name, evalCase.params);
if (evalCase.setup) {
await evalCase.setup(rig);
}
if (evalCase.files) {
await setupTestFiles(rig, evalCase.files);
}
@@ -375,7 +371,6 @@ export interface EvalCase {
prompt: string;
timeout?: number;
files?: Record<string, string>;
setup?: (rig: TestRig) => Promise<void> | void;
/** Conversation history to pre-load via --resume. Each entry is a message object with type, content, etc. */
messages?: Record<string, unknown>[];
/** Session ID for the resumed session. Auto-generated if not provided. */
-116
View File
@@ -1,116 +0,0 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, expect } from 'vitest';
import { evalTest } from './test-helper.js';
describe('update_topic_behavior', () => {
// Constants for tool names and params for robustness
const UPDATE_TOPIC_TOOL_NAME = 'update_topic';
/**
* Verifies the desired behavior of the update_topic tool. update_topic is used by the
* agent to share periodic, concise updates about what the agent is working on, independent
* of the regular model output and/or thoughts. This tool is expected to be called at least
* at the start and end of the session, and typically at least once in the middle, but no
* more than 1/4 turns.
*/
evalTest('USUALLY_PASSES', {
name: 'update_topic should be used at start, end and middle for complex tasks',
prompt: `Create a simple users REST API using Express.
1. Initialize a new npm project and install express.
2. Create src/app.ts as the main entry point.
3. Create src/routes/userRoutes.ts for user routes.
4. Create src/controllers/userController.ts for user logic.
5. Implement GET /users, POST /users, and GET /users/:id using an in-memory array.
6. Add a 'start' script to package.json.
7. Finally, run a quick grep to verify the routes are in src/app.ts.`,
files: {
'package.json': JSON.stringify(
{
name: 'users-api',
version: '1.0.0',
private: true,
},
null,
2,
),
'.gemini/settings.json': JSON.stringify({
experimental: {
topicUpdateNarration: true,
},
}),
},
assert: async (rig, result) => {
const toolLogs = rig.readToolLogs();
const topicCalls = toolLogs.filter(
(l) => l.toolRequest.name === UPDATE_TOPIC_TOOL_NAME,
);
// 1. Assert that update_topic is called at least 3 times (start, middle, end)
expect(
topicCalls.length,
`Expected at least 3 update_topic calls, but found ${topicCalls.length}`,
).toBeGreaterThanOrEqual(3);
// 2. Assert update_topic is called at the very beginning (first tool call)
expect(
toolLogs[0].toolRequest.name,
'First tool call should be update_topic',
).toBe(UPDATE_TOPIC_TOOL_NAME);
// 3. Assert update_topic is called near the end
const lastTopicCallIndex = toolLogs
.map((l) => l.toolRequest.name)
.lastIndexOf(UPDATE_TOPIC_TOOL_NAME);
expect(
lastTopicCallIndex,
'Expected update_topic to be used near the end of the task',
).toBeGreaterThanOrEqual(toolLogs.length * 0.7);
// 4. Assert there is at least one update_topic call in the middle (between start and end phases)
const middleTopicCalls = topicCalls.slice(1, -1);
expect(
middleTopicCalls.length,
'Expected at least one update_topic call in the middle of the task',
).toBeGreaterThanOrEqual(1);
// 5. Turn Ratio Assertion: update_topic should be <= 1/2 of total turns.
// We only enforce this for tasks that take more than 5 turns, as shorter tasks
// naturally have a higher ratio when following the "start, middle, end" rule.
const uniquePromptIds = new Set(
toolLogs
.map((l) => l.toolRequest.prompt_id)
.filter((id) => id !== undefined),
);
const totalTurns = uniquePromptIds.size;
if (totalTurns > 5) {
const topicTurns = new Set(
topicCalls
.map((l) => l.toolRequest.prompt_id)
.filter((id) => id !== undefined),
);
const topicTurnCount = topicTurns.size;
const ratio = topicTurnCount / totalTurns;
expect(
ratio,
`update_topic was used in ${topicTurnCount} out of ${totalTurns} turns (${(ratio * 100).toFixed(1)}%). Expected <= 50%.`,
).toBeLessThanOrEqual(0.5);
// Ideal ratio is closer to 1/5 (20%). We log high usage as a warning.
if (ratio > 0.25) {
console.warn(
`[Efficiency Warning] update_topic usage is high: ${(ratio * 100).toFixed(1)}% (Goal: ~20%)`,
);
}
}
},
});
});
+1 -1
View File
@@ -1 +1 @@
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"Part 1. "}],"role":"model"},"index":0}]},{"usageMetadata":{"promptTokenCount":100,"candidatesTokenCount":10,"totalTokenCount":110}},{"candidates":[{"content":{"parts":[{"text":"Part 2."}],"role":"model"},"index":0,"finishReason":"STOP"}]}]}
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"Part 1. "}],"role":"model"},"index":0}]},{"usageMetadata":{"promptTokenCount":100,"candidatesTokenCount":10,"totalTokenCount":110}},{"candidates":[{"content":{"parts":[{"text":"Part 2."}],"role":"model"},"index":0}],"finishReason":"STOP"}]}
+94 -105
View File
@@ -10,13 +10,8 @@ import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { execSync } from 'node:child_process';
import { existsSync, writeFileSync, readFileSync, mkdirSync } from 'node:fs';
import { env } from 'node:process';
import stripAnsi from 'strip-ansi';
// Browser agent Chrome DevTools MCP connection is flaky in Docker sandbox.
// See: https://github.com/google-gemini/gemini-cli/issues/24382
const isDockerSandbox = env['GEMINI_SANDBOX'] === 'docker';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
@@ -64,128 +59,122 @@ describe.skipIf(!chromeAvailable)('browser-policy', () => {
await rig.cleanup();
});
it.skipIf(isDockerSandbox)(
'should skip confirmation when "Allow all server tools for this session" is chosen',
async () => {
rig.setup('browser-policy-skip-confirmation', {
fakeResponsesPath: join(__dirname, 'browser-policy.responses'),
settings: {
agents: {
overrides: {
browser_agent: {
enabled: true,
},
},
browser: {
headless: true,
sessionMode: 'isolated',
allowedDomains: ['example.com'],
it('should skip confirmation when "Allow all server tools for this session" is chosen', async () => {
rig.setup('browser-policy-skip-confirmation', {
fakeResponsesPath: join(__dirname, 'browser-policy.responses'),
settings: {
agents: {
overrides: {
browser_agent: {
enabled: true,
},
},
browser: {
headless: true,
sessionMode: 'isolated',
allowedDomains: ['example.com'],
},
},
});
},
});
// Manually trust the folder to avoid the dialog and enable option 3
const geminiDir = join(rig.homeDir!, '.gemini');
mkdirSync(geminiDir, { recursive: true });
// Manually trust the folder to avoid the dialog and enable option 3
const geminiDir = join(rig.homeDir!, '.gemini');
mkdirSync(geminiDir, { recursive: true });
// Write to trustedFolders.json
const trustedFoldersPath = join(geminiDir, 'trustedFolders.json');
const trustedFolders = {
[rig.testDir!]: 'TRUST_FOLDER',
};
writeFileSync(
trustedFoldersPath,
JSON.stringify(trustedFolders, null, 2),
);
// Write to trustedFolders.json
const trustedFoldersPath = join(geminiDir, 'trustedFolders.json');
const trustedFolders = {
[rig.testDir!]: 'TRUST_FOLDER',
};
writeFileSync(trustedFoldersPath, JSON.stringify(trustedFolders, null, 2));
// Force confirmation for browser agent.
// NOTE: We don't force confirm browser tools here because "Allow all server tools"
// adds a rule with ALWAYS_ALLOW_PRIORITY (3.9x) which would be overshadowed by
// a rule in the user tier (4.x) like the one from this TOML.
// By removing the explicit mcp rule, the first MCP tool will still prompt
// due to default approvalMode = 'default', and then "Allow all" will correctly
// bypass subsequent tools.
const policyFile = join(rig.testDir!, 'force-confirm.toml');
writeFileSync(
policyFile,
`
// Force confirmation for browser agent.
// NOTE: We don't force confirm browser tools here because "Allow all server tools"
// adds a rule with ALWAYS_ALLOW_PRIORITY (3.9x) which would be overshadowed by
// a rule in the user tier (4.x) like the one from this TOML.
// By removing the explicit mcp rule, the first MCP tool will still prompt
// due to default approvalMode = 'default', and then "Allow all" will correctly
// bypass subsequent tools.
const policyFile = join(rig.testDir!, 'force-confirm.toml');
writeFileSync(
policyFile,
`
[[rule]]
name = "Force confirm browser_agent"
toolName = "browser_agent"
decision = "ask_user"
priority = 200
`,
);
);
// Update settings.json in both project and home directories to point to the policy file
for (const baseDir of [rig.testDir!, rig.homeDir!]) {
const settingsPath = join(baseDir, '.gemini', 'settings.json');
if (existsSync(settingsPath)) {
const settings = JSON.parse(readFileSync(settingsPath, 'utf-8'));
settings.policyPaths = [policyFile];
// Ensure folder trust is enabled
settings.security = settings.security || {};
settings.security.folderTrust = settings.security.folderTrust || {};
settings.security.folderTrust.enabled = true;
writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
}
// Update settings.json in both project and home directories to point to the policy file
for (const baseDir of [rig.testDir!, rig.homeDir!]) {
const settingsPath = join(baseDir, '.gemini', 'settings.json');
if (existsSync(settingsPath)) {
const settings = JSON.parse(readFileSync(settingsPath, 'utf-8'));
settings.policyPaths = [policyFile];
// Ensure folder trust is enabled
settings.security = settings.security || {};
settings.security.folderTrust = settings.security.folderTrust || {};
settings.security.folderTrust.enabled = true;
writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
}
}
const run = await rig.runInteractive({
approvalMode: 'default',
env: {
GEMINI_CLI_INTEGRATION_TEST: 'true',
},
});
const run = await rig.runInteractive({
approvalMode: 'default',
env: {
GEMINI_CLI_INTEGRATION_TEST: 'true',
},
});
await run.sendKeys(
'Open https://example.com and check if there is a heading\r',
);
await run.sendKeys('\r');
await run.sendKeys(
'Open https://example.com and check if there is a heading\r',
);
await run.sendKeys('\r');
// Handle confirmations.
// 1. Initial browser_agent delegation (likely only 3 options, so use option 1: Allow once)
await poll(
() => stripAnsi(run.output).toLowerCase().includes('action required'),
60000,
1000,
);
await run.sendKeys('1\r');
await new Promise((r) => setTimeout(r, 2000));
// Handle confirmations.
// 1. Initial browser_agent delegation (likely only 3 options, so use option 1: Allow once)
await poll(
() => stripAnsi(run.output).toLowerCase().includes('action required'),
60000,
1000,
);
await run.sendKeys('1\r');
await new Promise((r) => setTimeout(r, 2000));
// Handle privacy notice
await poll(
() => stripAnsi(run.output).toLowerCase().includes('privacy notice'),
5000,
100,
);
await run.sendKeys('1\r');
await new Promise((r) => setTimeout(r, 5000));
// Handle privacy notice
await poll(
() => stripAnsi(run.output).toLowerCase().includes('privacy notice'),
5000,
100,
);
await run.sendKeys('1\r');
await new Promise((r) => setTimeout(r, 5000));
// new_page (MCP tool, should have 4 options, use option 3: Allow all server tools)
await poll(
() => {
const stripped = stripAnsi(run.output).toLowerCase();
return (
stripped.includes('new_page') &&
stripped.includes('allow all server tools for this session')
);
},
60000,
1000,
);
// new_page (MCP tool, should have 4 options, use option 3: Allow all server tools)
await poll(
() => {
const stripped = stripAnsi(run.output).toLowerCase();
return (
stripped.includes('new_page') &&
stripped.includes('allow all server tools for this session')
);
},
60000,
1000,
);
// Select "Allow all server tools for this session" (option 3)
await run.sendKeys('3\r');
await new Promise((r) => setTimeout(r, 30000));
// Select "Allow all server tools for this session" (option 3)
await run.sendKeys('3\r');
await new Promise((r) => setTimeout(r, 30000));
const output = stripAnsi(run.output).toLowerCase();
const output = stripAnsi(run.output).toLowerCase();
expect(output).toContain('browser_agent');
expect(output).toContain('completed successfully');
},
);
expect(output).toContain('browser_agent');
expect(output).toContain('completed successfully');
});
it('should show the visible warning when browser agent starts in existing session mode', async () => {
rig.setup('browser-session-warning', {
-1
View File
@@ -121,7 +121,6 @@ describe('file-system', () => {
const result = await rig.run({
args: `write "hello" to "${fileName}" and then stop. Do not perform any other actions.`,
timeout: 600000, // 10 min — real LLM can be slow in Docker sandbox
});
const foundToolCall = await rig.waitForToolCall('write_file');
+15 -10
View File
@@ -23,9 +23,7 @@ describe('Plan Mode', () => {
'should allow read-only tools but deny write tools in plan mode',
{
settings: {
general: {
plan: { enabled: true },
},
experimental: { plan: true },
tools: {
core: [
'run_shell_command',
@@ -69,12 +67,15 @@ 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,
},
},
},
});
@@ -119,19 +120,22 @@ 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: 'Attempt to create a file named "hello.txt" in the current directory. Do not create a plan file, try to write hello.txt directly.',
args: 'Create a file called hello.txt in the current directory.',
});
const toolLogs = rig.readToolLogs();
@@ -152,9 +156,7 @@ 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: {
general: {
plan: { enabled: true },
},
experimental: { plan: true },
tools: {
core: ['enter_plan_mode'],
allowed: ['enter_plan_mode'],
@@ -182,12 +184,15 @@ 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,
},
},
},
});
+50 -25
View File
@@ -11,7 +11,7 @@
"packages/*"
],
"dependencies": {
"ink": "npm:@jrichman/ink@6.6.3",
"ink": "npm:@jrichman/ink@6.5.0",
"latest-version": "^9.0.0",
"node-fetch-native": "^1.6.7",
"proper-lockfile": "^4.1.2",
@@ -92,6 +92,46 @@
"zod": "^3.25.0 || ^4.0.0"
}
},
"node_modules/@alcalzone/ansi-tokenize": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/@alcalzone/ansi-tokenize/-/ansi-tokenize-0.2.2.tgz",
"integrity": "sha512-mkOh+Wwawzuf5wa30bvc4nA+Qb6DIrGWgBhRR/Pw4T9nsgYait8izvXkNyU78D6Wcu3Z+KUdwCmLCxlWjEotYA==",
"license": "MIT",
"dependencies": {
"ansi-styles": "^6.2.1",
"is-fullwidth-code-point": "^5.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@alcalzone/ansi-tokenize/node_modules/ansi-styles": {
"version": "6.2.3",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz",
"integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==",
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/@alcalzone/ansi-tokenize/node_modules/is-fullwidth-code-point": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz",
"integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==",
"license": "MIT",
"dependencies": {
"get-east-asian-width": "^1.3.1"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/@ampproject/remapping": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz",
@@ -10049,13 +10089,14 @@
},
"node_modules/ink": {
"name": "@jrichman/ink",
"version": "6.6.3",
"resolved": "https://registry.npmjs.org/@jrichman/ink/-/ink-6.6.3.tgz",
"integrity": "sha512-0v4S7TbbF2tpQrfqH1btwLgTgH+K0vY2BJbokTE5Lk1KBr4TqZ+Pyo+geSD5F+zytX6G2ajGHBQyHk8yGK4C7A==",
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/@jrichman/ink/-/ink-6.5.0.tgz",
"integrity": "sha512-S4g/ng7fPZmFwclO82iWkOce8vDLy/FIDgHIfkCWGOehqHe6dexHsmq3kNQD21okh198pA5SAQTCqNQJb/svRQ==",
"license": "MIT",
"dependencies": {
"@alcalzone/ansi-tokenize": "^0.2.1",
"ansi-escapes": "^7.0.0",
"ansi-styles": "^6.2.3",
"ansi-styles": "^6.2.1",
"auto-bind": "^5.0.1",
"chalk": "^5.6.0",
"cli-boxes": "^3.0.0",
@@ -10064,7 +10105,6 @@
"code-excerpt": "^4.0.0",
"es-toolkit": "^1.39.10",
"indent-string": "^5.0.0",
"is-fullwidth-code-point": "^5.0.0",
"is-in-ci": "^2.0.0",
"mnemonist": "^0.40.3",
"patch-console": "^2.0.0",
@@ -10134,9 +10174,9 @@
}
},
"node_modules/ink/node_modules/ansi-styles": {
"version": "6.2.3",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz",
"integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==",
"version": "6.2.1",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz",
"integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==",
"license": "MIT",
"engines": {
"node": ">=12"
@@ -10157,21 +10197,6 @@
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
"node_modules/ink/node_modules/is-fullwidth-code-point": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz",
"integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==",
"license": "MIT",
"dependencies": {
"get-east-asian-width": "^1.3.1"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/ink/node_modules/is-in-ci": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/is-in-ci/-/is-in-ci-2.0.0.tgz",
@@ -17526,7 +17551,7 @@
"fzf": "^0.5.2",
"glob": "^12.0.0",
"highlight.js": "^11.11.1",
"ink": "npm:@jrichman/ink@6.6.3",
"ink": "npm:@jrichman/ink@6.5.0",
"ink-gradient": "^3.0.0",
"ink-spinner": "^5.0.0",
"latest-version": "^9.0.0",
+2 -2
View File
@@ -68,7 +68,7 @@
"pre-commit": "node scripts/pre-commit.js"
},
"overrides": {
"ink": "npm:@jrichman/ink@6.6.3",
"ink": "npm:@jrichman/ink@6.5.0",
"wrap-ansi": "9.0.2",
"cliui": {
"wrap-ansi": "7.0.0"
@@ -136,7 +136,7 @@
"yargs": "^17.7.2"
},
"dependencies": {
"ink": "npm:@jrichman/ink@6.6.3",
"ink": "npm:@jrichman/ink@6.5.0",
"latest-version": "^9.0.0",
"node-fetch-native": "^1.6.7",
"proper-lockfile": "^4.1.2",
+147 -57
View File
@@ -424,8 +424,27 @@ describe('loadConfig', () => {
});
});
describe('authentication logic', () => {
const setupConfigMock = (refreshAuthMock: ReturnType<typeof vi.fn>) => {
describe('authentication fallback', () => {
beforeEach(() => {
vi.stubEnv('USE_CCPA', 'true');
vi.stubEnv('GEMINI_API_KEY', '');
});
afterEach(() => {
vi.unstubAllEnvs();
});
it('should fall back to COMPUTE_ADC in Cloud Shell if LOGIN_WITH_GOOGLE fails', async () => {
vi.stubEnv('CLOUD_SHELL', 'true');
vi.mocked(isHeadlessMode).mockReturnValue(false);
const refreshAuthMock = vi.fn().mockImplementation((authType) => {
if (authType === AuthType.LOGIN_WITH_GOOGLE) {
throw new FatalAuthenticationError('Non-interactive session');
}
return Promise.resolve();
});
// Update the mock implementation for this test
vi.mocked(Config).mockImplementation(
(params: unknown) =>
({
@@ -438,88 +457,159 @@ describe('loadConfig', () => {
setRemoteAdminSettings: vi.fn(),
}) as unknown as Config,
);
};
beforeEach(() => {
vi.stubEnv('USE_CCPA', 'true');
vi.stubEnv('GEMINI_API_KEY', '');
});
afterEach(() => {
vi.unstubAllEnvs();
});
it('should attempt COMPUTE_ADC by default and bypass LOGIN_WITH_GOOGLE if successful', async () => {
const refreshAuthMock = vi.fn().mockResolvedValue(undefined);
setupConfigMock(refreshAuthMock);
await loadConfig(mockSettings, mockExtensionLoader, taskId);
expect(refreshAuthMock).toHaveBeenCalledWith(AuthType.COMPUTE_ADC);
expect(refreshAuthMock).not.toHaveBeenCalledWith(
AuthType.LOGIN_WITH_GOOGLE,
);
});
it('should fallback to LOGIN_WITH_GOOGLE if COMPUTE_ADC fails and interactive mode is available', async () => {
vi.mocked(isHeadlessMode).mockReturnValue(false);
const refreshAuthMock = vi.fn().mockImplementation((authType) => {
if (authType === AuthType.COMPUTE_ADC) {
return Promise.reject(new Error('ADC failed'));
}
return Promise.resolve();
});
setupConfigMock(refreshAuthMock);
await loadConfig(mockSettings, mockExtensionLoader, taskId);
expect(refreshAuthMock).toHaveBeenCalledWith(AuthType.COMPUTE_ADC);
expect(refreshAuthMock).toHaveBeenCalledWith(
AuthType.LOGIN_WITH_GOOGLE,
);
expect(refreshAuthMock).toHaveBeenCalledWith(AuthType.COMPUTE_ADC);
});
it('should throw FatalAuthenticationError in headless mode if COMPUTE_ADC fails', async () => {
vi.mocked(isHeadlessMode).mockReturnValue(true);
it('should not fall back to COMPUTE_ADC if not in cloud environment', async () => {
vi.mocked(isHeadlessMode).mockReturnValue(false);
const refreshAuthMock = vi.fn().mockImplementation((authType) => {
if (authType === AuthType.COMPUTE_ADC) {
return Promise.reject(new Error('ADC not found'));
if (authType === AuthType.LOGIN_WITH_GOOGLE) {
throw new FatalAuthenticationError('Non-interactive session');
}
return Promise.resolve();
});
setupConfigMock(refreshAuthMock);
vi.mocked(Config).mockImplementation(
(params: unknown) =>
({
...(params as object),
initialize: vi.fn(),
waitForMcpInit: vi.fn(),
refreshAuth: refreshAuthMock,
getExperiments: vi.fn().mockReturnValue({ flags: {} }),
getRemoteAdminSettings: vi.fn(),
setRemoteAdminSettings: vi.fn(),
}) as unknown as Config,
);
await expect(
loadConfig(mockSettings, mockExtensionLoader, taskId),
).rejects.toThrow(
'COMPUTE_ADC failed: ADC not found. (LOGIN_WITH_GOOGLE fallback skipped due to headless mode. Run in an interactive terminal to use OAuth.)',
).rejects.toThrow('Non-interactive session');
expect(refreshAuthMock).toHaveBeenCalledWith(
AuthType.LOGIN_WITH_GOOGLE,
);
expect(refreshAuthMock).not.toHaveBeenCalledWith(AuthType.COMPUTE_ADC);
});
it('should skip LOGIN_WITH_GOOGLE and use COMPUTE_ADC directly in headless Cloud Shell', async () => {
vi.stubEnv('CLOUD_SHELL', 'true');
vi.mocked(isHeadlessMode).mockReturnValue(true);
const refreshAuthMock = vi.fn().mockResolvedValue(undefined);
vi.mocked(Config).mockImplementation(
(params: unknown) =>
({
...(params as object),
initialize: vi.fn(),
waitForMcpInit: vi.fn(),
refreshAuth: refreshAuthMock,
getExperiments: vi.fn().mockReturnValue({ flags: {} }),
getRemoteAdminSettings: vi.fn(),
setRemoteAdminSettings: vi.fn(),
}) as unknown as Config,
);
expect(refreshAuthMock).toHaveBeenCalledWith(AuthType.COMPUTE_ADC);
await loadConfig(mockSettings, mockExtensionLoader, taskId);
expect(refreshAuthMock).not.toHaveBeenCalledWith(
AuthType.LOGIN_WITH_GOOGLE,
);
expect(refreshAuthMock).toHaveBeenCalledWith(AuthType.COMPUTE_ADC);
});
it('should include both original and fallback error when LOGIN_WITH_GOOGLE fallback fails', async () => {
vi.mocked(isHeadlessMode).mockReturnValue(false);
it('should skip LOGIN_WITH_GOOGLE and use COMPUTE_ADC directly if GEMINI_CLI_USE_COMPUTE_ADC is true', async () => {
vi.stubEnv('GEMINI_CLI_USE_COMPUTE_ADC', 'true');
vi.mocked(isHeadlessMode).mockReturnValue(false); // Even if not headless
const refreshAuthMock = vi.fn().mockImplementation((authType) => {
if (authType === AuthType.COMPUTE_ADC) {
throw new Error('ADC failed');
}
if (authType === AuthType.LOGIN_WITH_GOOGLE) {
throw new FatalAuthenticationError('OAuth failed');
}
return Promise.resolve();
});
setupConfigMock(refreshAuthMock);
const refreshAuthMock = vi.fn().mockResolvedValue(undefined);
vi.mocked(Config).mockImplementation(
(params: unknown) =>
({
...(params as object),
initialize: vi.fn(),
waitForMcpInit: vi.fn(),
refreshAuth: refreshAuthMock,
getExperiments: vi.fn().mockReturnValue({ flags: {} }),
getRemoteAdminSettings: vi.fn(),
setRemoteAdminSettings: vi.fn(),
}) as unknown as Config,
);
await loadConfig(mockSettings, mockExtensionLoader, taskId);
expect(refreshAuthMock).not.toHaveBeenCalledWith(
AuthType.LOGIN_WITH_GOOGLE,
);
expect(refreshAuthMock).toHaveBeenCalledWith(AuthType.COMPUTE_ADC);
});
it('should throw FatalAuthenticationError in headless mode if no ADC fallback available', async () => {
vi.mocked(isHeadlessMode).mockReturnValue(true);
const refreshAuthMock = vi.fn().mockResolvedValue(undefined);
vi.mocked(Config).mockImplementation(
(params: unknown) =>
({
...(params as object),
initialize: vi.fn(),
waitForMcpInit: vi.fn(),
refreshAuth: refreshAuthMock,
getExperiments: vi.fn().mockReturnValue({ flags: {} }),
getRemoteAdminSettings: vi.fn(),
setRemoteAdminSettings: vi.fn(),
}) as unknown as Config,
);
await expect(
loadConfig(mockSettings, mockExtensionLoader, taskId),
).rejects.toThrow(
'OAuth failed. The initial COMPUTE_ADC attempt also failed: ADC failed',
'Interactive terminal required for LOGIN_WITH_GOOGLE. Run in an interactive terminal or set GEMINI_CLI_USE_COMPUTE_ADC=true to use Application Default Credentials.',
);
expect(refreshAuthMock).not.toHaveBeenCalled();
});
it('should include both original and fallback error when COMPUTE_ADC fallback fails', async () => {
vi.stubEnv('CLOUD_SHELL', 'true');
vi.mocked(isHeadlessMode).mockReturnValue(false);
const refreshAuthMock = vi.fn().mockImplementation((authType) => {
if (authType === AuthType.LOGIN_WITH_GOOGLE) {
throw new FatalAuthenticationError('OAuth failed');
}
if (authType === AuthType.COMPUTE_ADC) {
throw new Error('ADC failed');
}
return Promise.resolve();
});
vi.mocked(Config).mockImplementation(
(params: unknown) =>
({
...(params as object),
initialize: vi.fn(),
waitForMcpInit: vi.fn(),
refreshAuth: refreshAuthMock,
getExperiments: vi.fn().mockReturnValue({ flags: {} }),
getRemoteAdminSettings: vi.fn(),
setRemoteAdminSettings: vi.fn(),
}) as unknown as Config,
);
await expect(
loadConfig(mockSettings, mockExtensionLoader, taskId),
).rejects.toThrow(
'OAuth failed. Fallback to COMPUTE_ADC also failed: ADC failed',
);
});
});
+54 -28
View File
@@ -25,6 +25,7 @@ import {
ExperimentFlags,
isHeadlessMode,
FatalAuthenticationError,
isCloudShell,
PolicyDecision,
PRIORITY_YOLO_ALLOW_ALL,
type TelemetryTarget,
@@ -42,6 +43,7 @@ export async function loadConfig(
taskId: string,
): Promise<Config> {
const workspaceDir = process.cwd();
const adcFilePath = process.env['GOOGLE_APPLICATION_CREDENTIALS'];
const folderTrust =
settings.folderTrust === true ||
@@ -190,7 +192,7 @@ export async function loadConfig(
await config.waitForMcpInit();
startupProfiler.flush(config);
await refreshAuthentication(config, 'Config');
await refreshAuthentication(config, adcFilePath, 'Config');
return config;
}
@@ -261,51 +263,75 @@ function findEnvFile(startDir: string): string | null {
async function refreshAuthentication(
config: Config,
adcFilePath: string | undefined,
logPrefix: string,
): Promise<void> {
if (process.env['USE_CCPA']) {
logger.info(`[${logPrefix}] Using CCPA Auth:`);
logger.info(`[${logPrefix}] Attempting COMPUTE_ADC first.`);
try {
await config.refreshAuth(AuthType.COMPUTE_ADC);
logger.info(`[${logPrefix}] COMPUTE_ADC successful.`);
} catch (adcError) {
const adcMessage =
adcError instanceof Error ? adcError.message : String(adcError);
logger.info(
`[${logPrefix}] COMPUTE_ADC failed or not available: ${adcMessage}`,
if (adcFilePath) {
path.resolve(adcFilePath);
}
} catch (e) {
logger.error(
`[${logPrefix}] USE_CCPA env var is true but unable to resolve GOOGLE_APPLICATION_CREDENTIALS file path ${adcFilePath}. Error ${e}`,
);
}
const useComputeAdc =
process.env['GEMINI_CLI_USE_COMPUTE_ADC'] === 'true';
const isHeadless = isHeadlessMode();
const useComputeAdc = process.env['GEMINI_CLI_USE_COMPUTE_ADC'] === 'true';
const isHeadless = isHeadlessMode();
const shouldSkipOauth = isHeadless || useComputeAdc;
if (isHeadless || useComputeAdc) {
const reason = isHeadless
? 'headless mode'
: 'GEMINI_CLI_USE_COMPUTE_ADC=true';
if (shouldSkipOauth) {
if (isCloudShell() || useComputeAdc) {
logger.info(
`[${logPrefix}] Skipping LOGIN_WITH_GOOGLE due to ${isHeadless ? 'headless mode' : 'GEMINI_CLI_USE_COMPUTE_ADC'}. Attempting COMPUTE_ADC.`,
);
try {
await config.refreshAuth(AuthType.COMPUTE_ADC);
logger.info(`[${logPrefix}] COMPUTE_ADC successful.`);
} catch (adcError) {
const adcMessage =
adcError instanceof Error ? adcError.message : String(adcError);
throw new FatalAuthenticationError(
`COMPUTE_ADC failed: ${adcMessage}. (Skipped LOGIN_WITH_GOOGLE due to ${isHeadless ? 'headless mode' : 'GEMINI_CLI_USE_COMPUTE_ADC'})`,
);
}
} else {
throw new FatalAuthenticationError(
`COMPUTE_ADC failed: ${adcMessage}. (LOGIN_WITH_GOOGLE fallback skipped due to ${reason}. Run in an interactive terminal to use OAuth.)`,
`Interactive terminal required for LOGIN_WITH_GOOGLE. Run in an interactive terminal or set GEMINI_CLI_USE_COMPUTE_ADC=true to use Application Default Credentials.`,
);
}
logger.info(
`[${logPrefix}] COMPUTE_ADC failed, falling back to LOGIN_WITH_GOOGLE.`,
);
} else {
try {
await config.refreshAuth(AuthType.LOGIN_WITH_GOOGLE);
} catch (e) {
if (e instanceof FatalAuthenticationError) {
const originalMessage = e instanceof Error ? e.message : String(e);
throw new FatalAuthenticationError(
`${originalMessage}. The initial COMPUTE_ADC attempt also failed: ${adcMessage}`,
if (
e instanceof FatalAuthenticationError &&
(isCloudShell() || useComputeAdc)
) {
logger.warn(
`[${logPrefix}] LOGIN_WITH_GOOGLE failed. Attempting COMPUTE_ADC fallback.`,
);
try {
await config.refreshAuth(AuthType.COMPUTE_ADC);
logger.info(`[${logPrefix}] COMPUTE_ADC fallback successful.`);
} catch (adcError) {
logger.error(
`[${logPrefix}] COMPUTE_ADC fallback failed: ${adcError}`,
);
const originalMessage = e instanceof Error ? e.message : String(e);
const adcMessage =
adcError instanceof Error ? adcError.message : String(adcError);
throw new FatalAuthenticationError(
`${originalMessage}. Fallback to COMPUTE_ADC also failed: ${adcMessage}`,
);
}
} else {
throw e;
}
throw e;
}
}
logger.info(
`[${logPrefix}] GOOGLE_CLOUD_PROJECT: ${process.env['GOOGLE_CLOUD_PROJECT']}`,
);
@@ -109,8 +109,12 @@ export function createMockConfig(
enableEnvironmentVariableRedaction: false,
},
}),
isAutoDistillationEnabled: vi.fn().mockReturnValue(false),
getContextManagementConfig: vi.fn().mockReturnValue({ enabled: false }),
isExperimentalAgentHistoryTruncationEnabled: vi.fn().mockReturnValue(false),
getExperimentalAgentHistoryTruncationThreshold: vi.fn().mockReturnValue(50),
getExperimentalAgentHistoryRetainedMessages: vi.fn().mockReturnValue(30),
isExperimentalAgentHistorySummarizationEnabled: vi
.fn()
.mockReturnValue(false),
...overrides,
} as unknown as Config;
+1 -1
View File
@@ -49,7 +49,7 @@
"fzf": "^0.5.2",
"glob": "^12.0.0",
"highlight.js": "^11.11.1",
"ink": "npm:@jrichman/ink@6.6.3",
"ink": "npm:@jrichman/ink@6.5.0",
"ink-gradient": "^3.0.0",
"ink-spinner": "^5.0.0",
"latest-version": "^9.0.0",
+26 -34
View File
@@ -1364,8 +1364,8 @@ describe('Approval mode tool exclusion logic', () => {
'test',
];
const settings = createTestMergedSettings({
general: {
plan: { enabled: true },
experimental: {
plan: true,
},
});
const argv = await parseArguments(createTestMergedSettings());
@@ -1479,7 +1479,9 @@ describe('Approval mode tool exclusion logic', () => {
const settings = createTestMergedSettings({
general: {
defaultApprovalMode: 'plan',
plan: { enabled: false },
},
experimental: {
plan: false,
},
});
const argv = await parseArguments(settings);
@@ -1487,12 +1489,14 @@ describe('Approval mode tool exclusion logic', () => {
expect(config.getApprovalMode()).toBe(ApprovalMode.DEFAULT);
});
it('should allow plan approval mode if plan is enabled', async () => {
it('should allow plan approval mode if experimental plan is enabled', async () => {
process.argv = ['node', 'script.js'];
const settings = createTestMergedSettings({
general: {
defaultApprovalMode: 'plan',
plan: { enabled: true },
},
experimental: {
plan: true,
},
});
const argv = await parseArguments(settings);
@@ -2738,12 +2742,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 plan is enabled', async () => {
it('should set Plan approval mode when --approval-mode=plan is used and experimental.plan is enabled', async () => {
process.argv = ['node', 'script.js', '--approval-mode', 'plan'];
const argv = await parseArguments(createTestMergedSettings());
const settings = createTestMergedSettings({
general: {
plan: { enabled: true },
experimental: {
plan: true,
},
});
const config = await loadCliConfig(settings, 'test-session', argv);
@@ -2763,12 +2767,12 @@ describe('loadCliConfig approval mode', () => {
expect(config.getApprovalMode()).toBe(ServerConfig.ApprovalMode.DEFAULT);
});
it('should throw error when --approval-mode=plan is used but plan is disabled', async () => {
it('should throw error when --approval-mode=plan is used but experimental.plan is disabled', async () => {
process.argv = ['node', 'script.js', '--approval-mode', 'plan'];
const argv = await parseArguments(createTestMergedSettings());
const settings = createTestMergedSettings({
general: {
plan: { enabled: false },
experimental: {
plan: false,
},
});
@@ -2889,26 +2893,22 @@ describe('loadCliConfig approval mode', () => {
expect(config.getApprovalMode()).toBe(ServerConfig.ApprovalMode.YOLO);
});
it('should respect plan mode from settings when plan is enabled', async () => {
it('should respect plan mode from settings when experimental.plan is enabled', async () => {
process.argv = ['node', 'script.js'];
const settings = createTestMergedSettings({
general: {
defaultApprovalMode: 'plan',
plan: { enabled: true },
},
general: { defaultApprovalMode: 'plan' },
experimental: { plan: true },
});
const argv = await parseArguments(settings);
const config = await loadCliConfig(settings, 'test-session', argv);
expect(config.getApprovalMode()).toBe(ServerConfig.ApprovalMode.PLAN);
});
it('should fall back to default if plan mode is in settings but disabled', async () => {
it('should throw error if plan mode is in settings but experimental.plan is disabled', async () => {
process.argv = ['node', 'script.js'];
const settings = createTestMergedSettings({
general: {
defaultApprovalMode: 'plan',
plan: { enabled: false },
},
general: { defaultApprovalMode: 'plan' },
experimental: { plan: false },
});
const argv = await parseArguments(settings);
const config = await loadCliConfig(settings, 'test-session', argv);
@@ -3696,9 +3696,7 @@ 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({
general: {
plan: { enabled: true },
},
experimental: { plan: true },
});
const argv = await parseArguments(settings);
@@ -3717,11 +3715,9 @@ 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: {
enabled: true,
directory: 'user-plans-dir',
},
plan: { directory: 'user-plans-dir' },
},
});
const argv = await parseArguments(settings);
@@ -3742,9 +3738,7 @@ describe('loadCliConfig mcpEnabled', () => {
it('should NOT use plan directory from inactive extension', async () => {
process.argv = ['node', 'script.js'];
const settings = createTestMergedSettings({
general: {
plan: { enabled: true },
},
experimental: { plan: true },
});
const argv = await parseArguments(settings);
@@ -3765,9 +3759,7 @@ 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({
general: {
plan: { enabled: true },
},
experimental: { plan: true },
});
const argv = await parseArguments(settings);
+11 -7
View File
@@ -669,9 +669,9 @@ export async function loadCliConfig(
approvalMode = ApprovalMode.AUTO_EDIT;
break;
case 'plan':
if (!(settings.general?.plan?.enabled ?? true)) {
if (!(settings.experimental?.plan ?? false)) {
debugLogger.warn(
'Approval mode "plan" is disabled in your settings. Falling back to "default".',
'Approval mode "plan" is only available when experimental.plan is enabled. 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.general?.plan?.enabled ?? true,
plan: settings.experimental?.plan,
tracker: settings.experimental?.taskTracker,
directWebFetch: settings.experimental?.directWebFetch,
planSettings: settings.general?.plan?.directory
@@ -977,10 +977,14 @@ export async function loadCliConfig(
disabledSkills: settings.skills?.disabled,
experimentalJitContext: settings.experimental?.jitContext,
experimentalMemoryManager: settings.experimental?.memoryManager,
contextManagement: {
enabled: settings.experimental?.contextManagement,
...settings?.contextManagement,
},
experimentalAgentHistoryTruncation:
settings.experimental?.agentHistoryTruncation,
experimentalAgentHistoryTruncationThreshold:
settings.experimental?.agentHistoryTruncationThreshold,
experimentalAgentHistoryRetainedMessages:
settings.experimental?.agentHistoryRetainedMessages,
experimentalAgentHistorySummarization:
settings.experimental?.agentHistorySummarization,
modelSteering: settings.experimental?.modelSteering,
topicUpdateNarration: settings.experimental?.topicUpdateNarration,
toolOutputMasking: settings.experimental?.toolOutputMasking,
+77 -143
View File
@@ -5,153 +5,87 @@
*/
import { describe, it, expect } from 'vitest';
import {
deriveItemsFromLegacySettings,
resolveFooterState,
} from './footerItems.js';
import { deriveItemsFromLegacySettings } from './footerItems.js';
import { createMockSettings } from '../test-utils/settings.js';
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 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,
},
},
}).merged;
const items = deriveItemsFromLegacySettings(settings);
expect(items).toEqual([
'git-branch',
'sandbox',
'context-used',
'memory-usage',
]);
});
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('resolveFooterState', () => {
it('filters out auth item when showUserIdentity is false', () => {
const settings = createMockSettings({
ui: {
showUserIdentity: false,
footer: {
items: ['workspace', 'auth', 'model-name'],
},
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,
},
}).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);
});
},
}).merged;
const items = deriveItemsFromLegacySettings(settings);
expect(items).toEqual([
'git-branch',
'sandbox',
'context-used',
'memory-usage',
]);
});
});
+2 -17
View File
@@ -47,11 +47,6 @@ 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',
@@ -75,7 +70,6 @@ export const DEFAULT_ORDER = [
'quota',
'memory-usage',
'session-id',
'auth',
'code-changes',
'token-count',
];
@@ -127,19 +121,10 @@ 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) => filteredValidIds.has(id));
const others = DEFAULT_ORDER.filter(
(id) => !source.includes(id) && filteredValidIds.has(id),
);
).filter((id: string) => VALID_IDS.has(id));
const others = DEFAULT_ORDER.filter((id) => !source.includes(id));
return {
orderedIds: [...source, ...others],
selectedIds: new Set(source),
+4 -23
View File
@@ -1124,15 +1124,15 @@ function migrateExperimentalSettings(
};
let modified = false;
const migrateExperimental = <T = Record<string, unknown>>(
const migrateExperimental = (
oldKey: string,
migrateFn: (oldValue: T) => void,
migrateFn: (oldValue: Record<string, unknown>) => void,
) => {
const old = experimentalSettings[oldKey];
if (old !== undefined) {
if (old) {
foundDeprecated?.push(`experimental.${oldKey}`);
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
migrateFn(old as T);
migrateFn(old as Record<string, unknown>);
modified = true;
}
};
@@ -1197,24 +1197,6 @@ 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);
@@ -1223,7 +1205,6 @@ function migrateExperimentalSettings(
const newExperimental = { ...experimentalSettings };
delete newExperimental['codebaseInvestigatorSettings'];
delete newExperimental['cliHelpAgentSettings'];
delete newExperimental['plan'];
loadedSettings.setValue(scope, 'experimental', newExperimental);
}
return true;
@@ -418,17 +418,14 @@ describe('SettingsSchema', () => {
});
it('should have plan setting in schema', () => {
const setting =
getSettingsSchema().general.properties.plan.properties.enabled;
const setting = getSettingsSchema().experimental.properties.plan;
expect(setting).toBeDefined();
expect(setting.type).toBe('boolean');
expect(setting.category).toBe('General');
expect(setting.category).toBe('Experimental');
expect(setting.default).toBe(true);
expect(setting.requiresRestart).toBe(true);
expect(setting.showInDialog).toBe(true);
expect(setting.description).toBe(
'Enable Plan Mode for read-only safety during planning.',
);
expect(setting.description).toBe('Enable Plan Mode.');
});
it('should have hooksConfig.notifications setting in schema', () => {
+44 -136
View File
@@ -293,16 +293,6 @@ 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',
@@ -571,16 +561,6 @@ 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',
@@ -2056,7 +2036,7 @@ const SETTINGS_SCHEMA = {
label: 'JIT Context Loading',
category: 'Experimental',
requiresRestart: true,
default: false,
default: true,
description: 'Enable Just-In-Time (JIT) context loading.',
showInDialog: false,
},
@@ -2080,6 +2060,15 @@ 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',
@@ -2180,13 +2169,44 @@ 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,
},
contextManagement: {
agentHistoryTruncation: {
type: 'boolean',
label: 'Enable Context Management',
label: 'Agent History Truncation',
category: 'Experimental',
requiresRestart: true,
default: false,
description: 'Enable logic for context management.',
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.',
showInDialog: true,
},
topicUpdateNarration: {
@@ -2465,118 +2485,6 @@ const SETTINGS_SCHEMA = {
},
},
contextManagement: {
type: 'object',
label: 'Context Management',
category: 'Experimental',
requiresRestart: true,
default: {},
description:
'Settings for agent history and tool distillation context management.',
showInDialog: false,
properties: {
historyWindow: {
type: 'object',
label: 'History Window Settings',
category: 'Context Management',
requiresRestart: true,
default: {},
showInDialog: false,
properties: {
maxTokens: {
type: 'number',
label: 'Max Tokens',
category: 'Context Management',
requiresRestart: true,
default: 150_000,
description:
'The number of tokens to allow before triggering compression.',
showInDialog: false,
},
retainedTokens: {
type: 'number',
label: 'Retained Tokens',
category: 'Context Management',
requiresRestart: true,
default: 40_000,
description: 'The number of tokens to always retain.',
showInDialog: false,
},
},
},
messageLimits: {
type: 'object',
label: 'Message Limits',
category: 'Context Management',
requiresRestart: true,
default: {},
showInDialog: false,
properties: {
normalMaxTokens: {
type: 'number',
label: 'Normal Maximum Tokens',
category: 'Context Management',
requiresRestart: true,
default: 2500,
description:
'The target number of tokens to budget for a normal conversation turn.',
showInDialog: false,
},
retainedMaxTokens: {
type: 'number',
label: 'Retained Maximum Tokens',
category: 'Context Management',
requiresRestart: true,
default: 12000,
description:
'The maximum number of tokens a single conversation turn can consume before truncation.',
showInDialog: false,
},
normalizationHeadRatio: {
type: 'number',
label: 'Normalization Head Ratio',
category: 'Context Management',
requiresRestart: true,
default: 0.25,
description:
'The ratio of tokens to retain from the beginning of a truncated message (0.0 to 1.0).',
showInDialog: false,
},
},
},
toolDistillation: {
type: 'object',
label: 'Tool Distillation',
category: 'Context Management',
requiresRestart: true,
default: {},
showInDialog: false,
properties: {
maxOutputTokens: {
type: 'number',
label: 'Max Output Tokens',
category: 'Context Management',
requiresRestart: true,
default: 10_000,
description:
'Maximum tokens to show when truncating large tool outputs.',
showInDialog: false,
},
summarizationThresholdTokens: {
type: 'number',
label: 'Tool Summarization Threshold',
category: 'Context Management',
requiresRestart: true,
default: 20_000,
description:
'Threshold above which truncated tool outputs will be summarized by an LLM.',
showInDialog: false,
},
},
},
},
},
admin: {
type: 'object',
label: 'Admin',
-2
View File
@@ -46,7 +46,6 @@ 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;
@@ -58,7 +57,6 @@ export async function startInteractiveUI(
resumedSessionData: ResumedSessionData | undefined,
initializationResult: InitializationResult,
) {
initializeConsoleStore();
// Never enter Ink alternate buffer mode when screen reader mode is enabled
// as there is no benefit of alternate buffer mode when using a screen reader
// and the Ink alternate buffer mode requires line wrapping harmful to
-11
View File
@@ -194,17 +194,6 @@ 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;
+7 -22
View File
@@ -42,7 +42,6 @@ import {
type OverflowState,
} from '../ui/contexts/OverflowContext.js';
import { makeFakeConfig } from '@google/gemini-cli-core';
import { type Config } from '@google/gemini-cli-core';
import { FakePersistentState } from './persistentStateFake.js';
import { AppContext, type AppState } from '../ui/contexts/AppContext.js';
@@ -52,6 +51,7 @@ import { themeManager, DEFAULT_THEME } from '../ui/themes/theme-manager.js';
import { DefaultLight } from '../ui/themes/builtin/light/default-light.js';
import { pickDefaultThemeName } from '../ui/themes/theme.js';
import { generateSvgForTerminal } from './svg.js';
import { loadCliConfig, type CliArgs } from '../config/config.js';
export const persistentStateMock = new FakePersistentState();
@@ -613,7 +613,6 @@ export const renderWithProviders = async (
mouseEventsEnabled = false,
config,
uiActions,
toolActions,
persistentState,
appState = mockAppState,
}: {
@@ -624,11 +623,6 @@ 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;
@@ -666,11 +660,12 @@ export const renderWithProviders = async (
const terminalWidth = width ?? baseState.terminalWidth;
if (!config) {
config = makeFakeConfig({
useAlternateBuffer: settings.merged.ui?.useAlternateBuffer,
showMemoryUsage: settings.merged.ui?.showMemoryUsage,
accessibility: settings.merged.ui?.accessibility,
});
config = await loadCliConfig(
settings.merged,
'random-session-id',
{} as unknown as CliArgs,
{ cwd: '/' },
);
}
const mainAreaWidth = providedUiState?.mainAreaWidth ?? terminalWidth;
@@ -715,16 +710,6 @@ 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}
File diff suppressed because it is too large Load Diff
+4 -15
View File
@@ -75,7 +75,6 @@ const listCommand: SlashCommand = {
description: 'List saved manual conversation checkpoints',
kind: CommandKind.BUILT_IN,
autoExecute: true,
takesArgs: false,
action: async (context): Promise<void> => {
const chatDetails = await getSavedChatTags(context, false);
@@ -407,24 +406,14 @@ export const chatResumeSubCommands: SlashCommand[] = [
checkpointCompatibilityCommand,
];
import { parseSlashCommand } from '../../utils/commands.js';
export const chatCommand: SlashCommand = {
name: 'chat',
description: 'Browse auto-saved conversations and manage chat checkpoints',
kind: CommandKind.BUILT_IN,
autoExecute: true,
action: async (context, args) => {
if (args) {
const parsed = parseSlashCommand(`/${args}`, chatResumeSubCommands);
if (parsed.commandToExecute?.action) {
return parsed.commandToExecute.action(context, parsed.args);
}
}
return {
type: 'dialog',
dialog: 'sessionBrowser',
};
},
action: async () => ({
type: 'dialog',
dialog: 'sessionBrowser',
}),
subCommands: chatResumeSubCommands,
};
@@ -0,0 +1,54 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import v8 from 'node:v8';
import path from 'node:path';
import os from 'node:os';
import { MessageType } from '../types.js';
import {
CommandKind,
type SlashCommand,
} from './types.js';
export const debugHeapDumpCommand: SlashCommand = {
name: 'debug-heap-dump',
description: 'Generate a V8 heap snapshot for memory analysis',
kind: CommandKind.BUILT_IN,
autoExecute: true,
action: async (context) => {
context.ui.addItem(
{
type: MessageType.INFO,
text: 'Generating heap snapshot...',
},
Date.now(),
);
try {
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const filename = `gemini-cli-heap-${timestamp}.heapsnapshot`;
const filepath = path.join(os.tmpdir(), filename);
v8.writeHeapSnapshot(filepath);
context.ui.addItem(
{
type: MessageType.INFO,
text: `Heap snapshot generated successfully: ${filepath}\n\nTo analyze:\n1. Open Chrome DevTools (any tab).\n2. Go to the "Memory" tab.\n3. Click "Load" and select this file.\n4. Use "Summary" or "Comparison" views to find leaks.`,
},
Date.now(),
);
} catch (error) {
context.ui.addItem(
{
type: MessageType.ERROR,
text: `Error generating heap snapshot: ${error instanceof Error ? error.message : String(error)}`,
},
Date.now(),
);
}
},
};
@@ -0,0 +1,39 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import process from 'node:process';
import { MessageType } from '../types.js';
import {
CommandKind,
type SlashCommand,
} from './types.js';
import { formatBytes } from '../utils/formatters.js';
export const debugMemoryInfoCommand: SlashCommand = {
name: 'debug-memory-info',
description: 'Show detailed process memory information',
kind: CommandKind.BUILT_IN,
autoExecute: true,
action: async (context) => {
const usage = process.memoryUsage();
const lines = [
`RSS: ${formatBytes(usage.rss)} (Total memory allocated for the process)`,
`Heap Total: ${formatBytes(usage.heapTotal)} (V8 heap total size)`,
`Heap Used: ${formatBytes(usage.heapUsed)} (V8 heap actually used)`,
`External: ${formatBytes(usage.external)} (C++ objects bound to JS objects)`,
`Array Buffers: ${formatBytes(usage.arrayBuffers)} (Memory for ArrayBuffer and SharedArrayBuffer)`,
];
context.ui.addItem(
{
type: MessageType.INFO,
text: `Detailed Memory Info:\n${lines.map(l => `${l}`).join('\n')}`,
},
Date.now(),
);
},
};
@@ -789,7 +789,6 @@ const listExtensionsCommand: SlashCommand = {
description: 'List active extensions',
kind: CommandKind.BUILT_IN,
autoExecute: true,
takesArgs: false,
action: listAction,
};
@@ -850,7 +849,6 @@ const exploreExtensionsCommand: SlashCommand = {
description: 'Open extensions page in your browser',
kind: CommandKind.BUILT_IN,
autoExecute: true,
takesArgs: false,
action: exploreAction,
};
@@ -872,8 +870,6 @@ const configCommand: SlashCommand = {
action: configAction,
};
import { parseSlashCommand } from '../../utils/commands.js';
export function extensionsCommand(
enableExtensionReloading?: boolean,
): SlashCommand {
@@ -887,29 +883,20 @@ export function extensionsCommand(
configCommand,
]
: [];
const subCommands = [
listExtensionsCommand,
updateExtensionsCommand,
exploreExtensionsCommand,
reloadCommand,
...conditionalCommands,
];
return {
name: 'extensions',
description: 'Manage extensions',
kind: CommandKind.BUILT_IN,
autoExecute: false,
subCommands,
action: async (context, args) => {
if (args) {
const parsed = parseSlashCommand(`/${args}`, subCommands);
if (parsed.commandToExecute?.action) {
return parsed.commandToExecute.action(context, parsed.args);
}
}
subCommands: [
listExtensionsCommand,
updateExtensionsCommand,
exploreExtensionsCommand,
reloadCommand,
...conditionalCommands,
],
action: (context, args) =>
// Default to list if no subcommand is provided
return listExtensionsCommand.action!(context, args);
},
listExtensionsCommand.action!(context, args),
};
}
@@ -17,7 +17,7 @@ import {
} from '@google/gemini-cli-core';
import type { CallableTool } from '@google/genai';
import { MessageType, type HistoryItemMcpStatus } from '../types.js';
import { MessageType } from '../types.js';
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const actual =
@@ -280,41 +280,5 @@ describe('mcpCommand', () => {
}),
);
});
it('should filter servers by name when an argument is provided to list', async () => {
await mcpCommand.action!(mockContext, 'list server1');
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageType.MCP_STATUS,
servers: expect.objectContaining({
server1: expect.any(Object),
}),
}),
);
// Should NOT contain server2 or server3
const call = vi.mocked(mockContext.ui.addItem).mock
.calls[0][0] as HistoryItemMcpStatus;
expect(Object.keys(call.servers)).toEqual(['server1']);
});
it('should filter servers by name and show descriptions when an argument is provided to desc', async () => {
await mcpCommand.action!(mockContext, 'desc server2');
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageType.MCP_STATUS,
showDescriptions: true,
servers: expect.objectContaining({
server2: expect.any(Object),
}),
}),
);
const call = vi.mocked(mockContext.ui.addItem).mock
.calls[0][0] as HistoryItemMcpStatus;
expect(Object.keys(call.servers)).toEqual(['server2']);
});
});
});
+6 -36
View File
@@ -31,7 +31,6 @@ import {
canLoadServer,
} from '../../config/mcp/mcpServerEnablement.js';
import { loadSettings } from '../../config/settings.js';
import { parseSlashCommand } from '../../utils/commands.js';
const authCommand: SlashCommand = {
name: 'auth',
@@ -178,7 +177,6 @@ const listAction = async (
context: CommandContext,
showDescriptions = false,
showSchema = false,
serverNameFilter?: string,
): Promise<void | MessageActionReturn> => {
const agentContext = context.services.agentContext;
const config = agentContext?.config;
@@ -201,25 +199,11 @@ const listAction = async (
};
}
let mcpServers = config.getMcpClientManager()?.getMcpServers() || {};
const mcpServers = config.getMcpClientManager()?.getMcpServers() || {};
const serverNames = Object.keys(mcpServers);
const blockedMcpServers =
config.getMcpClientManager()?.getBlockedMcpServers() || [];
if (serverNameFilter) {
const filter = serverNameFilter.trim().toLowerCase();
if (filter) {
mcpServers = Object.fromEntries(
Object.entries(mcpServers).filter(
([name]) =>
name.toLowerCase().includes(filter) ||
normalizeServerId(name).includes(filter),
),
);
}
}
const serverNames = Object.keys(mcpServers);
const connectingServers = serverNames.filter(
(name) => getMCPServerStatus(name) === MCPServerStatus.CONNECTING,
);
@@ -322,7 +306,7 @@ const listCommand: SlashCommand = {
description: 'List configured MCP servers and tools',
kind: CommandKind.BUILT_IN,
autoExecute: true,
action: (context, args) => listAction(context, false, false, args),
action: (context) => listAction(context),
};
const descCommand: SlashCommand = {
@@ -331,7 +315,7 @@ const descCommand: SlashCommand = {
description: 'List configured MCP servers and tools with descriptions',
kind: CommandKind.BUILT_IN,
autoExecute: true,
action: (context, args) => listAction(context, true, false, args),
action: (context) => listAction(context, true),
};
const schemaCommand: SlashCommand = {
@@ -340,7 +324,7 @@ const schemaCommand: SlashCommand = {
'List configured MCP servers and tools with descriptions and schemas',
kind: CommandKind.BUILT_IN,
autoExecute: true,
action: (context, args) => listAction(context, true, true, args),
action: (context) => listAction(context, true, true),
};
const reloadCommand: SlashCommand = {
@@ -349,7 +333,6 @@ const reloadCommand: SlashCommand = {
description: 'Reloads MCP servers',
kind: CommandKind.BUILT_IN,
autoExecute: true,
takesArgs: false,
action: async (
context: CommandContext,
): Promise<void | SlashCommandActionReturn> => {
@@ -547,18 +530,5 @@ export const mcpCommand: SlashCommand = {
enableCommand,
disableCommand,
],
action: async (
context: CommandContext,
args: string,
): Promise<void | SlashCommandActionReturn> => {
if (args) {
const parsed = parseSlashCommand(`/${args}`, mcpCommand.subCommands!);
if (parsed.commandToExecute?.action) {
return parsed.commandToExecute.action(context, parsed.args);
}
// If no subcommand matches, treat the whole args as a filter for list
return listAction(context, false, false, args);
}
return listAction(context);
},
action: async (context: CommandContext) => listAction(context),
};
@@ -104,47 +104,6 @@ describe('planCommand', () => {
);
});
it('should not return a submit_prompt action if arguments are empty', async () => {
vi.mocked(
mockContext.services.agentContext!.config.isPlanEnabled,
).mockReturnValue(true);
mockContext.invocation = {
raw: '/plan',
name: 'plan',
args: '',
};
if (!planCommand.action) throw new Error('Action missing');
const result = await planCommand.action(mockContext, '');
expect(result).toBeUndefined();
expect(
mockContext.services.agentContext!.config.setApprovalMode,
).toHaveBeenCalledWith(ApprovalMode.PLAN);
});
it('should return a submit_prompt action if arguments are provided', async () => {
vi.mocked(
mockContext.services.agentContext!.config.isPlanEnabled,
).mockReturnValue(true);
mockContext.invocation = {
raw: '/plan implement auth',
name: 'plan',
args: 'implement auth',
};
if (!planCommand.action) throw new Error('Action missing');
const result = await planCommand.action(mockContext, 'implement auth');
expect(result).toEqual({
type: 'submit_prompt',
content: 'implement auth',
});
expect(
mockContext.services.agentContext!.config.setApprovalMode,
).toHaveBeenCalledWith(ApprovalMode.PLAN);
});
it('should display the approved plan from config', async () => {
const mockPlanPath = '/mock/plans/dir/approved-plan.md';
vi.mocked(
@@ -66,13 +66,6 @@ export const planCommand: SlashCommand = {
coreEvents.emitFeedback('info', 'Switched to Plan Mode.');
}
if (context.invocation?.args) {
return {
type: 'submit_prompt',
content: context.invocation.args,
};
}
const approvedPlanPath = config.getApprovedPlanPath();
if (!approvedPlanPath) {
@@ -93,14 +86,12 @@ export const planCommand: SlashCommand = {
type: MessageType.GEMINI,
text: partToString(content.llmContent),
});
return;
} catch (error) {
coreEvents.emitFeedback(
'error',
`Failed to read approved plan at ${approvedPlanPath}: ${error}`,
error,
);
return;
}
},
subCommands: [
@@ -109,7 +100,6 @@ export const planCommand: SlashCommand = {
description: 'Copy the currently approved plan to your clipboard',
kind: CommandKind.BUILT_IN,
autoExecute: true,
takesArgs: false,
action: copyAction,
},
],
+1 -11
View File
@@ -357,8 +357,6 @@ function enableCompletion(
.map((s) => s.name);
}
import { parseSlashCommand } from '../../utils/commands.js';
export const skillsCommand: SlashCommand = {
name: 'skills',
description:
@@ -404,13 +402,5 @@ export const skillsCommand: SlashCommand = {
action: reloadAction,
},
],
action: async (context, args) => {
if (args) {
const parsed = parseSlashCommand(`/${args}`, skillsCommand.subCommands!);
if (parsed.commandToExecute?.action) {
return parsed.commandToExecute.action(context, parsed.args);
}
}
return listAction(context, args);
},
action: listAction,
};
-9
View File
@@ -240,14 +240,5 @@ export interface SlashCommand {
*/
showCompletionLoading?: boolean;
/**
* Whether the command expects arguments.
* If false, and the command is a subcommand, the command parser may treat
* any following text as arguments for the parent command instead of this subcommand,
* provided the parent command has an action.
* Defaults to true.
*/
takesArgs?: boolean;
subCommands?: SlashCommand[];
}
@@ -1,80 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { Box, Text } from 'ink';
import { useUIState } from '../contexts/UIStateContext.js';
import { AppHeader } from './AppHeader.js';
import { HistoryItemDisplay } from './HistoryItemDisplay.js';
import { QuittingDisplay } from './QuittingDisplay.js';
import { useAppContext } from '../contexts/AppContext.js';
import { MAX_GEMINI_MESSAGE_LINES } from '../constants.js';
import { useConfirmingTool } from '../hooks/useConfirmingTool.js';
import { ToolStatusIndicator, ToolInfo } from './messages/ToolShared.js';
import { theme } from '../semantic-colors.js';
export const AlternateBufferQuittingDisplay = () => {
const { version } = useAppContext();
const uiState = useUIState();
const confirmingTool = useConfirmingTool();
const showPromptedTool = confirmingTool !== null;
// We render the entire chat history and header here to ensure that the
// conversation history is visible to the user after the app quits and the
// user exits alternate buffer mode.
// Our version of Ink is clever and will render a final frame outside of
// the alternate buffer on app exit.
return (
<Box
flexDirection="column"
flexShrink={0}
flexGrow={0}
width={uiState.terminalWidth}
>
<AppHeader key="app-header" version={version} />
{uiState.history.map((h) => (
<HistoryItemDisplay
terminalWidth={uiState.mainAreaWidth}
availableTerminalHeight={undefined}
availableTerminalHeightGemini={MAX_GEMINI_MESSAGE_LINES}
key={h.id}
item={h}
isPending={false}
commands={uiState.slashCommands}
/>
))}
{uiState.pendingHistoryItems.map((item, i) => (
<HistoryItemDisplay
key={i}
availableTerminalHeight={undefined}
terminalWidth={uiState.mainAreaWidth}
item={{ ...item, id: 0 }}
isPending={true}
/>
))}
{showPromptedTool && (
<Box flexDirection="column" marginTop={1} marginBottom={1}>
<Text color={theme.status.warning} bold>
Action Required (was prompted):
</Text>
<Box marginTop={1}>
<ToolStatusIndicator
status={confirmingTool.tool.status}
name={confirmingTool.tool.name}
/>
<ToolInfo
name={confirmingTool.tool.name}
status={confirmingTool.tool.status}
description={confirmingTool.tool.description}
emphasis="high"
/>
</Box>
</Box>
)}
<QuittingDisplay />
</Box>
);
};
@@ -1,166 +0,0 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { Box, Text } from 'ink';
import { UserIdentity } from './UserIdentity.js';
import { Tips } from './Tips.js';
import { useSettings } from '../contexts/SettingsContext.js';
import { useConfig } from '../contexts/ConfigContext.js';
import { useUIState } from '../contexts/UIStateContext.js';
import { Banner } from './Banner.js';
import { useBanner } from '../hooks/useBanner.js';
import { useTips } from '../hooks/useTips.js';
import { theme } from '../semantic-colors.js';
import { ThemedGradient } from './ThemedGradient.js';
import { CliSpinner } from './CliSpinner.js';
import { isAppleTerminal } from '@google/gemini-cli-core';
import { longAsciiLogoCompactText } from './AsciiArt.js';
import { getAsciiArtWidth } from '../utils/textUtils.js';
interface AppHeaderProps {
version: string;
showDetails?: boolean;
}
const DEFAULT_ICON = `▝▜▄
`;
/**
* The default Apple Terminal.app adds significant line-height padding between
* rows. This breaks Unicode block-drawing characters that rely on vertical
* adjacency (like half-blocks). This version is perfectly symmetric vertically,
* which makes the padding gaps look like an intentional "scanline" design
* rather than a broken image.
*/
const MAC_TERMINAL_ICON = `▝▜▄
`;
/**
* The horizontal padding (in columns) required for metadata (version, identity, etc.)
* when rendered alongside the ASCII logo.
*/
const LOGO_METADATA_PADDING = 20;
/**
* The terminal width below which we switch to a narrow/column layout to prevent
* UI elements from wrapping or overlapping.
*/
const NARROW_TERMINAL_BREAKPOINT = 60;
export const AppHeader = ({ version, showDetails = true }: AppHeaderProps) => {
const settings = useSettings();
const config = useConfig();
const { terminalWidth, bannerData, bannerVisible, updateInfo } = useUIState();
const { bannerText } = useBanner(bannerData);
const { showTips } = useTips();
const authType = config.getContentGeneratorConfig()?.authType;
const loggedOut = !authType;
const showHeader = !(
settings.merged.ui.hideBanner || config.getScreenReader()
);
const ICON = isAppleTerminal() ? MAC_TERMINAL_ICON : DEFAULT_ICON;
let logoTextArt = '';
if (loggedOut) {
const widthOfLongLogo =
getAsciiArtWidth(longAsciiLogoCompactText) + LOGO_METADATA_PADDING;
if (terminalWidth >= widthOfLongLogo) {
logoTextArt = longAsciiLogoCompactText.trim();
}
}
// If the terminal is too narrow to fit the icon and metadata (especially long nightly versions)
// side-by-side, we switch to column mode to prevent wrapping.
const isNarrow = terminalWidth < NARROW_TERMINAL_BREAKPOINT;
const renderLogo = () => (
<Box flexDirection="row">
<Box flexShrink={0}>
<ThemedGradient>{ICON}</ThemedGradient>
</Box>
{logoTextArt && (
<Box marginLeft={3}>
<Text color={theme.text.primary}>{logoTextArt}</Text>
</Box>
)}
</Box>
);
const renderMetadata = (isBelow = false) => (
<Box marginLeft={isBelow ? 0 : 2} flexDirection="column">
{/* Line 1: Gemini CLI vVersion [Updating] */}
<Box>
<Text bold color={theme.text.primary}>
Gemini CLI
</Text>
<Text color={theme.text.secondary}> v{version}</Text>
{updateInfo?.isUpdating && (
<Box marginLeft={2}>
<Text color={theme.text.secondary}>
<CliSpinner /> Updating
</Text>
</Box>
)}
</Box>
{showDetails && (
<>
{/* Line 2: Blank */}
<Box height={1} />
{/* Lines 3 & 4: User Identity info (Email /auth and Plan /upgrade) */}
{settings.merged.ui.showUserIdentity !== false && (
<UserIdentity config={config} />
)}
</>
)}
</Box>
);
const useColumnLayout = !!logoTextArt || isNarrow;
return (
<Box flexDirection="column">
{showHeader && (
<Box
flexDirection={useColumnLayout ? 'column' : 'row'}
marginTop={1}
marginBottom={1}
paddingLeft={1}
>
{renderLogo()}
{useColumnLayout ? (
<Box marginTop={1}>{renderMetadata(true)}</Box>
) : (
renderMetadata(false)
)}
</Box>
)}
{bannerVisible && bannerText && (
<Banner
width={terminalWidth}
bannerText={bannerText}
isWarning={bannerData.warningText !== ''}
/>
)}
{!(settings.merged.ui.hideTips || config.getScreenReader()) &&
showTips && <Tips config={config} />}
</Box>
);
};
-180
View File
@@ -1,180 +0,0 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { Box, useIsScreenReaderEnabled } from 'ink';
import { useState, useEffect } from 'react';
import { useConfig } from '../contexts/ConfigContext.js';
import { useSettings } from '../contexts/SettingsContext.js';
import { useUIState } from '../contexts/UIStateContext.js';
import { useUIActions } from '../contexts/UIActionsContext.js';
import { useVimMode } from '../contexts/VimModeContext.js';
import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js';
import { useTerminalSize } from '../hooks/useTerminalSize.js';
import { isNarrowWidth } from '../utils/isNarrowWidth.js';
import { ToastDisplay, shouldShowToast } from './ToastDisplay.js';
import { DetailedMessagesDisplay } from './DetailedMessagesDisplay.js';
import { ShortcutsHelp } from './ShortcutsHelp.js';
import { InputPrompt } from './InputPrompt.js';
import { Footer } from './Footer.js';
import { StatusRow } from './StatusRow.js';
import { ShowMoreLines } from './ShowMoreLines.js';
import { QueuedMessageDisplay } from './QueuedMessageDisplay.js';
import { OverflowProvider } from '../contexts/OverflowContext.js';
import { ConfigInitDisplay } from './ConfigInitDisplay.js';
import { TodoTray } from './messages/Todo.js';
import { useComposerStatus } from '../hooks/useComposerStatus.js';
export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
const uiState = useUIState();
const uiActions = useUIActions();
const settings = useSettings();
const config = useConfig();
const { vimEnabled, vimMode } = useVimMode();
const isScreenReaderEnabled = useIsScreenReaderEnabled();
const { columns: terminalWidth } = useTerminalSize();
const isNarrow = isNarrowWidth(terminalWidth);
const debugConsoleMaxHeight = Math.floor(Math.max(terminalWidth * 0.2, 5));
const [suggestionsVisible, setSuggestionsVisible] = useState(false);
const isAlternateBuffer = useAlternateBuffer();
const showUiDetails = uiState.cleanUiDetailsVisible;
const suggestionsPosition = isAlternateBuffer ? 'above' : 'below';
const hideContextSummary =
suggestionsVisible && suggestionsPosition === 'above';
const { hasPendingActionRequired, shouldCollapseDuringApproval } =
useComposerStatus();
const isPassiveShortcutsHelpState =
uiState.isInputActive &&
uiState.streamingState === 'idle' &&
!hasPendingActionRequired;
const { setShortcutsHelpVisible } = uiActions;
useEffect(() => {
if (uiState.shortcutsHelpVisible && !isPassiveShortcutsHelpState) {
setShortcutsHelpVisible(false);
}
}, [
uiState.shortcutsHelpVisible,
isPassiveShortcutsHelpState,
setShortcutsHelpVisible,
]);
const showShortcutsHelp =
uiState.shortcutsHelpVisible &&
uiState.streamingState === 'idle' &&
!hasPendingActionRequired;
if (hasPendingActionRequired && shouldCollapseDuringApproval) {
return null;
}
const hasToast = shouldShowToast(uiState);
const hideUiDetailsForSuggestions =
suggestionsVisible && suggestionsPosition === 'above';
// Mini Mode VIP Flags (Pure Content Triggers)
const showMinimalToast = hasToast;
return (
<Box
flexDirection="column"
width={uiState.terminalWidth}
flexGrow={0}
flexShrink={0}
>
{uiState.isResuming && (
<ConfigInitDisplay message="Resuming session..." />
)}
{showUiDetails && (
<QueuedMessageDisplay messageQueue={uiState.messageQueue} />
)}
{showUiDetails && <TodoTray />}
{showShortcutsHelp && <ShortcutsHelp />}
{(showUiDetails || showMinimalToast) && (
<Box minHeight={1} marginLeft={isNarrow ? 0 : 1}>
<ToastDisplay />
</Box>
)}
<Box width="100%" flexDirection="column">
<StatusRow
showUiDetails={showUiDetails}
isNarrow={isNarrow}
terminalWidth={terminalWidth}
hideContextSummary={hideContextSummary}
hideUiDetailsForSuggestions={hideUiDetailsForSuggestions}
hasPendingActionRequired={hasPendingActionRequired}
/>
</Box>
{showUiDetails && uiState.showErrorDetails && (
<OverflowProvider>
<Box flexDirection="column">
<DetailedMessagesDisplay
maxHeight={
uiState.constrainHeight ? debugConsoleMaxHeight : undefined
}
width={uiState.terminalWidth}
hasFocus={uiState.showErrorDetails}
/>
<ShowMoreLines constrainHeight={uiState.constrainHeight} />
</Box>
</OverflowProvider>
)}
{uiState.isInputActive && (
<InputPrompt
buffer={uiState.buffer}
inputWidth={uiState.inputWidth}
suggestionsWidth={uiState.suggestionsWidth}
onSubmit={uiActions.handleFinalSubmit}
userMessages={uiState.userMessages}
setBannerVisible={uiActions.setBannerVisible}
onClearScreen={uiActions.handleClearScreen}
config={config}
slashCommands={uiState.slashCommands || []}
commandContext={uiState.commandContext}
shellModeActive={uiState.shellModeActive}
setShellModeActive={uiActions.setShellModeActive}
approvalMode={uiState.showApprovalModeIndicator}
onEscapePromptChange={uiActions.onEscapePromptChange}
focus={isFocused}
vimHandleInput={uiActions.vimHandleInput}
isEmbeddedShellFocused={uiState.embeddedShellFocused}
popAllMessages={uiActions.popAllMessages}
onQueueMessage={uiActions.addMessage}
placeholder={
vimEnabled
? vimMode === 'INSERT'
? " Press 'Esc' for NORMAL mode."
: " Press 'i' for INSERT mode."
: uiState.shellModeActive
? ' Type your shell command'
: ' Type your message or @path/to/file'
}
setQueueErrorMessage={uiActions.setQueueErrorMessage}
streamingState={uiState.streamingState}
suggestionsPosition={suggestionsPosition}
onSuggestionsVisibilityChange={setSuggestionsVisible}
copyModeEnabled={uiState.copyModeEnabled}
/>
)}
{showUiDetails &&
!settings.merged.ui.hideFooter &&
!isScreenReaderEnabled && (
<Footer copyModeEnabled={uiState.copyModeEnabled} />
)}
</Box>
);
};
@@ -1,25 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { Box, Text } from 'ink';
import { useUIState } from '../contexts/UIStateContext.js';
import { theme } from '../semantic-colors.js';
export const CopyModeWarning: React.FC = () => {
const { copyModeEnabled } = useUIState();
return (
<Box height={1}>
{copyModeEnabled && (
<Text color={theme.status.warning}>
In Copy Mode. Use Page Up/Down to scroll. Press Ctrl+S or any other
key to exit.
</Text>
)}
</Box>
);
};
@@ -1,243 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { Text } from 'ink';
import { useEffect, useState } from 'react';
import { FixedDeque } from 'mnemonist';
import { theme } from '../semantic-colors.js';
import { useUIState } from '../contexts/UIStateContext.js';
import { debugState } from '../debug.js';
import { appEvents, AppEvent } from '../../utils/events.js';
import { coreEvents, CoreEvent, debugLogger } from '@google/gemini-cli-core';
// Frames that render at least this far before or after an action are considered
// idle frames.
const MIN_TIME_FROM_ACTION_TO_BE_IDLE = 500;
export const ACTION_TIMESTAMP_CAPACITY = 2048;
export const FRAME_TIMESTAMP_CAPACITY = 2048;
// Exported for testing purposes.
export const profiler = {
profilersActive: 0,
numFrames: 0,
totalIdleFrames: 0,
totalFlickerFrames: 0,
hasLoggedFirstFlicker: false,
lastFrameStartTime: 0,
openedDebugConsole: false,
lastActionTimestamp: 0,
possiblyIdleFrameTimestamps: new FixedDeque<number>(
Array,
FRAME_TIMESTAMP_CAPACITY,
),
actionTimestamps: new FixedDeque<number>(Array, ACTION_TIMESTAMP_CAPACITY),
reportAction() {
const now = Date.now();
if (now - this.lastActionTimestamp > 16) {
if (this.actionTimestamps.size >= ACTION_TIMESTAMP_CAPACITY) {
this.actionTimestamps.shift();
}
this.actionTimestamps.push(now);
this.lastActionTimestamp = now;
}
},
reportFrameRendered() {
if (this.profilersActive === 0) {
return;
}
const now = Date.now();
this.lastFrameStartTime = now;
this.numFrames++;
if (debugState.debugNumAnimatedComponents === 0) {
if (this.possiblyIdleFrameTimestamps.size >= FRAME_TIMESTAMP_CAPACITY) {
this.possiblyIdleFrameTimestamps.shift();
}
this.possiblyIdleFrameTimestamps.push(now);
} else {
// If a spinner is present, consider this an action that both prevents
// this frame from being idle and also should prevent a follow on frame
// from being considered idle.
if (this.actionTimestamps.size >= ACTION_TIMESTAMP_CAPACITY) {
this.actionTimestamps.shift();
}
this.actionTimestamps.push(now);
}
},
checkForIdleFrames() {
const now = Date.now();
const judgementCutoff = now - MIN_TIME_FROM_ACTION_TO_BE_IDLE;
const oneSecondIntervalFromJudgementCutoff = judgementCutoff - 1000;
let idleInPastSecond = 0;
while (
this.possiblyIdleFrameTimestamps.size > 0 &&
this.possiblyIdleFrameTimestamps.peekFirst()! <= judgementCutoff
) {
const frameTime = this.possiblyIdleFrameTimestamps.shift()!;
const start = frameTime - MIN_TIME_FROM_ACTION_TO_BE_IDLE;
const end = frameTime + MIN_TIME_FROM_ACTION_TO_BE_IDLE;
while (
this.actionTimestamps.size > 0 &&
this.actionTimestamps.peekFirst()! < start
) {
this.actionTimestamps.shift();
}
const hasAction =
this.actionTimestamps.size > 0 &&
this.actionTimestamps.peekFirst()! <= end;
if (!hasAction) {
if (frameTime >= oneSecondIntervalFromJudgementCutoff) {
idleInPastSecond++;
}
this.totalIdleFrames++;
}
}
if (idleInPastSecond >= 5) {
if (this.openedDebugConsole === false) {
this.openedDebugConsole = true;
appEvents.emit(AppEvent.OpenDebugConsole);
}
debugLogger.error(
`${idleInPastSecond} frames rendered while the app was ` +
`idle in the past second. This likely indicates severe infinite loop ` +
`React state management bugs.`,
);
}
},
registerFlickerHandler(constrainHeight: boolean) {
const flickerHandler = () => {
// If we are not constraining the height, we are intentionally
// overflowing the screen.
if (!constrainHeight) {
return;
}
this.totalFlickerFrames++;
this.reportAction();
if (!this.hasLoggedFirstFlicker) {
this.hasLoggedFirstFlicker = true;
debugLogger.error(
'A flicker frame was detected. This will cause UI instability. Type `/profile` for more info.',
);
}
};
appEvents.on(AppEvent.Flicker, flickerHandler);
return () => {
appEvents.off(AppEvent.Flicker, flickerHandler);
};
},
};
export const DebugProfiler = () => {
const { showDebugProfiler, constrainHeight } = useUIState();
const [forceRefresh, setForceRefresh] = useState(0);
// Effect for listening to stdin for keypresses and stdout for resize events.
useEffect(() => {
profiler.profilersActive++;
const stdin = process.stdin;
const stdout = process.stdout;
const handler = () => {
profiler.reportAction();
};
stdin.on('data', handler);
stdout.on('resize', handler);
// Register handlers for all core and app events to ensure they are
// considered "actions" and don't trigger spurious idle frame warnings.
// These events are expected to trigger UI renders.
for (const eventName of Object.values(CoreEvent)) {
coreEvents.on(eventName, handler);
}
for (const eventName of Object.values(AppEvent)) {
appEvents.on(eventName, handler);
}
// Register handlers for extension lifecycle events emitted on coreEvents
// but not part of the CoreEvent enum, to prevent false-positive idle warnings.
const extensionEvents = [
'extensionsStarting',
'extensionsStopping',
] as const;
for (const eventName of extensionEvents) {
coreEvents.on(eventName, handler);
}
return () => {
stdin.off('data', handler);
stdout.off('resize', handler);
for (const eventName of Object.values(CoreEvent)) {
coreEvents.off(eventName, handler);
}
for (const eventName of Object.values(AppEvent)) {
appEvents.off(eventName, handler);
}
for (const eventName of extensionEvents) {
coreEvents.off(eventName, handler);
}
profiler.profilersActive--;
};
}, []);
useEffect(() => {
const updateInterval = setInterval(() => {
profiler.checkForIdleFrames();
}, 1000);
return () => clearInterval(updateInterval);
}, []);
useEffect(
() => profiler.registerFlickerHandler(constrainHeight),
[constrainHeight],
);
// Effect for updating stats
useEffect(() => {
if (!showDebugProfiler) {
return;
}
// Only update the UX infrequently as updating the UX itself will cause
// frames to run so can disturb what we are measuring.
const forceRefreshInterval = setInterval(() => {
setForceRefresh((f) => f + 1);
profiler.reportAction();
}, 4000);
return () => clearInterval(forceRefreshInterval);
}, [showDebugProfiler]);
if (!showDebugProfiler) {
return null;
}
return (
<Text color={theme.status.warning} key={forceRefresh}>
Renders: {profiler.numFrames} (total),{' '}
<Text color={theme.status.error}>{profiler.totalIdleFrames} (idle)</Text>,{' '}
<Text color={theme.status.error}>
{profiler.totalFlickerFrames} (flicker)
</Text>
</Text>
);
};
@@ -1,121 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { renderWithProviders } from '../../test-utils/render.js';
import { DetailedMessagesDisplay } from './DetailedMessagesDisplay.js';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import type { ConsoleMessageItem } from '../types.js';
import { Box } from 'ink';
import type React from 'react';
import { createMockSettings } from '../../test-utils/settings.js';
import { useConsoleMessages } from '../hooks/useConsoleMessages.js';
vi.mock('../hooks/useConsoleMessages.js', () => ({
useConsoleMessages: vi.fn(),
}));
vi.mock('./shared/ScrollableList.js', () => ({
ScrollableList: ({
data,
renderItem,
}: {
data: unknown[];
renderItem: (props: { item: unknown }) => React.ReactNode;
}) => (
<Box flexDirection="column">
{data.map((item: unknown, index: number) => (
<Box key={index}>{renderItem({ item })}</Box>
))}
</Box>
),
}));
describe('DetailedMessagesDisplay', () => {
beforeEach(() => {
vi.mocked(useConsoleMessages).mockReturnValue([]);
});
it('renders nothing when messages are empty', async () => {
const { lastFrame, unmount } = await renderWithProviders(
<DetailedMessagesDisplay maxHeight={10} width={80} hasFocus={false} />,
{
settings: createMockSettings({ ui: { errorVerbosity: 'full' } }),
},
);
expect(lastFrame({ allowEmpty: true })).toBe('');
unmount();
});
it('renders messages correctly', async () => {
const messages: ConsoleMessageItem[] = [
{ type: 'log', content: 'Log message', count: 1 },
{ type: 'warn', content: 'Warning message', count: 1 },
{ type: 'error', content: 'Error message', count: 1 },
{ type: 'debug', content: 'Debug message', count: 1 },
];
vi.mocked(useConsoleMessages).mockReturnValue(messages);
const { lastFrame, unmount } = await renderWithProviders(
<DetailedMessagesDisplay maxHeight={20} width={80} hasFocus={true} />,
{
settings: createMockSettings({ ui: { errorVerbosity: 'full' } }),
},
);
const output = lastFrame();
expect(output).toMatchSnapshot();
unmount();
});
it('shows the F12 hint even in low error verbosity mode', async () => {
const messages: ConsoleMessageItem[] = [
{ type: 'error', content: 'Error message', count: 1 },
];
vi.mocked(useConsoleMessages).mockReturnValue(messages);
const { lastFrame, unmount } = await renderWithProviders(
<DetailedMessagesDisplay maxHeight={20} width={80} hasFocus={true} />,
{
settings: createMockSettings({ ui: { errorVerbosity: 'low' } }),
},
);
expect(lastFrame()).toContain('(F12 to close)');
unmount();
});
it('shows the F12 hint in full error verbosity mode', async () => {
const messages: ConsoleMessageItem[] = [
{ type: 'error', content: 'Error message', count: 1 },
];
vi.mocked(useConsoleMessages).mockReturnValue(messages);
const { lastFrame, unmount } = await renderWithProviders(
<DetailedMessagesDisplay maxHeight={20} width={80} hasFocus={true} />,
{
settings: createMockSettings({ ui: { errorVerbosity: 'full' } }),
},
);
expect(lastFrame()).toContain('(F12 to close)');
unmount();
});
it('renders message counts', async () => {
const messages: ConsoleMessageItem[] = [
{ type: 'log', content: 'Repeated message', count: 5 },
];
vi.mocked(useConsoleMessages).mockReturnValue(messages);
const { lastFrame, unmount } = await renderWithProviders(
<DetailedMessagesDisplay maxHeight={10} width={80} hasFocus={false} />,
{
settings: createMockSettings({ ui: { errorVerbosity: 'full' } }),
},
);
const output = lastFrame();
expect(output).toMatchSnapshot();
unmount();
});
});
@@ -1,131 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { useRef, useCallback, useMemo } from 'react';
import { Box, Text } from 'ink';
import { theme } from '../semantic-colors.js';
import type { ConsoleMessageItem } from '../types.js';
import {
ScrollableList,
type ScrollableListRef,
} from './shared/ScrollableList.js';
import { useConsoleMessages } from '../hooks/useConsoleMessages.js';
import { useConfig } from '../contexts/ConfigContext.js';
interface DetailedMessagesDisplayProps {
maxHeight: number | undefined;
width: number;
hasFocus: boolean;
}
const iconBoxWidth = 3;
export const DetailedMessagesDisplay: React.FC<
DetailedMessagesDisplayProps
> = ({ maxHeight, width, hasFocus }) => {
const scrollableListRef = useRef<ScrollableListRef<ConsoleMessageItem>>(null);
const consoleMessages = useConsoleMessages();
const config = useConfig();
const messages = useMemo(() => {
if (config.getDebugMode()) {
return consoleMessages;
}
return consoleMessages.filter((msg) => msg.type !== 'debug');
}, [consoleMessages, config]);
const borderAndPadding = 3;
const estimatedItemHeight = useCallback(
(index: number) => {
const msg = messages[index];
if (!msg) {
return 1;
}
const textWidth = width - borderAndPadding - iconBoxWidth;
if (textWidth <= 0) {
return 1;
}
const lines = Math.ceil((msg.content?.length || 1) / textWidth);
return Math.max(1, lines);
},
[width, messages],
);
if (messages.length === 0) {
return null;
}
return (
<Box
flexDirection="column"
marginTop={1}
borderStyle="round"
borderColor={theme.border.default}
paddingLeft={1}
width={width}
height={maxHeight}
flexShrink={0}
flexGrow={0}
overflow="hidden"
>
<Box marginBottom={1}>
<Text bold color={theme.text.primary}>
Debug Console <Text color={theme.text.secondary}>(F12 to close)</Text>
</Text>
</Box>
<Box height={maxHeight} width={width - borderAndPadding}>
<ScrollableList
ref={scrollableListRef}
data={messages}
renderItem={({ item: msg }: { item: ConsoleMessageItem }) => {
let textColor = theme.text.primary;
let icon = ''; // Information source ()
switch (msg.type) {
case 'warn':
textColor = theme.status.warning;
icon = '⚠'; // Warning sign (⚠)
break;
case 'error':
textColor = theme.status.error;
icon = '✖'; // Heavy multiplication x (✖)
break;
case 'debug':
textColor = theme.text.secondary; // Or theme.text.secondary
icon = '🔍'; // Left-pointing magnifying glass (🔍)
break;
case 'log':
default:
// Default textColor and icon are already set
break;
}
return (
<Box flexDirection="row">
<Box minWidth={iconBoxWidth} flexShrink={0}>
<Text color={textColor}>{icon}</Text>
</Box>
<Text color={textColor} wrap="wrap">
{msg.content}
{msg.count && msg.count > 1 && (
<Text color={theme.text.secondary}> (x{msg.count})</Text>
)}
</Text>
</Box>
);
}}
keyExtractor={(item, index) => `${item.content}-${index}`}
estimatedItemHeight={estimatedItemHeight}
hasFocus={hasFocus}
initialScrollIndex={Number.MAX_SAFE_INTEGER}
/>
</Box>
</Box>
);
};
@@ -1,367 +0,0 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { Box, Text } from 'ink';
import { IdeIntegrationNudge } from '../IdeIntegrationNudge.js';
import { LoopDetectionConfirmation } from './LoopDetectionConfirmation.js';
import { FolderTrustDialog } from './FolderTrustDialog.js';
import { ConsentPrompt } from './ConsentPrompt.js';
import { ThemeDialog } from './ThemeDialog.js';
import { SettingsDialog } from './SettingsDialog.js';
import { AuthInProgress } from '../auth/AuthInProgress.js';
import { AuthDialog } from '../auth/AuthDialog.js';
import { BannedAccountDialog } from '../auth/BannedAccountDialog.js';
import { ApiAuthDialog } from '../auth/ApiAuthDialog.js';
import { EditorSettingsDialog } from './EditorSettingsDialog.js';
import { PrivacyNotice } from '../privacy/PrivacyNotice.js';
import { ProQuotaDialog } from './ProQuotaDialog.js';
import { ValidationDialog } from './ValidationDialog.js';
import { OverageMenuDialog } from './OverageMenuDialog.js';
import { EmptyWalletDialog } from './EmptyWalletDialog.js';
import { relaunchApp } from '../../utils/processUtils.js';
import { SessionBrowser } from './SessionBrowser.js';
import { PermissionsModifyTrustDialog } from './PermissionsModifyTrustDialog.js';
import { ModelDialog } from './ModelDialog.js';
import { theme } from '../semantic-colors.js';
import { useUIState } from '../contexts/UIStateContext.js';
import { useUIActions } from '../contexts/UIActionsContext.js';
import { useConfig } from '../contexts/ConfigContext.js';
import { useSettings } from '../contexts/SettingsContext.js';
import process from 'node:process';
import { type UseHistoryManagerReturn } from '../hooks/useHistoryManager.js';
import { AdminSettingsChangedDialog } from './AdminSettingsChangedDialog.js';
import { IdeTrustChangeDialog } from './IdeTrustChangeDialog.js';
import { NewAgentsNotification } from './NewAgentsNotification.js';
import { AgentConfigDialog } from './AgentConfigDialog.js';
import { PolicyUpdateDialog } from './PolicyUpdateDialog.js';
interface DialogManagerProps {
addItem: UseHistoryManagerReturn['addItem'];
terminalWidth: number;
}
// Props for DialogManager
export const DialogManager = ({
addItem,
terminalWidth,
}: DialogManagerProps) => {
const config = useConfig();
const settings = useSettings();
const uiState = useUIState();
const uiActions = useUIActions();
const {
constrainHeight,
terminalHeight,
staticExtraHeight,
terminalWidth: uiTerminalWidth,
} = uiState;
if (uiState.adminSettingsChanged) {
return <AdminSettingsChangedDialog />;
}
if (uiState.showIdeRestartPrompt) {
return <IdeTrustChangeDialog reason={uiState.ideTrustRestartReason} />;
}
if (uiState.newAgents) {
return (
<NewAgentsNotification
agents={uiState.newAgents}
onSelect={uiActions.handleNewAgentsSelect}
/>
);
}
if (uiState.quota.proQuotaRequest) {
return (
<ProQuotaDialog
failedModel={uiState.quota.proQuotaRequest.failedModel}
fallbackModel={uiState.quota.proQuotaRequest.fallbackModel}
message={uiState.quota.proQuotaRequest.message}
isTerminalQuotaError={
uiState.quota.proQuotaRequest.isTerminalQuotaError
}
isModelNotFoundError={
!!uiState.quota.proQuotaRequest.isModelNotFoundError
}
authType={uiState.quota.proQuotaRequest.authType}
tierName={config?.getUserTierName()}
onChoice={uiActions.handleProQuotaChoice}
/>
);
}
if (uiState.quota.validationRequest) {
return (
<ValidationDialog
validationLink={uiState.quota.validationRequest.validationLink}
validationDescription={
uiState.quota.validationRequest.validationDescription
}
learnMoreUrl={uiState.quota.validationRequest.learnMoreUrl}
onChoice={uiActions.handleValidationChoice}
/>
);
}
if (uiState.quota.overageMenuRequest) {
return (
<OverageMenuDialog
failedModel={uiState.quota.overageMenuRequest.failedModel}
fallbackModel={uiState.quota.overageMenuRequest.fallbackModel}
resetTime={uiState.quota.overageMenuRequest.resetTime}
creditBalance={uiState.quota.overageMenuRequest.creditBalance}
onChoice={uiActions.handleOverageMenuChoice}
/>
);
}
if (uiState.quota.emptyWalletRequest) {
return (
<EmptyWalletDialog
failedModel={uiState.quota.emptyWalletRequest.failedModel}
fallbackModel={uiState.quota.emptyWalletRequest.fallbackModel}
resetTime={uiState.quota.emptyWalletRequest.resetTime}
onGetCredits={uiState.quota.emptyWalletRequest.onGetCredits}
onChoice={uiActions.handleEmptyWalletChoice}
/>
);
}
if (uiState.shouldShowIdePrompt) {
return (
<IdeIntegrationNudge
ide={uiState.currentIDE!}
onComplete={uiActions.handleIdePromptComplete}
/>
);
}
if (uiState.isFolderTrustDialogOpen) {
return (
<FolderTrustDialog
onSelect={uiActions.handleFolderTrustSelect}
isRestarting={uiState.isRestarting}
discoveryResults={uiState.folderDiscoveryResults}
/>
);
}
if (uiState.isPolicyUpdateDialogOpen) {
return (
<PolicyUpdateDialog
config={config}
request={uiState.policyUpdateConfirmationRequest!}
onClose={() => uiActions.setIsPolicyUpdateDialogOpen(false)}
/>
);
}
if (uiState.loopDetectionConfirmationRequest) {
return (
<LoopDetectionConfirmation
onComplete={uiState.loopDetectionConfirmationRequest.onComplete}
/>
);
}
if (uiState.permissionConfirmationRequest) {
const files = uiState.permissionConfirmationRequest.files;
const filesList = files.map((f) => `- ${f}`).join('\n');
return (
<ConsentPrompt
prompt={`The following files are outside your workspace:\n\n${filesList}\n\nDo you want to allow this read?`}
onConfirm={(allowed) => {
uiState.permissionConfirmationRequest?.onComplete({ allowed });
}}
terminalWidth={terminalWidth}
/>
);
}
// commandConfirmationRequest and authConsentRequest are kept separate
// to avoid focus deadlocks and state race conditions between the
// synchronous command loop and the asynchronous auth flow.
if (uiState.commandConfirmationRequest) {
return (
<ConsentPrompt
prompt={uiState.commandConfirmationRequest.prompt}
onConfirm={uiState.commandConfirmationRequest.onConfirm}
terminalWidth={terminalWidth}
/>
);
}
if (uiState.authConsentRequest) {
return (
<ConsentPrompt
prompt={uiState.authConsentRequest.prompt}
onConfirm={uiState.authConsentRequest.onConfirm}
terminalWidth={terminalWidth}
/>
);
}
if (uiState.confirmUpdateExtensionRequests.length > 0) {
const request = uiState.confirmUpdateExtensionRequests[0];
return (
<ConsentPrompt
prompt={request.prompt}
onConfirm={request.onConfirm}
terminalWidth={terminalWidth}
/>
);
}
if (uiState.isThemeDialogOpen) {
return (
<Box flexDirection="column">
{uiState.themeError && (
<Box marginBottom={1}>
<Text color={theme.status.error}>{uiState.themeError}</Text>
</Box>
)}
<ThemeDialog
onSelect={uiActions.handleThemeSelect}
onCancel={uiActions.closeThemeDialog}
onHighlight={uiActions.handleThemeHighlight}
settings={settings}
availableTerminalHeight={
constrainHeight ? terminalHeight - staticExtraHeight : undefined
}
terminalWidth={uiTerminalWidth}
/>
</Box>
);
}
if (uiState.isSettingsDialogOpen) {
return (
<Box flexDirection="column">
<SettingsDialog
onSelect={() => uiActions.closeSettingsDialog()}
onRestartRequest={relaunchApp}
availableTerminalHeight={terminalHeight - staticExtraHeight}
/>
</Box>
);
}
if (uiState.isModelDialogOpen) {
return <ModelDialog onClose={uiActions.closeModelDialog} />;
}
if (
uiState.isAgentConfigDialogOpen &&
uiState.selectedAgentName &&
uiState.selectedAgentDisplayName &&
uiState.selectedAgentDefinition
) {
return (
<Box flexDirection="column">
<AgentConfigDialog
agentName={uiState.selectedAgentName}
displayName={uiState.selectedAgentDisplayName}
definition={uiState.selectedAgentDefinition}
settings={settings}
availableTerminalHeight={terminalHeight - staticExtraHeight}
onClose={uiActions.closeAgentConfigDialog}
onSave={async () => {
// Reload agent registry to pick up changes
const agentRegistry = config?.getAgentRegistry();
if (agentRegistry) {
await agentRegistry.reload();
}
}}
/>
</Box>
);
}
if (uiState.accountSuspensionInfo) {
return (
<Box flexDirection="column">
<BannedAccountDialog
accountSuspensionInfo={uiState.accountSuspensionInfo}
onExit={() => {
process.exit(1);
}}
onChangeAuth={() => {
uiActions.clearAccountSuspension();
}}
/>
</Box>
);
}
if (uiState.isAuthenticating) {
return (
<AuthInProgress
onTimeout={() => {
uiActions.onAuthError('Authentication cancelled.');
}}
/>
);
}
if (uiState.isAwaitingApiKeyInput) {
return (
<Box flexDirection="column">
<ApiAuthDialog
key={uiState.apiKeyDefaultValue}
onSubmit={uiActions.handleApiKeySubmit}
onCancel={uiActions.handleApiKeyCancel}
error={uiState.authError}
defaultValue={uiState.apiKeyDefaultValue}
/>
</Box>
);
}
if (uiState.isAuthDialogOpen) {
return (
<Box flexDirection="column">
<AuthDialog
config={config}
settings={settings}
setAuthState={uiActions.setAuthState}
authError={uiState.authError}
onAuthError={uiActions.onAuthError}
setAuthContext={uiActions.setAuthContext}
/>
</Box>
);
}
if (uiState.isEditorDialogOpen) {
return (
<Box flexDirection="column">
{uiState.editorError && (
<Box marginBottom={1}>
<Text color={theme.status.error}>{uiState.editorError}</Text>
</Box>
)}
<EditorSettingsDialog
onSelect={uiActions.handleEditorSelect}
settings={settings}
onExit={uiActions.exitEditorDialog}
/>
</Box>
);
}
if (uiState.showPrivacyNotice) {
return (
<PrivacyNotice
onExit={() => uiActions.exitPrivacyNotice()}
config={config}
/>
);
}
if (uiState.isSessionBrowserOpen) {
return (
<SessionBrowser
config={config}
onResumeSession={uiActions.handleResumeSession}
onDeleteSession={uiActions.handleDeleteSession}
onExit={uiActions.closeSessionBrowser}
/>
);
}
if (uiState.isPermissionsDialogOpen) {
return (
<PermissionsModifyTrustDialog
onExit={uiActions.closePermissionsDialog}
addItem={addItem}
targetDirectory={uiState.permissionsDialogProps?.targetDirectory}
/>
);
}
return null;
};
@@ -1,29 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { Box, Text } from 'ink';
import { useUIState } from '../contexts/UIStateContext.js';
import { theme } from '../semantic-colors.js';
export const ExitWarning: React.FC = () => {
const uiState = useUIState();
return (
<>
{uiState.dialogsVisible && uiState.ctrlCPressedOnce && (
<Box marginTop={1}>
<Text color={theme.status.warning}>Press Ctrl+C again to exit.</Text>
</Box>
)}
{uiState.dialogsVisible && uiState.ctrlDPressedOnce && (
<Box marginTop={1}>
<Text color={theme.status.warning}>Press Ctrl+D again to exit.</Text>
</Box>
)}
</>
);
};
@@ -1,318 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { Box, Text } from 'ink';
import type React from 'react';
import { useEffect, useState, useCallback } from 'react';
import { theme } from '../semantic-colors.js';
import stripAnsi from 'strip-ansi';
import {
RadioButtonSelect,
type RadioSelectItem,
} from './shared/RadioButtonSelect.js';
import { MaxSizedBox } from './shared/MaxSizedBox.js';
import { Scrollable } from './shared/Scrollable.js';
import { useKeypress } from '../hooks/useKeypress.js';
import * as process from 'node:process';
import * as path from 'node:path';
import { relaunchApp } from '../../utils/processUtils.js';
import { runExitCleanup } from '../../utils/cleanup.js';
import {
ExitCodes,
type FolderDiscoveryResults,
} from '@google/gemini-cli-core';
import { useUIState } from '../contexts/UIStateContext.js';
import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js';
import { OverflowProvider } from '../contexts/OverflowContext.js';
import { ShowMoreLines } from './ShowMoreLines.js';
import { StickyHeader } from './StickyHeader.js';
export enum FolderTrustChoice {
TRUST_FOLDER = 'trust_folder',
TRUST_PARENT = 'trust_parent',
DO_NOT_TRUST = 'do_not_trust',
}
interface FolderTrustDialogProps {
onSelect: (choice: FolderTrustChoice) => void;
isRestarting?: boolean;
discoveryResults?: FolderDiscoveryResults | null;
}
export const FolderTrustDialog: React.FC<FolderTrustDialogProps> = ({
onSelect,
isRestarting,
discoveryResults,
}) => {
const [exiting, setExiting] = useState(false);
const { terminalHeight, terminalWidth, constrainHeight } = useUIState();
const isAlternateBuffer = useAlternateBuffer();
const isExpanded = !constrainHeight;
useEffect(() => {
let timer: ReturnType<typeof setTimeout>;
if (isRestarting) {
timer = setTimeout(relaunchApp, 250);
}
return () => {
if (timer) clearTimeout(timer);
};
}, [isRestarting]);
const handleExit = useCallback(() => {
setExiting(true);
// Give time for the UI to render the exiting message
setTimeout(async () => {
await runExitCleanup();
process.exit(ExitCodes.FATAL_CANCELLATION_ERROR);
}, 100);
}, []);
useKeypress(
(key) => {
if (key.name === 'escape') {
handleExit();
return true;
}
return false;
},
{ isActive: !isRestarting },
);
const dirName = path.basename(process.cwd());
const parentFolder = path.basename(path.dirname(process.cwd()));
const options: Array<RadioSelectItem<FolderTrustChoice>> = [
{
label: `Trust folder (${dirName})`,
value: FolderTrustChoice.TRUST_FOLDER,
key: `Trust folder (${dirName})`,
},
{
label: `Trust parent folder (${parentFolder})`,
value: FolderTrustChoice.TRUST_PARENT,
key: `Trust parent folder (${parentFolder})`,
},
{
label: "Don't trust",
value: FolderTrustChoice.DO_NOT_TRUST,
key: "Don't trust",
},
];
const hasDiscovery =
discoveryResults &&
(discoveryResults.commands.length > 0 ||
discoveryResults.mcps.length > 0 ||
discoveryResults.hooks.length > 0 ||
discoveryResults.skills.length > 0 ||
discoveryResults.settings.length > 0);
const hasWarnings =
discoveryResults && discoveryResults.securityWarnings.length > 0;
const hasErrors =
discoveryResults &&
discoveryResults.discoveryErrors &&
discoveryResults.discoveryErrors.length > 0;
const dialogWidth = terminalWidth - 2;
const borderColor = theme.status.warning;
// Header: 3 lines
// Options: options.length + 2 lines for margins
// Footer: 1 line
// Safety margin: 2 lines
const overhead = 3 + options.length + 2 + 1 + 2;
const scrollableHeight = Math.max(4, terminalHeight - overhead);
const groups = [
{ label: 'Commands', items: discoveryResults?.commands ?? [] },
{ label: 'MCP Servers', items: discoveryResults?.mcps ?? [] },
{ label: 'Hooks', items: discoveryResults?.hooks ?? [] },
{ label: 'Skills', items: discoveryResults?.skills ?? [] },
{ label: 'Agents', items: discoveryResults?.agents ?? [] },
{ label: 'Setting overrides', items: discoveryResults?.settings ?? [] },
].filter((g) => g.items.length > 0);
const discoveryContent = (
<Box flexDirection="column">
<Box marginBottom={1}>
<Text color={theme.text.primary}>
Trusting a folder allows Gemini CLI to load its local configurations,
including custom commands, hooks, MCP servers, agent skills, and
settings. These configurations could execute code on your behalf or
change the behavior of the CLI.
</Text>
</Box>
{hasErrors && (
<Box flexDirection="column" marginBottom={1}>
<Text color={theme.status.error} bold>
Discovery Errors:
</Text>
{discoveryResults.discoveryErrors.map((error, i) => (
<Box key={i} marginLeft={2}>
<Text color={theme.status.error}> {stripAnsi(error)}</Text>
</Box>
))}
</Box>
)}
{hasWarnings && (
<Box flexDirection="column" marginBottom={1}>
<Text color={theme.status.warning} bold>
Security Warnings:
</Text>
{discoveryResults.securityWarnings.map((warning, i) => (
<Box key={i} marginLeft={2}>
<Text color={theme.status.warning}> {stripAnsi(warning)}</Text>
</Box>
))}
</Box>
)}
{hasDiscovery && (
<Box flexDirection="column" marginBottom={1}>
<Text color={theme.text.primary} bold>
This folder contains:
</Text>
{groups.map((group) => (
<Box key={group.label} flexDirection="column" marginLeft={2}>
<Text color={theme.text.primary} bold>
{group.label} ({group.items.length}):
</Text>
{group.items.map((item, idx) => (
<Box key={idx} marginLeft={2}>
<Text color={theme.text.primary}>- {stripAnsi(item)}</Text>
</Box>
))}
</Box>
))}
</Box>
)}
</Box>
);
const title = (
<Text bold color={theme.text.primary}>
Do you trust the files in this folder?
</Text>
);
const selectOptions = (
<RadioButtonSelect
items={options}
onSelect={onSelect}
isFocused={!isRestarting}
/>
);
const renderContent = () => {
if (isAlternateBuffer) {
return (
<Box flexDirection="column" width={dialogWidth}>
<StickyHeader
width={dialogWidth}
isFirst={true}
borderColor={borderColor}
borderDimColor={false}
>
{title}
</StickyHeader>
<Box
flexDirection="column"
borderLeft={true}
borderRight={true}
borderColor={borderColor}
borderStyle="round"
borderTop={false}
borderBottom={false}
width={dialogWidth}
>
<Scrollable
hasFocus={!isRestarting}
height={scrollableHeight}
width={dialogWidth - 2}
>
<Box flexDirection="column" paddingX={1}>
{discoveryContent}
</Box>
</Scrollable>
<Box paddingX={1} marginY={1}>
{selectOptions}
</Box>
</Box>
<Box
height={0}
width={dialogWidth}
borderLeft={true}
borderRight={true}
borderTop={false}
borderBottom={true}
borderColor={borderColor}
borderStyle="round"
/>
</Box>
);
}
return (
<Box
flexDirection="column"
borderStyle="round"
borderColor={borderColor}
padding={1}
width="100%"
>
<Box marginBottom={1}>{title}</Box>
<MaxSizedBox
maxHeight={isExpanded ? undefined : Math.max(4, terminalHeight - 12)}
overflowDirection="bottom"
>
{discoveryContent}
</MaxSizedBox>
<Box marginTop={1}>{selectOptions}</Box>
</Box>
);
};
const content = (
<Box flexDirection="column" width="100%">
<Box flexDirection="column" marginLeft={1} marginRight={1}>
{renderContent()}
</Box>
<Box paddingX={2} marginBottom={1}>
<ShowMoreLines constrainHeight={constrainHeight} />
</Box>
{isRestarting && (
<Box marginLeft={1} marginTop={1}>
<Text color={theme.status.warning}>
Gemini CLI is restarting to apply the trust changes...
</Text>
</Box>
)}
{exiting && (
<Box marginLeft={1} marginTop={1}>
<Text color={theme.status.warning}>
A folder trust level must be selected to continue. Exiting since
escape was pressed.
</Text>
</Box>
)}
</Box>
);
return <OverflowProvider>{content}</OverflowProvider>;
};
+3 -82
View File
@@ -8,11 +8,7 @@ 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,
UserAccountManager,
AuthType,
} from '@google/gemini-cli-core';
import { type Config } from '@google/gemini-cli-core';
import path from 'node:path';
// Normalize paths to POSIX slashes for stable cross-platform snapshots.
@@ -73,17 +69,14 @@ const defaultProps = {
branchName: 'main',
};
const mockConfigPlain = {
const mockConfig = {
getTargetDir: () => defaultProps.targetDir,
getDebugMode: () => false,
getModel: () => defaultProps.model,
getIdeMode: () => false,
isTrustedFolder: () => true,
getExtensionRegistryURI: () => undefined,
getContentGeneratorConfig: () => ({ authType: undefined }),
};
const mockConfig = mockConfigPlain as unknown as Config;
} as unknown as Config;
const mockSessionStats = {
sessionId: 'test-session-id',
@@ -441,7 +434,6 @@ describe('<Footer />', () => {
it('renders footer with all optional sections hidden (minimal footer)', async () => {
const { lastFrame, unmount } = await renderWithProviders(<Footer />, {
config: mockConfig,
width: 120,
uiState: { sessionStats: mockSessionStats },
settings: createMockSettings({
@@ -559,7 +551,6 @@ describe('<Footer />', () => {
describe('Footer Token Formatting', () => {
const renderWithTokens = async (tokens: number) => {
const result = await renderWithProviders(<Footer />, {
config: mockConfig,
width: 120,
uiState: {
sessionStats: {
@@ -684,75 +675,6 @@ 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,
@@ -812,7 +734,6 @@ describe('<Footer />', () => {
it('handles empty items array', async () => {
const { lastFrame, unmount } = await renderWithProviders(<Footer />, {
config: mockConfig,
width: 120,
uiState: { sessionStats: mockSessionStats },
settings: createMockSettings({
-546
View File
@@ -1,546 +0,0 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { useState, useEffect } from 'react';
import { Box, Text } from 'ink';
import { theme } from '../semantic-colors.js';
import {
shortenPath,
tildeifyPath,
getDisplayString,
checkExhaustive,
AuthType,
UserAccountManager,
} from '@google/gemini-cli-core';
import { ConsoleSummaryDisplay } from './ConsoleSummaryDisplay.js';
import process from 'node:process';
import { MemoryUsageDisplay } from './MemoryUsageDisplay.js';
import { ContextUsageDisplay } from './ContextUsageDisplay.js';
import { QuotaDisplay } from './QuotaDisplay.js';
import { DebugProfiler } from './DebugProfiler.js';
import { useUIState } from '../contexts/UIStateContext.js';
import { useConfig } from '../contexts/ConfigContext.js';
import { useSettings } from '../contexts/SettingsContext.js';
import { useVimMode } from '../contexts/VimModeContext.js';
import {
ALL_ITEMS,
type FooterItemId,
deriveItemsFromLegacySettings,
} from '../../config/footerItems.js';
import { isDevelopment } from '../../utils/installationInfo.js';
interface CwdIndicatorProps {
targetDir: string;
maxWidth: number;
debugMode?: boolean;
debugMessage?: string;
color?: string;
}
const CwdIndicator: React.FC<CwdIndicatorProps> = ({
targetDir,
maxWidth,
debugMode,
debugMessage,
color = theme.text.primary,
}) => {
const debugSuffix = debugMode ? ' ' + (debugMessage || '--debug') : '';
const availableForPath = Math.max(10, maxWidth - debugSuffix.length);
const displayPath = shortenPath(tildeifyPath(targetDir), availableForPath);
return (
<Text color={color}>
{displayPath}
{debugMode && <Text color={theme.status.error}>{debugSuffix}</Text>}
</Text>
);
};
interface SandboxIndicatorProps {
isTrustedFolder: boolean | undefined;
}
const SandboxIndicator: React.FC<SandboxIndicatorProps> = ({
isTrustedFolder,
}) => {
if (isTrustedFolder === false) {
return <Text color={theme.status.warning}>untrusted</Text>;
}
const sandbox = process.env['SANDBOX'];
if (sandbox && sandbox !== 'sandbox-exec') {
return (
<Text color="green">{sandbox.replace(/^gemini-(?:cli-)?/, '')}</Text>
);
}
if (sandbox === 'sandbox-exec') {
return (
<Text color={theme.status.warning}>
macOS Seatbelt{' '}
<Text color={theme.ui.comment}>
({process.env['SEATBELT_PROFILE']})
</Text>
</Text>
);
}
return <Text color={theme.status.error}>no sandbox</Text>;
};
const CorgiIndicator: React.FC = () => (
<Text>
<Text color={theme.status.error}></Text>
<Text color={theme.text.primary}>(´</Text>
<Text color={theme.status.error}></Text>
<Text color={theme.text.primary}>`)</Text>
<Text color={theme.status.error}></Text>
</Text>
);
export interface FooterRowItem {
key: string;
header: string;
element: React.ReactNode;
flexGrow?: number;
flexShrink?: number;
isFocused?: boolean;
alignItems?: 'flex-start' | 'center' | 'flex-end';
}
const COLUMN_GAP = 3;
export const FooterRow: React.FC<{
items: FooterRowItem[];
showLabels: boolean;
}> = ({ items, showLabels }) => {
const elements: React.ReactNode[] = [];
items.forEach((item, idx) => {
if (idx > 0) {
elements.push(
<Box
key={`sep-${item.key}`}
flexGrow={1}
flexShrink={1}
minWidth={showLabels ? COLUMN_GAP : 3}
justifyContent="center"
alignItems="center"
>
{!showLabels && <Text color={theme.ui.comment}> · </Text>}
</Box>,
);
}
elements.push(
<Box
key={item.key}
flexDirection="column"
flexGrow={item.flexGrow ?? 0}
flexShrink={item.flexShrink ?? 1}
alignItems={item.alignItems}
backgroundColor={item.isFocused ? theme.background.focus : undefined}
>
{showLabels && (
<Box height={1}>
<Text
color={item.isFocused ? theme.text.primary : theme.ui.comment}
>
{item.header}
</Text>
</Box>
)}
<Box height={1}>{item.element}</Box>
</Box>,
);
});
return (
<Box flexDirection="row" flexWrap="nowrap" width="100%">
{elements}
</Box>
);
};
function isFooterItemId(id: string): id is FooterItemId {
return ALL_ITEMS.some((i) => i.id === id);
}
interface FooterColumn {
id: string;
header: string;
element: (maxWidth: number) => React.ReactNode;
width: number;
isHighPriority: boolean;
}
export const Footer: React.FC<{ copyModeEnabled?: boolean }> = ({
copyModeEnabled = false,
}) => {
const uiState = useUIState();
const config = useConfig();
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} />;
}
const {
model,
targetDir,
debugMode,
branchName,
debugMessage,
corgiMode,
errorCount,
showErrorDetails,
promptTokenCount,
isTrustedFolder,
terminalWidth,
quotaStats,
} = {
model: uiState.currentModel,
targetDir: config.getTargetDir(),
debugMode: config.getDebugMode(),
branchName: uiState.branchName,
debugMessage: uiState.debugMessage,
corgiMode: uiState.corgiMode,
errorCount: uiState.errorCount,
showErrorDetails: uiState.showErrorDetails,
promptTokenCount: uiState.sessionStats.lastPromptTokenCount,
isTrustedFolder: uiState.isTrustedFolder,
terminalWidth: uiState.terminalWidth,
quotaStats: uiState.quota.stats,
};
const isFullErrorVerbosity = settings.merged.ui.errorVerbosity === 'full';
const showErrorSummary =
!showErrorDetails &&
errorCount > 0 &&
(isFullErrorVerbosity || debugMode || isDevelopment);
const displayVimMode = vimEnabled ? vimMode : undefined;
const items =
settings.merged.ui.footer.items ??
deriveItemsFromLegacySettings(settings.merged);
const showLabels = settings.merged.ui.footer.showLabels !== false;
const itemColor = showLabels ? theme.text.primary : theme.ui.comment;
const potentialColumns: FooterColumn[] = [];
const addCol = (
id: string,
header: string,
element: (maxWidth: number) => React.ReactNode,
dataWidth: number,
isHighPriority = false,
) => {
potentialColumns.push({
id,
header: showLabels ? header : '',
element,
width: Math.max(dataWidth, showLabels ? header.length : 0),
isHighPriority,
});
};
// 1. System Indicators (Far Left, high priority)
if (uiState.showDebugProfiler) {
addCol('debug', '', () => <DebugProfiler />, 45, true);
}
if (displayVimMode) {
const vimStr = `[${displayVimMode}]`;
addCol(
'vim',
'',
() => <Text color={theme.text.accent}>{vimStr}</Text>,
vimStr.length,
true,
);
}
// 2. Main Configurable Items
for (const id of items) {
if (!isFooterItemId(id)) continue;
const itemConfig = ALL_ITEMS.find((i) => i.id === id);
const header = itemConfig?.header ?? id;
switch (id) {
case 'workspace': {
const fullPath = tildeifyPath(targetDir);
const debugSuffix = debugMode ? ' ' + (debugMessage || '--debug') : '';
addCol(
id,
header,
(maxWidth) => (
<CwdIndicator
targetDir={targetDir}
maxWidth={maxWidth}
debugMode={debugMode}
debugMessage={debugMessage}
color={itemColor}
/>
),
fullPath.length + debugSuffix.length,
);
break;
}
case 'git-branch': {
if (branchName) {
addCol(
id,
header,
() => <Text color={itemColor}>{branchName}</Text>,
branchName.length,
);
}
break;
}
case 'sandbox': {
let str = 'no sandbox';
const sandbox = process.env['SANDBOX'];
if (isTrustedFolder === false) str = 'untrusted';
else if (sandbox === 'sandbox-exec')
str = `macOS Seatbelt (${process.env['SEATBELT_PROFILE']})`;
else if (sandbox) str = sandbox.replace(/^gemini-(?:cli-)?/, '');
addCol(
id,
header,
() => <SandboxIndicator isTrustedFolder={isTrustedFolder} />,
str.length,
);
break;
}
case 'model-name': {
const str = getDisplayString(model);
addCol(
id,
header,
() => <Text color={itemColor}>{str}</Text>,
str.length,
);
break;
}
case 'context-used': {
addCol(
id,
header,
() => (
<ContextUsageDisplay
promptTokenCount={promptTokenCount}
model={model}
terminalWidth={terminalWidth}
/>
),
10, // "100% used" is 9 chars
);
break;
}
case 'quota': {
if (quotaStats?.remaining !== undefined && quotaStats.limit) {
addCol(
id,
header,
() => (
<QuotaDisplay
remaining={quotaStats.remaining}
limit={quotaStats.limit}
resetTime={quotaStats.resetTime}
terse={true}
forceShow={true}
lowercase={true}
/>
),
10, // "daily 100%" is 10 chars, but terse is "100%" (4 chars)
);
}
break;
}
case 'memory-usage': {
addCol(
id,
header,
() => (
<MemoryUsageDisplay
color={itemColor}
isActive={!uiState.copyModeEnabled}
/>
),
10,
);
break;
}
case 'session-id': {
addCol(
id,
header,
() => (
<Text color={itemColor}>
{uiState.sessionStats.sessionId.slice(0, 8)}
</Text>
),
8,
);
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;
if (added > 0 || removed > 0) {
const str = `+${added} -${removed}`;
addCol(
id,
header,
() => (
<Text>
<Text color={theme.status.success}>+{added}</Text>{' '}
<Text color={theme.status.error}>-{removed}</Text>
</Text>
),
str.length,
);
}
break;
}
case 'token-count': {
let total = 0;
for (const m of Object.values(uiState.sessionStats.metrics.models))
total += m.tokens.total;
if (total > 0) {
const formatter = new Intl.NumberFormat('en-US', {
notation: 'compact',
maximumFractionDigits: 1,
});
const formatted = formatter.format(total).toLowerCase();
addCol(
id,
header,
() => <Text color={itemColor}>{formatted} tokens</Text>,
formatted.length + 7,
);
}
break;
}
default:
checkExhaustive(id);
break;
}
}
// 3. Transients
if (corgiMode) addCol('corgi', '', () => <CorgiIndicator />, 5);
if (showErrorSummary) {
addCol(
'error-count',
'',
() => <ConsoleSummaryDisplay errorCount={errorCount} />,
12,
true,
);
}
// --- Width Fitting Logic ---
const columnsToRender: FooterColumn[] = [];
let droppedAny = false;
let currentUsedWidth = 2; // Initial padding
for (const col of potentialColumns) {
const gap = columnsToRender.length > 0 ? (showLabels ? COLUMN_GAP : 3) : 0;
const budgetWidth = col.id === 'workspace' ? 20 : col.width;
if (
col.isHighPriority ||
currentUsedWidth + gap + budgetWidth <= terminalWidth - 2
) {
columnsToRender.push(col);
currentUsedWidth += gap + budgetWidth;
} else {
droppedAny = true;
}
}
const rowItems: FooterRowItem[] = columnsToRender.map((col, index) => {
const isWorkspace = col.id === 'workspace';
const isLast = index === columnsToRender.length - 1;
// Calculate exact space available for growth to prevent over-estimation truncation
const otherItemsWidth = columnsToRender
.filter((c) => c.id !== 'workspace')
.reduce((sum, c) => sum + c.width, 0);
const numItems = columnsToRender.length + (droppedAny ? 1 : 0);
const numGaps = numItems > 1 ? numItems - 1 : 0;
const gapsWidth = numGaps * (showLabels ? COLUMN_GAP : 3);
const ellipsisWidth = droppedAny ? 1 : 0;
const availableForWorkspace = Math.max(
20,
terminalWidth - 2 - gapsWidth - otherItemsWidth - ellipsisWidth,
);
const estimatedWidth = isWorkspace ? availableForWorkspace : col.width;
return {
key: col.id,
header: col.header,
element: col.element(estimatedWidth),
flexGrow: 0,
flexShrink: isWorkspace ? 1 : 0,
alignItems:
isLast && !droppedAny && index > 0 ? 'flex-end' : 'flex-start',
};
});
if (droppedAny) {
rowItems.push({
key: 'ellipsis',
header: '',
element: <Text color={theme.ui.comment}></Text>,
flexGrow: 0,
flexShrink: 0,
alignItems: 'flex-end',
});
}
return (
<Box width={terminalWidth} paddingX={1} overflow="hidden" flexWrap="nowrap">
<FooterRow items={rowItems} showLabels={showLabels} />
</Box>
);
};
@@ -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 < 9; i++) {
for (let i = 0; i < 8; i++) {
act(() => {
stdin.write('\u001b[B'); // Down arrow
});
@@ -1,396 +0,0 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { useCallback, useMemo, useReducer, useState } from 'react';
import { Box, Text } from 'ink';
import { theme } from '../semantic-colors.js';
import { useSettingsStore } from '../contexts/SettingsContext.js';
import { useUIState } from '../contexts/UIStateContext.js';
import { useKeypress, type Key } from '../hooks/useKeypress.js';
import { Command } from '../key/keyMatchers.js';
import { FooterRow, type FooterRowItem } from './Footer.js';
import { ALL_ITEMS, resolveFooterState } from '../../config/footerItems.js';
import { SettingScope } from '../../config/settings.js';
import { BaseSelectionList } from './shared/BaseSelectionList.js';
import type { SelectionListItem } from '../hooks/useSelectionList.js';
import { DialogFooter } from './shared/DialogFooter.js';
import { useKeyMatchers } from '../hooks/useKeyMatchers.js';
interface FooterConfigDialogProps {
onClose?: () => void;
}
interface FooterConfigItem {
key: string;
id: string;
label: string;
description?: string;
type: 'config' | 'labels-toggle' | 'reset';
}
interface FooterConfigState {
orderedIds: string[];
selectedIds: Set<string>;
}
type FooterConfigAction =
| { type: 'MOVE_ITEM'; id: string; direction: number }
| { type: 'TOGGLE_ITEM'; id: string }
| { type: 'SET_STATE'; payload: Partial<FooterConfigState> };
function footerConfigReducer(
state: FooterConfigState,
action: FooterConfigAction,
): FooterConfigState {
switch (action.type) {
case 'MOVE_ITEM': {
const currentIndex = state.orderedIds.indexOf(action.id);
const newIndex = currentIndex + action.direction;
if (
currentIndex === -1 ||
newIndex < 0 ||
newIndex >= state.orderedIds.length
) {
return state;
}
const newOrderedIds = [...state.orderedIds];
[newOrderedIds[currentIndex], newOrderedIds[newIndex]] = [
newOrderedIds[newIndex],
newOrderedIds[currentIndex],
];
return { ...state, orderedIds: newOrderedIds };
}
case 'TOGGLE_ITEM': {
const nextSelected = new Set(state.selectedIds);
if (nextSelected.has(action.id)) {
nextSelected.delete(action.id);
} else {
nextSelected.add(action.id);
}
return { ...state, selectedIds: nextSelected };
}
case 'SET_STATE':
return { ...state, ...action.payload };
default:
return state;
}
}
export const FooterConfigDialog: React.FC<FooterConfigDialogProps> = ({
onClose,
}) => {
const keyMatchers = useKeyMatchers();
const { settings, setSetting } = useSettingsStore();
const { constrainHeight, terminalHeight, staticExtraHeight } = useUIState();
const [state, dispatch] = useReducer(footerConfigReducer, undefined, () =>
resolveFooterState(settings.merged),
);
const { orderedIds, selectedIds } = state;
const [focusKey, setFocusKey] = useState<string | undefined>(orderedIds[0]);
const listItems = useMemo((): Array<SelectionListItem<FooterConfigItem>> => {
const items: Array<SelectionListItem<FooterConfigItem>> = orderedIds
.map((id: string) => {
const item = ALL_ITEMS.find((i) => i.id === id);
if (!item) return null;
return {
key: id,
value: {
key: id,
id,
label: item.id,
description: item.description as string,
type: 'config' as const,
},
};
})
.filter((i): i is NonNullable<typeof i> => i !== null);
items.push({
key: 'show-labels',
value: {
key: 'show-labels',
id: 'show-labels',
label: 'Show footer labels',
type: 'labels-toggle',
},
});
items.push({
key: 'reset',
value: {
key: 'reset',
id: 'reset',
label: 'Reset to default footer',
type: 'reset',
},
});
return items;
}, [orderedIds]);
const handleSaveAndClose = useCallback(() => {
const finalItems = orderedIds.filter((id: string) => selectedIds.has(id));
const currentSetting = settings.merged.ui?.footer?.items;
if (JSON.stringify(finalItems) !== JSON.stringify(currentSetting)) {
setSetting(SettingScope.User, 'ui.footer.items', finalItems);
}
onClose?.();
}, [
orderedIds,
selectedIds,
setSetting,
settings.merged.ui?.footer?.items,
onClose,
]);
const handleResetToDefaults = useCallback(() => {
setSetting(SettingScope.User, 'ui.footer.items', undefined);
const newState = resolveFooterState(settings.merged);
dispatch({ type: 'SET_STATE', payload: newState });
setFocusKey(newState.orderedIds[0]);
}, [setSetting, settings.merged]);
const handleToggleLabels = useCallback(() => {
const current = settings.merged.ui.footer.showLabels !== false;
setSetting(SettingScope.User, 'ui.footer.showLabels', !current);
}, [setSetting, settings.merged.ui.footer.showLabels]);
const handleSelect = useCallback(
(item: FooterConfigItem) => {
if (item.type === 'config') {
dispatch({ type: 'TOGGLE_ITEM', id: item.id });
} else if (item.type === 'labels-toggle') {
handleToggleLabels();
} else if (item.type === 'reset') {
handleResetToDefaults();
}
},
[handleResetToDefaults, handleToggleLabels],
);
const handleHighlight = useCallback((item: FooterConfigItem) => {
setFocusKey(item.key);
}, []);
useKeypress(
(key: Key) => {
if (keyMatchers[Command.ESCAPE](key)) {
handleSaveAndClose();
return true;
}
if (keyMatchers[Command.MOVE_LEFT](key)) {
if (focusKey && orderedIds.includes(focusKey)) {
dispatch({ type: 'MOVE_ITEM', id: focusKey, direction: -1 });
return true;
}
}
if (keyMatchers[Command.MOVE_RIGHT](key)) {
if (focusKey && orderedIds.includes(focusKey)) {
dispatch({ type: 'MOVE_ITEM', id: focusKey, direction: 1 });
return true;
}
}
return false;
},
{ isActive: true, priority: true },
);
const showLabels = settings.merged.ui.footer.showLabels !== false;
// Preview logic
const previewContent = useMemo(() => {
if (focusKey === 'reset') {
return (
<Text color={theme.ui.comment} italic>
Default footer (uses legacy settings)
</Text>
);
}
const itemsToPreview = orderedIds.filter((id: string) =>
selectedIds.has(id),
);
if (itemsToPreview.length === 0) return null;
const itemColor = showLabels ? theme.text.primary : theme.ui.comment;
const getColor = (id: string, defaultColor?: string) =>
defaultColor || itemColor;
// Mock data for preview (headers come from ALL_ITEMS)
const mockData: Record<string, React.ReactNode> = {
workspace: (
<Text color={getColor('workspace', itemColor)}>~/project/path</Text>
),
'git-branch': <Text color={getColor('git-branch', itemColor)}>main</Text>,
sandbox: <Text color={getColor('sandbox', 'green')}>docker</Text>,
'model-name': (
<Text color={getColor('model-name', itemColor)}>gemini-2.5-pro</Text>
),
'context-used': (
<Text color={getColor('context-used', itemColor)}>85% used</Text>
),
quota: <Text color={getColor('quota', itemColor)}>97%</Text>,
'memory-usage': (
<Text color={getColor('memory-usage', itemColor)}>260 MB</Text>
),
'session-id': (
<Text color={getColor('session-id', itemColor)}>769992f9</Text>
),
'code-changes': (
<Box flexDirection="row">
<Text color={getColor('code-changes', theme.status.success)}>
+12
</Text>
<Text color={getColor('code-changes')}> </Text>
<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>
),
};
const rowItems: FooterRowItem[] = itemsToPreview
.filter((id: string) => mockData[id])
.map((id: string) => ({
key: id,
header: ALL_ITEMS.find((i) => i.id === id)?.header ?? id,
element: mockData[id],
flexGrow: 0,
isFocused: id === focusKey,
}));
return (
<Box overflow="hidden" flexWrap="nowrap" width="100%">
<FooterRow items={rowItems} showLabels={showLabels} />
</Box>
);
}, [orderedIds, selectedIds, focusKey, showLabels]);
const availableTerminalHeight = constrainHeight
? terminalHeight - staticExtraHeight
: Number.MAX_SAFE_INTEGER;
const BORDER_HEIGHT = 2; // Outer round border
const STATIC_ELEMENTS = 13; // Text, margins, preview box, dialog footer
// Default padding adds 2 lines (top and bottom)
let includePadding = true;
if (availableTerminalHeight < BORDER_HEIGHT + 2 + STATIC_ELEMENTS + 6) {
includePadding = false;
}
const effectivePaddingY = includePadding ? 2 : 0;
const availableListSpace = Math.max(
0,
availableTerminalHeight -
BORDER_HEIGHT -
effectivePaddingY -
STATIC_ELEMENTS,
);
const maxItemsToShow = Math.max(
1,
Math.min(listItems.length, Math.floor(availableListSpace / 2)),
);
return (
<Box
flexDirection="column"
borderStyle="round"
borderColor={theme.border.default}
paddingX={2}
paddingY={includePadding ? 1 : 0}
width="100%"
>
<Text bold>Configure Footer{'\n'}</Text>
<Text color={theme.text.secondary}>
Select which items to display in the footer.
</Text>
<Box flexDirection="column" marginTop={1} flexGrow={1}>
<BaseSelectionList<FooterConfigItem>
items={listItems}
onSelect={handleSelect}
onHighlight={handleHighlight}
focusKey={focusKey}
showNumbers={false}
maxItemsToShow={maxItemsToShow}
showScrollArrows={true}
selectedIndicator=">"
renderItem={(item, { isSelected, titleColor }) => {
const configItem = item.value;
const isChecked =
configItem.type === 'config'
? selectedIds.has(configItem.id)
: configItem.type === 'labels-toggle'
? showLabels
: false;
return (
<Box flexDirection="column" minHeight={2}>
<Box flexDirection="row">
{configItem.type !== 'reset' && (
<Text
color={
isChecked ? theme.status.success : theme.text.secondary
}
>
[{isChecked ? '✓' : ' '}]
</Text>
)}
<Text
color={
configItem.type === 'reset' && isSelected
? theme.status.warning
: titleColor
}
>
{configItem.type !== 'reset' ? ' ' : ''}
{configItem.label}
</Text>
</Box>
{configItem.description && (
<Text color={theme.text.secondary} wrap="wrap">
{' '}
{configItem.description}
</Text>
)}
</Box>
);
}}
/>
</Box>
<DialogFooter
primaryAction="Enter to select"
navigationActions="↑/↓ to navigate · ←/→ to reorder"
cancelAction="Esc to close"
/>
<Box
marginTop={1}
borderStyle="single"
borderColor={theme.border.default}
paddingX={1}
flexDirection="column"
>
<Text bold>Preview:</Text>
<Box flexDirection="row" width="100%">
{previewContent}
</Box>
</Box>
</Box>
);
};
@@ -1,82 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { render } from '../../test-utils/render.js';
import { GeminiRespondingSpinner } from './GeminiRespondingSpinner.js';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { useStreamingContext } from '../contexts/StreamingContext.js';
import { Text, useIsScreenReaderEnabled } from 'ink';
import { StreamingState } from '../types.js';
import {
SCREEN_READER_LOADING,
SCREEN_READER_RESPONDING,
} from '../textConstants.js';
vi.mock('../contexts/StreamingContext.js');
vi.mock('ink', async (importOriginal) => {
const actual = await importOriginal<typeof import('ink')>();
return {
...actual,
useIsScreenReaderEnabled: vi.fn(),
};
});
vi.mock('./GeminiSpinner.js', () => ({
GeminiSpinner: ({ altText }: { altText?: string }) => (
<Text>GeminiSpinner {altText}</Text>
),
}));
describe('GeminiRespondingSpinner', () => {
const mockUseStreamingContext = vi.mocked(useStreamingContext);
const mockUseIsScreenReaderEnabled = vi.mocked(useIsScreenReaderEnabled);
beforeEach(() => {
vi.clearAllMocks();
mockUseIsScreenReaderEnabled.mockReturnValue(false);
});
it('renders spinner when responding', async () => {
mockUseStreamingContext.mockReturnValue(StreamingState.Responding);
const { lastFrame, unmount } = await render(<GeminiRespondingSpinner />);
expect(lastFrame()).toContain('GeminiSpinner');
unmount();
});
it('renders screen reader text when responding and screen reader enabled', async () => {
mockUseStreamingContext.mockReturnValue(StreamingState.Responding);
mockUseIsScreenReaderEnabled.mockReturnValue(true);
const { lastFrame, unmount } = await render(<GeminiRespondingSpinner />);
expect(lastFrame()).toContain(SCREEN_READER_RESPONDING);
unmount();
});
it('renders nothing when not responding and no non-responding display', async () => {
mockUseStreamingContext.mockReturnValue(StreamingState.Idle);
const { lastFrame, unmount } = await render(<GeminiRespondingSpinner />);
expect(lastFrame({ allowEmpty: true })).toBe('');
unmount();
});
it('renders non-responding display when provided', async () => {
mockUseStreamingContext.mockReturnValue(StreamingState.Idle);
const { lastFrame, unmount } = await render(
<GeminiRespondingSpinner nonRespondingDisplay="Waiting..." />,
);
expect(lastFrame()).toContain('Waiting...');
unmount();
});
it('renders screen reader loading text when non-responding display provided and screen reader enabled', async () => {
mockUseStreamingContext.mockReturnValue(StreamingState.Idle);
mockUseIsScreenReaderEnabled.mockReturnValue(true);
const { lastFrame, unmount } = await render(
<GeminiRespondingSpinner nonRespondingDisplay="Waiting..." />,
);
expect(lastFrame()).toContain(SCREEN_READER_LOADING);
unmount();
});
});
@@ -1,65 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { Text, useIsScreenReaderEnabled } from 'ink';
import type { SpinnerName } from 'cli-spinners';
import { useStreamingContext } from '../contexts/StreamingContext.js';
import { StreamingState } from '../types.js';
import {
SCREEN_READER_LOADING,
SCREEN_READER_RESPONDING,
} from '../textConstants.js';
import { theme } from '../semantic-colors.js';
import { GeminiSpinner } from './GeminiSpinner.js';
interface GeminiRespondingSpinnerProps {
/**
* Optional string to display when not in Responding state.
* If not provided and not Responding, renders null.
*/
nonRespondingDisplay?: string;
spinnerType?: SpinnerName;
/**
* If true, we prioritize showing the nonRespondingDisplay (hook icon)
* even if the state is Responding.
*/
isHookActive?: boolean;
color?: string;
}
export const GeminiRespondingSpinner: React.FC<
GeminiRespondingSpinnerProps
> = ({
nonRespondingDisplay,
spinnerType = 'dots',
isHookActive = false,
color,
}) => {
const streamingState = useStreamingContext();
const isScreenReaderEnabled = useIsScreenReaderEnabled();
// If a hook is active, we want to show the hook icon (nonRespondingDisplay)
// to be consistent, instead of the rainbow spinner which means "Gemini is talking".
if (streamingState === StreamingState.Responding && !isHookActive) {
return (
<GeminiSpinner
spinnerType={spinnerType}
altText={SCREEN_READER_RESPONDING}
/>
);
}
if (nonRespondingDisplay) {
return isScreenReaderEnabled ? (
<Text>{SCREEN_READER_LOADING}</Text>
) : (
<Text color={color ?? theme.text.primary}>{nonRespondingDisplay}</Text>
);
}
return null;
};
@@ -17,7 +17,6 @@ import { ToolGroupMessage } from './messages/ToolGroupMessage.js';
import { GeminiMessageContent } from './messages/GeminiMessageContent.js';
import { CompressionMessage } from './messages/CompressionMessage.js';
import { WarningMessage } from './messages/WarningMessage.js';
import { SubagentHistoryMessage } from './messages/SubagentHistoryMessage.js';
import { Box } from 'ink';
import { AboutBox } from './AboutBox.js';
import { StatsDisplay } from './StatsDisplay.js';
@@ -216,12 +215,6 @@ export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
isExpandable={isExpandable}
/>
)}
{itemForDisplay.type === 'subagent' && (
<SubagentHistoryMessage
item={itemForDisplay}
terminalWidth={terminalWidth}
/>
)}
{itemForDisplay.type === 'compression' && (
<CompressionMessage compression={itemForDisplay.compression} />
)}
@@ -2222,67 +2222,85 @@ describe('InputPrompt', () => {
name: 'mid-word',
text: 'hello world',
visualCursor: [0, 3],
expected: `hel${chalk.inverse('l')}o world`,
},
{
name: 'at the beginning of the line',
text: 'hello',
visualCursor: [0, 0],
expected: `${chalk.inverse('h')}ello`,
},
{
name: 'at the end of the line',
text: 'hello',
visualCursor: [0, 5],
expected: `hello${chalk.inverse(' ')}`,
},
{
name: 'on a highlighted token',
text: 'run @path/to/file',
visualCursor: [0, 9],
expected: `@path/${chalk.inverse('t')}o/file`,
},
{
name: 'for multi-byte unicode characters',
text: 'hello 👍 world',
visualCursor: [0, 6],
expected: `hello ${chalk.inverse('👍')} world`,
},
{
name: 'after multi-byte unicode characters',
text: '👍A',
visualCursor: [0, 1],
expected: `👍${chalk.inverse('A')}`,
},
{
name: 'at the end of a line with unicode characters',
text: 'hello 👍',
visualCursor: [0, 8],
expected: `hello 👍`, // skip checking inverse ansi due to ink truncation bug
},
{
name: 'at the end of a short line with unicode characters',
text: '👍',
visualCursor: [0, 1],
expected: `👍${chalk.inverse(' ')}`,
},
{
name: 'on an empty line',
text: '',
visualCursor: [0, 0],
expected: chalk.inverse(' '),
},
{
name: 'on a space between words',
text: 'hello world',
visualCursor: [0, 5],
expected: `hello${chalk.inverse(' ')}world`,
},
])(
'should display cursor correctly $name',
async ({ text, visualCursor }) => {
async ({ name, text, visualCursor, expected }) => {
mockBuffer.text = text;
mockBuffer.lines = [text];
mockBuffer.viewportVisualLines = [text];
mockBuffer.visualCursor = visualCursor as [number, number];
props.config.getUseBackgroundColor = () => false;
const renderResult = await renderWithProviders(
const { stdout, unmount } = await renderWithProviders(
<InputPrompt {...props} />,
);
await renderResult.waitUntilReady();
await expect(renderResult).toMatchSvgSnapshot();
renderResult.unmount();
await waitFor(() => {
const frame = stdout.lastFrameRaw();
expect(stripAnsi(frame)).toContain(stripAnsi(expected));
if (
name !== 'at the end of a line with unicode characters' &&
name !== 'on a highlighted token'
) {
expect(frame).toContain('\u001b[7m');
}
});
unmount();
},
);
});
@@ -2298,6 +2316,7 @@ describe('InputPrompt', () => {
[1, 0],
[2, 0],
],
expected: `sec${chalk.inverse('o')}nd line`,
},
{
name: 'at the beginning of a line',
@@ -2307,6 +2326,7 @@ describe('InputPrompt', () => {
[0, 0],
[1, 0],
],
expected: `${chalk.inverse('s')}econd line`,
},
{
name: 'at the end of a line',
@@ -2316,10 +2336,11 @@ describe('InputPrompt', () => {
[0, 0],
[1, 0],
],
expected: `first line${chalk.inverse(' ')}`,
},
])(
'should display cursor correctly $name in a multiline block',
async ({ text, visualCursor, visualToLogicalMap }) => {
async ({ name, text, visualCursor, expected, visualToLogicalMap }) => {
mockBuffer.text = text;
mockBuffer.lines = text.split('\n');
mockBuffer.viewportVisualLines = text.split('\n');
@@ -2329,12 +2350,20 @@ describe('InputPrompt', () => {
>;
props.config.getUseBackgroundColor = () => false;
const renderResult = await renderWithProviders(
const { stdout, unmount } = await renderWithProviders(
<InputPrompt {...props} />,
);
await renderResult.waitUntilReady();
await expect(renderResult).toMatchSvgSnapshot();
renderResult.unmount();
await waitFor(() => {
const frame = stdout.lastFrameRaw();
expect(stripAnsi(frame)).toContain(stripAnsi(expected));
if (
name !== 'at the end of a line with unicode characters' &&
name !== 'on a highlighted token'
) {
expect(frame).toContain('\u001b[7m');
}
});
unmount();
},
);
@@ -2351,12 +2380,18 @@ describe('InputPrompt', () => {
];
props.config.getUseBackgroundColor = () => false;
const renderResult = await renderWithProviders(
const { stdout, unmount } = await renderWithProviders(
<InputPrompt {...props} />,
);
await renderResult.waitUntilReady();
await expect(renderResult).toMatchSvgSnapshot();
renderResult.unmount();
await waitFor(() => {
const frame = stdout.lastFrameRaw();
const lines = frame.split('\n');
// The line with the cursor should just be an inverted space inside the box border
expect(
lines.find((l) => l.includes(chalk.inverse(' '))),
).not.toBeUndefined();
});
unmount();
});
});
});
@@ -2377,14 +2412,22 @@ describe('InputPrompt', () => {
];
props.config.getUseBackgroundColor = () => false;
const renderResult = await renderWithProviders(
const { stdout, unmount } = await renderWithProviders(
<InputPrompt {...props} />,
);
await waitFor(() => {
const frame = stdout.lastFrameRaw();
// Check that all lines, including the empty one, are rendered.
// This implicitly tests that the Box wrapper provides height for the empty line.
expect(frame).toContain('hello');
expect(frame).toContain('world');
expect(frame).toContain(chalk.inverse(' '));
await renderResult.waitUntilReady();
await expect(renderResult).toMatchSvgSnapshot();
renderResult.unmount();
const outputLines = frame.trim().split('\n');
// The number of lines should be 2 for the border plus 3 for the content.
expect(outputLines.length).toBe(5);
});
unmount();
});
});
@@ -4045,12 +4088,14 @@ describe('InputPrompt', () => {
it('should not show inverted cursor when shell is focused', async () => {
props.isEmbeddedShellFocused = true;
props.focus = false;
const renderResult = await renderWithProviders(
const { stdout, unmount } = await renderWithProviders(
<InputPrompt {...props} />,
);
await renderResult.waitUntilReady();
await expect(renderResult).toMatchSvgSnapshot();
renderResult.unmount();
await waitFor(() => {
expect(stdout.lastFrame()).not.toContain(`{chalk.inverse(' ')}`);
});
expect(stdout.lastFrame()).toMatchSnapshot();
unmount();
});
});
File diff suppressed because it is too large Load Diff
@@ -1,467 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import React, { act } from 'react';
import { renderWithProviders } from '../../test-utils/render.js';
import { Text } from 'ink';
import { LoadingIndicator } from './LoadingIndicator.js';
import { StreamingContext } from '../contexts/StreamingContext.js';
import { StreamingState } from '../types.js';
import { describe, it, expect, vi } from 'vitest';
import * as useTerminalSize from '../hooks/useTerminalSize.js';
// Mock GeminiRespondingSpinner
vi.mock('./GeminiRespondingSpinner.js', () => ({
GeminiRespondingSpinner: ({
nonRespondingDisplay,
}: {
nonRespondingDisplay?: string;
}) => {
const streamingState = React.useContext(StreamingContext)!;
if (streamingState === StreamingState.Responding) {
return <Text>MockRespondingSpinner</Text>;
} else if (nonRespondingDisplay) {
return <Text>{nonRespondingDisplay}</Text>;
}
return null;
},
}));
vi.mock('../hooks/useTerminalSize.js', () => ({
useTerminalSize: vi.fn(),
}));
const useTerminalSizeMock = vi.mocked(useTerminalSize.useTerminalSize);
const renderWithContext = async (
ui: React.ReactElement,
streamingStateValue: StreamingState,
width = 120,
) => {
useTerminalSizeMock.mockReturnValue({ columns: width, rows: 24 });
return renderWithProviders(ui, {
uiState: { streamingState: streamingStateValue },
width,
});
};
describe('<LoadingIndicator />', () => {
const defaultProps = {
currentLoadingPhrase: 'Thinking...',
elapsedTime: 5,
};
it('should render blank when streamingState is Idle and no loading phrase or thought', async () => {
const { lastFrame, waitUntilReady } = await renderWithContext(
<LoadingIndicator elapsedTime={5} />,
StreamingState.Idle,
);
await waitUntilReady();
expect(lastFrame({ allowEmpty: true })?.trim()).toBe('');
});
it('should render spinner, phrase, and time when streamingState is Responding', async () => {
const { lastFrame, waitUntilReady } = await renderWithContext(
<LoadingIndicator {...defaultProps} />,
StreamingState.Responding,
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('MockRespondingSpinner');
expect(output).toContain('Thinking...');
expect(output).toContain('(esc to cancel, 5s)');
});
it('should render spinner (static), phrase but no time/cancel when streamingState is WaitingForConfirmation', async () => {
const props = {
currentLoadingPhrase: 'Confirm action',
elapsedTime: 10,
};
const { lastFrame, waitUntilReady } = await renderWithContext(
<LoadingIndicator {...props} />,
StreamingState.WaitingForConfirmation,
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('⠏'); // Static char for WaitingForConfirmation
expect(output).toContain('Confirm action');
expect(output).not.toContain('(esc to cancel)');
expect(output).not.toContain(', 10s');
});
it('should display the currentLoadingPhrase correctly', async () => {
const props = {
currentLoadingPhrase: 'Processing data...',
elapsedTime: 3,
};
const { lastFrame, unmount, waitUntilReady } = await renderWithContext(
<LoadingIndicator {...props} />,
StreamingState.Responding,
);
await waitUntilReady();
expect(lastFrame()).toContain('Processing data...');
unmount();
});
it('should display the elapsedTime correctly when Responding', async () => {
const props = {
currentLoadingPhrase: 'Thinking...',
elapsedTime: 60,
};
const { lastFrame, unmount, waitUntilReady } = await renderWithContext(
<LoadingIndicator {...props} />,
StreamingState.Responding,
);
await waitUntilReady();
expect(lastFrame()).toContain('(esc to cancel, 1m)');
unmount();
});
it('should display the elapsedTime correctly in human-readable format', async () => {
const props = {
currentLoadingPhrase: 'Thinking...',
elapsedTime: 125,
};
const { lastFrame, unmount, waitUntilReady } = await renderWithContext(
<LoadingIndicator {...props} />,
StreamingState.Responding,
);
await waitUntilReady();
expect(lastFrame()).toContain('(esc to cancel, 2m 5s)');
unmount();
});
it('should render rightContent when provided', async () => {
const rightContent = <Text>Extra Info</Text>;
const { lastFrame, unmount, waitUntilReady } = await renderWithContext(
<LoadingIndicator {...defaultProps} rightContent={rightContent} />,
StreamingState.Responding,
);
await waitUntilReady();
expect(lastFrame()).toContain('Extra Info');
unmount();
});
it('should transition correctly between states', async () => {
let setStateExternally:
| React.Dispatch<
React.SetStateAction<{
state: StreamingState;
phrase?: string;
elapsedTime: number;
}>
>
| undefined;
const TestWrapper = () => {
const [config, setConfig] = React.useState<{
state: StreamingState;
phrase?: string;
elapsedTime: number;
}>({
state: StreamingState.Idle,
phrase: undefined,
elapsedTime: 5,
});
setStateExternally = setConfig;
return (
<StreamingContext.Provider value={config.state}>
<LoadingIndicator
currentLoadingPhrase={config.phrase}
elapsedTime={config.elapsedTime}
/>
</StreamingContext.Provider>
);
};
const { lastFrame, unmount, waitUntilReady } = await renderWithProviders(
<TestWrapper />,
);
await waitUntilReady();
expect(lastFrame({ allowEmpty: true })?.trim()).toBe(''); // Initial: Idle (no loading phrase)
// Transition to Responding
await act(async () => {
setStateExternally?.({
state: StreamingState.Responding,
phrase: 'Now Responding',
elapsedTime: 2,
});
});
await waitUntilReady();
let output = lastFrame();
expect(output).toContain('MockRespondingSpinner');
expect(output).toContain('Now Responding');
expect(output).toContain('(esc to cancel, 2s)');
// Transition to WaitingForConfirmation
await act(async () => {
setStateExternally?.({
state: StreamingState.WaitingForConfirmation,
phrase: 'Please Confirm',
elapsedTime: 15,
});
});
await waitUntilReady();
output = lastFrame();
expect(output).toContain('⠏');
expect(output).toContain('Please Confirm');
expect(output).not.toContain('(esc to cancel)');
expect(output).not.toContain(', 15s');
// Transition back to Idle
await act(async () => {
setStateExternally?.({
state: StreamingState.Idle,
phrase: undefined,
elapsedTime: 5,
});
});
await waitUntilReady();
expect(lastFrame({ allowEmpty: true })?.trim()).toBe(''); // Idle with no loading phrase and no spinner
unmount();
});
it('should display fallback phrase if thought is empty', async () => {
const props = {
thought: null,
currentLoadingPhrase: 'Thinking...',
elapsedTime: 5,
};
const { lastFrame, unmount, waitUntilReady } = await renderWithContext(
<LoadingIndicator {...props} />,
StreamingState.Responding,
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('Thinking...');
unmount();
});
it('should display the subject of a thought', async () => {
const props = {
thought: {
subject: 'Thinking about something...',
description: 'and other stuff.',
},
elapsedTime: 5,
};
const { lastFrame, unmount, waitUntilReady } = await renderWithContext(
<LoadingIndicator {...props} />,
StreamingState.Responding,
);
await waitUntilReady();
const output = lastFrame();
expect(output).toBeDefined();
if (output) {
// Should NOT contain "Thinking... " prefix because the subject already starts with "Thinking"
expect(output).not.toContain('Thinking... Thinking');
expect(output).toContain('Thinking about something...');
expect(output).not.toContain('and other stuff.');
}
unmount();
});
it('should NOT prepend "Thinking... " even if the subject does not start with "Thinking"', async () => {
const props = {
thought: {
subject: 'Planning the response...',
description: 'details',
},
elapsedTime: 5,
};
const { lastFrame, unmount, waitUntilReady } = await renderWithContext(
<LoadingIndicator {...props} />,
StreamingState.Responding,
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('Planning the response...');
expect(output).not.toContain('Thinking... ');
unmount();
});
it('should prioritize thought.subject over currentLoadingPhrase', async () => {
const props = {
thought: {
subject: 'This should be displayed',
description: 'A description',
},
currentLoadingPhrase: 'This should not be displayed',
elapsedTime: 5,
};
const { lastFrame, unmount, waitUntilReady } = await renderWithContext(
<LoadingIndicator {...props} />,
StreamingState.Responding,
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('This should be displayed');
expect(output).not.toContain('This should not be displayed');
unmount();
});
it('should not display thought indicator for non-thought loading phrases', async () => {
const { lastFrame, unmount, waitUntilReady } = await renderWithContext(
<LoadingIndicator
currentLoadingPhrase="some random tip..."
elapsedTime={3}
/>,
StreamingState.Responding,
);
await waitUntilReady();
expect(lastFrame()).not.toContain('Thinking... ');
unmount();
});
it('should truncate long primary text instead of wrapping', async () => {
const { lastFrame, unmount, waitUntilReady } = await renderWithContext(
<LoadingIndicator
{...defaultProps}
currentLoadingPhrase={
'This is an extremely long loading phrase that should be truncated in the UI to keep the primary line concise.'
}
/>,
StreamingState.Responding,
80,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
unmount();
});
describe('responsive layout', () => {
it('should render on a single line on a wide terminal', async () => {
const { lastFrame, unmount, waitUntilReady } = await renderWithContext(
<LoadingIndicator
{...defaultProps}
rightContent={<Text>Right</Text>}
/>,
StreamingState.Responding,
120,
);
await waitUntilReady();
const output = lastFrame();
// Check for single line output
expect(output?.trim().includes('\n')).toBe(false);
expect(output).toContain('Thinking...');
expect(output).toContain('(esc to cancel, 5s)');
expect(output).toContain('Right');
unmount();
});
it('should render on multiple lines on a narrow terminal', async () => {
const { lastFrame, unmount, waitUntilReady } = await renderWithContext(
<LoadingIndicator
{...defaultProps}
rightContent={<Text>Right</Text>}
/>,
StreamingState.Responding,
79,
);
await waitUntilReady();
const output = lastFrame();
const lines = output?.trim().split('\n');
// Expecting 3 lines:
// 1. Spinner + Primary Text
// 2. Cancel + Timer
// 3. Right Content
expect(lines).toHaveLength(3);
if (lines) {
expect(lines[0]).toContain('Thinking...');
expect(lines[0]).not.toContain('(esc to cancel, 5s)');
expect(lines[1]).toContain('(esc to cancel, 5s)');
expect(lines[2]).toContain('Right');
}
unmount();
});
it('should use wide layout at 80 columns', async () => {
const { lastFrame, unmount, waitUntilReady } = await renderWithContext(
<LoadingIndicator {...defaultProps} />,
StreamingState.Responding,
80,
);
await waitUntilReady();
expect(lastFrame()?.trim().includes('\n')).toBe(false);
unmount();
});
it('should use narrow layout at 79 columns', async () => {
const { lastFrame, unmount, waitUntilReady } = await renderWithContext(
<LoadingIndicator {...defaultProps} />,
StreamingState.Responding,
79,
);
await waitUntilReady();
expect(lastFrame()?.includes('\n')).toBe(true);
unmount();
});
it('should render witty phrase after cancel and timer hint in wide layout', async () => {
const { lastFrame, unmount, waitUntilReady } = await renderWithContext(
<LoadingIndicator
elapsedTime={5}
wittyPhrase="I am witty"
showWit={true}
currentLoadingPhrase="Thinking..."
/>,
StreamingState.Responding,
120,
);
await waitUntilReady();
const output = lastFrame();
// Sequence should be: Primary Text -> Cancel/Timer -> Witty Phrase
expect(output).toContain('Thinking... (esc to cancel, 5s) I am witty');
unmount();
});
it('should render witty phrase after cancel and timer hint in narrow layout', async () => {
const { lastFrame, unmount, waitUntilReady } = await renderWithContext(
<LoadingIndicator
elapsedTime={5}
wittyPhrase="I am witty"
showWit={true}
currentLoadingPhrase="Thinking..."
/>,
StreamingState.Responding,
79,
);
await waitUntilReady();
const output = lastFrame();
const lines = output?.trim().split('\n');
// Expecting 3 lines:
// 1. Spinner + Primary Text
// 2. Cancel + Timer
// 3. Witty Phrase
expect(lines).toHaveLength(3);
if (lines) {
expect(lines[0]).toContain('Thinking...');
expect(lines[1]).toContain('(esc to cancel, 5s)');
expect(lines[2]).toContain('I am witty');
}
unmount();
});
});
it('should use spinnerIcon when provided', async () => {
const props = {
currentLoadingPhrase: 'Confirm action',
elapsedTime: 10,
spinnerIcon: '?',
};
const { lastFrame, waitUntilReady, unmount } = await renderWithContext(
<LoadingIndicator {...props} />,
StreamingState.WaitingForConfirmation,
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('?');
expect(output).not.toContain('⠏');
unmount();
});
});
@@ -1,183 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type { ThoughtSummary } from '@google/gemini-cli-core';
import type React from 'react';
import { Box, Text } from 'ink';
import { theme } from '../semantic-colors.js';
import { useStreamingContext } from '../contexts/StreamingContext.js';
import { StreamingState } from '../types.js';
import { GeminiRespondingSpinner } from './GeminiRespondingSpinner.js';
import { formatDuration } from '../utils/formatters.js';
import { useTerminalSize } from '../hooks/useTerminalSize.js';
import { isNarrowWidth } from '../utils/isNarrowWidth.js';
import { INTERACTIVE_SHELL_WAITING_PHRASE } from '../hooks/usePhraseCycler.js';
interface LoadingIndicatorProps {
currentLoadingPhrase?: string;
wittyPhrase?: string;
showWit?: boolean;
showTips?: boolean;
errorVerbosity?: 'low' | 'full';
elapsedTime: number;
inline?: boolean;
rightContent?: React.ReactNode;
thought?: ThoughtSummary | null;
thoughtLabel?: string;
showCancelAndTimer?: boolean;
forceRealStatusOnly?: boolean;
spinnerIcon?: string;
isHookActive?: boolean;
}
export const LoadingIndicator: React.FC<LoadingIndicatorProps> = ({
currentLoadingPhrase,
wittyPhrase,
showWit = false,
elapsedTime,
inline = false,
rightContent,
thought,
thoughtLabel,
showCancelAndTimer = true,
forceRealStatusOnly = false,
spinnerIcon,
isHookActive = false,
}) => {
const streamingState = useStreamingContext();
const { columns: terminalWidth } = useTerminalSize();
const isNarrow = isNarrowWidth(terminalWidth);
if (
streamingState === StreamingState.Idle &&
!currentLoadingPhrase &&
!thought
) {
return null;
}
// Prioritize the interactive shell waiting phrase over the thought subject
// because it conveys an actionable state for the user (waiting for input).
const primaryText =
currentLoadingPhrase === INTERACTIVE_SHELL_WAITING_PHRASE
? currentLoadingPhrase
: thought?.subject
? (thoughtLabel ?? thought.subject)
: currentLoadingPhrase ||
(streamingState === StreamingState.Responding
? 'Thinking...'
: undefined);
const cancelAndTimerContent =
showCancelAndTimer &&
streamingState !== StreamingState.WaitingForConfirmation
? `(esc to cancel, ${elapsedTime < 60 ? `${elapsedTime}s` : formatDuration(elapsedTime * 1000)})`
: null;
const wittyPhraseNode =
!forceRealStatusOnly &&
showWit &&
wittyPhrase &&
primaryText === 'Thinking...' ? (
<Box marginLeft={1}>
<Text color={theme.text.secondary} dimColor italic>
{wittyPhrase}
</Text>
</Box>
) : null;
if (inline) {
return (
<Box>
<Box marginRight={1}>
<GeminiRespondingSpinner
nonRespondingDisplay={
spinnerIcon ??
(streamingState === StreamingState.WaitingForConfirmation
? '⠏'
: '')
}
isHookActive={isHookActive}
/>
</Box>
{primaryText && (
<Box flexShrink={1}>
<Text color={theme.text.primary} italic wrap="truncate-end">
{primaryText}
</Text>
{primaryText === INTERACTIVE_SHELL_WAITING_PHRASE && (
<Text color={theme.ui.active} italic>
{' '}
(press tab to focus)
</Text>
)}
</Box>
)}
{cancelAndTimerContent && (
<>
<Box flexShrink={0} width={1} />
<Text color={theme.text.secondary}>{cancelAndTimerContent}</Text>
</>
)}
{wittyPhraseNode}
</Box>
);
}
return (
<Box paddingLeft={0} flexDirection="column">
{/* Main loading line */}
<Box
width="100%"
flexDirection={isNarrow ? 'column' : 'row'}
alignItems={isNarrow ? 'flex-start' : 'center'}
>
<Box>
<Box marginRight={1}>
<GeminiRespondingSpinner
nonRespondingDisplay={
spinnerIcon ??
(streamingState === StreamingState.WaitingForConfirmation
? '⠏'
: '')
}
isHookActive={isHookActive}
/>
</Box>
{primaryText && (
<Box flexShrink={1}>
<Text color={theme.text.primary} italic wrap="truncate-end">
{primaryText}
</Text>
{primaryText === INTERACTIVE_SHELL_WAITING_PHRASE && (
<Text color={theme.ui.active} italic>
{' '}
(press tab to focus)
</Text>
)}
</Box>
)}
{!isNarrow && cancelAndTimerContent && (
<>
<Box flexShrink={0} width={1} />
<Text color={theme.text.secondary}>{cancelAndTimerContent}</Text>
</>
)}
{!isNarrow && wittyPhraseNode}
</Box>
{!isNarrow && <Box flexGrow={1}>{/* Spacer */}</Box>}
{!isNarrow && rightContent && <Box>{rightContent}</Box>}
</Box>
{isNarrow && cancelAndTimerContent && (
<Box>
<Text color={theme.text.secondary}>{cancelAndTimerContent}</Text>
</Box>
)}
{isNarrow && wittyPhraseNode}
{isNarrow && rightContent && <Box>{rightContent}</Box>}
</Box>
);
};
@@ -27,10 +27,6 @@ import {
} from '../hooks/useConfirmingTool.js';
// Mock dependencies
vi.mock('ink-spinner', () => ({
default: () => <Text></Text>,
}));
const mockUseSettings = vi.fn().mockReturnValue({
merged: {
ui: {
@@ -1,322 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { Box, Static } from 'ink';
import { HistoryItemDisplay } from './HistoryItemDisplay.js';
import { useUIState } from '../contexts/UIStateContext.js';
import { useSettings } from '../contexts/SettingsContext.js';
import { useAppContext } from '../contexts/AppContext.js';
import { AppHeader } from './AppHeader.js';
import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js';
import {
SCROLL_TO_ITEM_END,
type VirtualizedListRef,
} from './shared/VirtualizedList.js';
import { ScrollableList } from './shared/ScrollableList.js';
import { useMemo, memo, useCallback, useEffect, useRef } from 'react';
import { MAX_GEMINI_MESSAGE_LINES } from '../constants.js';
import { useConfirmingTool } from '../hooks/useConfirmingTool.js';
import { ToolConfirmationQueue } from './ToolConfirmationQueue.js';
import { isTopicTool } from './messages/TopicMessage.js';
const MemoizedHistoryItemDisplay = memo(HistoryItemDisplay);
const MemoizedAppHeader = memo(AppHeader);
// Limit Gemini messages to a very high number of lines to mitigate performance
// issues in the worst case if we somehow get an enormous response from Gemini.
// This threshold is arbitrary but should be high enough to never impact normal
// usage.
export const MainContent = () => {
const { version } = useAppContext();
const uiState = useUIState();
const isAlternateBuffer = useAlternateBuffer();
const confirmingTool = useConfirmingTool();
const showConfirmationQueue = confirmingTool !== null;
const confirmingToolCallId = confirmingTool?.tool.callId;
const scrollableListRef = useRef<VirtualizedListRef<unknown>>(null);
useEffect(() => {
if (showConfirmationQueue) {
scrollableListRef.current?.scrollToEnd();
}
}, [showConfirmationQueue, confirmingToolCallId]);
const {
pendingHistoryItems,
mainAreaWidth,
staticAreaMaxItemHeight,
availableTerminalHeight,
cleanUiDetailsVisible,
} = uiState;
const showHeaderDetails = cleanUiDetailsVisible;
const lastUserPromptIndex = useMemo(() => {
for (let i = uiState.history.length - 1; i >= 0; i--) {
const type = uiState.history[i].type;
if (type === 'user' || type === 'user_shell') {
return i;
}
}
return -1;
}, [uiState.history]);
const settings = useSettings();
const topicUpdateNarrationEnabled =
settings.merged.experimental?.topicUpdateNarration === true;
const suppressNarrationFlags = useMemo(() => {
const combinedHistory = [...uiState.history, ...pendingHistoryItems];
const flags = new Array<boolean>(combinedHistory.length).fill(false);
if (topicUpdateNarrationEnabled) {
let toolGroupInTurn = false;
for (let i = combinedHistory.length - 1; i >= 0; i--) {
const item = combinedHistory[i];
if (item.type === 'user' || item.type === 'user_shell') {
toolGroupInTurn = false;
} else if (item.type === 'tool_group') {
toolGroupInTurn = item.tools.some((t) => isTopicTool(t.name));
} else if (
(item.type === 'thinking' ||
item.type === 'gemini' ||
item.type === 'gemini_content') &&
toolGroupInTurn
) {
flags[i] = true;
}
}
}
return flags;
}, [uiState.history, pendingHistoryItems, topicUpdateNarrationEnabled]);
const augmentedHistory = useMemo(
() =>
uiState.history.map((item, i) => {
const prevType = i > 0 ? uiState.history[i - 1]?.type : undefined;
const isFirstThinking =
item.type === 'thinking' && prevType !== 'thinking';
const isFirstAfterThinking =
item.type !== 'thinking' && prevType === 'thinking';
return {
item,
isExpandable: i > lastUserPromptIndex,
isFirstThinking,
isFirstAfterThinking,
suppressNarration: suppressNarrationFlags[i] ?? false,
};
}),
[uiState.history, lastUserPromptIndex, suppressNarrationFlags],
);
const historyItems = useMemo(
() =>
augmentedHistory.map(
({
item,
isExpandable,
isFirstThinking,
isFirstAfterThinking,
suppressNarration,
}) => (
<MemoizedHistoryItemDisplay
terminalWidth={mainAreaWidth}
availableTerminalHeight={
uiState.constrainHeight || !isExpandable
? staticAreaMaxItemHeight
: undefined
}
availableTerminalHeightGemini={MAX_GEMINI_MESSAGE_LINES}
key={item.id}
item={item}
isPending={false}
commands={uiState.slashCommands}
isExpandable={isExpandable}
isFirstThinking={isFirstThinking}
isFirstAfterThinking={isFirstAfterThinking}
suppressNarration={suppressNarration}
/>
),
),
[
augmentedHistory,
mainAreaWidth,
staticAreaMaxItemHeight,
uiState.slashCommands,
uiState.constrainHeight,
],
);
const staticHistoryItems = useMemo(
() => historyItems.slice(0, lastUserPromptIndex + 1),
[historyItems, lastUserPromptIndex],
);
const lastResponseHistoryItems = useMemo(
() => historyItems.slice(lastUserPromptIndex + 1),
[historyItems, lastUserPromptIndex],
);
const pendingItems = useMemo(
() => (
<Box flexDirection="column" key="pending-items-group">
{pendingHistoryItems.map((item, i) => {
const prevType =
i === 0
? uiState.history.at(-1)?.type
: pendingHistoryItems[i - 1]?.type;
const isFirstThinking =
item.type === 'thinking' && prevType !== 'thinking';
const isFirstAfterThinking =
item.type !== 'thinking' && prevType === 'thinking';
const suppressNarration =
suppressNarrationFlags[uiState.history.length + i] ?? false;
return (
<HistoryItemDisplay
key={`pending-${i}`}
availableTerminalHeight={
uiState.constrainHeight ? availableTerminalHeight : undefined
}
terminalWidth={mainAreaWidth}
item={{ ...item, id: -(i + 1) }}
isPending={true}
isExpandable={true}
isFirstThinking={isFirstThinking}
isFirstAfterThinking={isFirstAfterThinking}
suppressNarration={suppressNarration}
/>
);
})}
{showConfirmationQueue && confirmingTool && (
<ToolConfirmationQueue
key="confirmation-queue"
confirmingTool={confirmingTool}
/>
)}
</Box>
),
[
pendingHistoryItems,
uiState.constrainHeight,
availableTerminalHeight,
mainAreaWidth,
showConfirmationQueue,
confirmingTool,
uiState.history,
suppressNarrationFlags,
],
);
const virtualizedData = useMemo(
() => [
{ type: 'header' as const },
...augmentedHistory.map(
({
item,
isExpandable,
isFirstThinking,
isFirstAfterThinking,
suppressNarration,
}) => ({
type: 'history' as const,
item,
isExpandable,
isFirstThinking,
isFirstAfterThinking,
suppressNarration,
}),
),
{ type: 'pending' as const },
],
[augmentedHistory],
);
const renderItem = useCallback(
({ item }: { item: (typeof virtualizedData)[number] }) => {
if (item.type === 'header') {
return (
<MemoizedAppHeader
key="app-header"
version={version}
showDetails={showHeaderDetails}
/>
);
} else if (item.type === 'history') {
return (
<MemoizedHistoryItemDisplay
terminalWidth={mainAreaWidth}
availableTerminalHeight={
uiState.constrainHeight || !item.isExpandable
? staticAreaMaxItemHeight
: undefined
}
availableTerminalHeightGemini={MAX_GEMINI_MESSAGE_LINES}
key={item.item.id}
item={item.item}
isPending={false}
commands={uiState.slashCommands}
isExpandable={item.isExpandable}
isFirstThinking={item.isFirstThinking}
isFirstAfterThinking={item.isFirstAfterThinking}
suppressNarration={item.suppressNarration}
/>
);
} else {
return pendingItems;
}
},
[
showHeaderDetails,
version,
mainAreaWidth,
uiState.slashCommands,
pendingItems,
uiState.constrainHeight,
staticAreaMaxItemHeight,
],
);
if (isAlternateBuffer) {
return (
<ScrollableList
ref={scrollableListRef}
hasFocus={!uiState.isEditorDialogOpen && !uiState.embeddedShellFocused}
width={uiState.terminalWidth}
data={virtualizedData}
renderItem={renderItem}
estimatedItemHeight={() => 100}
keyExtractor={(item, _index) => {
if (item.type === 'header') return 'header';
if (item.type === 'history') return item.item.id.toString();
return 'pending';
}}
initialScrollIndex={SCROLL_TO_ITEM_END}
initialScrollOffsetInIndex={SCROLL_TO_ITEM_END}
/>
);
}
return (
<>
<Static
key={uiState.historyRemountKey}
items={[
<AppHeader key="app-header" version={version} />,
...staticHistoryItems,
...lastResponseHistoryItems,
]}
>
{(item) => item}
</Static>
{pendingItems}
</>
);
};
@@ -1,44 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { useEffect, useState } from 'react';
import { Text, Box } from 'ink';
import { theme } from '../semantic-colors.js';
import process from 'node:process';
import { formatBytes } from '../utils/formatters.js';
export const MemoryUsageDisplay: React.FC<{
color?: string;
isActive?: boolean;
}> = ({ color = theme.text.primary, isActive = true }) => {
const [memoryUsage, setMemoryUsage] = useState<string>('');
const [memoryUsageColor, setMemoryUsageColor] = useState<string>(color);
useEffect(() => {
if (!isActive) {
return;
}
const updateMemory = () => {
const usage = process.memoryUsage().rss;
setMemoryUsage(formatBytes(usage));
setMemoryUsageColor(
usage >= 2 * 1024 * 1024 * 1024 ? theme.status.error : color,
);
};
const intervalId = setInterval(updateMemory, 2000);
updateMemory(); // Initial update
return () => clearInterval(intervalId);
}, [color, isActive]);
return (
<Box>
<Text color={memoryUsageColor}>{memoryUsage}</Text>
</Box>
);
};
@@ -21,6 +21,7 @@ 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';
@@ -55,6 +56,7 @@ 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;
@@ -65,6 +67,7 @@ describe('<ModelDialog />', () => {
getGemini31FlashLiteLaunchedSync: () => boolean;
getProModelNoAccess: () => Promise<boolean>;
getProModelNoAccessSync: () => boolean;
getUserTier: () => UserTierId | undefined;
}
const mockConfig: MockConfig = {
@@ -76,6 +79,7 @@ describe('<ModelDialog />', () => {
getGemini31FlashLiteLaunchedSync: mockGetGemini31FlashLiteLaunchedSync,
getProModelNoAccess: mockGetProModelNoAccess,
getProModelNoAccessSync: mockGetProModelNoAccessSync,
getUserTier: mockGetUserTier,
};
beforeEach(() => {
@@ -86,6 +90,7 @@ describe('<ModelDialog />', () => {
mockGetGemini31FlashLiteLaunchedSync.mockReturnValue(false);
mockGetProModelNoAccess.mockResolvedValue(false);
mockGetProModelNoAccessSync.mockReturnValue(false);
mockGetUserTier.mockReturnValue(UserTierId.STANDARD);
// Default implementation for getDisplayString
mockGetDisplayString.mockImplementation((val: string) => {
@@ -131,6 +136,7 @@ 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();
@@ -436,11 +442,34 @@ describe('<ModelDialog />', () => {
unmount();
});
it('shows Flash Lite Preview model regardless of tier when flag is enabled', async () => {
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 () => {
mockGetProModelNoAccessSync.mockReturnValue(false);
mockGetProModelNoAccess.mockResolvedValue(false);
mockGetHasAccessToPreviewModel.mockReturnValue(true);
mockGetGemini31FlashLiteLaunchedSync.mockReturnValue(true);
mockGetUserTier.mockReturnValue(UserTierId.FREE);
const { lastFrame, stdin, waitUntilReady, unmount } =
await renderComponent();
@@ -23,6 +23,7 @@ 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';
@@ -189,6 +190,7 @@ 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 &&
@@ -205,6 +207,9 @@ 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;
@@ -287,7 +292,7 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element {
},
];
if (useGemini31FlashLite) {
if (isFreeTier && useGemini31FlashLite) {
previewOptions.push({
value: PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL,
title: getDisplayString(PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL),
@@ -1,181 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { Box, Text, useIsScreenReaderEnabled } from 'ink';
import { useEffect, useState, useMemo, useRef, useCallback } from 'react';
import { useAppContext } from '../contexts/AppContext.js';
import { useUIState } from '../contexts/UIStateContext.js';
import { theme } from '../semantic-colors.js';
import { StreamingState } from '../types.js';
import { UpdateNotification } from './UpdateNotification.js';
import { persistentState } from '../../utils/persistentState.js';
import { useKeypress } from '../hooks/useKeypress.js';
import { KeypressPriority } from '../contexts/KeypressContext.js';
import {
GEMINI_DIR,
Storage,
homedir,
WarningPriority,
} from '@google/gemini-cli-core';
import * as fs from 'node:fs/promises';
import path from 'node:path';
const settingsPath = path.join(homedir(), GEMINI_DIR, 'settings.json');
const screenReaderNudgeFilePath = path.join(
Storage.getGlobalTempDir(),
'seen_screen_reader_nudge.json',
);
const MAX_STARTUP_WARNING_SHOW_COUNT = 3;
export const Notifications = () => {
const { startupWarnings } = useAppContext();
const { initError, streamingState, updateInfo } = useUIState();
const isScreenReaderEnabled = useIsScreenReaderEnabled();
const showInitError =
initError && streamingState !== StreamingState.Responding;
const [hasSeenScreenReaderNudge, setHasSeenScreenReaderNudge] = useState(() =>
persistentState.get('hasSeenScreenReaderNudge'),
);
const [dismissed, setDismissed] = useState(false);
// Track if we have already incremented the show count in this session
const hasIncrementedRef = useRef(false);
// Filter warnings based on persistent state count if low priority
const visibleWarnings = useMemo(() => {
if (dismissed) return [];
const counts = persistentState.get('startupWarningCounts') || {};
return startupWarnings.filter((w) => {
if (w.priority === WarningPriority.Low) {
const count = counts[w.id] || 0;
return count < MAX_STARTUP_WARNING_SHOW_COUNT;
}
return true;
});
}, [startupWarnings, dismissed]);
const showStartupWarnings = visibleWarnings.length > 0;
// Increment counts for low priority warnings when shown
useEffect(() => {
if (visibleWarnings.length > 0 && !hasIncrementedRef.current) {
const counts = { ...(persistentState.get('startupWarningCounts') || {}) };
let changed = false;
visibleWarnings.forEach((w) => {
if (w.priority === WarningPriority.Low) {
counts[w.id] = (counts[w.id] || 0) + 1;
changed = true;
}
});
if (changed) {
persistentState.set('startupWarningCounts', counts);
}
hasIncrementedRef.current = true;
}
}, [visibleWarnings]);
const handleKeyPress = useCallback(() => {
if (showStartupWarnings) {
setDismissed(true);
}
return false;
}, [showStartupWarnings]);
useKeypress(handleKeyPress, {
isActive: showStartupWarnings,
priority: KeypressPriority.Critical,
});
useEffect(() => {
const checkLegacyScreenReaderNudge = async () => {
if (hasSeenScreenReaderNudge !== undefined) return;
try {
await fs.access(screenReaderNudgeFilePath);
persistentState.set('hasSeenScreenReaderNudge', true);
setHasSeenScreenReaderNudge(true);
// Best effort cleanup of legacy file
await fs.unlink(screenReaderNudgeFilePath).catch(() => {});
} catch {
setHasSeenScreenReaderNudge(false);
}
};
if (isScreenReaderEnabled) {
// eslint-disable-next-line @typescript-eslint/no-floating-promises
checkLegacyScreenReaderNudge();
}
}, [isScreenReaderEnabled, hasSeenScreenReaderNudge]);
const showScreenReaderNudge =
isScreenReaderEnabled && hasSeenScreenReaderNudge === false;
useEffect(() => {
if (showScreenReaderNudge) {
persistentState.set('hasSeenScreenReaderNudge', true);
}
}, [showScreenReaderNudge]);
if (
!showStartupWarnings &&
!showInitError &&
!updateInfo &&
!showScreenReaderNudge
) {
return null;
}
return (
<>
{showScreenReaderNudge && (
<Text>
You are currently in screen reader-friendly view. To switch out, open{' '}
{settingsPath} and remove the entry for {'"screenReader"'}. This will
disappear on next run.
</Text>
)}
{updateInfo && <UpdateNotification message={updateInfo.message} />}
{showStartupWarnings && (
<Box marginY={1} flexDirection="column">
{visibleWarnings.map((warning, index) => (
<Box key={index} flexDirection="row">
<Box width={3}>
<Text color={theme.status.warning}> </Text>
</Box>
<Box flexGrow={1}>
<Text color={theme.status.warning}>{warning.message}</Text>
</Box>
</Box>
))}
</Box>
)}
{showInitError && (
<Box
borderStyle="round"
borderColor={theme.status.error}
paddingX={1}
marginBottom={1}
>
<Text color={theme.status.error}>
Initialization Error: {initError}
</Text>
<Text color={theme.status.error}>
{' '}
Please check API key and configuration.
</Text>
</Box>
)}
</>
);
};
@@ -1,37 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { Box } from 'ink';
import { useUIState } from '../contexts/UIStateContext.js';
import { HistoryItemDisplay } from './HistoryItemDisplay.js';
import { useTerminalSize } from '../hooks/useTerminalSize.js';
export const QuittingDisplay = () => {
const uiState = useUIState();
const { rows: terminalHeight, columns: terminalWidth } = useTerminalSize();
const availableTerminalHeight = terminalHeight;
if (!uiState.quittingMessages) {
return null;
}
return (
<Box flexDirection="column" marginBottom={1}>
{uiState.quittingMessages.map((item) => (
<HistoryItemDisplay
key={item.id}
availableTerminalHeight={
uiState.constrainHeight ? availableTerminalHeight : undefined
}
terminalWidth={terminalWidth}
item={item}
isPending={false}
/>
))}
</Box>
);
};
@@ -1,334 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { useMemo, useState } from 'react';
import { Box, Text, useIsScreenReaderEnabled } from 'ink';
import { useUIState } from '../contexts/UIStateContext.js';
import {
type ConversationRecord,
type MessageRecord,
partToString,
} from '@google/gemini-cli-core';
import { BaseSelectionList } from './shared/BaseSelectionList.js';
import { theme } from '../semantic-colors.js';
import { useKeypress } from '../hooks/useKeypress.js';
import { useRewind } from '../hooks/useRewind.js';
import { RewindConfirmation, RewindOutcome } from './RewindConfirmation.js';
import { stripReferenceContent } from '../utils/formatters.js';
import { Command } from '../key/keyMatchers.js';
import { CliSpinner } from './CliSpinner.js';
import { ExpandableText } from './shared/ExpandableText.js';
import { useKeyMatchers } from '../hooks/useKeyMatchers.js';
interface RewindViewerProps {
conversation: ConversationRecord;
onExit: () => void;
onRewind: (
messageId: string,
newText: string,
outcome: RewindOutcome,
) => Promise<void>;
}
const MAX_LINES_PER_BOX = 2;
const getCleanedRewindText = (userPrompt: MessageRecord): string => {
const contentToUse = userPrompt.displayContent || userPrompt.content;
const originalUserText = contentToUse ? partToString(contentToUse) : '';
return userPrompt.displayContent
? originalUserText
: stripReferenceContent(originalUserText);
};
export const RewindViewer: React.FC<RewindViewerProps> = ({
conversation,
onExit,
onRewind,
}) => {
const keyMatchers = useKeyMatchers();
const [isRewinding, setIsRewinding] = useState(false);
const { terminalWidth, terminalHeight } = useUIState();
const isScreenReaderEnabled = useIsScreenReaderEnabled();
const {
selectedMessageId,
getStats,
confirmationStats,
selectMessage,
clearSelection,
} = useRewind(conversation);
const [highlightedMessageId, setHighlightedMessageId] = useState<
string | null
>(null);
const [expandedMessageId, setExpandedMessageId] = useState<string | null>(
null,
);
const interactions = useMemo(
() => conversation.messages.filter((msg) => msg.type === 'user'),
[conversation.messages],
);
const items = useMemo(() => {
const interactionItems = interactions.map((msg, idx) => ({
key: `${msg.id || 'msg'}-${idx}`,
value: msg,
index: idx,
}));
// Add "Current Position" as the last item
return [
...interactionItems,
{
key: 'current-position',
value: {
id: 'current-position',
type: 'user',
content: 'Stay at current position',
timestamp: new Date().toISOString(),
} as MessageRecord,
index: interactionItems.length,
},
];
}, [interactions]);
useKeypress(
(key) => {
if (!selectedMessageId) {
if (keyMatchers[Command.ESCAPE](key)) {
onExit();
return true;
}
if (keyMatchers[Command.EXPAND_SUGGESTION](key)) {
if (
highlightedMessageId &&
highlightedMessageId !== 'current-position'
) {
setExpandedMessageId(highlightedMessageId);
return true;
}
}
if (keyMatchers[Command.COLLAPSE_SUGGESTION](key)) {
setExpandedMessageId(null);
return true;
}
}
return false;
},
{ isActive: true },
);
// Height constraint calculations
const DIALOG_PADDING = 2; // Top/bottom padding
const HEADER_HEIGHT = 2; // Title + margin
const CONTROLS_HEIGHT = 2; // Controls text + margin
const listHeight = Math.max(
5,
terminalHeight - DIALOG_PADDING - HEADER_HEIGHT - CONTROLS_HEIGHT - 2,
);
const maxItemsToShow = Math.max(1, Math.floor(listHeight / 4));
if (selectedMessageId) {
if (isRewinding) {
return (
<Box
borderStyle="round"
borderColor={theme.border.default}
padding={1}
width={terminalWidth}
flexDirection="row"
>
<Box>
<CliSpinner />
</Box>
<Text>Rewinding...</Text>
</Box>
);
}
if (selectedMessageId === 'current-position') {
onExit();
return null;
}
const selectedMessage = interactions.find(
(m) => m.id === selectedMessageId,
);
return (
<RewindConfirmation
stats={confirmationStats}
terminalWidth={terminalWidth}
timestamp={selectedMessage?.timestamp}
onConfirm={(outcome) => {
if (outcome === RewindOutcome.Cancel) {
clearSelection();
} else {
void (async () => {
const userPrompt = interactions.find(
(m) => m.id === selectedMessageId,
);
if (userPrompt) {
const cleanedText = getCleanedRewindText(userPrompt);
setIsRewinding(true);
await onRewind(selectedMessageId, cleanedText, outcome);
}
})();
}
}}
/>
);
}
if (isScreenReaderEnabled) {
return (
<Box flexDirection="column" width={terminalWidth}>
<Text bold>Rewind - Select a conversation point:</Text>
<BaseSelectionList
items={items}
initialIndex={items.length - 1}
isFocused={true}
showNumbers={true}
wrapAround={false}
onSelect={(item: MessageRecord) => {
if (item?.id) {
if (item.id === 'current-position') {
onExit();
} else {
selectMessage(item.id);
}
}
}}
renderItem={(itemWrapper) => {
const item = itemWrapper.value;
const text =
item.id === 'current-position'
? 'Stay at current position'
: getCleanedRewindText(item);
return <Text>{text}</Text>;
}}
/>
<Text color={theme.text.secondary}>
Press Esc to exit, Enter to select, arrow keys to navigate.
</Text>
</Box>
);
}
return (
<Box
borderStyle="round"
borderColor={theme.border.default}
flexDirection="column"
width={terminalWidth}
paddingX={1}
paddingY={1}
>
<Box marginBottom={1}>
<Text bold>{'> '}Rewind</Text>
</Box>
<Box flexDirection="column" flexGrow={1}>
<BaseSelectionList
items={items}
initialIndex={items.length - 1}
isFocused={true}
showNumbers={false}
wrapAround={false}
onSelect={(item: MessageRecord) => {
const userPrompt = item;
if (userPrompt && userPrompt.id) {
if (userPrompt.id === 'current-position') {
onExit();
} else {
selectMessage(userPrompt.id);
}
}
}}
onHighlight={(item: MessageRecord) => {
if (item.id) {
setHighlightedMessageId(item.id);
// Collapse when moving selection
setExpandedMessageId(null);
}
}}
maxItemsToShow={maxItemsToShow}
renderItem={(itemWrapper, { isSelected }) => {
const userPrompt = itemWrapper.value;
if (userPrompt.id === 'current-position') {
return (
<Box flexDirection="column" marginBottom={1}>
<Text
color={
isSelected ? theme.status.success : theme.text.primary
}
>
{partToString(
userPrompt.displayContent || userPrompt.content,
)}
</Text>
<Text color={theme.text.secondary}>
Cancel rewind and stay here
</Text>
</Box>
);
}
const stats = getStats(userPrompt);
const firstFileName = stats?.details?.at(0)?.fileName;
const cleanedText = getCleanedRewindText(userPrompt);
return (
<Box flexDirection="column" marginBottom={1}>
<Box>
<ExpandableText
label={cleanedText}
isExpanded={expandedMessageId === userPrompt.id}
textColor={
isSelected ? theme.status.success : theme.text.primary
}
maxWidth={(terminalWidth - 4) * MAX_LINES_PER_BOX}
maxLines={MAX_LINES_PER_BOX}
/>
</Box>
{stats ? (
<Box flexDirection="row">
<Text color={theme.text.secondary}>
{stats.fileCount === 1
? firstFileName
? firstFileName
: '1 file changed'
: `${stats.fileCount} files changed`}{' '}
</Text>
{stats.addedLines > 0 && (
<Text color="green">+{stats.addedLines} </Text>
)}
{stats.removedLines > 0 && (
<Text color="red">-{stats.removedLines}</Text>
)}
</Box>
) : (
<Text color={theme.text.secondary}>
No files have been changed
</Text>
)}
</Box>
);
}}
/>
</Box>
<Box marginTop={1}>
<Text color={theme.text.secondary}>
(Use Enter to select a message, Esc to close, Right/Left to
expand/collapse)
</Text>
</Box>
</Box>
);
};
@@ -1,96 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { Box, Text } from 'ink';
import { theme } from '../semantic-colors.js';
import { isNarrowWidth } from '../utils/isNarrowWidth.js';
import { SectionHeader } from './shared/SectionHeader.js';
import { useUIState } from '../contexts/UIStateContext.js';
import { Command } from '../key/keyBindings.js';
import { formatCommand } from '../key/keybindingUtils.js';
type ShortcutItem = {
key: string;
description: string;
};
const buildShortcutItems = (): ShortcutItem[] => [
{ key: '!', description: 'shell mode' },
{ key: '@', description: 'select file or folder' },
{ key: 'Double Esc', description: 'clear & rewind' },
{ key: formatCommand(Command.FOCUS_SHELL_INPUT), description: 'focus UI' },
{ key: formatCommand(Command.TOGGLE_YOLO), description: 'YOLO mode' },
{
key: formatCommand(Command.CYCLE_APPROVAL_MODE),
description: 'cycle mode',
},
{
key: formatCommand(Command.PASTE_CLIPBOARD),
description: 'paste images',
},
{
key: formatCommand(Command.TOGGLE_MARKDOWN),
description: 'raw markdown mode',
},
{
key: formatCommand(Command.REVERSE_SEARCH),
description: 'reverse-search history',
},
{
key: formatCommand(Command.OPEN_EXTERNAL_EDITOR),
description: 'open external editor',
},
];
const Shortcut: React.FC<{ item: ShortcutItem }> = ({ item }) => (
<Box flexDirection="row">
<Box flexShrink={0} marginRight={1}>
<Text color={theme.text.accent}>{item.key}</Text>
</Box>
<Box flexGrow={1}>
<Text color={theme.text.primary}>{item.description}</Text>
</Box>
</Box>
);
export const ShortcutsHelp: React.FC = () => {
const { terminalWidth } = useUIState();
const isNarrow = isNarrowWidth(terminalWidth);
const items = buildShortcutItems();
const itemsForDisplay = isNarrow
? items
: [
// Keep first column stable: !, @, Esc Esc, Tab Tab.
items[0],
items[5],
items[6],
items[1],
items[4],
items[7],
items[2],
items[8],
items[9],
items[3],
];
return (
<Box flexDirection="column" width="100%">
<SectionHeader title=" Shortcuts" subtitle=" See /help for more" />
<Box flexDirection="row" flexWrap="wrap" paddingLeft={1} paddingRight={2}>
{itemsForDisplay.map((item, index) => (
<Box
key={`${item.key}-${index}`}
width={isNarrow ? '100%' : '33%'}
paddingRight={isNarrow ? 0 : 2}
>
<Shortcut item={item} />
</Box>
))}
</Box>
</Box>
);
};
@@ -1,110 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { render } from '../../test-utils/render.js';
import { ShowMoreLines } from './ShowMoreLines.js';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { useOverflowState } from '../contexts/OverflowContext.js';
import { useStreamingContext } from '../contexts/StreamingContext.js';
import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js';
import { StreamingState } from '../types.js';
vi.mock('../contexts/OverflowContext.js');
vi.mock('../contexts/StreamingContext.js');
vi.mock('../hooks/useAlternateBuffer.js');
describe('ShowMoreLines', () => {
const mockUseOverflowState = vi.mocked(useOverflowState);
const mockUseStreamingContext = vi.mocked(useStreamingContext);
const mockUseAlternateBuffer = vi.mocked(useAlternateBuffer);
beforeEach(() => {
vi.clearAllMocks();
mockUseAlternateBuffer.mockReturnValue(false);
});
it.each([
[new Set(), StreamingState.Idle, true], // No overflow
[new Set(['1']), StreamingState.Idle, false], // Not constraining height
])(
'renders nothing when: overflow=%s, streaming=%s, constrain=%s',
async (overflowingIds, streamingState, constrainHeight) => {
mockUseOverflowState.mockReturnValue({ overflowingIds } as NonNullable<
ReturnType<typeof useOverflowState>
>);
mockUseStreamingContext.mockReturnValue(streamingState);
const { lastFrame, unmount } = await render(
<ShowMoreLines constrainHeight={constrainHeight} />,
);
expect(lastFrame({ allowEmpty: true })).toBe('');
unmount();
},
);
it('renders message in STANDARD mode when overflowing', async () => {
mockUseAlternateBuffer.mockReturnValue(false);
mockUseOverflowState.mockReturnValue({
overflowingIds: new Set(['1']),
} as NonNullable<ReturnType<typeof useOverflowState>>);
mockUseStreamingContext.mockReturnValue(StreamingState.Idle);
const { lastFrame, unmount } = await render(
<ShowMoreLines constrainHeight={true} />,
);
expect(lastFrame().toLowerCase()).toContain(
'press ctrl+o to show more lines',
);
unmount();
});
it.each([
[StreamingState.Idle],
[StreamingState.WaitingForConfirmation],
[StreamingState.Responding],
])(
'renders message in ASB mode when overflowing and state is %s',
async (streamingState) => {
mockUseAlternateBuffer.mockReturnValue(true);
mockUseOverflowState.mockReturnValue({
overflowingIds: new Set(['1']),
} as NonNullable<ReturnType<typeof useOverflowState>>);
mockUseStreamingContext.mockReturnValue(streamingState);
const { lastFrame, unmount } = await render(
<ShowMoreLines constrainHeight={true} />,
);
expect(lastFrame().toLowerCase()).toContain(
'press ctrl+o to show more lines',
);
unmount();
},
);
it('renders message in ASB mode when isOverflowing prop is true even if internal overflow state is empty', async () => {
mockUseAlternateBuffer.mockReturnValue(true);
mockUseOverflowState.mockReturnValue({
overflowingIds: new Set(),
} as NonNullable<ReturnType<typeof useOverflowState>>);
mockUseStreamingContext.mockReturnValue(StreamingState.Idle);
const { lastFrame, unmount } = await render(
<ShowMoreLines constrainHeight={true} isOverflowing={true} />,
);
expect(lastFrame().toLowerCase()).toContain(
'press ctrl+o to show more lines',
);
unmount();
});
it('renders nothing when isOverflowing prop is false even if internal overflow state has IDs', async () => {
mockUseOverflowState.mockReturnValue({
overflowingIds: new Set(['1']),
} as NonNullable<ReturnType<typeof useOverflowState>>);
mockUseStreamingContext.mockReturnValue(StreamingState.Idle);
const { lastFrame, unmount } = await render(
<ShowMoreLines constrainHeight={true} isOverflowing={false} />,
);
expect(lastFrame({ allowEmpty: true })).toBe('');
unmount();
});
});
@@ -1,48 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { Box, Text } from 'ink';
import { useOverflowState } from '../contexts/OverflowContext.js';
import { useStreamingContext } from '../contexts/StreamingContext.js';
import { StreamingState } from '../types.js';
import { theme } from '../semantic-colors.js';
interface ShowMoreLinesProps {
constrainHeight: boolean;
isOverflowing?: boolean;
}
export const ShowMoreLines = ({
constrainHeight,
isOverflowing: isOverflowingProp,
}: ShowMoreLinesProps) => {
const overflowState = useOverflowState();
const streamingState = useStreamingContext();
const isOverflowing =
isOverflowingProp ??
(overflowState !== undefined && overflowState.overflowingIds.size > 0);
if (
!isOverflowing ||
!constrainHeight ||
!(
streamingState === StreamingState.Idle ||
streamingState === StreamingState.WaitingForConfirmation ||
streamingState === StreamingState.Responding
)
) {
return null;
}
return (
<Box paddingX={1} marginBottom={1}>
<Text color={theme.text.accent} wrap="truncate">
Press Ctrl+O to show more lines
</Text>
</Box>
);
};
@@ -1,93 +0,0 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { Box, Text } from 'ink';
import { render } from '../../test-utils/render.js';
import { ShowMoreLines } from './ShowMoreLines.js';
import { useOverflowState } from '../contexts/OverflowContext.js';
import { useStreamingContext } from '../contexts/StreamingContext.js';
import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js';
import { StreamingState } from '../types.js';
vi.mock('../contexts/OverflowContext.js');
vi.mock('../contexts/StreamingContext.js');
vi.mock('../hooks/useAlternateBuffer.js');
describe('ShowMoreLines layout and padding', () => {
const mockUseOverflowState = vi.mocked(useOverflowState);
const mockUseStreamingContext = vi.mocked(useStreamingContext);
const mockUseAlternateBuffer = vi.mocked(useAlternateBuffer);
beforeEach(() => {
vi.clearAllMocks();
mockUseAlternateBuffer.mockReturnValue(true);
mockUseOverflowState.mockReturnValue({
overflowingIds: new Set(['1']),
} as NonNullable<ReturnType<typeof useOverflowState>>);
mockUseStreamingContext.mockReturnValue(StreamingState.Idle);
});
afterEach(() => {
vi.restoreAllMocks();
});
it('renders with single padding (paddingX=1, marginBottom=1)', async () => {
const TestComponent = () => (
<Box flexDirection="column">
<Text>Top</Text>
<ShowMoreLines constrainHeight={true} />
<Text>Bottom</Text>
</Box>
);
const { lastFrame, unmount } = await render(<TestComponent />);
// lastFrame() strips some formatting but keeps layout
const output = lastFrame({ allowEmpty: true });
// With paddingX=1, there should be a space before the text
// With marginBottom=1, there should be an empty line between the text and "Bottom"
// Since "Top" is just above it without margin, it should be on the previous line
const lines = output.split('\n');
expect(lines).toEqual([
'Top',
' Press Ctrl+O to show more lines',
'',
'Bottom',
'',
]);
unmount();
});
it('renders in Standard mode as well', async () => {
mockUseAlternateBuffer.mockReturnValue(false); // Standard mode
const TestComponent = () => (
<Box flexDirection="column">
<Text>Top</Text>
<ShowMoreLines constrainHeight={true} />
<Text>Bottom</Text>
</Box>
);
const { lastFrame, unmount } = await render(<TestComponent />);
const output = lastFrame({ allowEmpty: true });
const lines = output.split('\n');
expect(lines).toEqual([
'Top',
' Press Ctrl+O to show more lines',
'',
'Bottom',
'',
]);
unmount();
});
});
@@ -1,47 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { Text } from 'ink';
import { theme } from '../semantic-colors.js';
import { useUIState } from '../contexts/UIStateContext.js';
import { useSettings } from '../contexts/SettingsContext.js';
import { useConfig } from '../contexts/ConfigContext.js';
import { ContextSummaryDisplay } from './ContextSummaryDisplay.js';
export interface StatusDisplayProps {
hideContextSummary: boolean;
}
export const StatusDisplay: React.FC<StatusDisplayProps> = ({
hideContextSummary,
}) => {
const uiState = useUIState();
const settings = useSettings();
const config = useConfig();
if (process.env['GEMINI_SYSTEM_MD']) {
return <Text color={theme.status.error}>|_|</Text>;
}
if (!settings.merged.ui.hideContextSummary && !hideContextSummary) {
return (
<ContextSummaryDisplay
ideContext={uiState.ideContextState}
geminiMdFileCount={uiState.geminiMdFileCount}
contextFileNames={uiState.contextFileNames}
mcpServers={config.getMcpClientManager()?.getMcpServers() ?? {}}
blockedMcpServers={
config.getMcpClientManager()?.getBlockedMcpServers() ?? []
}
skillCount={config.getSkillManager().getDisplayableSkills().length}
backgroundProcessCount={uiState.backgroundTaskCount}
/>
);
}
return null;
};
@@ -1,437 +0,0 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { useCallback, useRef, useState } from 'react';
import { Box, Text, ResizeObserver, type DOMElement } from 'ink';
import {
isUserVisibleHook,
type ThoughtSummary,
} from '@google/gemini-cli-core';
import stripAnsi from 'strip-ansi';
import { type ActiveHook } from '../types.js';
import { useUIState } from '../contexts/UIStateContext.js';
import { useSettings } from '../contexts/SettingsContext.js';
import { theme } from '../semantic-colors.js';
import { GENERIC_WORKING_LABEL } from '../textConstants.js';
import { INTERACTIVE_SHELL_WAITING_PHRASE } from '../hooks/usePhraseCycler.js';
import { LoadingIndicator } from './LoadingIndicator.js';
import { StatusDisplay } from './StatusDisplay.js';
import { ContextUsageDisplay } from './ContextUsageDisplay.js';
import { HorizontalLine } from './shared/HorizontalLine.js';
import { ApprovalModeIndicator } from './ApprovalModeIndicator.js';
import { ShellModeIndicator } from './ShellModeIndicator.js';
import { RawMarkdownIndicator } from './RawMarkdownIndicator.js';
import { useComposerStatus } from '../hooks/useComposerStatus.js';
/**
* Layout constants to prevent magic numbers.
*/
const LAYOUT = {
STATUS_MIN_HEIGHT: 1,
TIP_LEFT_MARGIN: 2,
TIP_RIGHT_MARGIN_NARROW: 0,
TIP_RIGHT_MARGIN_WIDE: 1,
INDICATOR_LEFT_MARGIN: 1,
CONTEXT_DISPLAY_TOP_MARGIN_NARROW: 1,
CONTEXT_DISPLAY_LEFT_MARGIN_NARROW: 1,
CONTEXT_DISPLAY_LEFT_MARGIN_WIDE: 0,
COLLISION_GAP: 10,
};
interface StatusRowProps {
showUiDetails: boolean;
isNarrow: boolean;
terminalWidth: number;
hideContextSummary: boolean;
hideUiDetailsForSuggestions: boolean;
hasPendingActionRequired: boolean;
}
/**
* Renders the loading or hook execution status.
*/
export const StatusNode: React.FC<{
showTips: boolean;
showWit: boolean;
thought: ThoughtSummary | null;
elapsedTime: number;
currentWittyPhrase: string | undefined;
activeHooks: ActiveHook[];
showLoadingIndicator: boolean;
errorVerbosity: 'low' | 'full' | undefined;
onResize?: (width: number) => void;
}> = ({
showTips,
showWit,
thought,
elapsedTime,
currentWittyPhrase,
activeHooks,
showLoadingIndicator,
errorVerbosity,
onResize,
}) => {
const observerRef = useRef<ResizeObserver | null>(null);
const onRefChange = useCallback(
(node: DOMElement | null) => {
if (observerRef.current) {
observerRef.current.disconnect();
observerRef.current = null;
}
if (node && onResize) {
const observer = new ResizeObserver((entries) => {
const entry = entries[0];
if (entry) {
onResize(Math.round(entry.contentRect.width));
}
});
observer.observe(node);
observerRef.current = observer;
}
},
[onResize],
);
if (activeHooks.length === 0 && !showLoadingIndicator) return null;
let currentLoadingPhrase: string | undefined = undefined;
let currentThought: ThoughtSummary | null = null;
if (activeHooks.length > 0) {
const userVisibleHooks = activeHooks.filter((h) =>
isUserVisibleHook(h.source),
);
if (userVisibleHooks.length > 0) {
const label =
userVisibleHooks.length > 1 ? 'Executing Hooks' : 'Executing Hook';
const displayNames = userVisibleHooks.map((h) => {
let name = stripAnsi(h.name);
if (h.index && h.total && h.total > 1) {
name += ` (${h.index}/${h.total})`;
}
return name;
});
currentLoadingPhrase = `${label}: ${displayNames.join(', ')}`;
} else {
currentLoadingPhrase = GENERIC_WORKING_LABEL;
}
} else {
// Sanitize thought subject to prevent terminal injection
currentThought = thought
? { ...thought, subject: stripAnsi(thought.subject) }
: null;
}
return (
<Box ref={onRefChange}>
<LoadingIndicator
inline
showTips={showTips}
showWit={showWit}
errorVerbosity={errorVerbosity}
thought={currentThought}
currentLoadingPhrase={currentLoadingPhrase}
elapsedTime={elapsedTime}
forceRealStatusOnly={false}
wittyPhrase={currentWittyPhrase}
/>
</Box>
);
};
export const StatusRow: React.FC<StatusRowProps> = ({
showUiDetails,
isNarrow,
terminalWidth,
hideContextSummary,
hideUiDetailsForSuggestions,
hasPendingActionRequired,
}) => {
const uiState = useUIState();
const settings = useSettings();
const {
isInteractiveShellWaiting,
showLoadingIndicator,
showTips,
showWit,
modeContentObj,
showMinimalContext,
} = useComposerStatus();
const [statusWidth, setStatusWidth] = useState(0);
const [tipWidth, setTipWidth] = useState(0);
const tipObserverRef = useRef<ResizeObserver | null>(null);
const onTipRefChange = useCallback((node: DOMElement | null) => {
if (tipObserverRef.current) {
tipObserverRef.current.disconnect();
tipObserverRef.current = null;
}
if (node) {
const observer = new ResizeObserver((entries) => {
const entry = entries[0];
if (entry) {
const width = Math.round(entry.contentRect.width);
// Only update if width > 0 to prevent layout feedback loops
// when the tip is hidden. This ensures we always use the
// intrinsic width for collision detection.
if (width > 0) {
setTipWidth(width);
}
}
});
observer.observe(node);
tipObserverRef.current = observer;
}
}, []);
const tipContentStr = (() => {
// 1. Proactive Tip (Priority)
if (
showTips &&
uiState.currentTip &&
!(
isInteractiveShellWaiting &&
uiState.currentTip === INTERACTIVE_SHELL_WAITING_PHRASE
)
) {
return uiState.currentTip;
}
// 2. Shortcut Hint (Fallback)
if (
settings.merged.ui.showShortcutsHint &&
!hideUiDetailsForSuggestions &&
!hasPendingActionRequired &&
uiState.buffer.text.length === 0
) {
return showUiDetails ? '? for shortcuts' : 'press tab twice for more';
}
return undefined;
})();
// Collision detection using measured widths
const willCollideTip =
statusWidth + tipWidth + LAYOUT.COLLISION_GAP > terminalWidth;
const showTipLine = Boolean(
!hasPendingActionRequired && tipContentStr && !willCollideTip && !isNarrow,
);
const showRow1Minimal =
showLoadingIndicator || uiState.activeHooks.length > 0 || showTipLine;
const showRow2Minimal =
(Boolean(modeContentObj) && !hideUiDetailsForSuggestions) ||
showMinimalContext;
const showRow1 = showUiDetails || showRow1Minimal;
const showRow2 = showUiDetails || showRow2Minimal;
const onStatusResize = useCallback((width: number) => {
if (width > 0) setStatusWidth(width);
}, []);
const statusNode = (
<StatusNode
showTips={showTips}
showWit={showWit}
thought={uiState.thought}
elapsedTime={uiState.elapsedTime}
currentWittyPhrase={uiState.currentWittyPhrase}
activeHooks={uiState.activeHooks}
showLoadingIndicator={showLoadingIndicator}
errorVerbosity={
settings.merged.ui.errorVerbosity as 'low' | 'full' | undefined
}
onResize={onStatusResize}
/>
);
const renderTipNode = () => {
if (!tipContentStr) return null;
const isShortcutHint =
tipContentStr === '? for shortcuts' ||
tipContentStr === 'press tab twice for more';
const color =
isShortcutHint && uiState.shortcutsHelpVisible
? theme.text.accent
: theme.text.secondary;
return (
<Box flexDirection="row" justifyContent="flex-end" ref={onTipRefChange}>
<Text
color={color}
wrap="truncate-end"
italic={
!isShortcutHint && tipContentStr === uiState.currentWittyPhrase
}
>
{tipContentStr === uiState.currentTip
? `Tip: ${tipContentStr}`
: tipContentStr}
</Text>
</Box>
);
};
if (!showUiDetails && !showRow1Minimal && !showRow2Minimal) {
return <Box height={LAYOUT.STATUS_MIN_HEIGHT} />;
}
return (
<Box flexDirection="column" width="100%">
{/* Row 1: Status & Tips */}
{showRow1 && (
<Box
width="100%"
flexDirection="row"
alignItems="center"
justifyContent="space-between"
minHeight={LAYOUT.STATUS_MIN_HEIGHT}
>
<Box flexDirection="row" flexGrow={1} flexShrink={1}>
{!showUiDetails && showRow1Minimal ? (
<Box flexDirection="row" columnGap={1}>
{statusNode}
{!showUiDetails && showRow2Minimal && modeContentObj && (
<Box>
<Text color={modeContentObj.color}>
{modeContentObj.text}
</Text>
</Box>
)}
</Box>
) : isInteractiveShellWaiting ? (
<Box width="100%" marginLeft={LAYOUT.INDICATOR_LEFT_MARGIN}>
<Text color={theme.status.warning}>
! Shell awaiting input (Tab to focus)
</Text>
</Box>
) : (
<Box
flexDirection="row"
alignItems={isNarrow ? 'flex-start' : 'center'}
flexGrow={1}
flexShrink={0}
marginLeft={LAYOUT.INDICATOR_LEFT_MARGIN}
>
{statusNode}
</Box>
)}
</Box>
<Box
flexShrink={0}
marginLeft={showTipLine ? LAYOUT.TIP_LEFT_MARGIN : 0}
marginRight={
showTipLine
? isNarrow
? LAYOUT.TIP_RIGHT_MARGIN_NARROW
: LAYOUT.TIP_RIGHT_MARGIN_WIDE
: 0
}
position={showTipLine ? 'relative' : 'absolute'}
{...(showTipLine ? {} : { top: -100, left: -100 })}
>
{/*
We always render the tip node so it can be measured by ResizeObserver.
When hidden, we use absolute positioning so it can still be measured
but doesn't affect the layout of Row 1. This prevents layout loops.
*/}
{!isNarrow && tipContentStr && renderTipNode()}
</Box>
</Box>
)}
{/* Internal Separator */}
{showRow1 &&
showRow2 &&
(showUiDetails || (showRow1Minimal && showRow2Minimal)) && (
<Box width="100%">
<HorizontalLine dim />
</Box>
)}
{/* Row 2: Modes & Context */}
{showRow2 && (
<Box
width="100%"
flexDirection={isNarrow ? 'column' : 'row'}
alignItems={isNarrow ? 'flex-start' : 'center'}
justifyContent="space-between"
>
<Box
flexDirection="row"
alignItems="center"
marginLeft={LAYOUT.INDICATOR_LEFT_MARGIN}
>
{showUiDetails ? (
<>
{!hideUiDetailsForSuggestions && !uiState.shellModeActive && (
<ApprovalModeIndicator
approvalMode={uiState.showApprovalModeIndicator}
allowPlanMode={uiState.allowPlanMode}
/>
)}
{uiState.shellModeActive && (
<Box marginLeft={LAYOUT.INDICATOR_LEFT_MARGIN}>
<ShellModeIndicator />
</Box>
)}
{!uiState.renderMarkdown && (
<Box marginLeft={LAYOUT.INDICATOR_LEFT_MARGIN}>
<RawMarkdownIndicator />
</Box>
)}
</>
) : (
showRow2Minimal &&
modeContentObj && (
<Text color={modeContentObj.color}>
{modeContentObj.text}
</Text>
)
)}
</Box>
<Box
marginTop={isNarrow ? LAYOUT.CONTEXT_DISPLAY_TOP_MARGIN_NARROW : 0}
flexDirection="row"
alignItems="center"
marginLeft={
isNarrow
? LAYOUT.CONTEXT_DISPLAY_LEFT_MARGIN_NARROW
: LAYOUT.CONTEXT_DISPLAY_LEFT_MARGIN_WIDE
}
>
{(showUiDetails || showMinimalContext) && (
<StatusDisplay hideContextSummary={hideContextSummary} />
)}
{showMinimalContext && !showUiDetails && (
<Box marginLeft={LAYOUT.INDICATOR_LEFT_MARGIN}>
<ContextUsageDisplay
promptTokenCount={uiState.sessionStats.lastPromptTokenCount}
model={
typeof uiState.currentModel === 'string'
? uiState.currentModel
: undefined
}
terminalWidth={terminalWidth}
/>
</Box>
)}
</Box>
</Box>
)}
</Box>
);
};
@@ -1,398 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { useCallback, useState } from 'react';
import { Box, Text } from 'ink';
import { theme } from '../semantic-colors.js';
import { themeManager, DEFAULT_THEME } from '../themes/theme-manager.js';
import { pickDefaultThemeName, type Theme } from '../themes/theme.js';
import { RadioButtonSelect } from './shared/RadioButtonSelect.js';
import { DiffRenderer } from './messages/DiffRenderer.js';
import { colorizeCode } from '../utils/CodeColorizer.js';
import type {
LoadableSettingScope,
LoadedSettings,
} from '../../config/settings.js';
import { SettingScope } from '../../config/settings.js';
import { getScopeMessageForSetting } from '../../utils/dialogScopeUtils.js';
import { useKeypress } from '../hooks/useKeypress.js';
import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js';
import { ScopeSelector } from './shared/ScopeSelector.js';
import { useUIState } from '../contexts/UIStateContext.js';
import { ColorsDisplay } from './ColorsDisplay.js';
import { isDevelopment } from '../../utils/installationInfo.js';
interface ThemeDialogProps {
/** Callback function when a theme is selected */
onSelect: (
themeName: string,
scope: LoadableSettingScope,
) => void | Promise<void>;
/** Callback function when the dialog is cancelled */
onCancel: () => void;
/** Callback function when a theme is highlighted */
onHighlight: (themeName: string | undefined) => void;
/** The settings object */
settings: LoadedSettings;
availableTerminalHeight?: number;
terminalWidth: number;
}
import { resolveColor } from '../themes/color-utils.js';
function generateThemeItem(
name: string,
typeDisplay: string,
fullTheme: Theme | undefined,
terminalBackgroundColor: string | undefined,
) {
const isCompatible = fullTheme
? themeManager.isThemeCompatible(fullTheme, terminalBackgroundColor)
: true;
const themeBackground = fullTheme
? resolveColor(fullTheme.colors.Background)
: undefined;
const isBackgroundMatch =
terminalBackgroundColor &&
themeBackground &&
terminalBackgroundColor.toLowerCase() === themeBackground.toLowerCase();
return {
label: name,
value: name,
themeNameDisplay: name,
themeTypeDisplay: typeDisplay,
themeWarning: isCompatible ? '' : ' (Incompatible)',
themeMatch: isBackgroundMatch ? ' (Matches terminal)' : '',
key: name,
isCompatible,
};
}
export function ThemeDialog({
onSelect,
onCancel,
onHighlight,
settings,
availableTerminalHeight,
terminalWidth,
}: ThemeDialogProps): React.JSX.Element {
const isAlternateBuffer = useAlternateBuffer();
const { terminalBackgroundColor } = useUIState();
const [selectedScope, setSelectedScope] = useState<LoadableSettingScope>(
SettingScope.User,
);
// Track the currently highlighted theme name
const [highlightedThemeName, setHighlightedThemeName] = useState<string>(
() => {
// If a theme is already set, use it.
if (settings.merged.ui.theme) {
return settings.merged.ui.theme;
}
// Otherwise, try to pick a theme that matches the terminal background.
return pickDefaultThemeName(
terminalBackgroundColor,
themeManager.getAllThemes(),
DEFAULT_THEME.name,
'Default Light',
);
},
);
const capitalize = (s: string) => s.charAt(0).toUpperCase() + s.slice(1);
// Generate theme items
const themeItems = themeManager
.getAvailableThemes()
.map((theme) => {
const fullTheme = themeManager.getTheme(theme.name);
const capitalizedType = capitalize(theme.type);
const typeDisplay = theme.name.endsWith(capitalizedType)
? ''
: capitalizedType;
return generateThemeItem(
theme.name,
typeDisplay,
fullTheme,
terminalBackgroundColor,
);
})
.sort((a, b) => {
// Show compatible themes first
if (a.isCompatible && !b.isCompatible) return -1;
if (!a.isCompatible && b.isCompatible) return 1;
// Then sort by name
return a.label.localeCompare(b.label);
});
// Find the index of the selected theme, but only if it exists in the list
const initialThemeIndex = themeItems.findIndex(
(item) => item.value === highlightedThemeName,
);
// If not found, fall back to the first theme
const safeInitialThemeIndex = initialThemeIndex >= 0 ? initialThemeIndex : 0;
const handleThemeSelect = useCallback(
async (themeName: string) => {
await onSelect(themeName, selectedScope);
},
[onSelect, selectedScope],
);
const handleThemeHighlight = (themeName: string) => {
setHighlightedThemeName(themeName);
onHighlight(themeName);
};
const handleScopeHighlight = useCallback((scope: LoadableSettingScope) => {
setSelectedScope(scope);
}, []);
const handleScopeSelect = useCallback(
async (scope: LoadableSettingScope) => {
await onSelect(highlightedThemeName, scope);
},
[onSelect, highlightedThemeName],
);
const [mode, setMode] = useState<'theme' | 'scope'>('theme');
useKeypress(
(key) => {
if (key.name === 'tab') {
setMode((prev) => (prev === 'theme' ? 'scope' : 'theme'));
return true;
}
if (key.name === 'escape') {
onCancel();
return true;
}
return false;
},
{ isActive: true },
);
// Generate scope message for theme setting
const otherScopeModifiedMessage = getScopeMessageForSetting(
'ui.theme',
selectedScope,
settings,
);
// Constants for calculating preview pane layout.
// These values are based on the JSX structure below.
const PREVIEW_PANE_WIDTH_PERCENTAGE = 0.55;
// A safety margin to prevent text from touching the border.
// This is a complete hack unrelated to the 0.9 used in App.tsx
const PREVIEW_PANE_WIDTH_SAFETY_MARGIN = 0.9;
// Combined horizontal padding from the dialog and preview pane.
const TOTAL_HORIZONTAL_PADDING = 4;
const colorizeCodeWidth = Math.max(
Math.floor(
(terminalWidth - TOTAL_HORIZONTAL_PADDING) *
PREVIEW_PANE_WIDTH_PERCENTAGE *
PREVIEW_PANE_WIDTH_SAFETY_MARGIN,
),
1,
);
const DIALOG_PADDING = 2;
const selectThemeHeight = themeItems.length + 1;
const TAB_TO_SELECT_HEIGHT = 2;
availableTerminalHeight = availableTerminalHeight ?? Number.MAX_SAFE_INTEGER;
availableTerminalHeight -= 2; // Top and bottom borders.
availableTerminalHeight -= TAB_TO_SELECT_HEIGHT;
let totalLeftHandSideHeight = DIALOG_PADDING + selectThemeHeight;
let includePadding = true;
// Remove content from the LHS that can be omitted if it exceeds the available height.
if (totalLeftHandSideHeight > availableTerminalHeight) {
includePadding = false;
totalLeftHandSideHeight -= DIALOG_PADDING;
}
// Vertical space taken by elements other than the two code blocks in the preview pane.
// Includes "Preview" title, borders, and margin between blocks.
const PREVIEW_PANE_FIXED_VERTICAL_SPACE = 8;
// The right column doesn't need to ever be shorter than the left column.
availableTerminalHeight = Math.max(
availableTerminalHeight,
totalLeftHandSideHeight,
);
const availableTerminalHeightCodeBlock =
availableTerminalHeight -
PREVIEW_PANE_FIXED_VERTICAL_SPACE -
(includePadding ? 2 : 0) * 2;
// Subtract margin between code blocks from available height.
const availableHeightForPanes = Math.max(
0,
availableTerminalHeightCodeBlock - 1,
);
// The code block is slightly longer than the diff, so give it more space.
const codeBlockHeight = Math.ceil(availableHeightForPanes * 0.6);
const diffHeight = Math.floor(availableHeightForPanes * 0.4);
const previewTheme =
themeManager.getTheme(highlightedThemeName || DEFAULT_THEME.name) ||
DEFAULT_THEME;
return (
<Box
borderStyle="round"
borderColor={theme.border.default}
flexDirection="column"
paddingTop={includePadding ? 1 : 0}
paddingBottom={includePadding ? 1 : 0}
paddingLeft={1}
paddingRight={1}
width="100%"
>
{mode === 'theme' ? (
<Box flexDirection="row">
{/* Left Column: Selection */}
<Box flexDirection="column" width="45%" paddingRight={2}>
<Text bold={mode === 'theme'} wrap="truncate">
{mode === 'theme' ? '> ' : ' '}Select Theme{' '}
<Text color={theme.text.secondary}>
{otherScopeModifiedMessage}
</Text>
</Text>
<RadioButtonSelect
items={themeItems}
initialIndex={safeInitialThemeIndex}
onSelect={handleThemeSelect}
onHighlight={handleThemeHighlight}
isFocused={mode === 'theme'}
maxItemsToShow={12}
showScrollArrows={true}
showNumbers={mode === 'theme'}
renderItem={(item, { titleColor }) => {
// We know item has themeWarning because we put it there, but we need to cast or access safely
const itemWithExtras = item as typeof item & {
themeWarning?: string;
themeMatch?: string;
};
if (item.themeNameDisplay && item.themeTypeDisplay) {
const match = item.themeNameDisplay.match(/^(.*) \((.*)\)$/);
let themeNamePart: React.ReactNode = item.themeNameDisplay;
if (match) {
themeNamePart = (
<>
{match[1]}{' '}
<Text color={theme.text.secondary}>({match[2]})</Text>
</>
);
}
return (
<Text color={titleColor} wrap="truncate" key={item.key}>
{themeNamePart}{' '}
<Text color={theme.text.secondary}>
{item.themeTypeDisplay}
</Text>
{itemWithExtras.themeMatch && (
<Text color={theme.status.success}>
{itemWithExtras.themeMatch}
</Text>
)}
{itemWithExtras.themeWarning && (
<Text color={theme.status.warning}>
{itemWithExtras.themeWarning}
</Text>
)}
</Text>
);
}
// Regular label display
return (
<Text color={titleColor} wrap="truncate">
{item.label}
</Text>
);
}}
/>
</Box>
{/* Right Column: Preview */}
<Box flexDirection="column" width="55%" paddingLeft={2}>
<Text bold color={theme.text.primary}>
Preview
</Text>
<Box
borderStyle="single"
borderColor={theme.border.default}
paddingTop={includePadding ? 1 : 0}
paddingBottom={includePadding ? 1 : 0}
paddingLeft={1}
paddingRight={1}
flexDirection="column"
>
{colorizeCode({
code: `# function
def fibonacci(n):
a, b = 0, 1
for _ in range(n):
a, b = b, a + b
return a`,
language: 'python',
availableHeight:
isAlternateBuffer === false ? codeBlockHeight : undefined,
maxWidth: colorizeCodeWidth,
settings,
})}
<Box marginTop={1} />
<DiffRenderer
diffContent={`--- a/util.py
+++ b/util.py
@@ -1,2 +1,2 @@
- print("Hello, " + name)
+ print(f"Hello, {name}!")
`}
availableTerminalHeight={
isAlternateBuffer === false ? diffHeight : undefined
}
terminalWidth={colorizeCodeWidth}
theme={previewTheme}
/>
</Box>
{isDevelopment && (
<Box marginTop={1}>
<ColorsDisplay activeTheme={previewTheme} />
</Box>
)}
</Box>
</Box>
) : (
<ScopeSelector
onSelect={handleScopeSelect}
onHighlight={handleScopeHighlight}
isFocused={mode === 'scope'}
initialScope={selectedScope}
/>
)}
<Box marginTop={1}>
<Text color={theme.text.secondary} wrap="truncate">
(Use Enter to {mode === 'theme' ? 'select' : 'apply scope'}, Tab to{' '}
{mode === 'theme' ? 'configure scope' : 'select theme'}, Esc to close)
</Text>
</Box>
</Box>
);
}
@@ -1,87 +0,0 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { Text } from 'ink';
import { theme } from '../semantic-colors.js';
import { useUIState, type UIState } from '../contexts/UIStateContext.js';
import { TransientMessageType } from '../../utils/events.js';
export function shouldShowToast(uiState: UIState): boolean {
return (
uiState.ctrlCPressedOnce ||
Boolean(uiState.transientMessage) ||
uiState.ctrlDPressedOnce ||
(uiState.showEscapePrompt &&
(uiState.buffer.text.length > 0 || uiState.history.length > 0)) ||
Boolean(uiState.queueErrorMessage) ||
uiState.showIsExpandableHint
);
}
export const ToastDisplay: React.FC = () => {
const uiState = useUIState();
if (uiState.ctrlCPressedOnce) {
return (
<Text color={theme.status.warning}>Press Ctrl+C again to exit.</Text>
);
}
if (
uiState.transientMessage?.type === TransientMessageType.Warning &&
uiState.transientMessage.text
) {
return (
<Text color={theme.status.warning}>{uiState.transientMessage.text}</Text>
);
}
if (uiState.ctrlDPressedOnce) {
return (
<Text color={theme.status.warning}>Press Ctrl+D again to exit.</Text>
);
}
if (uiState.showEscapePrompt) {
const isPromptEmpty = uiState.buffer.text.length === 0;
const hasHistory = uiState.history.length > 0;
if (isPromptEmpty && !hasHistory) {
return null;
}
return (
<Text color={theme.text.secondary}>
Press Esc again to {isPromptEmpty ? 'rewind' : 'clear prompt'}.
</Text>
);
}
if (
uiState.transientMessage?.type === TransientMessageType.Hint &&
uiState.transientMessage.text
) {
return (
<Text color={theme.text.secondary}>{uiState.transientMessage.text}</Text>
);
}
if (uiState.queueErrorMessage) {
return <Text color={theme.status.error}>{uiState.queueErrorMessage}</Text>;
}
if (uiState.showIsExpandableHint) {
const action = uiState.constrainHeight ? 'show more' : 'collapse';
return (
<Text color={theme.text.secondary}>
Press Ctrl+O to {action} lines of the last response
</Text>
);
}
return null;
};
@@ -5,7 +5,7 @@
*/
import type React from 'react';
import { useMemo, useState, useEffect } from 'react';
import { useMemo } from 'react';
import { Box, Text } from 'ink';
import { theme } from '../semantic-colors.js';
import {
@@ -21,15 +21,12 @@ interface UserIdentityProps {
export const UserIdentity: React.FC<UserIdentityProps> = ({ config }) => {
const authType = config.getContentGeneratorConfig()?.authType;
const [email, setEmail] = useState<string | undefined>();
useEffect(() => {
const email = useMemo(() => {
if (authType) {
const userAccountManager = new UserAccountManager();
setEmail(userAccountManager.getCachedGoogleAccount() ?? undefined);
} else {
setEmail(undefined);
return userAccountManager.getCachedGoogleAccount() ?? undefined;
}
return undefined;
}, [authType]);
const tierName = useMemo(
@@ -43,12 +43,10 @@ Tips for getting started:
│ ✓ tool1 Description for tool 1 │
│ │
╰──────────────────────────────────────────────────────────────────────────╯
╭──────────────────────────────────────────────────────────────────────────╮
│ ✓ tool2 Description for tool 2 │
│ │
╰──────────────────────────────────────────────────────────────────────────╯
╭──────────────────────────────────────────────────────────────────────────╮
│ o tool3 Description for tool 3 │
│ │
@@ -95,7 +93,6 @@ 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="734" viewBox="0 0 920 734">
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="700" viewBox="0 0 920 700">
<style>
text { font-family: Consolas, "Courier New", monospace; font-size: 14px; dominant-baseline: text-before-edge; white-space: pre; }
</style>
<rect width="920" height="734" fill="#000000" />
<rect width="920" height="700" 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,92 +74,85 @@
<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>
<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>
<rect x="27" y="374" width="9" height="17" fill="#001a00" />
<text x="27" y="376" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">&gt;</text>
<rect x="36" y="374" width="9" height="17" fill="#001a00" />
<rect x="45" y="374" width="27" height="17" fill="#001a00" />
<text x="45" y="376" fill="#d7ffd7" textLength="27" lengthAdjust="spacingAndGlyphs">[✓]</text>
<rect x="72" y="374" width="117" height="17" fill="#001a00" />
<text x="72" y="376" fill="#00cd00" textLength="117" lengthAdjust="spacingAndGlyphs"> code-changes</text>
<rect x="189" y="374" width="684" height="17" fill="#001a00" />
<text x="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="252" lengthAdjust="spacingAndGlyphs"> Current authentication info</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="891" y="393" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="410" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<rect x="27" y="408" width="9" height="17" fill="#001a00" />
<text x="27" y="410" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">&gt;</text>
<rect x="36" y="408" width="9" height="17" fill="#001a00" />
<rect x="45" y="408" width="27" height="17" fill="#001a00" />
<text x="45" y="410" fill="#d7ffd7" textLength="27" lengthAdjust="spacingAndGlyphs">[✓]</text>
<rect x="72" y="408" width="117" height="17" fill="#001a00" />
<text x="72" y="410" fill="#00cd00" textLength="117" lengthAdjust="spacingAndGlyphs"> code-changes</text>
<rect x="189" y="408" width="684" height="17" fill="#001a00" />
<text x="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="891" y="410" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="427" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></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="45" y="427" fill="#afafaf" textLength="495" lengthAdjust="spacingAndGlyphs"> Total tokens used 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="#afafaf" textLength="27" lengthAdjust="spacingAndGlyphs">[ ]</text>
<text x="72" y="444" fill="#ffffff" textLength="108" lengthAdjust="spacingAndGlyphs"> token-count</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="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="#d7ffd7" textLength="27" lengthAdjust="spacingAndGlyphs">[✓]</text>
<text x="72" y="478" fill="#ffffff" textLength="171" lengthAdjust="spacingAndGlyphs"> Show footer labels</text>
<text x="45" y="478" fill="#ffffff" textLength="207" lengthAdjust="spacingAndGlyphs">Reset to default footer</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="#afafaf" textLength="585" lengthAdjust="spacingAndGlyphs">Enter to select · ↑/↓ to navigate · ←/→ to reorder · Esc to close</text>
<text x="27" y="563" fill="#333333" textLength="846" lengthAdjust="spacingAndGlyphs">┌────────────────────────────────────────────────────────────────────────────────────────────┐</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="846" 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="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="72" lengthAdjust="spacingAndGlyphs" font-weight="bold">Preview:</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="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="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="27" y="631" fill="#333333" textLength="846" 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="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>
<text x="0" y="665" fill="#333333" textLength="900" lengthAdjust="spacingAndGlyphs">╰──────────────────────────────────────────────────────────────────────────────────────────────────╯</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 16 KiB

@@ -1,8 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="734" viewBox="0 0 920 734">
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="700" viewBox="0 0 920 700">
<style>
text { font-family: Consolas, "Courier New", monospace; font-size: 14px; dominant-baseline: text-before-edge; white-space: pre; }
</style>
<rect width="920" height="734" fill="#000000" />
<rect width="920" height="700" 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,77 +84,70 @@
<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="45" lengthAdjust="spacingAndGlyphs"> auth</text>
<text x="72" y="376" fill="#ffffff" textLength="117" lengthAdjust="spacingAndGlyphs"> code-changes</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="252" lengthAdjust="spacingAndGlyphs"> Current authentication info</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="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="117" lengthAdjust="spacingAndGlyphs"> code-changes</text>
<text x="72" y="410" fill="#ffffff" textLength="108" lengthAdjust="spacingAndGlyphs"> token-count</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="513" lengthAdjust="spacingAndGlyphs"> Lines added/removed in the session (not shown when zero)</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="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="#afafaf" textLength="27" lengthAdjust="spacingAndGlyphs">[ ]</text>
<text x="72" y="444" fill="#ffffff" textLength="108" lengthAdjust="spacingAndGlyphs"> token-count</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="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="#d7ffd7" textLength="27" lengthAdjust="spacingAndGlyphs">[✓]</text>
<text x="72" y="478" fill="#ffffff" textLength="171" lengthAdjust="spacingAndGlyphs"> Show footer labels</text>
<text x="45" y="478" fill="#ffffff" textLength="207" lengthAdjust="spacingAndGlyphs">Reset to default footer</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="#afafaf" textLength="585" lengthAdjust="spacingAndGlyphs">Enter to select · ↑/↓ to navigate · ←/→ to reorder · Esc to close</text>
<text x="27" y="563" fill="#333333" textLength="846" lengthAdjust="spacingAndGlyphs">┌────────────────────────────────────────────────────────────────────────────────────────────┐</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="846" 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="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="72" lengthAdjust="spacingAndGlyphs" font-weight="bold">Preview:</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="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>
<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="27" y="631" fill="#333333" textLength="846" 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="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>
<text x="0" y="665" 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="717" viewBox="0 0 920 717">
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="683" viewBox="0 0 920 683">
<style>
text { font-family: Consolas, "Courier New", monospace; font-size: 14px; dominant-baseline: text-before-edge; white-space: pre; }
</style>
<rect width="920" height="717" fill="#000000" />
<rect width="920" height="683" 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,76 +75,69 @@
<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="45" lengthAdjust="spacingAndGlyphs"> auth</text>
<text x="72" y="376" fill="#ffffff" textLength="117" lengthAdjust="spacingAndGlyphs"> code-changes</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="252" lengthAdjust="spacingAndGlyphs"> Current authentication info</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="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="117" lengthAdjust="spacingAndGlyphs"> code-changes</text>
<text x="72" y="410" fill="#ffffff" textLength="108" lengthAdjust="spacingAndGlyphs"> token-count</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="513" lengthAdjust="spacingAndGlyphs"> Lines added/removed in the session (not shown when zero)</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="891" y="427" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="444" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<rect x="27" y="442" width="9" height="17" fill="#001a00" />
<text x="27" y="444" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">&gt;</text>
<rect x="36" y="442" width="9" height="17" fill="#001a00" />
<rect x="45" y="442" width="27" height="17" fill="#001a00" />
<text x="45" y="444" fill="#afafaf" textLength="27" lengthAdjust="spacingAndGlyphs">[ ]</text>
<text x="72" y="444" fill="#ffffff" textLength="108" lengthAdjust="spacingAndGlyphs"> token-count</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="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>
<rect x="27" y="459" width="846" height="17" fill="#001a00" />
<text x="891" y="461" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="478" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<rect x="27" y="476" width="9" height="17" fill="#001a00" />
<text x="27" y="478" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">&gt;</text>
<rect x="36" y="476" width="9" height="17" fill="#001a00" />
<rect x="45" y="476" width="27" height="17" fill="#001a00" />
<text x="45" y="478" fill="#afafaf" textLength="27" lengthAdjust="spacingAndGlyphs">[ ]</text>
<rect x="72" y="476" width="171" height="17" fill="#001a00" />
<text x="72" y="478" fill="#00cd00" textLength="171" lengthAdjust="spacingAndGlyphs"> Show footer labels</text>
<rect x="243" y="476" width="630" height="17" fill="#001a00" />
<text x="45" y="478" fill="#ffffff" textLength="207" lengthAdjust="spacingAndGlyphs">Reset to default footer</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>
<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="#afafaf" textLength="585" lengthAdjust="spacingAndGlyphs">Enter to select · ↑/↓ to navigate · ←/→ to reorder · Esc to close</text>
<text x="27" y="563" fill="#333333" textLength="846" lengthAdjust="spacingAndGlyphs">┌────────────────────────────────────────────────────────────────────────────────────────────┐</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="846" 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="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="72" lengthAdjust="spacingAndGlyphs" font-weight="bold">Preview:</text>
<text x="864" y="614" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="27" y="614" fill="#333333" textLength="846" 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="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>
<text x="0" y="648" fill="#333333" textLength="900" lengthAdjust="spacingAndGlyphs">╰──────────────────────────────────────────────────────────────────────────────────────────────────╯</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 15 KiB

@@ -23,8 +23,6 @@ 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 │
@@ -68,8 +66,6 @@ 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 │
@@ -114,8 +110,6 @@ 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 │
@@ -159,8 +153,6 @@ 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 │
@@ -1,19 +0,0 @@
<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="0" y="2" fill="#00cd00" textLength="900" lengthAdjust="spacingAndGlyphs">────────────────────────────────────────────────────────────────────────────────────────────────────</text>
<text x="0" y="19" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="19" fill="#d7afff" textLength="18" lengthAdjust="spacingAndGlyphs">&gt; </text>
<text x="36" y="19" fill="#ffffff" textLength="90" lengthAdjust="spacingAndGlyphs">first line</text>
<text x="891" y="19" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="36" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<rect x="36" y="34" width="9" height="17" fill="#ffffff" />
<text x="36" y="36" fill="#000000" textLength="9" lengthAdjust="spacingAndGlyphs">s</text>
<text x="45" y="36" fill="#ffffff" textLength="90" lengthAdjust="spacingAndGlyphs">econd line</text>
<text x="891" y="36" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="53" fill="#00cd00" textLength="900" lengthAdjust="spacingAndGlyphs">────────────────────────────────────────────────────────────────────────────────────────────────────</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.9 KiB

@@ -1,18 +0,0 @@
<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="0" y="2" fill="#00cd00" textLength="900" lengthAdjust="spacingAndGlyphs">────────────────────────────────────────────────────────────────────────────────────────────────────</text>
<text x="0" y="19" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="19" fill="#d7afff" textLength="18" lengthAdjust="spacingAndGlyphs">&gt; </text>
<text x="36" y="19" fill="#ffffff" textLength="90" lengthAdjust="spacingAndGlyphs">first line</text>
<rect x="126" y="17" width="9" height="17" fill="#ffffff" />
<text x="891" y="19" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="36" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="36" y="36" fill="#ffffff" textLength="99" lengthAdjust="spacingAndGlyphs">second line</text>
<text x="891" y="36" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="53" fill="#00cd00" textLength="900" lengthAdjust="spacingAndGlyphs">────────────────────────────────────────────────────────────────────────────────────────────────────</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.8 KiB

@@ -1,23 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="105" viewBox="0 0 920 105">
<style>
text { font-family: Consolas, "Courier New", monospace; font-size: 14px; dominant-baseline: text-before-edge; white-space: pre; }
</style>
<rect width="920" height="105" fill="#000000" />
<g transform="translate(10, 10)">
<text x="0" y="2" fill="#00cd00" textLength="900" lengthAdjust="spacingAndGlyphs">────────────────────────────────────────────────────────────────────────────────────────────────────</text>
<text x="0" y="19" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="19" fill="#d7afff" textLength="18" lengthAdjust="spacingAndGlyphs">&gt; </text>
<text x="36" y="19" fill="#ffffff" textLength="90" lengthAdjust="spacingAndGlyphs">first line</text>
<text x="891" y="19" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="36" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="36" y="36" fill="#ffffff" textLength="27" lengthAdjust="spacingAndGlyphs">sec</text>
<rect x="63" y="34" width="9" height="17" fill="#ffffff" />
<text x="63" y="36" fill="#000000" textLength="9" lengthAdjust="spacingAndGlyphs">o</text>
<text x="72" y="36" fill="#ffffff" textLength="63" lengthAdjust="spacingAndGlyphs">nd line</text>
<text x="891" y="36" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="53" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="36" y="53" fill="#ffffff" textLength="90" lengthAdjust="spacingAndGlyphs">third line</text>
<text x="891" y="53" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="70" fill="#00cd00" textLength="900" lengthAdjust="spacingAndGlyphs">────────────────────────────────────────────────────────────────────────────────────────────────────</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 2.3 KiB

@@ -1,20 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="105" viewBox="0 0 920 105">
<style>
text { font-family: Consolas, "Courier New", monospace; font-size: 14px; dominant-baseline: text-before-edge; white-space: pre; }
</style>
<rect width="920" height="105" fill="#000000" />
<g transform="translate(10, 10)">
<text x="0" y="2" fill="#00cd00" textLength="900" lengthAdjust="spacingAndGlyphs">────────────────────────────────────────────────────────────────────────────────────────────────────</text>
<text x="0" y="19" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="19" fill="#d7afff" textLength="18" lengthAdjust="spacingAndGlyphs">&gt; </text>
<text x="36" y="19" fill="#ffffff" textLength="90" lengthAdjust="spacingAndGlyphs">first line</text>
<text x="891" y="19" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="36" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<rect x="36" y="34" width="9" height="17" fill="#ffffff" />
<text x="891" y="36" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="53" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="36" y="53" fill="#ffffff" textLength="90" lengthAdjust="spacingAndGlyphs">third line</text>
<text x="891" y="53" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="70" fill="#00cd00" textLength="900" lengthAdjust="spacingAndGlyphs">────────────────────────────────────────────────────────────────────────────────────────────────────</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 2.0 KiB

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