Compare commits

..

49 Commits

Author SHA1 Message Date
Alisa 820df74040 Merge branch 'main' into make_memory_usual 2026-02-10 18:10:59 -08:00
Alisa Novikova 422c2a60ea Make tests usually pass till. 2026-02-10 18:08:37 -08:00
Abhijit Balaji b3ecac7086 fix(evals): prevent false positive in hierarchical memory test (#18777) 2026-02-11 01:51:05 +00:00
Brad Dux 6d3fff2ea4 fix(core): prevent race condition in policy persistence (#18506)
Co-authored-by: Allen Hutchison <adh@google.com>
2026-02-10 23:35:09 +00:00
Jacob Richman be2ebd1772 Add autoconfigure memory usage setting to the dialog (#18510)
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-02-10 23:24:08 +00:00
N. Taylor Mullen cb4e1e684d chore(core): update activate_skill prompt verbiage to be more direct (#18605) 2026-02-10 22:17:42 +00:00
Adam Weidman b7a3243334 chore: cleanup unused and add unlisted dependencies in packages/core (#18762) 2026-02-10 22:07:06 +00:00
gemini-cli-robot 8257ec447a chore(release): bump version to 0.30.0-nightly.20260210.a2174751d (#18772) 2026-02-10 17:13:00 -05:00
Shreya Keshive 9590a092ae Revert "fix(mcp): ensure MCP transport is closed to prevent memory leaks" (#18771) 2026-02-10 22:00:36 +00:00
Dev Randalpura 49533cd106 feat(ux): added text wrapping capabilities to markdown tables (#18240)
Co-authored-by: jacob314 <jacob314@gmail.com>
2026-02-10 21:12:53 +00:00
Kevin Ramdass a2174751de feat(telemetry): Ensure experiment IDs are included in OpenTelemetry logs (#18747) 2026-02-10 21:01:35 +00:00
Christian Gunderman 8b762111a8 Fix issue where Gemini CLI creates tests in a new file (#18409) 2026-02-10 20:53:29 +00:00
Adam Weidman c03d96b46c chore: cleanup unused and add unlisted dependencies in packages/cli (#18749) 2026-02-10 20:53:19 +00:00
Sehoon Shon ea1f19aa52 fix(cli): fix history navigation regression after prompt autocomplete (#18752) 2026-02-10 20:53:06 +00:00
Christian Gunderman 2eb1c92347 Fix issues with rip grep (#18756) 2026-02-10 20:48:56 +00:00
Andrew Garrett ef02cec2cd fix(cli): hide scrollbars when in alternate buffer copy mode (#18354)
Co-authored-by: Jacob Richman <jacob314@gmail.com>
2026-02-10 20:30:27 +00:00
Jacob Richman f9fc9335f5 Code review cleanup for thinking display (#18720) 2026-02-10 19:12:40 +00:00
Sehoon Shon 9813531f81 Update Gemini.md to use the curent year on creating new files (#18460) 2026-02-10 19:06:17 +00:00
N. Taylor Mullen 55571de066 feat: redact disabled tools from system prompt (#13597) (#18613) 2026-02-10 19:00:36 +00:00
Jack Wotherspoon 740f0e4c3d fix: allow ask_user tool in yolo mode (#18541) 2026-02-10 18:56:51 +00:00
N. Taylor Mullen b37e67451a ci: shard windows tests and fix event listener leaks (#18670) 2026-02-10 18:46:42 +00:00
Aishanee Shah 262138cad5 test: add model-specific snapshots for coreTools (#18707)
Co-authored-by: matt korwel <matt.korwel@gmail.com>
2026-02-10 18:41:01 +00:00
Keith Guerin 5920750c24 ui: update & subdue footer colors and animate progress indicator (#18570) 2026-02-10 17:36:20 +00:00
Sandy Tao f5b1245f51 feat(cli): defer devtools startup and integrate with F12 (#18695) 2026-02-10 16:54:23 +00:00
Sri Pasumarthi f2ca0bb38d feat: add extensionsExplore setting to enable extensions explore UI. (#18686) 2026-02-10 16:49:24 +00:00
Jack Wotherspoon 4e9bafdacd chore: consolidate to green in ask user dialog (#18734) 2026-02-10 16:01:06 +00:00
N. Taylor Mullen 41bbe6ca0a fix(core): standardize tool formatting in system prompts (#18615) 2026-02-10 15:30:08 +00:00
Shreya Keshive eb5492f9b5 fix(ide): fix ide nudge setting (#18733) 2026-02-10 15:29:22 +00:00
matt korwel 37f128a109 feat(routing): restrict numerical routing to Gemini 3 family (#18478) 2026-02-10 14:25:21 +00:00
Jack Wotherspoon 79753ec5ec fix: move toasts location to left side (#18705) 2026-02-10 13:39:28 +00:00
Adib234 e151b4890b fix(plan): update persistent approval mode setting (#18638)
Co-authored-by: Sandy Tao <sandytao520@icloud.com>
2026-02-10 13:07:04 +00:00
Jerop Kipruto e6b43cb846 feat(cli): update approval mode labels and shortcuts per latest UX spec (#18698) 2026-02-10 12:45:14 +00:00
N. Taylor Mullen 2ae5e1ae20 feat(core): optimize sub-agents system prompt intro (#18608) 2026-02-10 08:25:42 +00:00
N. Taylor Mullen 67d9b76e81 test(core): remove hardcoded model from TestRig (#18710) 2026-02-10 07:54:23 +00:00
N. Taylor Mullen 4a3c7ae8b7 docs(skills): enhance pr-creator safety and interactivity (#18616) 2026-02-10 05:38:10 +00:00
Christian Gunderman bd2744031f Fix layout rounding. (#18667) 2026-02-10 04:53:49 +00:00
N. Taylor Mullen 92a5f725a1 refactor(core): refine Security & System Integrity section in system prompt (#18601) 2026-02-10 04:32:36 +00:00
N. Taylor Mullen 4494f9e062 test(cli): fix AppContainer act() warnings and improve waitFor resilience (#18676) 2026-02-09 20:44:22 -08:00
N. Taylor Mullen ece001f264 fix(ui): remove redundant newlines in Gemini messages (#18538) 2026-02-10 04:18:21 +00:00
Dmitry Lyalin d3cfbdb3b7 Inline thinking bubbles with summary/full modes (#18033)
Co-authored-by: Jacob Richman <jacob314@gmail.com>
2026-02-10 03:24:41 +00:00
Jerop Kipruto 7a132512cf docs: remove TOC marker from Plan Mode header (#18678) 2026-02-10 03:13:04 +00:00
Spencer 6dae3a5402 Feature/quota visibility 16795 (#18203) 2026-02-10 02:53:10 +00:00
N. Taylor Mullen 0a3ecf3a75 fix(cli): Improve header spacing (#18531) 2026-02-10 02:12:42 +00:00
Jack Wotherspoon 9081743a7f feat: Ctrl+O to expand paste placeholder (#18103) 2026-02-10 02:04:34 +00:00
joshualitt 89d4556c45 feat(core): Render memory hierarchically in context. (#18350) 2026-02-10 02:01:59 +00:00
Aishanee Shah 5d0570b113 refactor(core): centralize core tool definitions and support model-specific schemas (#18662) 2026-02-10 01:29:52 +00:00
Tommaso Sciortino eb94284256 Properly parse at-commands with narrow non-breaking spaces (#18677) 2026-02-10 00:51:24 +00:00
N. Taylor Mullen cc2798018b feat: handle multiple dynamic context filenames in system prompt (#18598) 2026-02-10 00:37:08 +00:00
g-samroberts c9f9a7f67a Change event type for release (#18693) 2026-02-10 00:26:20 +00:00
221 changed files with 8501 additions and 4035 deletions
+1 -1
View File
@@ -25,7 +25,7 @@ You are an expert at fixing behavioral evaluations.
the same scenario. We don't want to lose test fidelity by making the prompts too
direct (i.e.: easy).
- Your primary mechanism for improving the agent's behavior is to make changes to
tool instructions, prompt.ts, and/or modules that contribute to the prompt.
tool instructions, system prompt (snippets.ts), and/or modules that contribute to the prompt.
- If prompt and description changes are unsuccessful, use logs and debugging to
confirm that everything is working as expected.
- If unable to fix the test, you can make recommendations for architecture changes
+29 -9
View File
@@ -14,25 +14,34 @@ repository's standards.
Follow these steps to create a Pull Request:
1. **Branch Management**: Check the current branch to avoid working directly
on `main`.
1. **Branch Management**: **CRITICAL:** Ensure you are NOT working on the
`main` branch.
- Run `git branch --show-current`.
- If the current branch is `main`, create and switch to a new descriptive
branch:
- If the current branch is `main`, you MUST create and switch to a new
descriptive branch:
```bash
git checkout -b <new-branch-name>
```
2. **Locate Template**: Search for a pull request template in the repository.
2. **Commit Changes**: Verify that all intended changes are committed.
- Run `git status` to check for unstaged or uncommitted changes.
- If there are uncommitted changes, stage and commit them with a descriptive
message before proceeding. NEVER commit directly to `main`.
```bash
git add .
git commit -m "type(scope): description"
```
3. **Locate Template**: Search for a pull request template in the repository.
- Check `.github/pull_request_template.md`
- Check `.github/PULL_REQUEST_TEMPLATE.md`
- If multiple templates exist (e.g., in `.github/PULL_REQUEST_TEMPLATE/`),
ask the user which one to use or select the most appropriate one based on
the context (e.g., `bug_fix.md` vs `feature.md`).
3. **Read Template**: Read the content of the identified template file.
4. **Read Template**: Read the content of the identified template file.
4. **Draft Description**: Create a PR description that strictly follows the
5. **Draft Description**: Create a PR description that strictly follows the
template's structure.
- **Headings**: Keep all headings from the template.
- **Checklists**: Review each item. Mark with `[x]` if completed. If an item
@@ -44,14 +53,24 @@ Follow these steps to create a Pull Request:
- **Related Issues**: Link any issues fixed or related to this PR (e.g.,
"Fixes #123").
5. **Preflight Check**: Before creating the PR, run the workspace preflight
6. **Preflight Check**: Before creating the PR, run the workspace preflight
script to ensure all build, lint, and test checks pass.
```bash
npm run preflight
```
If any checks fail, address the issues before proceeding to create the PR.
6. **Create PR**: Use the `gh` CLI to create the PR. To avoid shell escaping
7. **Push Branch**: Push the current branch to the remote repository.
**CRITICAL SAFETY RAIL:** Double-check your branch name before pushing.
NEVER push if the current branch is `main`.
```bash
# Verify current branch is NOT main
git branch --show-current
# Push non-interactively
git push -u origin HEAD
```
8. **Create PR**: Use the `gh` CLI to create the PR. To avoid shell escaping
issues with multi-line Markdown, write the description to a temporary file
first.
```bash
@@ -68,6 +87,7 @@ Follow these steps to create a Pull Request:
## Principles
- **Safety First**: NEVER push to `main`. This is your highest priority.
- **Compliance**: Never ignore the PR template. It exists for a reason.
- **Completeness**: Fill out all relevant sections.
- **Accuracy**: Don't check boxes for tasks you haven't done.
+15 -2
View File
@@ -356,11 +356,17 @@ jobs:
clean-script: 'clean'
test_windows:
name: 'Slow Test - Win'
name: 'Slow Test - Win - ${{ matrix.shard }}'
runs-on: 'gemini-cli-windows-16-core'
needs: 'merge_queue_skipper'
if: "${{needs.merge_queue_skipper.outputs.skip == 'false'}}"
continue-on-error: true
timeout-minutes: 60
strategy:
matrix:
shard:
- 'cli'
- 'others'
steps:
- name: 'Checkout'
@@ -411,7 +417,14 @@ jobs:
NODE_OPTIONS: '--max-old-space-size=32768 --max-semi-space-size=256'
UV_THREADPOOL_SIZE: '32'
NODE_ENV: 'test'
run: 'npm run test:ci -- --coverage.enabled=false'
run: |
if ("${{ matrix.shard }}" -eq "cli") {
npm run test:ci --workspace @google/gemini-cli -- --coverage.enabled=false
} else {
# Explicitly list non-cli packages to ensure they are sharded correctly
npm run test:ci --workspace @google/gemini-cli-core --workspace @google/gemini-cli-a2a-server --workspace gemini-cli-vscode-ide-companion --workspace @google/gemini-cli-test-utils --if-present -- --coverage.enabled=false
npm run test:scripts
}
shell: 'pwsh'
- name: 'Bundle'
+1 -1
View File
@@ -4,7 +4,7 @@ name: 'Generate Release Notes'
on:
release:
types: ['created']
types: ['published']
workflow_dispatch:
inputs:
version:
+3
View File
@@ -67,6 +67,9 @@ powerful tool for developers.
and `packages/core` (Backend logic).
- **Imports:** Use specific imports and avoid restricted relative imports
between packages (enforced by ESLint).
- **License Headers:** For all new source code files (`.ts`, `.tsx`, `.js`),
include the Apache-2.0 license header with the current year. (e.g.,
`Copyright 2026 Google LLC`). This is enforced by ESLint.
## Testing Conventions
+5 -3
View File
@@ -106,6 +106,7 @@ available combinations.
| Toggle YOLO (auto-approval) mode for tool calls. | `Ctrl + Y` |
| Cycle through approval modes: default (prompt), auto_edit (auto-approve edits), and plan (read-only). | `Shift + Tab` |
| Expand a height-constrained response to show additional lines when not in alternate buffer mode. | `Ctrl + O`<br />`Ctrl + S` |
| Expand or collapse a paste placeholder when cursor is over placeholder. | `Ctrl + O` |
| Toggle current background shell visibility. | `Ctrl + B` |
| Toggle background shell list. | `Ctrl + L` |
| Kill the active background shell. | `Ctrl + K` |
@@ -139,6 +140,7 @@ available combinations.
single-line input, navigate backward or forward through prompt history.
- `Number keys (1-9, multi-digit)` inside selection dialogs: Jump directly to
the numbered radio option and confirm when the full number is entered.
- `Double-click` on a paste placeholder (`[Pasted Text: X lines]`) in alternate
buffer mode: Expand to view full content inline. Double-click again to
collapse.
- `Ctrl + O`: Expand or collapse paste placeholders (`[Pasted Text: X lines]`)
inline when the cursor is over the placeholder.
- `Double-click` on a paste placeholder (alternate buffer mode only): Expand to
view full content inline. Double-click again to collapse.
+4 -4
View File
@@ -1,4 +1,4 @@
# Plan Mode (experimental) <!-- omit in toc -->
# Plan Mode (experimental)
Plan Mode is a safe, read-only mode for researching and designing complex
changes. It prevents modifications while you research, design and plan an
@@ -36,7 +36,7 @@ implementation strategy.
You can configure Gemini CLI to start directly in Plan Mode by default:
1. Type `/settings` in the CLI.
2. Search for `Approval Mode`.
2. Search for `Default Approval Mode`.
3. Set the value to `Plan`.
Other ways to start in Plan Mode:
@@ -46,8 +46,8 @@ Other ways to start in Plan Mode:
```json
{
"tools": {
"approvalMode": "plan"
"general": {
"defaultApprovalMode": "plan"
}
}
```
+22 -15
View File
@@ -22,13 +22,14 @@ they appear in the UI.
### General
| UI Label | Setting | Description | Default |
| ------------------------ | ---------------------------------- | ------------------------------------------------------------- | ------- |
| Vim Mode | `general.vimMode` | Enable Vim keybindings | `false` |
| Enable Auto Update | `general.enableAutoUpdate` | Enable automatic updates. | `true` |
| Enable Prompt Completion | `general.enablePromptCompletion` | Enable AI-powered prompt completion suggestions while typing. | `false` |
| Debug Keystroke Logging | `general.debugKeystrokeLogging` | Enable debug logging of keystrokes to the console. | `false` |
| Enable Session Cleanup | `general.sessionRetention.enabled` | Enable automatic session cleanup | `false` |
| UI Label | Setting | Description | Default |
| ------------------------ | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------- |
| Vim Mode | `general.vimMode` | Enable Vim keybindings | `false` |
| 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' is not supported yet. | `"default"` |
| Enable Auto Update | `general.enableAutoUpdate` | Enable automatic updates. | `true` |
| Enable Prompt Completion | `general.enablePromptCompletion` | Enable AI-powered prompt completion suggestions while typing. | `false` |
| Debug Keystroke Logging | `general.debugKeystrokeLogging` | Enable debug logging of keystrokes to the console. | `false` |
| Enable Session Cleanup | `general.sessionRetention.enabled` | Enable automatic session cleanup | `false` |
### Output
@@ -43,6 +44,7 @@ they appear in the UI.
| Auto Theme Switching | `ui.autoThemeSwitching` | Automatically switch between default light and dark themes based on terminal background color. | `true` |
| Terminal Background Polling Interval | `ui.terminalBackgroundPollingInterval` | Interval in seconds to poll the terminal background color. | `60` |
| Hide Window Title | `ui.hideWindowTitle` | Hide the window title bar | `false` |
| Inline Thinking | `ui.inlineThinkingMode` | Display model thinking inline: off or full. | `"off"` |
| Show Thoughts in Title | `ui.showStatusInTitle` | Show Gemini CLI model thoughts in the terminal window title during the working phase | `false` |
| Dynamic Window Title | `ui.dynamicWindowTitle` | Update the terminal window title with current status icons (Ready: ◇, Action Required: ✋, Working: ✦) | `true` |
| Show Home Directory Warning | `ui.showHomeDirectoryWarning` | Show a warning when running Gemini CLI in the home directory. | `true` |
@@ -95,14 +97,13 @@ they appear in the UI.
### Tools
| UI Label | Setting | Description | Default |
| -------------------------------- | ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------- |
| Enable Interactive Shell | `tools.shell.enableInteractiveShell` | Use node-pty for an interactive shell experience. Fallback to child_process still applies. | `true` |
| Show Color | `tools.shell.showColor` | Show color in shell output. | `false` |
| Approval Mode | `tools.approvalMode` | The default approval mode for tool execution. 'default' prompts for approval, 'auto_edit' auto-approves edit tools, and 'plan' is read-only mode. 'yolo' is not supported yet. | `"default"` |
| Use Ripgrep | `tools.useRipgrep` | Use ripgrep for file content search instead of the fallback implementation. Provides faster search performance. | `true` |
| Tool Output Truncation Threshold | `tools.truncateToolOutputThreshold` | Maximum characters to show when truncating large tool outputs. Set to 0 or negative to disable truncation. | `40000` |
| Disable LLM Correction | `tools.disableLLMCorrection` | Disable LLM-based error correction for edit tools. When enabled, tools will fail immediately if exact string matches are not found, instead of attempting to self-correct. | `true` |
| UI Label | Setting | Description | Default |
| -------------------------------- | ------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- |
| Enable Interactive Shell | `tools.shell.enableInteractiveShell` | Use node-pty for an interactive shell experience. Fallback to child_process still applies. | `true` |
| Show Color | `tools.shell.showColor` | Show color in shell output. | `false` |
| Use Ripgrep | `tools.useRipgrep` | Use ripgrep for file content search instead of the fallback implementation. Provides faster search performance. | `true` |
| Tool Output Truncation Threshold | `tools.truncateToolOutputThreshold` | Maximum characters to show when truncating large tool outputs. Set to 0 or negative to disable truncation. | `40000` |
| Disable LLM Correction | `tools.disableLLMCorrection` | Disable LLM-based error correction for edit tools. When enabled, tools will fail immediately if exact string matches are not found, instead of attempting to self-correct. | `true` |
### Security
@@ -115,6 +116,12 @@ they appear in the UI.
| Folder Trust | `security.folderTrust.enabled` | Setting to track whether Folder trust is enabled. | `true` |
| Enable Environment Variable Redaction | `security.environmentVariableRedaction.enabled` | Enable redaction of environment variables that may contain secrets. | `false` |
### Advanced
| UI Label | Setting | Description | Default |
| --------------------------------- | ------------------------------ | --------------------------------------------- | ------- |
| Auto Configure Max Old Space Size | `advanced.autoConfigureMemory` | Automatically configure Node.js memory limits | `false` |
### Experimental
| UI Label | Setting | Description | Default |
+17 -7
View File
@@ -106,6 +106,13 @@ their corresponding top-level category object in your `settings.json` file.
- **Description:** Enable Vim keybindings
- **Default:** `false`
- **`general.defaultApprovalMode`** (enum):
- **Description:** The default approval mode for tool execution. 'default'
prompts for approval, 'auto_edit' auto-approves edit tools, and 'plan' is
read-only mode. 'yolo' is not supported yet.
- **Default:** `"default"`
- **Values:** `"default"`, `"auto_edit"`, `"plan"`
- **`general.devtools`** (boolean):
- **Description:** Enable DevTools inspector on launch.
- **Default:** `false`
@@ -188,6 +195,11 @@ their corresponding top-level category object in your `settings.json` file.
- **Default:** `false`
- **Requires restart:** Yes
- **`ui.inlineThinkingMode`** (enum):
- **Description:** Display model thinking inline: off or full.
- **Default:** `"off"`
- **Values:** `"off"`, `"full"`
- **`ui.showStatusInTitle`** (boolean):
- **Description:** Show Gemini CLI model thoughts in the terminal window title
during the working phase
@@ -676,13 +688,6 @@ their corresponding top-level category object in your `settings.json` file.
performance.
- **Default:** `true`
- **`tools.approvalMode`** (enum):
- **Description:** The default approval mode for tool execution. 'default'
prompts for approval, 'auto_edit' auto-approves edit tools, and 'plan' is
read-only mode. 'yolo' is not supported yet.
- **Default:** `"default"`
- **Values:** `"default"`, `"auto_edit"`, `"plan"`
- **`tools.core`** (array):
- **Description:** Restrict the set of built-in tools with an allowlist. Match
semantics mirror tools.allowed; see the built-in tools documentation for
@@ -859,6 +864,11 @@ their corresponding top-level category object in your `settings.json` file.
- **Default:** `true`
- **Requires restart:** Yes
- **`experimental.extensionRegistry`** (boolean):
- **Description:** Enable extension registry explore UI.
- **Default:** `false`
- **Requires restart:** Yes
- **`experimental.extensionReloading`** (boolean):
- **Description:** Enables extension loading/unloading within the CLI session.
- **Default:** `false`
+3 -2
View File
@@ -23,6 +23,7 @@ const __dirname = path.dirname(__filename);
// Determine the monorepo root (assuming eslint.config.js is at the root)
const projectRoot = __dirname;
const currentYear = new Date().getFullYear();
export default tseslint.config(
{
@@ -267,8 +268,8 @@ export default tseslint.config(
].join('\n'),
patterns: {
year: {
pattern: '202[5-6]',
defaultValue: '2026',
pattern: `202[5-${currentYear.toString().slice(-1)}]`,
defaultValue: currentYear.toString(),
},
},
},
+110
View File
@@ -0,0 +1,110 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, expect } from 'vitest';
import { evalTest } from './test-helper.js';
describe('Edits location eval', () => {
/**
* Ensure that Gemini CLI always updates existing test files, if present,
* instead of creating a new one.
*/
evalTest('USUALLY_PASSES', {
name: 'should update existing test file instead of creating a new one',
files: {
'package.json': JSON.stringify(
{
name: 'test-location-repro',
version: '1.0.0',
scripts: {
test: 'vitest run',
},
devDependencies: {
vitest: '^1.0.0',
typescript: '^5.0.0',
},
},
null,
2,
),
'src/math.ts': `
export function add(a: number, b: number): number {
return a + b;
}
export function subtract(a: number, b: number): number {
return a - b;
}
export function multiply(a: number, b: number): number {
return a + b;
}
`,
'src/math.test.ts': `
import { expect, test } from 'vitest';
import { add, subtract } from './math';
test('add adds two numbers', () => {
expect(add(2, 3)).toBe(5);
});
test('subtract subtracts two numbers', () => {
expect(subtract(5, 3)).toBe(2);
});
`,
'src/utils.ts': `
export function capitalize(s: string): string {
return s.charAt(0).toUpperCase() + s.slice(1);
}
`,
'src/utils.test.ts': `
import { expect, test } from 'vitest';
import { capitalize } from './utils';
test('capitalize capitalizes the first letter', () => {
expect(capitalize('hello')).toBe('Hello');
});
`,
},
prompt: 'Fix the bug in src/math.ts. Do not run the code.',
timeout: 180000,
assert: async (rig) => {
const toolLogs = rig.readToolLogs();
const replaceCalls = toolLogs.filter(
(t) => t.toolRequest.name === 'replace',
);
const writeFileCalls = toolLogs.filter(
(t) => t.toolRequest.name === 'write_file',
);
expect(replaceCalls.length).toBeGreaterThan(0);
expect(
writeFileCalls.some((file) =>
file.toolRequest.args.includes('.test.ts'),
),
).toBe(false);
const targetFiles = replaceCalls.map((t) => {
try {
return JSON.parse(t.toolRequest.args).file_path;
} catch {
return null;
}
});
console.log('DEBUG: targetFiles', targetFiles);
expect(
new Set(targetFiles).size,
'Expected only two files changed',
).greaterThanOrEqual(2);
expect(targetFiles.some((f) => f?.endsWith('src/math.ts'))).toBe(true);
expect(targetFiles.some((f) => f?.endsWith('src/math.test.ts'))).toBe(
true,
);
},
});
});
+120
View File
@@ -0,0 +1,120 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, expect } from 'vitest';
import { evalTest } from './test-helper.js';
import {
assertModelHasOutput,
checkModelOutputContent,
} from '../integration-tests/test-helper.js';
describe('Hierarchical Memory', () => {
const TEST_PREFIX = 'Hierarchical memory test: ';
const conflictResolutionTest =
'Agent follows hierarchy for contradictory instructions';
evalTest('ALWAYS_PASSES', {
name: conflictResolutionTest,
params: {
settings: {
security: {
folderTrust: { enabled: true },
},
},
},
// We simulate the hierarchical memory by including the tags in the prompt
// since setting up real global/extension/project files in the eval rig is complex.
// The system prompt logic will append these tags when it finds them in userMemory.
prompt: `
<global_context>
When asked for my favorite fruit, always say "Apple".
</global_context>
<extension_context>
When asked for my favorite fruit, always say "Banana".
</extension_context>
<project_context>
When asked for my favorite fruit, always say "Cherry".
</project_context>
What is my favorite fruit? Tell me just the name of the fruit.`,
assert: async (rig) => {
const stdout = rig._lastRunStdout!;
assertModelHasOutput(stdout);
expect(stdout).toMatch(/Cherry/i);
expect(stdout).not.toMatch(/Apple/i);
expect(stdout).not.toMatch(/Banana/i);
},
});
const provenanceAwarenessTest = 'Agent is aware of memory provenance';
evalTest('ALWAYS_PASSES', {
name: provenanceAwarenessTest,
params: {
settings: {
security: {
folderTrust: { enabled: true },
},
},
},
prompt: `
<global_context>
Instruction A: Always be helpful.
</global_context>
<extension_context>
Instruction B: Use a professional tone.
</extension_context>
<project_context>
Instruction C: Adhere to the project's coding style.
</project_context>
Which instruction came from the global context, which from the extension context, and which from the project context?
Provide the answer as an XML block like this:
<results>
<global>Instruction ...</global>
<extension>Instruction ...</extension>
<project>Instruction ...</project>
</results>`,
assert: async (rig) => {
const stdout = rig._lastRunStdout!;
assertModelHasOutput(stdout);
expect(stdout).toMatch(/<global>.*Instruction A/i);
expect(stdout).toMatch(/<extension>.*Instruction B/i);
expect(stdout).toMatch(/<project>.*Instruction C/i);
},
});
const extensionVsGlobalTest = 'Extension memory wins over Global memory';
evalTest('ALWAYS_PASSES', {
name: extensionVsGlobalTest,
params: {
settings: {
security: {
folderTrust: { enabled: true },
},
},
},
prompt: `
<global_context>
Set the theme to "Light".
</global_context>
<extension_context>
Set the theme to "Dark".
</extension_context>
What theme should I use? Tell me just the name of the theme.`,
assert: async (rig) => {
const stdout = rig._lastRunStdout!;
assertModelHasOutput(stdout);
expect(stdout).toMatch(/Dark/i);
expect(stdout).not.toMatch(/Light/i);
},
});
});
+9 -9
View File
@@ -36,7 +36,7 @@ describe('save_memory', () => {
},
});
const rememberingCommandRestrictions = 'Agent remembers command restrictions';
evalTest('ALWAYS_PASSES', {
evalTest('USUALLY_PASSES', {
name: rememberingCommandRestrictions,
params: {
settings: { tools: { core: ['save_memory'] } },
@@ -79,7 +79,7 @@ describe('save_memory', () => {
const ignoringTemporaryInformation =
'Agent ignores temporary conversation details';
evalTest('ALWAYS_PASSES', {
evalTest('USUALLY_PASSES', {
name: ignoringTemporaryInformation,
params: {
settings: { tools: { core: ['save_memory'] } },
@@ -104,7 +104,7 @@ describe('save_memory', () => {
});
const rememberingPetName = "Agent remembers user's pet's name";
evalTest('ALWAYS_PASSES', {
evalTest('USUALLY_PASSES', {
name: rememberingPetName,
params: {
settings: { tools: { core: ['save_memory'] } },
@@ -125,7 +125,7 @@ describe('save_memory', () => {
});
const rememberingCommandAlias = 'Agent remembers custom command aliases';
evalTest('ALWAYS_PASSES', {
evalTest('USUALLY_PASSES', {
name: rememberingCommandAlias,
params: {
settings: { tools: { core: ['save_memory'] } },
@@ -147,7 +147,7 @@ describe('save_memory', () => {
const ignoringDbSchemaLocation =
"Agent ignores workspace's database schema location";
evalTest('ALWAYS_PASSES', {
evalTest('USUALLY_PASSES', {
name: ignoringDbSchemaLocation,
params: {
settings: {
@@ -178,7 +178,7 @@ describe('save_memory', () => {
const rememberingCodingStyle =
"Agent remembers user's coding style preference";
evalTest('ALWAYS_PASSES', {
evalTest('USUALLY_PASSES', {
name: rememberingCodingStyle,
params: {
settings: { tools: { core: ['save_memory'] } },
@@ -200,7 +200,7 @@ describe('save_memory', () => {
const ignoringBuildArtifactLocation =
'Agent ignores workspace build artifact location';
evalTest('ALWAYS_PASSES', {
evalTest('USUALLY_PASSES', {
name: ignoringBuildArtifactLocation,
params: {
settings: {
@@ -230,7 +230,7 @@ describe('save_memory', () => {
});
const ignoringMainEntryPoint = "Agent ignores workspace's main entry point";
evalTest('ALWAYS_PASSES', {
evalTest('USUALLY_PASSES', {
name: ignoringMainEntryPoint,
params: {
settings: {
@@ -260,7 +260,7 @@ describe('save_memory', () => {
});
const rememberingBirthday = "Agent remembers user's birthday";
evalTest('ALWAYS_PASSES', {
evalTest('USUALLY_PASSES', {
name: rememberingBirthday,
params: {
settings: { tools: { core: ['save_memory'] } },
+3 -3
View File
@@ -20,7 +20,7 @@ describe('Shell Efficiency', () => {
return typeof args === 'string' ? args : (args as any)['command'];
};
evalTest('ALWAYS_PASSES', {
evalTest('USUALLY_PASSES', {
name: 'should use --silent/--quiet flags when installing packages',
prompt: 'Install the "lodash" package using npm.',
assert: async (rig) => {
@@ -49,7 +49,7 @@ describe('Shell Efficiency', () => {
},
});
evalTest('ALWAYS_PASSES', {
evalTest('USUALLY_PASSES', {
name: 'should use --no-pager with git commands',
prompt: 'Show the git log.',
assert: async (rig) => {
@@ -72,7 +72,7 @@ describe('Shell Efficiency', () => {
},
});
evalTest('ALWAYS_PASSES', {
evalTest('USUALLY_PASSES', {
name: 'should NOT use efficiency flags when enableShellOutputEfficiency is disabled',
params: {
settings: {
+22 -1
View File
@@ -11,6 +11,7 @@ import * as os from 'node:os';
import { RipGrepTool } from '../packages/core/src/tools/ripGrep.js';
import { Config } from '../packages/core/src/config/config.js';
import { WorkspaceContext } from '../packages/core/src/utils/workspaceContext.js';
import { createMockMessageBus } from '../packages/core/src/test-utils/mock-message-bus.js';
// Mock Config to provide necessary context
class MockConfig {
@@ -66,7 +67,7 @@ describe('ripgrep-real-direct', () => {
await fs.writeFile(path.join(tempDir, 'file3.txt'), 'goodbye moon\n');
const config = new MockConfig(tempDir) as unknown as Config;
tool = new RipGrepTool(config);
tool = new RipGrepTool(config, createMockMessageBus());
});
afterAll(async () => {
@@ -108,4 +109,24 @@ describe('ripgrep-real-direct', () => {
expect(result.llmContent).toContain('script.js');
expect(result.llmContent).not.toContain('file1.txt');
});
it('should support context parameters', async () => {
// Create a file with multiple lines
await fs.writeFile(
path.join(tempDir, 'context.txt'),
'line1\nline2\nline3 match\nline4\nline5\n',
);
const invocation = tool.build({
pattern: 'match',
context: 1,
});
const result = await invocation.execute(new AbortController().signal);
expect(result.llmContent).toContain('Found 1 match');
expect(result.llmContent).toContain('context.txt');
expect(result.llmContent).toContain('L2- line2');
expect(result.llmContent).toContain('L3: line3 match');
expect(result.llmContent).toContain('L4- line4');
});
});
+3
View File
@@ -20,5 +20,8 @@ export default defineConfig({
maxThreads: 16,
},
},
env: {
GEMINI_TEST_TYPE: 'integration',
},
},
});
+309 -1148
View File
File diff suppressed because it is too large Load Diff
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli",
"version": "0.29.0-nightly.20260203.71f46f116",
"version": "0.30.0-nightly.20260210.a2174751d",
"engines": {
"node": ">=20.0.0"
},
@@ -14,7 +14,7 @@
"url": "git+https://github.com/google-gemini/gemini-cli.git"
},
"config": {
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.29.0-nightly.20260203.71f46f116"
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.30.0-nightly.20260210.a2174751d"
},
"scripts": {
"start": "cross-env NODE_ENV=development node scripts/start.js",
@@ -64,7 +64,7 @@
"pre-commit": "node scripts/pre-commit.js"
},
"overrides": {
"ink": "npm:@jrichman/ink@6.4.8",
"ink": "npm:@jrichman/ink@6.4.10",
"wrap-ansi": "9.0.2",
"cliui": {
"wrap-ansi": "7.0.0"
@@ -126,7 +126,7 @@
"yargs": "^17.7.2"
},
"dependencies": {
"ink": "npm:@jrichman/ink@6.4.8",
"ink": "npm:@jrichman/ink@6.4.10",
"latest-version": "^9.0.0",
"proper-lockfile": "^4.1.2",
"simple-git": "^3.28.0"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli-a2a-server",
"version": "0.29.0-nightly.20260203.71f46f116",
"version": "0.30.0-nightly.20260210.a2174751d",
"description": "Gemini CLI A2A Server",
"repository": {
"type": "git",
@@ -41,9 +41,11 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
};
return mockConfig;
}),
loadServerHierarchicalMemory: vi
.fn()
.mockResolvedValue({ memoryContent: '', fileCount: 0, filePaths: [] }),
loadServerHierarchicalMemory: vi.fn().mockResolvedValue({
memoryContent: { global: '', extension: '', project: '' },
fileCount: 0,
filePaths: [],
}),
startupProfiler: {
flush: vi.fn(),
},
@@ -4,6 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import * as path from 'node:path';
import type {
Task as SDKTask,
TaskStatusUpdateEvent,
@@ -16,6 +17,7 @@ import {
GeminiClient,
HookSystem,
PolicyDecision,
tmpdir,
} from '@google/gemini-cli-core';
import { createMockMessageBus } from '@google/gemini-cli-core/src/test-utils/mock-message-bus.js';
import type { Config, Storage } from '@google/gemini-cli-core';
@@ -24,6 +26,7 @@ import { expect, vi } from 'vitest';
export function createMockConfig(
overrides: Partial<Config> = {},
): Partial<Config> {
const tmpDir = tmpdir();
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const mockConfig = {
getToolRegistry: vi.fn().mockReturnValue({
@@ -39,12 +42,12 @@ export function createMockConfig(
getWorkspaceContext: vi.fn().mockReturnValue({
isPathWithinWorkspace: () => true,
}),
getTargetDir: () => '/test',
getTargetDir: () => tmpDir,
getCheckpointingEnabled: vi.fn().mockReturnValue(false),
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
storage: {
getProjectTempDir: () => '/tmp',
getProjectTempCheckpointsDir: () => '/tmp/checkpoints',
getProjectTempDir: () => tmpDir,
getProjectTempCheckpointsDir: () => path.join(tmpDir, 'checkpoints'),
} as Storage,
getTruncateToolOutputThreshold: () =>
DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD,
+7 -15
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli",
"version": "0.29.0-nightly.20260203.71f46f116",
"version": "0.30.0-nightly.20260210.a2174751d",
"description": "Gemini CLI",
"license": "Apache-2.0",
"repository": {
@@ -26,7 +26,7 @@
"dist"
],
"config": {
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.29.0-nightly.20260203.71f46f116"
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.30.0-nightly.20260210.a2174751d"
},
"dependencies": {
"@agentclientprotocol/sdk": "^0.12.0",
@@ -34,10 +34,11 @@
"@google/genai": "1.30.0",
"@iarna/toml": "^2.2.5",
"@modelcontextprotocol/sdk": "^1.23.0",
"@types/update-notifier": "^6.0.8",
"ansi-escapes": "^7.3.0",
"ansi-regex": "^6.2.2",
"chalk": "^4.1.2",
"cli-spinners": "^2.9.2",
"clipboardy": "^5.0.0",
"color-convert": "^2.0.1",
"command-exists": "^1.2.9",
"comment-json": "^4.2.5",
"diff": "^8.0.3",
@@ -46,7 +47,7 @@
"fzf": "^0.5.2",
"glob": "^12.0.0",
"highlight.js": "^11.11.1",
"ink": "npm:@jrichman/ink@6.4.8",
"ink": "npm:@jrichman/ink@6.4.10",
"ink-gradient": "^3.0.0",
"ink-spinner": "^5.0.0",
"latest-version": "^9.0.0",
@@ -56,7 +57,6 @@
"prompts": "^2.4.2",
"proper-lockfile": "^4.1.2",
"react": "^19.2.0",
"read-package-up": "^11.0.0",
"shell-quote": "^1.8.3",
"simple-git": "^3.28.0",
"string-width": "^8.1.0",
@@ -65,29 +65,21 @@
"tar": "^7.5.2",
"tinygradient": "^1.1.5",
"undici": "^7.10.0",
"wrap-ansi": "9.0.2",
"ws": "^8.16.0",
"yargs": "^17.7.2",
"zod": "^3.23.8"
},
"devDependencies": {
"@babel/runtime": "^7.27.6",
"@google/gemini-cli-test-utils": "file:../test-utils",
"@types/archiver": "^6.0.3",
"@types/command-exists": "^1.2.3",
"@types/dotenv": "^6.1.1",
"@types/hast": "^3.0.4",
"@types/node": "^20.11.24",
"@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0",
"@types/semver": "^7.7.0",
"@types/shell-quote": "^1.7.5",
"@types/tar": "^6.1.13",
"@types/ws": "^8.5.10",
"@types/yargs": "^17.0.32",
"archiver": "^7.0.1",
"ink-testing-library": "^4.0.0",
"pretty-format": "^30.0.2",
"react-dom": "^19.2.0",
"typescript": "^5.3.3",
"vitest": "^3.1.1"
},
+7 -6
View File
@@ -1867,10 +1867,11 @@ describe('loadCliConfig with includeDirectories', () => {
vi.restoreAllMocks();
});
it('should combine and resolve paths from settings and CLI arguments', async () => {
it.skip('should combine and resolve paths from settings and CLI arguments', async () => {
const mockCwd = path.resolve(path.sep, 'home', 'user', 'project');
process.argv = [
'node',
'script.js',
'--include-directories',
`${path.resolve(path.sep, 'cli', 'path1')},${path.join(mockCwd, 'cli', 'path2')}`,
@@ -2624,7 +2625,7 @@ describe('loadCliConfig approval mode', () => {
it('should use approvalMode from settings when no CLI flags are set', async () => {
process.argv = ['node', 'script.js'];
const settings = createTestMergedSettings({
tools: { approvalMode: 'auto_edit' },
general: { defaultApprovalMode: 'auto_edit' },
});
const argv = await parseArguments(settings);
const config = await loadCliConfig(settings, 'test-session', argv);
@@ -2636,7 +2637,7 @@ describe('loadCliConfig approval mode', () => {
it('should prioritize --approval-mode flag over settings', async () => {
process.argv = ['node', 'script.js', '--approval-mode', 'auto_edit'];
const settings = createTestMergedSettings({
tools: { approvalMode: 'default' },
general: { defaultApprovalMode: 'default' },
});
const argv = await parseArguments(settings);
const config = await loadCliConfig(settings, 'test-session', argv);
@@ -2648,7 +2649,7 @@ describe('loadCliConfig approval mode', () => {
it('should prioritize --yolo flag over settings', async () => {
process.argv = ['node', 'script.js', '--yolo'];
const settings = createTestMergedSettings({
tools: { approvalMode: 'auto_edit' },
general: { defaultApprovalMode: 'auto_edit' },
});
const argv = await parseArguments(settings);
const config = await loadCliConfig(settings, 'test-session', argv);
@@ -2658,7 +2659,7 @@ describe('loadCliConfig approval mode', () => {
it('should respect plan mode from settings when experimental.plan is enabled', async () => {
process.argv = ['node', 'script.js'];
const settings = createTestMergedSettings({
tools: { approvalMode: 'plan' },
general: { defaultApprovalMode: 'plan' },
experimental: { plan: true },
});
const argv = await parseArguments(settings);
@@ -2669,7 +2670,7 @@ describe('loadCliConfig approval mode', () => {
it('should throw error if plan mode is in settings but experimental.plan is disabled', async () => {
process.argv = ['node', 'script.js'];
const settings = createTestMergedSettings({
tools: { approvalMode: 'plan' },
general: { defaultApprovalMode: 'plan' },
experimental: { plan: false },
});
const argv = await parseArguments(settings);
+7 -8
View File
@@ -32,6 +32,7 @@ import {
ASK_USER_TOOL_NAME,
getVersion,
PREVIEW_GEMINI_MODEL_AUTO,
type HierarchicalMemory,
coreEvents,
GEMINI_MODEL_ALIAS_AUTO,
getAdminErrorMessage,
@@ -39,11 +40,9 @@ import {
Config,
applyAdminAllowlist,
getAdminBlockedMcpServersMessage,
} from '@google/gemini-cli-core';
import type {
HookDefinition,
HookEventName,
OutputFormat,
type HookDefinition,
type HookEventName,
type OutputFormat,
} from '@google/gemini-cli-core';
import {
type Settings,
@@ -489,7 +488,7 @@ export async function loadCliConfig(
const experimentalJitContext = settings.experimental?.jitContext ?? false;
let memoryContent = '';
let memoryContent: string | HierarchicalMemory = '';
let fileCount = 0;
let filePaths: string[] = [];
@@ -520,8 +519,8 @@ export async function loadCliConfig(
const rawApprovalMode =
argv.approvalMode ||
(argv.yolo ? 'yolo' : undefined) ||
((settings.tools?.approvalMode as string) !== 'yolo'
? settings.tools.approvalMode
((settings.general?.defaultApprovalMode as string) !== 'yolo'
? settings.general?.defaultApprovalMode
: undefined);
if (rawApprovalMode) {
@@ -16,10 +16,11 @@ import {
vi,
afterEach,
} from 'vitest';
import { createExtension } from '../test-utils/createExtension.js';
import { ExtensionManager } from './extension-manager.js';
import { themeManager, DEFAULT_THEME } from '../ui/themes/theme-manager.js';
import { GEMINI_DIR, type Config } from '@google/gemini-cli-core';
import { GEMINI_DIR, type Config, tmpdir } from '@google/gemini-cli-core';
import { createTestMergedSettings, SettingScope } from './settings.js';
describe('ExtensionManager theme loading', () => {
@@ -29,7 +30,7 @@ describe('ExtensionManager theme loading', () => {
beforeAll(async () => {
tempHomeDir = await fs.promises.mkdtemp(
path.join(fs.realpathSync('/tmp'), 'gemini-cli-test-'),
path.join(tmpdir(), 'gemini-cli-test-'),
);
});
@@ -67,6 +67,14 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
loadAgentsFromDirectory: vi
.fn()
.mockResolvedValue({ agents: [], errors: [] }),
logExtensionInstallEvent: vi.fn().mockResolvedValue(undefined),
logExtensionUpdateEvent: vi.fn().mockResolvedValue(undefined),
logExtensionUninstall: vi.fn().mockResolvedValue(undefined),
logExtensionEnable: vi.fn().mockResolvedValue(undefined),
logExtensionDisable: vi.fn().mockResolvedValue(undefined),
Config: vi.fn().mockImplementation(() => ({
getEnableExtensionReloading: vi.fn().mockReturnValue(true),
})),
};
});
+5
View File
@@ -91,6 +91,7 @@ export enum Command {
TOGGLE_YOLO = 'app.toggleYolo',
CYCLE_APPROVAL_MODE = 'app.cycleApprovalMode',
SHOW_MORE_LINES = 'app.showMoreLines',
EXPAND_PASTE = 'app.expandPaste',
FOCUS_SHELL_INPUT = 'app.focusShellInput',
UNFOCUS_SHELL_INPUT = 'app.unfocusShellInput',
CLEAR_SCREEN = 'app.clearScreen',
@@ -289,6 +290,7 @@ export const defaultKeyBindings: KeyBindingConfig = {
{ key: 'o', ctrl: true },
{ key: 's', ctrl: true },
],
[Command.EXPAND_PASTE]: [{ key: 'o', ctrl: true }],
[Command.FOCUS_SHELL_INPUT]: [{ key: 'tab', shift: false }],
[Command.UNFOCUS_SHELL_INPUT]: [{ key: 'tab', shift: true }],
[Command.CLEAR_SCREEN]: [{ key: 'l', ctrl: true }],
@@ -399,6 +401,7 @@ export const commandCategories: readonly CommandCategory[] = [
Command.TOGGLE_YOLO,
Command.CYCLE_APPROVAL_MODE,
Command.SHOW_MORE_LINES,
Command.EXPAND_PASTE,
Command.TOGGLE_BACKGROUND_SHELL,
Command.TOGGLE_BACKGROUND_SHELL_LIST,
Command.KILL_BACKGROUND_SHELL,
@@ -499,6 +502,8 @@ export const commandDescriptions: Readonly<Record<Command, string>> = {
'Cycle through approval modes: default (prompt), auto_edit (auto-approve edits), and plan (read-only).',
[Command.SHOW_MORE_LINES]:
'Expand a height-constrained response to show additional lines when not in alternate buffer mode.',
[Command.EXPAND_PASTE]:
'Expand or collapse a paste placeholder when cursor is over placeholder.',
[Command.BACKGROUND_SHELL_SELECT]:
'Confirm selection in background shell list.',
[Command.BACKGROUND_SHELL_ESCAPE]: 'Dismiss background shell list.',
+34
View File
@@ -1936,6 +1936,40 @@ describe('Settings Loading and Merging', () => {
);
});
it('should migrate tools.approvalMode to general.defaultApprovalMode', () => {
const userSettingsContent = {
tools: {
approvalMode: 'plan',
},
};
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p === USER_SETTINGS_PATH)
return JSON.stringify(userSettingsContent);
return '{}';
},
);
const setValueSpy = vi.spyOn(LoadedSettings.prototype, 'setValue');
const loadedSettings = loadSettings(MOCK_WORKSPACE_DIR);
migrateDeprecatedSettings(loadedSettings, true);
expect(setValueSpy).toHaveBeenCalledWith(
SettingScope.User,
'general',
expect.objectContaining({ defaultApprovalMode: 'plan' }),
);
// Verify removal
expect(setValueSpy).toHaveBeenCalledWith(
SettingScope.User,
'tools',
expect.not.objectContaining({ approvalMode: 'plan' }),
);
});
it('should migrate all 4 inverted boolean settings', () => {
const userSettingsContent = {
general: {
+30
View File
@@ -911,6 +911,36 @@ export function migrateDeprecatedSettings(
}
}
// Migrate tools settings
const toolsSettings = settings.tools as Record<string, unknown> | undefined;
if (toolsSettings) {
if (toolsSettings['approvalMode'] !== undefined) {
foundDeprecated.push('tools.approvalMode');
const generalSettings =
(settings.general as Record<string, unknown> | undefined) || {};
const newGeneral = { ...generalSettings };
// Only set defaultApprovalMode if it's not already set
if (newGeneral['defaultApprovalMode'] === undefined) {
newGeneral['defaultApprovalMode'] = toolsSettings['approvalMode'];
loadedSettings.setValue(scope, 'general', newGeneral);
if (!settingsFile.readOnly) {
anyModified = true;
}
}
if (removeDeprecated) {
const newTools = { ...toolsSettings };
delete newTools['approvalMode'];
loadedSettings.setValue(scope, 'tools', newTools);
if (!settingsFile.readOnly) {
anyModified = true;
}
}
}
}
// Migrate experimental agent settings
const experimentalModified = migrateExperimentalSettings(
settings,
@@ -224,7 +224,7 @@ describe('SettingsSchema', () => {
expect(
getSettingsSchema().advanced.properties.autoConfigureMemory
.showInDialog,
).toBe(false);
).toBe(true);
});
it('should infer Settings type correctly', () => {
+42 -19
View File
@@ -179,6 +179,24 @@ const SETTINGS_SCHEMA = {
description: 'Enable Vim keybindings',
showInDialog: true,
},
defaultApprovalMode: {
type: 'enum',
label: 'Default Approval Mode',
category: 'General',
requiresRestart: false,
default: 'default',
description: oneLine`
The default approval mode for tool execution.
'default' prompts for approval, 'auto_edit' auto-approves edit tools,
and 'plan' is read-only mode. 'yolo' is not supported yet.
`,
showInDialog: true,
options: [
{ value: 'default', label: 'Default' },
{ value: 'auto_edit', label: 'Auto Edit' },
{ value: 'plan', label: 'Plan' },
],
},
devtools: {
type: 'boolean',
label: 'DevTools',
@@ -392,6 +410,19 @@ const SETTINGS_SCHEMA = {
description: 'Hide the window title bar',
showInDialog: true,
},
inlineThinkingMode: {
type: 'enum',
label: 'Inline Thinking',
category: 'UI',
requiresRestart: false,
default: 'off',
description: 'Display model thinking inline: off or full.',
showInDialog: true,
options: [
{ value: 'off', label: 'Off' },
{ value: 'full', label: 'Full' },
],
},
showStatusInTitle: {
type: 'boolean',
label: 'Show Thoughts in Title',
@@ -1070,24 +1101,7 @@ const SETTINGS_SCHEMA = {
},
},
},
approvalMode: {
type: 'enum',
label: 'Approval Mode',
category: 'Tools',
requiresRestart: false,
default: 'default',
description: oneLine`
The default approval mode for tool execution.
'default' prompts for approval, 'auto_edit' auto-approves edit tools,
and 'plan' is read-only mode. 'yolo' is not supported yet.
`,
showInDialog: true,
options: [
{ value: 'default', label: 'Default' },
{ value: 'auto_edit', label: 'Auto Edit' },
{ value: 'plan', label: 'Plan' },
],
},
core: {
type: 'array',
label: 'Core Tools',
@@ -1399,7 +1413,7 @@ const SETTINGS_SCHEMA = {
requiresRestart: true,
default: false,
description: 'Automatically configure Node.js memory limits',
showInDialog: false,
showInDialog: true,
},
dnsResolutionOrder: {
type: 'string',
@@ -1523,6 +1537,15 @@ const SETTINGS_SCHEMA = {
description: 'Enable requesting and fetching of extension settings.',
showInDialog: false,
},
extensionRegistry: {
type: 'boolean',
label: 'Extension Registry Explore UI',
category: 'Experimental',
requiresRestart: true,
default: false,
description: 'Enable extension registry explore UI.',
showInDialog: false,
},
extensionReloading: {
type: 'boolean',
label: 'Extension Reloading',
+8 -7
View File
@@ -519,10 +519,10 @@ export async function main() {
adminControlsListner.setConfig(config);
if (config.isInteractive() && settings.merged.general.devtools) {
const { registerActivityLogger } = await import(
const { setupInitialActivityLogger } = await import(
'./utils/devtoolsService.js'
);
await registerActivityLogger(config);
await setupInitialActivityLogger(config);
}
// Register config for telemetry shutdown
@@ -603,12 +603,13 @@ export async function main() {
}
// This cleanup isn't strictly needed but may help in certain situations.
process.on('SIGTERM', () => {
const restoreRawMode = () => {
process.stdin.setRawMode(wasRaw);
});
process.on('SIGINT', () => {
process.stdin.setRawMode(wasRaw);
});
};
process.off('SIGTERM', restoreRawMode);
process.on('SIGTERM', restoreRawMode);
process.off('SIGINT', restoreRawMode);
process.on('SIGINT', restoreRawMode);
}
await setupTerminalAndTheme(config, settings);
+4 -4
View File
@@ -38,9 +38,9 @@ import type { LoadedSettings } from './config/settings.js';
// Mock core modules
vi.mock('./ui/hooks/atCommandProcessor.js');
const mockRegisterActivityLogger = vi.hoisted(() => vi.fn());
const mockSetupInitialActivityLogger = vi.hoisted(() => vi.fn());
vi.mock('./utils/devtoolsService.js', () => ({
registerActivityLogger: mockRegisterActivityLogger,
setupInitialActivityLogger: mockSetupInitialActivityLogger,
}));
const mockCoreEvents = vi.hoisted(() => ({
@@ -286,7 +286,7 @@ describe('runNonInteractive', () => {
prompt_id: 'prompt-id-activity-logger',
});
expect(mockRegisterActivityLogger).toHaveBeenCalledWith(mockConfig);
expect(mockSetupInitialActivityLogger).toHaveBeenCalledWith(mockConfig);
vi.unstubAllEnvs();
});
@@ -309,7 +309,7 @@ describe('runNonInteractive', () => {
prompt_id: 'prompt-id-activity-logger-off',
});
expect(mockRegisterActivityLogger).not.toHaveBeenCalled();
expect(mockSetupInitialActivityLogger).not.toHaveBeenCalled();
vi.unstubAllEnvs();
});
+2 -2
View File
@@ -72,10 +72,10 @@ export async function runNonInteractive({
});
if (process.env['GEMINI_CLI_ACTIVITY_LOG_TARGET']) {
const { registerActivityLogger } = await import(
const { setupInitialActivityLogger } = await import(
'./utils/devtoolsService.js'
);
await registerActivityLogger(config);
await setupInitialActivityLogger(config);
}
const { stdout: workingStdout } = createWorkingStdio();
+7 -2
View File
@@ -5,6 +5,7 @@
*/
import { act } from 'react';
import { vi } from 'vitest';
// The waitFor from vitest doesn't properly wrap in act(), so we have to
// implement our own like the one in @testing-library/react
@@ -13,7 +14,7 @@ import { act } from 'react';
// for React state updates.
export async function waitFor(
assertion: () => void,
{ timeout = 1000, interval = 50 } = {},
{ timeout = 2000, interval = 50 } = {},
): Promise<void> {
const startTime = Date.now();
@@ -27,7 +28,11 @@ export async function waitFor(
}
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, interval));
if (vi.isFakeTimers()) {
await vi.advanceTimersByTimeAsync(interval);
} else {
await new Promise((resolve) => setTimeout(resolve, interval));
}
});
}
}
+10 -2
View File
@@ -1,6 +1,6 @@
/**
* @license
* Copyright 2025 Google LLC
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
@@ -121,6 +121,9 @@ const configProxy = new Proxy({} as Config, {
return () =>
'/Users/test/project/foo/bar/and/some/more/directories/to/make/it/long';
}
if (prop === 'getUseBackgroundColor') {
return () => true;
}
const internal = getMockConfigInternal();
if (prop in internal) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
@@ -151,6 +154,12 @@ const baseMockUiState = {
activePtyId: undefined,
backgroundShells: new Map(),
backgroundShellHeight: 0,
quota: {
userTier: undefined,
stats: undefined,
proQuotaRequest: null,
validationRequest: null,
},
};
export const mockAppState: AppState = {
@@ -200,7 +209,6 @@ const mockUIActions: UIActions = {
setActiveBackgroundShellPid: vi.fn(),
setIsBackgroundShellListOpen: vi.fn(),
setAuthContext: vi.fn(),
handleWarning: vi.fn(),
handleRestart: vi.fn(),
handleNewAgentsSelect: vi.fn(),
};
+1 -1
View File
@@ -1,6 +1,6 @@
/**
* @license
* Copyright 2025 Google LLC
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
+32 -4
View File
@@ -1,6 +1,6 @@
/**
* @license
* Copyright 2025 Google LLC
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
@@ -147,12 +147,28 @@ vi.mock('./hooks/useLogger.js');
vi.mock('./hooks/useInputHistoryStore.js');
vi.mock('./hooks/atCommandProcessor.js');
vi.mock('./hooks/useHookDisplayState.js');
vi.mock('./hooks/useBanner.js', () => ({
useBanner: vi.fn((bannerData) => ({
bannerText: (
bannerData.warningText ||
bannerData.defaultText ||
''
).replace(/\\n/g, '\n'),
})),
}));
vi.mock('./hooks/useShellInactivityStatus.js', () => ({
useShellInactivityStatus: vi.fn(() => ({
shouldShowFocusHint: false,
inactivityStatus: 'none',
})),
}));
vi.mock('./hooks/useTerminalTheme.js', () => ({
useTerminalTheme: vi.fn(),
}));
import { useHookDisplayState } from './hooks/useHookDisplayState.js';
import { useTerminalTheme } from './hooks/useTerminalTheme.js';
import { useShellInactivityStatus } from './hooks/useShellInactivityStatus.js';
// Mock external utilities
vi.mock('../utils/events.js');
@@ -256,6 +272,7 @@ describe('AppContainer State Management', () => {
const mockedUseInputHistoryStore = useInputHistoryStore as Mock;
const mockedUseHookDisplayState = useHookDisplayState as Mock;
const mockedUseTerminalTheme = useTerminalTheme as Mock;
const mockedUseShellInactivityStatus = useShellInactivityStatus as Mock;
const DEFAULT_GEMINI_STREAM_MOCK = {
streamingState: 'idle',
@@ -385,6 +402,10 @@ describe('AppContainer State Management', () => {
});
mockedUseHookDisplayState.mockReturnValue([]);
mockedUseTerminalTheme.mockReturnValue(undefined);
mockedUseShellInactivityStatus.mockReturnValue({
shouldShowFocusHint: false,
inactivityStatus: 'none',
});
// Mock Config
mockConfig = makeFakeConfig();
@@ -951,7 +972,7 @@ describe('AppContainer State Management', () => {
});
await waitFor(() => {
// Assert that the context value is as expected
expect(capturedUIState.proQuotaRequest).toBeNull();
expect(capturedUIState.quota.proQuotaRequest).toBeNull();
});
unmount!();
});
@@ -976,7 +997,7 @@ describe('AppContainer State Management', () => {
});
await waitFor(() => {
// Assert: The mock request is correctly passed through the context
expect(capturedUIState.proQuotaRequest).toEqual(mockRequest);
expect(capturedUIState.quota.proQuotaRequest).toEqual(mockRequest);
});
unmount!();
});
@@ -1247,8 +1268,15 @@ describe('AppContainer State Management', () => {
});
describe('Shell Focus Action Required', () => {
beforeEach(() => {
beforeEach(async () => {
vi.useFakeTimers();
// Use real implementation for these tests to verify title updates
const actual = await vi.importActual<
typeof import('./hooks/useShellInactivityStatus.js')
>('./hooks/useShellInactivityStatus.js');
mockedUseShellInactivityStatus.mockImplementation(
actual.useShellInactivityStatus,
);
});
afterEach(() => {
+120 -60
View File
@@ -1,6 +1,6 @@
/**
* @license
* Copyright 2025 Google LLC
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
@@ -29,6 +29,7 @@ import {
AuthState,
type ConfirmationRequest,
type PermissionConfirmationRequest,
type QuotaStats,
} from './types.js';
import { checkPermissions } from './hooks/atCommandProcessor.js';
import { MessageType, StreamingState } from './types.js';
@@ -55,6 +56,7 @@ import {
coreEvents,
CoreEvent,
refreshServerHierarchicalMemory,
flattenMemory,
type MemoryChangedPayload,
writeToStdout,
disableMouseEvents,
@@ -105,7 +107,7 @@ import { useShellInactivityStatus } from './hooks/useShellInactivityStatus.js';
import { useFolderTrust } from './hooks/useFolderTrust.js';
import { useIdeTrustListener } from './hooks/useIdeTrustListener.js';
import { type IdeIntegrationNudgeResult } from './IdeIntegrationNudge.js';
import { appEvents, AppEvent } from '../utils/events.js';
import { appEvents, AppEvent, TransientMessageType } from '../utils/events.js';
import { type UpdateObject } from './utils/updateCheck.js';
import { setUpdateHandler } from '../utils/handleAutoUpdate.js';
import { registerCleanup, runExitCleanup } from '../utils/cleanup.js';
@@ -142,6 +144,7 @@ import { LoginWithGoogleRestartDialog } from './auth/LoginWithGoogleRestartDialo
import { NewAgentsChoice } from './components/NewAgentsNotification.js';
import { isSlashCommand } from './utils/commandUtils.js';
import { useTerminalTheme } from './hooks/useTerminalTheme.js';
import { useTimedMessage } from './hooks/useTimedMessage.js';
import { isITerm2 } from './utils/terminalUtils.js';
function isToolExecuting(pendingHistoryItems: HistoryItemWithoutId[]) {
@@ -321,6 +324,16 @@ export const AppContainer = (props: AppContainerProps) => {
const [currentModel, setCurrentModel] = useState(config.getModel());
const [userTier, setUserTier] = useState<UserTierId | undefined>(undefined);
const [quotaStats, setQuotaStats] = useState<QuotaStats | undefined>(() => {
const remaining = config.getQuotaRemaining();
const limit = config.getQuotaLimit();
const resetTime = config.getQuotaResetTime();
return remaining !== undefined ||
limit !== undefined ||
resetTime !== undefined
? { remaining, limit, resetTime }
: undefined;
});
const [isConfigInitialized, setConfigInitialized] = useState(false);
@@ -423,9 +436,23 @@ export const AppContainer = (props: AppContainerProps) => {
setCurrentModel(config.getModel());
};
const handleQuotaChanged = (payload: {
remaining: number | undefined;
limit: number | undefined;
resetTime?: string;
}) => {
setQuotaStats({
remaining: payload.remaining,
limit: payload.limit,
resetTime: payload.resetTime,
});
};
coreEvents.on(CoreEvent.ModelChanged, handleModelChanged);
coreEvents.on(CoreEvent.QuotaChanged, handleQuotaChanged);
return () => {
coreEvents.off(CoreEvent.ModelChanged, handleModelChanged);
coreEvents.off(CoreEvent.QuotaChanged, handleQuotaChanged);
};
}, [config]);
@@ -871,12 +898,14 @@ Logging in with Google... Restarting Gemini CLI to continue.
const { memoryContent, fileCount } =
await refreshServerHierarchicalMemory(config);
const flattenedMemory = flattenMemory(memoryContent);
historyManager.addItem(
{
type: MessageType.INFO,
text: `Memory refreshed successfully. ${
memoryContent.length > 0
? `Loaded ${memoryContent.length} characters from ${fileCount} file(s).`
flattenedMemory.length > 0
? `Loaded ${flattenedMemory.length} characters from ${fileCount} file(s).`
: 'No memory content found.'
}`,
},
@@ -884,7 +913,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
);
if (config.getDebugMode()) {
debugLogger.log(
`[DEBUG] Refreshed memory content in config: ${memoryContent.substring(
`[DEBUG] Refreshed memory content in config: ${flattenedMemory.substring(
0,
200,
)}...`,
@@ -1154,11 +1183,9 @@ Logging in with Google... Restarting Gemini CLI to continue.
useLayoutEffect(() => {
if (mainControlsRef.current) {
const fullFooterMeasurement = measureElement(mainControlsRef.current);
if (
fullFooterMeasurement.height > 0 &&
fullFooterMeasurement.height !== controlsHeight
) {
setControlsHeight(fullFooterMeasurement.height);
const roundedHeight = Math.round(fullFooterMeasurement.height);
if (roundedHeight > 0 && roundedHeight !== controlsHeight) {
setControlsHeight(roundedHeight);
}
}
}, [buffer, terminalWidth, terminalHeight, controlsHeight]);
@@ -1286,7 +1313,11 @@ Logging in with Google... Restarting Gemini CLI to continue.
>();
const [showEscapePrompt, setShowEscapePrompt] = useState(false);
const [showIdeRestartPrompt, setShowIdeRestartPrompt] = useState(false);
const [warningMessage, setWarningMessage] = useState<string | null>(null);
const [transientMessage, showTransientMessage] = useTimedMessage<{
text: string;
type: TransientMessageType;
}>(WARNING_PROMPT_DURATION_MS);
const { isFolderTrustDialogOpen, handleFolderTrustSelect, isRestarting } =
useFolderTrust(settings, setIsTrustedFolder, historyManager.addItem);
@@ -1298,41 +1329,42 @@ Logging in with Google... Restarting Gemini CLI to continue.
useIncludeDirsTrust(config, isTrustedFolder, historyManager, setCustomDialog);
const warningTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const tabFocusTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const handleWarning = useCallback((message: string) => {
setWarningMessage(message);
if (warningTimeoutRef.current) {
clearTimeout(warningTimeoutRef.current);
}
warningTimeoutRef.current = setTimeout(() => {
setWarningMessage(null);
}, WARNING_PROMPT_DURATION_MS);
}, []);
useEffect(() => {
const handleTransientMessage = (payload: {
message: string;
type: TransientMessageType;
}) => {
showTransientMessage({ text: payload.message, type: payload.type });
};
// Handle timeout cleanup on unmount
useEffect(
() => () => {
if (warningTimeoutRef.current) {
clearTimeout(warningTimeoutRef.current);
}
const handleSelectionWarning = () => {
showTransientMessage({
text: 'Press Ctrl-S to enter selection mode to copy text.',
type: TransientMessageType.Warning,
});
};
const handlePasteTimeout = () => {
showTransientMessage({
text: 'Paste Timed out. Possibly due to slow connection.',
type: TransientMessageType.Warning,
});
};
appEvents.on(AppEvent.TransientMessage, handleTransientMessage);
appEvents.on(AppEvent.SelectionWarning, handleSelectionWarning);
appEvents.on(AppEvent.PasteTimeout, handlePasteTimeout);
return () => {
appEvents.off(AppEvent.TransientMessage, handleTransientMessage);
appEvents.off(AppEvent.SelectionWarning, handleSelectionWarning);
appEvents.off(AppEvent.PasteTimeout, handlePasteTimeout);
if (tabFocusTimeoutRef.current) {
clearTimeout(tabFocusTimeoutRef.current);
}
},
[],
);
useEffect(() => {
const handlePasteTimeout = () => {
handleWarning('Paste Timed out. Possibly due to slow connection.');
};
appEvents.on(AppEvent.PasteTimeout, handlePasteTimeout);
return () => {
appEvents.off(AppEvent.PasteTimeout, handlePasteTimeout);
};
}, [handleWarning]);
}, [showTransientMessage]);
useEffect(() => {
if (ideNeedsRestart) {
@@ -1432,17 +1464,9 @@ Logging in with Google... Restarting Gemini CLI to continue.
if (result.userSelection === 'yes') {
// eslint-disable-next-line @typescript-eslint/no-floating-promises
handleSlashCommand('/ide install');
settings.setValue(
SettingScope.User,
'hasSeenIdeIntegrationNudge',
true,
);
settings.setValue(SettingScope.User, 'ide.hasSeenNudge', true);
} else if (result.userSelection === 'dismiss') {
settings.setValue(
SettingScope.User,
'hasSeenIdeIntegrationNudge',
true,
);
settings.setValue(SettingScope.User, 'ide.hasSeenNudge', true);
}
setIdePromptAnswered(true);
},
@@ -1494,13 +1518,43 @@ Logging in with Google... Restarting Gemini CLI to continue.
}
if (keyMatchers[Command.SHOW_ERROR_DETAILS](key)) {
setShowErrorDetails((prev) => !prev);
if (settings.merged.general.devtools) {
void (async () => {
try {
const { startDevToolsServer } = await import(
'../utils/devtoolsService.js'
);
const { openBrowserSecurely, shouldLaunchBrowser } = await import(
'@google/gemini-cli-core'
);
const url = await startDevToolsServer(config);
if (shouldLaunchBrowser()) {
try {
await openBrowserSecurely(url);
} catch (e) {
setShowErrorDetails((prev) => !prev);
debugLogger.warn('Failed to open browser securely:', e);
}
} else {
setShowErrorDetails((prev) => !prev);
}
} catch (e) {
setShowErrorDetails(true);
debugLogger.error('Failed to start DevTools server:', e);
}
})();
} else {
setShowErrorDetails((prev) => !prev);
}
return true;
} else if (keyMatchers[Command.SUSPEND_APP](key)) {
const undoMessage = isITerm2()
? 'Undo has been moved to Option + Z'
: 'Undo has been moved to Alt/Option + Z or Cmd + Z';
handleWarning(undoMessage);
showTransientMessage({
text: undoMessage,
type: TransientMessageType.Warning,
});
return true;
} else if (keyMatchers[Command.SHOW_FULL_TODOS](key)) {
setShowFullTodos((prev) => !prev);
@@ -1540,7 +1594,10 @@ Logging in with Google... Restarting Gemini CLI to continue.
if (lastOutputTimeRef.current === capturedTime) {
setEmbeddedShellFocused(false);
} else {
handleWarning('Use Shift+Tab to unfocus');
showTransientMessage({
text: 'Use Shift+Tab to unfocus',
type: TransientMessageType.Warning,
});
}
}, 150);
return false;
@@ -1620,7 +1677,8 @@ Logging in with Google... Restarting Gemini CLI to continue.
setIsBackgroundShellListOpen,
lastOutputTimeRef,
tabFocusTimeoutRef,
handleWarning,
showTransientMessage,
settings.merged.general.devtools,
],
);
@@ -1872,9 +1930,12 @@ Logging in with Google... Restarting Gemini CLI to continue.
queueErrorMessage,
showApprovalModeIndicator,
currentModel,
userTier,
proQuotaRequest,
validationRequest,
quota: {
userTier,
stats: quotaStats,
proQuotaRequest,
validationRequest,
},
contextFileNames,
errorCount,
availableTerminalHeight,
@@ -1903,7 +1964,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
showDebugProfiler,
customDialog,
copyModeEnabled,
warningMessage,
transientMessage,
bannerData,
bannerVisible,
terminalBackgroundColor: config.getTerminalBackground(),
@@ -1979,6 +2040,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
queueErrorMessage,
showApprovalModeIndicator,
userTier,
quotaStats,
proQuotaRequest,
validationRequest,
contextFileNames,
@@ -2013,7 +2075,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
apiKeyDefaultValue,
authState,
copyModeEnabled,
warningMessage,
transientMessage,
bannerData,
bannerVisible,
config,
@@ -2070,7 +2132,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
handleApiKeyCancel,
setBannerVisible,
setShortcutsHelpVisible,
handleWarning,
setEmbeddedShellFocused,
dismissBackgroundShell,
setActiveBackgroundShellPid,
@@ -2147,7 +2208,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
handleApiKeyCancel,
setBannerVisible,
setShortcutsHelpVisible,
handleWarning,
setEmbeddedShellFocused,
dismissBackgroundShell,
setActiveBackgroundShellPid,
@@ -19,6 +19,7 @@ import {
showMemory,
addMemory,
listMemoryFiles,
flattenMemory,
} from '@google/gemini-cli-core';
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
@@ -33,7 +34,7 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
refreshMemory: vi.fn(async (config) => {
if (config.isJitContextEnabled()) {
await config.getContextManager()?.refresh();
const memoryContent = config.getUserMemory() || '';
const memoryContent = original.flattenMemory(config.getUserMemory());
const fileCount = config.getGeminiMdFileCount() || 0;
return {
type: 'message',
@@ -85,7 +86,7 @@ describe('memoryCommand', () => {
mockGetGeminiMdFileCount = vi.fn();
vi.mocked(showMemory).mockImplementation((config) => {
const memoryContent = config.getUserMemory() || '';
const memoryContent = flattenMemory(config.getUserMemory());
const fileCount = config.getGeminiMdFileCount() || 0;
let content;
if (memoryContent.length > 0) {
@@ -1,6 +1,6 @@
/**
* @license
* Copyright 2025 Google LLC
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
@@ -54,6 +54,7 @@ describe('statsCommand', () => {
selectedAuthType: '',
tier: undefined,
userEmail: 'mock@example.com',
currentModel: undefined,
});
});
@@ -63,9 +64,20 @@ describe('statsCommand', () => {
const mockQuota = { buckets: [] };
const mockRefreshUserQuota = vi.fn().mockResolvedValue(mockQuota);
const mockGetUserTierName = vi.fn().mockReturnValue('Basic');
const mockGetModel = vi.fn().mockReturnValue('gemini-pro');
const mockGetQuotaRemaining = vi.fn().mockReturnValue(85);
const mockGetQuotaLimit = vi.fn().mockReturnValue(100);
const mockGetQuotaResetTime = vi
.fn()
.mockReturnValue('2025-01-01T12:00:00Z');
mockContext.services.config = {
refreshUserQuota: mockRefreshUserQuota,
getUserTierName: mockGetUserTierName,
getModel: mockGetModel,
getQuotaRemaining: mockGetQuotaRemaining,
getQuotaLimit: mockGetQuotaLimit,
getQuotaResetTime: mockGetQuotaResetTime,
} as unknown as Config;
await statsCommand.action(mockContext, '');
@@ -75,6 +87,10 @@ describe('statsCommand', () => {
expect.objectContaining({
quotas: mockQuota,
tier: 'Basic',
currentModel: 'gemini-pro',
pooledRemaining: 85,
pooledLimit: 100,
pooledResetTime: '2025-01-01T12:00:00Z',
}),
);
});
@@ -93,6 +109,9 @@ describe('statsCommand', () => {
selectedAuthType: '',
tier: undefined,
userEmail: 'mock@example.com',
currentModel: undefined,
pooledRemaining: undefined,
pooledLimit: undefined,
});
});
+14 -1
View File
@@ -1,6 +1,6 @@
/**
* @license
* Copyright 2025 Google LLC
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
@@ -44,6 +44,7 @@ async function defaultSessionView(context: CommandContext) {
const wallDuration = now.getTime() - sessionStartTime.getTime();
const { selectedAuthType, userEmail, tier } = getUserIdentity(context);
const currentModel = context.services.config?.getModel();
const statsItem: HistoryItemStats = {
type: MessageType.STATS,
@@ -51,12 +52,16 @@ async function defaultSessionView(context: CommandContext) {
selectedAuthType,
userEmail,
tier,
currentModel,
};
if (context.services.config) {
const quota = await context.services.config.refreshUserQuota();
if (quota) {
statsItem.quotas = quota;
statsItem.pooledRemaining = context.services.config.getQuotaRemaining();
statsItem.pooledLimit = context.services.config.getQuotaLimit();
statsItem.pooledResetTime = context.services.config.getQuotaResetTime();
}
}
@@ -89,11 +94,19 @@ export const statsCommand: SlashCommand = {
autoExecute: true,
action: (context: CommandContext) => {
const { selectedAuthType, userEmail, tier } = getUserIdentity(context);
const currentModel = context.services.config?.getModel();
const pooledRemaining = context.services.config?.getQuotaRemaining();
const pooledLimit = context.services.config?.getQuotaLimit();
const pooledResetTime = context.services.config?.getQuotaResetTime();
context.ui.addItem({
type: MessageType.MODEL_STATS,
selectedAuthType,
userEmail,
tier,
currentModel,
pooledRemaining,
pooledLimit,
pooledResetTime,
} as HistoryItemModelStats);
},
},
@@ -1,6 +1,6 @@
/**
* @license
* Copyright 2025 Google LLC
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
@@ -15,8 +15,8 @@ describe('ApprovalModeIndicator', () => {
<ApprovalModeIndicator approvalMode={ApprovalMode.AUTO_EDIT} />,
);
const output = lastFrame();
expect(output).toContain('auto-edit');
expect(output).toContain('shift + tab to enter default mode');
expect(output).toContain('auto-accept edits');
expect(output).toContain('shift+tab to manual');
});
it('renders correctly for AUTO_EDIT mode with plan enabled', () => {
@@ -27,8 +27,8 @@ describe('ApprovalModeIndicator', () => {
/>,
);
const output = lastFrame();
expect(output).toContain('auto-edit');
expect(output).toContain('shift + tab to enter default mode');
expect(output).toContain('auto-accept edits');
expect(output).toContain('shift+tab to manual');
});
it('renders correctly for PLAN mode', () => {
@@ -37,7 +37,7 @@ describe('ApprovalModeIndicator', () => {
);
const output = lastFrame();
expect(output).toContain('plan');
expect(output).toContain('shift + tab to enter auto-edit mode');
expect(output).toContain('shift+tab to accept edits');
});
it('renders correctly for YOLO mode', () => {
@@ -46,7 +46,7 @@ describe('ApprovalModeIndicator', () => {
);
const output = lastFrame();
expect(output).toContain('YOLO');
expect(output).toContain('shift + tab to enter auto-edit mode');
expect(output).toContain('ctrl+y');
});
it('renders correctly for DEFAULT mode', () => {
@@ -54,7 +54,7 @@ describe('ApprovalModeIndicator', () => {
<ApprovalModeIndicator approvalMode={ApprovalMode.DEFAULT} />,
);
const output = lastFrame();
expect(output).toContain('shift + tab to enter auto-edit mode');
expect(output).toContain('shift+tab to accept edits');
});
it('renders correctly for DEFAULT mode with plan enabled', () => {
@@ -65,6 +65,6 @@ describe('ApprovalModeIndicator', () => {
/>,
);
const output = lastFrame();
expect(output).toContain('shift + tab to enter plan mode');
expect(output).toContain('shift+tab to plan');
});
});
@@ -25,26 +25,26 @@ export const ApprovalModeIndicator: React.FC<ApprovalModeIndicatorProps> = ({
switch (approvalMode) {
case ApprovalMode.AUTO_EDIT:
textColor = theme.status.warning;
textContent = 'auto-edit';
subText = 'shift + tab to enter default mode';
textContent = 'auto-accept edits';
subText = 'shift+tab to manual';
break;
case ApprovalMode.PLAN:
textColor = theme.status.success;
textContent = 'plan';
subText = 'shift + tab to enter auto-edit mode';
subText = 'shift+tab to accept edits';
break;
case ApprovalMode.YOLO:
textColor = theme.status.error;
textContent = 'YOLO';
subText = 'shift + tab to enter auto-edit mode';
subText = 'ctrl+y';
break;
case ApprovalMode.DEFAULT:
default:
textColor = theme.text.accent;
textContent = '';
subText = isPlanEnabled
? 'shift + tab to enter plan mode'
: 'shift + tab to enter auto-edit mode';
? 'shift+tab to plan'
: 'shift+tab to accept edits';
break;
}
@@ -361,7 +361,7 @@ const TextQuestionView: React.FC<TextQuestionViewProps> = ({
</Box>
<Box flexDirection="row" marginBottom={1}>
<Text color={theme.text.accent}>{'> '}</Text>
<Text color={theme.status.success}>{'> '}</Text>
<TextInput
buffer={buffer}
placeholder={placeholder}
@@ -838,7 +838,9 @@ const ChoiceQuestionView: React.FC<ChoiceQuestionViewProps> = ({
<Box flexDirection="row">
{showCheck && (
<Text
color={isChecked ? theme.text.accent : theme.text.secondary}
color={
isChecked ? theme.status.success : theme.text.secondary
}
>
[{isChecked ? 'x' : ' '}]
</Text>
@@ -870,7 +872,9 @@ const ChoiceQuestionView: React.FC<ChoiceQuestionViewProps> = ({
<Box flexDirection="row">
{showCheck && (
<Text
color={isChecked ? theme.text.accent : theme.text.secondary}
color={
isChecked ? theme.status.success : theme.text.secondary
}
>
[{isChecked ? 'x' : ' '}]
</Text>
@@ -5,7 +5,7 @@
*/
import { render } from '../../test-utils/render.js';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { BackgroundShellDisplay } from './BackgroundShellDisplay.js';
import { type BackgroundShell } from '../hooks/shellCommandProcessor.js';
import { ShellExecutionService } from '@google/gemini-cli-core';
@@ -20,16 +20,12 @@ const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
const mockDismissBackgroundShell = vi.fn();
const mockSetActiveBackgroundShellPid = vi.fn();
const mockSetIsBackgroundShellListOpen = vi.fn();
const mockHandleWarning = vi.fn();
const mockSetEmbeddedShellFocused = vi.fn();
vi.mock('../contexts/UIActionsContext.js', () => ({
useUIActions: () => ({
dismissBackgroundShell: mockDismissBackgroundShell,
setActiveBackgroundShellPid: mockSetActiveBackgroundShellPid,
setIsBackgroundShellListOpen: mockSetIsBackgroundShellListOpen,
handleWarning: mockHandleWarning,
setEmbeddedShellFocused: mockSetEmbeddedShellFocused,
}),
}));
@@ -103,6 +99,10 @@ vi.mock('./shared/ScrollableList.js', () => ({
),
}));
afterEach(() => {
vi.restoreAllMocks();
});
const createMockKey = (overrides: Partial<Key>): Key => ({
name: '',
ctrl: false,
+118 -68
View File
@@ -1,10 +1,10 @@
/**
* @license
* Copyright 2025 Google LLC
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi } from 'vitest';
import { describe, it, expect, vi, afterEach } from 'vitest';
import { render } from '../../test-utils/render.js';
import { Box, Text } from 'ink';
import { Composer } from './Composer.js';
@@ -24,13 +24,41 @@ vi.mock('../contexts/VimModeContext.js', () => ({
})),
}));
import { ApprovalMode } from '@google/gemini-cli-core';
import type { Config } from '@google/gemini-cli-core';
import { StreamingState, ToolCallStatus } from '../types.js';
import { TransientMessageType } from '../../utils/events.js';
import type { LoadedSettings } from '../../config/settings.js';
import type { SessionMetrics } from '../contexts/SessionContext.js';
// Mock child components
vi.mock('./LoadingIndicator.js', () => ({
LoadingIndicator: ({ thought }: { thought?: string }) => (
<Text>LoadingIndicator{thought ? `: ${thought}` : ''}</Text>
),
LoadingIndicator: ({
thought,
thoughtLabel,
}: {
thought?: { subject?: string } | string;
thoughtLabel?: string;
}) => {
const fallbackText =
typeof thought === 'string' ? thought : thought?.subject;
const text = thoughtLabel ?? fallbackText;
return <Text>LoadingIndicator{text ? `: ${text}` : ''}</Text>;
},
}));
vi.mock('./StatusDisplay.js', () => ({
StatusDisplay: () => <Text>StatusDisplay</Text>,
}));
vi.mock('./ToastDisplay.js', () => ({
ToastDisplay: () => <Text>ToastDisplay</Text>,
shouldShowToast: (uiState: UIState) =>
uiState.ctrlCPressedOnce ||
Boolean(uiState.transientMessage) ||
uiState.ctrlDPressedOnce ||
(uiState.showEscapePrompt &&
(uiState.buffer.text.length > 0 || uiState.history.length > 0)) ||
Boolean(uiState.queueErrorMessage),
}));
vi.mock('./ContextSummaryDisplay.js', () => ({
@@ -145,6 +173,12 @@ const createMockUIState = (overrides: Partial<UIState> = {}): UIState =>
activeHooks: [],
isBackgroundShellVisible: false,
embeddedShellFocused: false,
quota: {
userTier: undefined,
stats: undefined,
proQuotaRequest: null,
validationRequest: null,
},
...overrides,
}) as UIState;
@@ -155,31 +189,30 @@ const createMockUIActions = (): UIActions =>
setShellModeActive: vi.fn(),
onEscapePromptChange: vi.fn(),
vimHandleInput: vi.fn(),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
}) as any;
}) as Partial<UIActions> as UIActions;
const createMockConfig = (overrides = {}) => ({
getModel: vi.fn(() => 'gemini-1.5-pro'),
getTargetDir: vi.fn(() => '/test/dir'),
getDebugMode: vi.fn(() => false),
getAccessibility: vi.fn(() => ({})),
getMcpServers: vi.fn(() => ({})),
isPlanEnabled: vi.fn(() => false),
getToolRegistry: () => ({
getTool: vi.fn(),
}),
getSkillManager: () => ({
getSkills: () => [],
getDisplayableSkills: () => [],
}),
getMcpClientManager: () => ({
getMcpServers: () => ({}),
getBlockedMcpServers: () => [],
}),
...overrides,
});
const createMockConfig = (overrides = {}): Config =>
({
getModel: vi.fn(() => 'gemini-1.5-pro'),
getTargetDir: vi.fn(() => '/test/dir'),
getDebugMode: vi.fn(() => false),
getAccessibility: vi.fn(() => ({})),
getMcpServers: vi.fn(() => ({})),
isPlanEnabled: vi.fn(() => false),
getToolRegistry: () => ({
getTool: vi.fn(),
}),
getSkillManager: () => ({
getSkills: () => [],
getDisplayableSkills: () => [],
}),
getMcpClientManager: () => ({
getMcpServers: () => ({}),
getBlockedMcpServers: () => [],
}),
...overrides,
}) as unknown as Config;
/* eslint-disable @typescript-eslint/no-explicit-any */
const renderComposer = (
uiState: UIState,
settings = createMockSettings(),
@@ -187,8 +220,8 @@ const renderComposer = (
uiActions = createMockUIActions(),
) =>
render(
<ConfigContext.Provider value={config as any}>
<SettingsContext.Provider value={settings as any}>
<ConfigContext.Provider value={config as unknown as Config}>
<SettingsContext.Provider value={settings as unknown as LoadedSettings}>
<UIStateContext.Provider value={uiState}>
<UIActionsContext.Provider value={uiActions}>
<Composer />
@@ -197,9 +230,12 @@ const renderComposer = (
</SettingsContext.Provider>
</ConfigContext.Provider>,
);
/* eslint-enable @typescript-eslint/no-explicit-any */
describe('Composer', () => {
afterEach(() => {
vi.restoreAllMocks();
});
describe('Footer Display Settings', () => {
it('renders Footer by default when hideFooter is false', () => {
const uiState = createMockUIState();
@@ -229,8 +265,11 @@ describe('Composer', () => {
sessionStats: {
sessionId: 'test-session',
sessionStartTime: new Date(),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
metrics: {} as any,
metrics: {
models: {},
tools: {},
files: {},
} as SessionMetrics,
lastPromptTokenCount: 150,
promptCount: 5,
},
@@ -251,8 +290,9 @@ describe('Composer', () => {
vi.mocked(useVimMode).mockReturnValueOnce({
vimEnabled: true,
vimMode: 'INSERT',
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any);
toggleVimEnabled: vi.fn(),
setVimMode: vi.fn(),
} as unknown as ReturnType<typeof useVimMode>);
const { lastFrame } = renderComposer(uiState, settings, config);
@@ -276,7 +316,25 @@ describe('Composer', () => {
const { lastFrame } = renderComposer(uiState);
const output = lastFrame();
expect(output).toContain('LoadingIndicator');
expect(output).toContain('LoadingIndicator: Processing');
});
it('renders generic thinking text in loading indicator when full inline thinking is enabled', () => {
const uiState = createMockUIState({
streamingState: StreamingState.Responding,
thought: {
subject: 'Detailed in-history thought',
description: 'Full text is already in history',
},
});
const settings = createMockSettings({
ui: { inlineThinkingMode: 'full' },
});
const { lastFrame } = renderComposer(uiState, settings);
const output = lastFrame();
expect(output).toContain('LoadingIndicator: Thinking ...');
});
it('keeps shortcuts hint visible while loading', () => {
@@ -410,7 +468,7 @@ describe('Composer', () => {
});
describe('Context and Status Display', () => {
it('shows ContextSummaryDisplay in normal state', () => {
it('shows StatusDisplay and ApprovalModeIndicator in normal state', () => {
const uiState = createMockUIState({
ctrlCPressedOnce: false,
ctrlDPressedOnce: false,
@@ -419,49 +477,38 @@ describe('Composer', () => {
const { lastFrame } = renderComposer(uiState);
expect(lastFrame()).toContain('ContextSummaryDisplay');
const output = lastFrame();
expect(output).toContain('StatusDisplay');
expect(output).toContain('ApprovalModeIndicator');
expect(output).not.toContain('ToastDisplay');
});
it('renders HookStatusDisplay instead of ContextSummaryDisplay with active hooks', () => {
const uiState = createMockUIState({
activeHooks: [{ name: 'test-hook', eventName: 'before-agent' }],
});
const { lastFrame } = renderComposer(uiState);
expect(lastFrame()).toContain('HookStatusDisplay');
expect(lastFrame()).not.toContain('ContextSummaryDisplay');
});
it('shows Ctrl+C exit prompt when ctrlCPressedOnce is true', () => {
it('shows ToastDisplay and hides ApprovalModeIndicator when a toast is present', () => {
const uiState = createMockUIState({
ctrlCPressedOnce: true,
});
const { lastFrame } = renderComposer(uiState);
expect(lastFrame()).toContain('Press Ctrl+C again to exit');
const output = lastFrame();
expect(output).toContain('ToastDisplay');
expect(output).not.toContain('ApprovalModeIndicator');
expect(output).toContain('StatusDisplay');
});
it('shows Ctrl+D exit prompt when ctrlDPressedOnce is true', () => {
it('shows ToastDisplay for other toast types', () => {
const uiState = createMockUIState({
ctrlDPressedOnce: true,
transientMessage: {
text: 'Warning',
type: TransientMessageType.Warning,
},
});
const { lastFrame } = renderComposer(uiState);
expect(lastFrame()).toContain('Press Ctrl+D again to exit');
});
it('shows escape prompt when showEscapePrompt is true', () => {
const uiState = createMockUIState({
showEscapePrompt: true,
history: [{ id: 1, type: 'user', text: 'test' }],
});
const { lastFrame } = renderComposer(uiState);
expect(lastFrame()).toContain('Press Esc again to rewind');
const output = lastFrame();
expect(output).toContain('ToastDisplay');
expect(output).not.toContain('ApprovalModeIndicator');
});
});
@@ -541,9 +588,12 @@ describe('Composer', () => {
const uiState = createMockUIState({
showErrorDetails: true,
filteredConsoleMessages: [
{ level: 'error', message: 'Test error', timestamp: new Date() },
// eslint-disable-next-line @typescript-eslint/no-explicit-any
] as any,
{
type: 'error',
content: 'Test error',
count: 1,
},
],
});
const { lastFrame } = renderComposer(uiState);
+53 -42
View File
@@ -1,6 +1,6 @@
/**
* @license
* Copyright 2025 Google LLC
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
@@ -8,6 +8,7 @@ import { useState } from 'react';
import { Box, useIsScreenReaderEnabled } from 'ink';
import { LoadingIndicator } from './LoadingIndicator.js';
import { StatusDisplay } from './StatusDisplay.js';
import { ToastDisplay, shouldShowToast } from './ToastDisplay.js';
import { ApprovalModeIndicator } from './ApprovalModeIndicator.js';
import { ShellModeIndicator } from './ShellModeIndicator.js';
import { DetailedMessagesDisplay } from './DetailedMessagesDisplay.js';
@@ -30,6 +31,7 @@ import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js';
import { StreamingState, ToolCallStatus } from '../types.js';
import { ConfigInitDisplay } from '../components/ConfigInitDisplay.js';
import { TodoTray } from './messages/Todo.js';
import { getInlineThinkingMode } from '../utils/inlineThinkingMode.js';
export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
const config = useConfig();
@@ -38,7 +40,8 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
const uiState = useUIState();
const uiActions = useUIActions();
const { vimEnabled, vimMode } = useVimMode();
const terminalWidth = process.stdout.columns;
const inlineThinkingMode = getInlineThinkingMode(settings);
const terminalWidth = uiState.terminalWidth;
const isNarrow = isNarrowWidth(terminalWidth);
const debugConsoleMaxHeight = Math.floor(Math.max(terminalWidth * 0.2, 5));
const [suggestionsVisible, setSuggestionsVisible] = useState(false);
@@ -59,9 +62,10 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
Boolean(uiState.authConsentRequest) ||
(uiState.confirmUpdateExtensionRequests?.length ?? 0) > 0 ||
Boolean(uiState.loopDetectionConfirmationRequest) ||
Boolean(uiState.proQuotaRequest) ||
Boolean(uiState.validationRequest) ||
Boolean(uiState.quota.proQuotaRequest) ||
Boolean(uiState.quota.validationRequest) ||
Boolean(uiState.customDialog);
const hasToast = shouldShowToast(uiState);
const showLoadingIndicator =
(!uiState.embeddedShellFocused || uiState.isBackgroundShellVisible) &&
uiState.streamingState === StreamingState.Responding &&
@@ -117,6 +121,9 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
? undefined
: uiState.currentLoadingPhrase
}
thoughtLabel={
inlineThinkingMode === 'full' ? 'Thinking ...' : undefined
}
elapsedTime={uiState.elapsedTime}
/>
)}
@@ -148,44 +155,48 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
alignItems="center"
flexGrow={1}
>
{!showLoadingIndicator && (
<Box
flexDirection={isNarrow ? 'column' : 'row'}
alignItems={isNarrow ? 'flex-start' : 'center'}
>
{showApprovalIndicator && (
<ApprovalModeIndicator
approvalMode={showApprovalModeIndicator}
isPlanEnabled={config.isPlanEnabled()}
/>
)}
{uiState.shellModeActive && (
<Box
marginLeft={showApprovalIndicator && !isNarrow ? 1 : 0}
marginTop={showApprovalIndicator && isNarrow ? 1 : 0}
>
<ShellModeIndicator />
</Box>
)}
{showRawMarkdownIndicator && (
<Box
marginLeft={
(showApprovalIndicator || uiState.shellModeActive) &&
!isNarrow
? 1
: 0
}
marginTop={
(showApprovalIndicator || uiState.shellModeActive) &&
isNarrow
? 1
: 0
}
>
<RawMarkdownIndicator />
</Box>
)}
</Box>
{hasToast ? (
<ToastDisplay />
) : (
!showLoadingIndicator && (
<Box
flexDirection={isNarrow ? 'column' : 'row'}
alignItems={isNarrow ? 'flex-start' : 'center'}
>
{showApprovalIndicator && (
<ApprovalModeIndicator
approvalMode={showApprovalModeIndicator}
isPlanEnabled={config.isPlanEnabled()}
/>
)}
{uiState.shellModeActive && (
<Box
marginLeft={showApprovalIndicator && !isNarrow ? 1 : 0}
marginTop={showApprovalIndicator && isNarrow ? 1 : 0}
>
<ShellModeIndicator />
</Box>
)}
{showRawMarkdownIndicator && (
<Box
marginLeft={
(showApprovalIndicator || uiState.shellModeActive) &&
!isNarrow
? 1
: 0
}
marginTop={
(showApprovalIndicator || uiState.shellModeActive) &&
isNarrow
? 1
: 0
}
>
<RawMarkdownIndicator />
</Box>
)}
</Box>
)
)}
</Box>
@@ -1,6 +1,6 @@
/**
* @license
* Copyright 2025 Google LLC
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
@@ -25,7 +25,7 @@ export const ConsentPrompt = (props: ConsentPromptProps) => {
borderStyle="round"
borderColor={theme.border.default}
flexDirection="column"
paddingY={1}
paddingTop={1}
paddingX={2}
>
{typeof prompt === 'string' ? (
@@ -24,8 +24,8 @@ export const ContextUsageDisplay = ({
return (
<Text color={theme.text.secondary}>
({percentageLeft}
{label})
{percentageLeft}
{label}
</Text>
);
};
@@ -1,6 +1,6 @@
/**
* @license
* Copyright 2025 Google LLC
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
@@ -75,7 +75,12 @@ describe('DialogManager', () => {
terminalWidth: 80,
confirmUpdateExtensionRequests: [],
showIdeRestartPrompt: false,
proQuotaRequest: null,
quota: {
userTier: undefined,
stats: undefined,
proQuotaRequest: null,
validationRequest: null,
},
shouldShowIdePrompt: false,
isFolderTrustDialogOpen: false,
loopDetectionConfirmationRequest: null,
@@ -99,8 +104,7 @@ describe('DialogManager', () => {
it('renders nothing by default', () => {
const { lastFrame } = renderWithProviders(
<DialogManager {...defaultProps} />,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
{ uiState: baseUiState as any },
{ uiState: baseUiState as Partial<UIState> as UIState },
);
expect(lastFrame()).toBe('');
});
@@ -115,12 +119,17 @@ describe('DialogManager', () => {
],
[
{
proQuotaRequest: {
failedModel: 'a',
fallbackModel: 'b',
message: 'c',
isTerminalQuotaError: false,
resolve: vi.fn(),
quota: {
userTier: undefined,
stats: undefined,
proQuotaRequest: {
failedModel: 'a',
fallbackModel: 'b',
message: 'c',
isTerminalQuotaError: false,
resolve: vi.fn(),
},
validationRequest: null,
},
},
'ProQuotaDialog',
@@ -185,8 +194,10 @@ describe('DialogManager', () => {
const { lastFrame } = renderWithProviders(
<DialogManager {...defaultProps} />,
{
// eslint-disable-next-line @typescript-eslint/no-explicit-any
uiState: { ...baseUiState, ...uiStateOverride } as any,
uiState: {
...baseUiState,
...uiStateOverride,
} as Partial<UIState> as UIState,
},
);
expect(lastFrame()).toContain(expectedComponent);
@@ -1,6 +1,6 @@
/**
* @license
* Copyright 2025 Google LLC
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
@@ -71,24 +71,30 @@ export const DialogManager = ({
/>
);
}
if (uiState.proQuotaRequest) {
if (uiState.quota.proQuotaRequest) {
return (
<ProQuotaDialog
failedModel={uiState.proQuotaRequest.failedModel}
fallbackModel={uiState.proQuotaRequest.fallbackModel}
message={uiState.proQuotaRequest.message}
isTerminalQuotaError={uiState.proQuotaRequest.isTerminalQuotaError}
isModelNotFoundError={!!uiState.proQuotaRequest.isModelNotFoundError}
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
}
onChoice={uiActions.handleProQuotaChoice}
/>
);
}
if (uiState.validationRequest) {
if (uiState.quota.validationRequest) {
return (
<ValidationDialog
validationLink={uiState.validationRequest.validationLink}
validationDescription={uiState.validationRequest.validationDescription}
learnMoreUrl={uiState.validationRequest.learnMoreUrl}
validationLink={uiState.quota.validationRequest.validationLink}
validationDescription={
uiState.quota.validationRequest.validationDescription
}
learnMoreUrl={uiState.quota.validationRequest.learnMoreUrl}
onChoice={uiActions.handleValidationChoice}
/>
);
+69 -8
View File
@@ -1,10 +1,10 @@
/**
* @license
* Copyright 2025 Google LLC
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi } from 'vitest';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { renderWithProviders } from '../../test-utils/render.js';
import { createMockSettings } from '../../test-utils/settings.js';
import { Footer } from './Footer.js';
@@ -128,7 +128,70 @@ describe('<Footer />', () => {
}),
});
expect(lastFrame()).toContain(defaultProps.model);
expect(lastFrame()).toMatch(/\(\d+% context left\)/);
expect(lastFrame()).toMatch(/\d+% context left/);
});
it('displays the usage indicator when usage is low', () => {
const { lastFrame } = renderWithProviders(<Footer />, {
width: 120,
uiState: {
sessionStats: mockSessionStats,
quota: {
userTier: undefined,
stats: {
remaining: 15,
limit: 100,
resetTime: undefined,
},
proQuotaRequest: null,
validationRequest: null,
},
},
});
expect(lastFrame()).toContain('15%');
expect(lastFrame()).toMatchSnapshot();
});
it('hides the usage indicator when usage is not near limit', () => {
const { lastFrame } = renderWithProviders(<Footer />, {
width: 120,
uiState: {
sessionStats: mockSessionStats,
quota: {
userTier: undefined,
stats: {
remaining: 85,
limit: 100,
resetTime: undefined,
},
proQuotaRequest: null,
validationRequest: null,
},
},
});
expect(lastFrame()).not.toContain('Usage remaining');
expect(lastFrame()).toMatchSnapshot();
});
it('displays "Limit reached" message when remaining is 0', () => {
const { lastFrame } = renderWithProviders(<Footer />, {
width: 120,
uiState: {
sessionStats: mockSessionStats,
quota: {
userTier: undefined,
stats: {
remaining: 0,
limit: 100,
resetTime: undefined,
},
proQuotaRequest: null,
validationRequest: null,
},
},
});
expect(lastFrame()).toContain('Limit reached');
expect(lastFrame()).toMatchSnapshot();
});
it('displays the model name and abbreviated context percentage', () => {
@@ -144,7 +207,7 @@ describe('<Footer />', () => {
}),
});
expect(lastFrame()).toContain(defaultProps.model);
expect(lastFrame()).toMatch(/\(\d+%\)/);
expect(lastFrame()).toMatch(/\d+%/);
});
describe('sandbox and trust info', () => {
@@ -289,9 +352,8 @@ describe('<Footer />', () => {
}),
});
expect(lastFrame()).toContain(defaultProps.model);
expect(lastFrame()).not.toMatch(/\(\d+% context left\)/);
expect(lastFrame()).not.toMatch(/\d+% context left/);
});
it('shows the context percentage when hideContextPercentage is false', () => {
const { lastFrame } = renderWithProviders(<Footer />, {
width: 120,
@@ -305,9 +367,8 @@ describe('<Footer />', () => {
}),
});
expect(lastFrame()).toContain(defaultProps.model);
expect(lastFrame()).toMatch(/\(\d+% context left\)/);
expect(lastFrame()).toMatch(/\d+% context left/);
});
it('renders complete footer in narrow terminal (baseline narrow)', () => {
const { lastFrame } = renderWithProviders(<Footer />, {
width: 79,
+25 -20
View File
@@ -1,6 +1,6 @@
/**
* @license
* Copyright 2025 Google LLC
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
@@ -14,9 +14,9 @@ import {
} from '@google/gemini-cli-core';
import { ConsoleSummaryDisplay } from './ConsoleSummaryDisplay.js';
import process from 'node:process';
import { ThemedGradient } from './ThemedGradient.js';
import { MemoryUsageDisplay } from './MemoryUsageDisplay.js';
import { ContextUsageDisplay } from './ContextUsageDisplay.js';
import { QuotaDisplay } from './QuotaDisplay.js';
import { DebugProfiler } from './DebugProfiler.js';
import { isDevelopment } from '../../utils/installationInfo.js';
import { useUIState } from '../contexts/UIStateContext.js';
@@ -40,9 +40,9 @@ export const Footer: React.FC = () => {
errorCount,
showErrorDetails,
promptTokenCount,
nightly,
isTrustedFolder,
terminalWidth,
quotaStats,
} = {
model: uiState.currentModel,
targetDir: config.getTargetDir(),
@@ -53,9 +53,9 @@ export const Footer: React.FC = () => {
errorCount: uiState.errorCount,
showErrorDetails: uiState.showErrorDetails,
promptTokenCount: uiState.sessionStats.lastPromptTokenCount,
nightly: uiState.nightly,
isTrustedFolder: uiState.isTrustedFolder,
terminalWidth: uiState.terminalWidth,
quotaStats: uiState.quota.stats,
};
const showMemoryUsage =
@@ -87,20 +87,14 @@ export const Footer: React.FC = () => {
{displayVimMode && (
<Text color={theme.text.secondary}>[{displayVimMode}] </Text>
)}
{!hideCWD &&
(nightly ? (
<ThemedGradient>
{displayPath}
{branchName && <Text> ({branchName}*)</Text>}
</ThemedGradient>
) : (
<Text color={theme.text.link}>
{displayPath}
{branchName && (
<Text color={theme.text.secondary}> ({branchName}*)</Text>
)}
</Text>
))}
{!hideCWD && (
<Text color={theme.text.primary}>
{displayPath}
{branchName && (
<Text color={theme.text.secondary}> ({branchName}*)</Text>
)}
</Text>
)}
{debugMode && (
<Text color={theme.status.error}>
{' ' + (debugMessage || '--debug')}
@@ -146,9 +140,9 @@ export const Footer: React.FC = () => {
{!hideModelInfo && (
<Box alignItems="center" justifyContent="flex-end">
<Box alignItems="center">
<Text color={theme.text.accent}>
<Text color={theme.text.primary}>
<Text color={theme.text.secondary}>/model </Text>
{getDisplayString(model)}
<Text color={theme.text.secondary}> /model</Text>
{!hideContextPercentage && (
<>
{' '}
@@ -159,6 +153,17 @@ export const Footer: React.FC = () => {
/>
</>
)}
{quotaStats && (
<>
{' '}
<QuotaDisplay
remaining={quotaStats.remaining}
limit={quotaStats.limit}
resetTime={quotaStats.resetTime}
terse={true}
/>
</>
)}
</Text>
{showMemoryUsage && <MemoryUsageDisplay />}
</Box>
@@ -5,6 +5,7 @@
*/
import type React from 'react';
import { useState, useEffect, useMemo } from 'react';
import { Text, useIsScreenReaderEnabled } from 'ink';
import { CliSpinner } from './CliSpinner.js';
import type { SpinnerName } from 'cli-spinners';
@@ -15,6 +16,10 @@ import {
SCREEN_READER_RESPONDING,
} from '../textConstants.js';
import { theme } from '../semantic-colors.js';
import { Colors } from '../colors.js';
import tinygradient from 'tinygradient';
const COLOR_CYCLE_DURATION_MS = 4000;
interface GeminiRespondingSpinnerProps {
/**
@@ -37,13 +42,16 @@ export const GeminiRespondingSpinner: React.FC<
altText={SCREEN_READER_RESPONDING}
/>
);
} else if (nonRespondingDisplay) {
}
if (nonRespondingDisplay) {
return isScreenReaderEnabled ? (
<Text>{SCREEN_READER_LOADING}</Text>
) : (
<Text color={theme.text.primary}>{nonRespondingDisplay}</Text>
);
}
return null;
};
@@ -57,10 +65,39 @@ export const GeminiSpinner: React.FC<GeminiSpinnerProps> = ({
altText,
}) => {
const isScreenReaderEnabled = useIsScreenReaderEnabled();
const [time, setTime] = useState(0);
const googleGradient = useMemo(() => {
const brandColors = [
Colors.AccentPurple,
Colors.AccentBlue,
Colors.AccentCyan,
Colors.AccentGreen,
Colors.AccentYellow,
Colors.AccentRed,
];
return tinygradient([...brandColors, brandColors[0]]);
}, []);
useEffect(() => {
if (isScreenReaderEnabled) {
return;
}
const interval = setInterval(() => {
setTime((prevTime) => prevTime + 30);
}, 30); // ~33fps for smooth color transitions
return () => clearInterval(interval);
}, [isScreenReaderEnabled]);
const progress = (time % COLOR_CYCLE_DURATION_MS) / COLOR_CYCLE_DURATION_MS;
const currentColor = googleGradient.rgbAt(progress).toHexString();
return isScreenReaderEnabled ? (
<Text>{altText}</Text>
) : (
<Text color={theme.text.primary}>
<Text color={currentColor}>
<CliSpinner type={spinnerType} />
</Text>
);
@@ -15,6 +15,7 @@ import type {
} from '@google/gemini-cli-core';
import { ToolGroupMessage } from './messages/ToolGroupMessage.js';
import { renderWithProviders } from '../../test-utils/render.js';
import { createMockSettings } from '../../test-utils/settings.js';
// Mock child components
vi.mock('./messages/ToolGroupMessage.js', () => ({
@@ -232,6 +233,44 @@ describe('<HistoryItemDisplay />', () => {
);
});
describe('thinking items', () => {
it('renders thinking item when enabled', () => {
const item: HistoryItem = {
...baseItem,
type: 'thinking',
thought: { subject: 'Thinking', description: 'test' },
};
const { lastFrame } = renderWithProviders(
<HistoryItemDisplay {...baseItem} item={item} />,
{
settings: createMockSettings({
merged: { ui: { inlineThinkingMode: 'full' } },
}),
},
);
expect(lastFrame()).toMatchSnapshot();
});
it('does not render thinking item when disabled', () => {
const item: HistoryItem = {
...baseItem,
type: 'thinking',
thought: { subject: 'Thinking', description: 'test' },
};
const { lastFrame } = renderWithProviders(
<HistoryItemDisplay {...baseItem} item={item} />,
{
settings: createMockSettings({
merged: { ui: { inlineThinkingMode: 'off' } },
}),
},
);
expect(lastFrame()).toBe('');
});
});
describe.each([true, false])(
'gemini items (alternateBuffer=%s)',
(useAlternateBuffer) => {
@@ -1,6 +1,6 @@
/**
* @license
* Copyright 2025 Google LLC
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
@@ -34,6 +34,9 @@ import { McpStatus } from './views/McpStatus.js';
import { ChatList } from './views/ChatList.js';
import { HooksList } from './views/HooksList.js';
import { ModelMessage } from './messages/ModelMessage.js';
import { ThinkingMessage } from './messages/ThinkingMessage.js';
import { getInlineThinkingMode } from '../utils/inlineThinkingMode.js';
import { useSettings } from '../contexts/SettingsContext.js';
interface HistoryItemDisplayProps {
item: HistoryItem;
@@ -58,11 +61,16 @@ export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
embeddedShellFocused,
availableTerminalHeightGemini,
}) => {
const settings = useSettings();
const inlineThinkingMode = getInlineThinkingMode(settings);
const itemForDisplay = useMemo(() => escapeAnsiCtrlCodes(item), [item]);
return (
<Box flexDirection="column" key={itemForDisplay.id} width={terminalWidth}>
{/* Render standard message types */}
{itemForDisplay.type === 'thinking' && inlineThinkingMode !== 'off' && (
<ThinkingMessage thought={itemForDisplay.thought} />
)}
{itemForDisplay.type === 'user' && (
<UserMessage text={itemForDisplay.text} width={terminalWidth} />
)}
@@ -125,6 +133,18 @@ export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
selectedAuthType={itemForDisplay.selectedAuthType}
userEmail={itemForDisplay.userEmail}
tier={itemForDisplay.tier}
currentModel={itemForDisplay.currentModel}
quotaStats={
itemForDisplay.pooledRemaining !== undefined ||
itemForDisplay.pooledLimit !== undefined ||
itemForDisplay.pooledResetTime !== undefined
? {
remaining: itemForDisplay.pooledRemaining,
limit: itemForDisplay.pooledLimit,
resetTime: itemForDisplay.pooledResetTime,
}
: undefined
}
/>
)}
{itemForDisplay.type === 'model_stats' && (
@@ -132,6 +152,18 @@ export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
selectedAuthType={itemForDisplay.selectedAuthType}
userEmail={itemForDisplay.userEmail}
tier={itemForDisplay.tier}
currentModel={itemForDisplay.currentModel}
quotaStats={
itemForDisplay.pooledRemaining !== undefined ||
itemForDisplay.pooledLimit !== undefined ||
itemForDisplay.pooledResetTime !== undefined
? {
remaining: itemForDisplay.pooledRemaining,
limit: itemForDisplay.pooledLimit,
resetTime: itemForDisplay.pooledResetTime,
}
: undefined
}
/>
)}
{itemForDisplay.type === 'tool_stats' && <ToolStatsDisplay />}
@@ -9,7 +9,7 @@ import { createMockSettings } from '../../test-utils/settings.js';
import { waitFor } from '../../test-utils/async.js';
import { act, useState } from 'react';
import type { InputPromptProps } from './InputPrompt.js';
import { InputPrompt } from './InputPrompt.js';
import { InputPrompt, tryTogglePasteExpansion } from './InputPrompt.js';
import type { TextBuffer } from './shared/text-buffer.js';
import {
calculateTransformationsForLine,
@@ -46,6 +46,11 @@ import { isLowColorDepth } from '../utils/terminalUtils.js';
import { cpLen } from '../utils/textUtils.js';
import { keyMatchers, Command } from '../keyMatchers.js';
import type { Key } from '../hooks/useKeypress.js';
import {
appEvents,
AppEvent,
TransientMessageType,
} from '../../utils/events.js';
vi.mock('../hooks/useShellHistory.js');
vi.mock('../hooks/useCommandCompletion.js');
@@ -69,6 +74,10 @@ vi.mock('ink', async (importOriginal) => {
};
});
afterEach(() => {
vi.restoreAllMocks();
});
const mockSlashCommands: SlashCommand[] = [
{
name: 'clear',
@@ -272,7 +281,10 @@ describe('InputPrompt', () => {
navigateDown: vi.fn(),
handleSubmit: vi.fn(),
};
mockedUseInputHistory.mockReturnValue(mockInputHistory);
mockedUseInputHistory.mockImplementation(({ onSubmit }) => {
mockInputHistory.handleSubmit = vi.fn((val) => onSubmit(val));
return mockInputHistory;
});
mockReverseSearchCompletion = {
suggestions: [],
@@ -3826,11 +3838,265 @@ describe('InputPrompt', () => {
unmount();
});
});
describe('Ctrl+O paste expansion', () => {
const CTRL_O = '\x0f'; // Ctrl+O key sequence
it('Ctrl+O triggers paste expansion via keybinding', async () => {
const id = '[Pasted Text: 10 lines]';
const toggleFn = vi.fn();
const buffer = {
...props.buffer,
text: id,
cursor: [0, 0] as number[],
pastedContent: {
[id]: 'line1\nline2\nline3\nline4\nline5\nline6\nline7\nline8\nline9\nline10',
},
transformationsByLine: [
[
{
logStart: 0,
logEnd: id.length,
logicalText: id,
collapsedText: id,
type: 'paste',
id,
},
],
],
expandedPaste: null,
getExpandedPasteAtLine: vi.fn().mockReturnValue(null),
togglePasteExpansion: toggleFn,
} as unknown as TextBuffer;
const { stdin, unmount } = renderWithProviders(
<InputPrompt {...props} buffer={buffer} />,
{ uiActions },
);
await act(async () => {
stdin.write(CTRL_O);
});
await waitFor(() => {
expect(toggleFn).toHaveBeenCalledWith(id, 0, 0);
});
unmount();
});
it.each([
{
name: 'hint appears on large paste via Ctrl+V',
text: 'line1\nline2\nline3\nline4\nline5\nline6',
method: 'ctrl-v',
expectHint: true,
},
{
name: 'hint does not appear for small pastes via Ctrl+V',
text: 'hello',
method: 'ctrl-v',
expectHint: false,
},
{
name: 'hint appears on large terminal paste event',
text: 'line1\nline2\nline3\nline4\nline5\nline6',
method: 'terminal-paste',
expectHint: true,
},
])('$name', async ({ text, method, expectHint }) => {
vi.mocked(clipboardy.read).mockResolvedValue(text);
vi.mocked(clipboardUtils.clipboardHasImage).mockResolvedValue(false);
const emitSpy = vi.spyOn(appEvents, 'emit');
const buffer = {
...props.buffer,
handleInput: vi.fn().mockReturnValue(true),
} as unknown as TextBuffer;
// Need kitty protocol enabled for terminal paste events
if (method === 'terminal-paste') {
mockedUseKittyKeyboardProtocol.mockReturnValue({
enabled: true,
checking: false,
});
}
const { stdin, unmount } = renderWithProviders(
<InputPrompt
{...props}
buffer={method === 'terminal-paste' ? buffer : props.buffer}
/>,
);
await act(async () => {
if (method === 'ctrl-v') {
stdin.write('\x16'); // Ctrl+V
} else {
stdin.write(`\x1b[200~${text}\x1b[201~`);
}
});
await waitFor(() => {
if (expectHint) {
expect(emitSpy).toHaveBeenCalledWith(AppEvent.TransientMessage, {
message: 'Press Ctrl+O to expand pasted text',
type: TransientMessageType.Hint,
});
} else {
// If no hint expected, verify buffer was still updated
if (method === 'ctrl-v') {
expect(mockBuffer.insert).toHaveBeenCalledWith(text, {
paste: true,
});
} else {
expect(buffer.handleInput).toHaveBeenCalled();
}
}
});
if (!expectHint) {
expect(emitSpy).not.toHaveBeenCalledWith(
AppEvent.TransientMessage,
expect.any(Object),
);
}
emitSpy.mockRestore();
unmount();
});
});
describe('tryTogglePasteExpansion', () => {
it.each([
{
name: 'returns false when no pasted content exists',
cursor: [0, 0],
pastedContent: {},
getExpandedPasteAtLine: null,
expected: false,
},
{
name: 'expands placeholder under cursor',
cursor: [0, 2],
pastedContent: { '[Pasted Text: 6 lines]': 'content' },
transformations: [
{
logStart: 0,
logEnd: '[Pasted Text: 6 lines]'.length,
id: '[Pasted Text: 6 lines]',
},
],
expected: true,
expectedToggle: ['[Pasted Text: 6 lines]', 0, 2],
},
{
name: 'collapses expanded paste when cursor is inside',
cursor: [1, 0],
pastedContent: { '[Pasted Text: 6 lines]': 'a\nb\nc' },
getExpandedPasteAtLine: '[Pasted Text: 6 lines]',
expected: true,
expectedToggle: ['[Pasted Text: 6 lines]', 1, 0],
},
{
name: 'expands placeholder when cursor is immediately after it',
cursor: [0, '[Pasted Text: 6 lines]'.length],
pastedContent: { '[Pasted Text: 6 lines]': 'content' },
transformations: [
{
logStart: 0,
logEnd: '[Pasted Text: 6 lines]'.length,
id: '[Pasted Text: 6 lines]',
},
],
expected: true,
expectedToggle: [
'[Pasted Text: 6 lines]',
0,
'[Pasted Text: 6 lines]'.length,
],
},
{
name: 'shows hint when cursor is not on placeholder but placeholders exist',
cursor: [0, 0],
pastedContent: { '[Pasted Text: 6 lines]': 'content' },
transformationsByLine: [
[],
[
{
logStart: 0,
logEnd: '[Pasted Text: 6 lines]'.length,
type: 'paste',
id: '[Pasted Text: 6 lines]',
},
],
],
expected: true,
expectedHint: 'Move cursor within placeholder to expand',
},
])(
'$name',
({
cursor,
pastedContent,
transformations,
transformationsByLine,
getExpandedPasteAtLine,
expected,
expectedToggle,
expectedHint,
}) => {
const id = '[Pasted Text: 6 lines]';
const buffer = {
cursor,
pastedContent,
transformationsByLine: transformationsByLine || [
transformations
? transformations.map((t) => ({
...t,
logicalText: id,
collapsedText: id,
type: 'paste',
}))
: [],
],
getExpandedPasteAtLine: vi
.fn()
.mockReturnValue(getExpandedPasteAtLine),
togglePasteExpansion: vi.fn(),
} as unknown as TextBuffer;
const emitSpy = vi.spyOn(appEvents, 'emit');
expect(tryTogglePasteExpansion(buffer)).toBe(expected);
if (expectedToggle) {
expect(buffer.togglePasteExpansion).toHaveBeenCalledWith(
...expectedToggle,
);
} else {
expect(buffer.togglePasteExpansion).not.toHaveBeenCalled();
}
if (expectedHint) {
expect(emitSpy).toHaveBeenCalledWith(AppEvent.TransientMessage, {
message: expectedHint,
type: TransientMessageType.Hint,
});
} else {
expect(emitSpy).not.toHaveBeenCalledWith(
AppEvent.TransientMessage,
expect.any(Object),
);
}
emitSpy.mockRestore();
},
);
});
describe('History Navigation and Completion Suppression', () => {
beforeEach(() => {
props.userMessages = ['first message', 'second message'];
// Mock useInputHistory to actually call onChange
mockedUseInputHistory.mockImplementation(({ onChange }) => ({
mockedUseInputHistory.mockImplementation(({ onChange, onSubmit }) => ({
navigateUp: () => {
onChange('second message', 'start');
return true;
@@ -3839,7 +4105,7 @@ describe('InputPrompt', () => {
onChange('first message', 'end');
return true;
},
handleSubmit: vi.fn(),
handleSubmit: vi.fn((val) => onSubmit(val)),
}));
});
+105 -33
View File
@@ -17,6 +17,8 @@ import {
logicalPosToOffset,
PASTED_TEXT_PLACEHOLDER_REGEX,
getTransformUnderCursor,
LARGE_PASTE_LINE_THRESHOLD,
LARGE_PASTE_CHAR_THRESHOLD,
} from './shared/text-buffer.js';
import {
cpSlice,
@@ -54,11 +56,19 @@ import {
} from '../utils/commandUtils.js';
import * as path from 'node:path';
import { SCREEN_READER_USER_PREFIX } from '../textConstants.js';
import { DEFAULT_BACKGROUND_OPACITY } from '../constants.js';
import {
DEFAULT_BACKGROUND_OPACITY,
DEFAULT_INPUT_BACKGROUND_OPACITY,
} from '../constants.js';
import { getSafeLowColorBackground } from '../themes/color-utils.js';
import { isLowColorDepth } from '../utils/terminalUtils.js';
import { useShellFocusState } from '../contexts/ShellFocusContext.js';
import { useUIState } from '../contexts/UIStateContext.js';
import {
appEvents,
AppEvent,
TransientMessageType,
} from '../../utils/events.js';
import { useSettings } from '../contexts/SettingsContext.js';
import { StreamingState } from '../types.js';
import { useMouseClick } from '../hooks/useMouseClick.js';
@@ -122,6 +132,55 @@ export const calculatePromptWidths = (mainContentWidth: number) => {
} as const;
};
/**
* Returns true if the given text exceeds the thresholds for being considered a "large paste".
*/
export function isLargePaste(text: string): boolean {
const pasteLineCount = text.split('\n').length;
return (
pasteLineCount > LARGE_PASTE_LINE_THRESHOLD ||
text.length > LARGE_PASTE_CHAR_THRESHOLD
);
}
/**
* Attempt to toggle expansion of a paste placeholder in the buffer.
* Returns true if a toggle action was performed or hint was shown, false otherwise.
*/
export function tryTogglePasteExpansion(buffer: TextBuffer): boolean {
if (!buffer.pastedContent || Object.keys(buffer.pastedContent).length === 0) {
return false;
}
const [row, col] = buffer.cursor;
// 1. Check if cursor is on or immediately after a collapsed placeholder
const transform = getTransformUnderCursor(
row,
col,
buffer.transformationsByLine,
{ includeEdge: true },
);
if (transform?.type === 'paste' && transform.id) {
buffer.togglePasteExpansion(transform.id, row, col);
return true;
}
// 2. Check if cursor is inside an expanded paste region — collapse it
const expandedId = buffer.getExpandedPasteAtLine(row);
if (expandedId) {
buffer.togglePasteExpansion(expandedId, row, col);
return true;
}
// 3. Placeholders exist but cursor isn't on one — show hint
appEvents.emit(AppEvent.TransientMessage, {
message: 'Move cursor within placeholder to expand',
type: TransientMessageType.Hint,
});
return true;
}
export const InputPrompt: React.FC<InputPromptProps> = ({
buffer,
onSubmit,
@@ -278,31 +337,6 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
],
);
const handleSubmit = useCallback(
(submittedValue: string) => {
const trimmedMessage = submittedValue.trim();
const isSlash = isSlashCommand(trimmedMessage);
const isShell = shellModeActive;
if (
(isSlash || isShell) &&
streamingState === StreamingState.Responding
) {
setQueueErrorMessage(
`${isShell ? 'Shell' : 'Slash'} commands cannot be queued`,
);
return;
}
handleSubmitAndClear(trimmedMessage);
},
[
handleSubmitAndClear,
shellModeActive,
streamingState,
setQueueErrorMessage,
],
);
const customSetTextAndResetCompletionSignal = useCallback(
(newText: string, cursorPosition?: 'start' | 'end' | number) => {
buffer.setText(newText, cursorPosition);
@@ -322,6 +356,26 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
onChange: customSetTextAndResetCompletionSignal,
});
const handleSubmit = useCallback(
(submittedValue: string) => {
const trimmedMessage = submittedValue.trim();
const isSlash = isSlashCommand(trimmedMessage);
const isShell = shellModeActive;
if (
(isSlash || isShell) &&
streamingState === StreamingState.Responding
) {
setQueueErrorMessage(
`${isShell ? 'Shell' : 'Slash'} commands cannot be queued`,
);
return;
}
inputHistory.handleSubmit(trimmedMessage);
},
[inputHistory, shellModeActive, streamingState, setQueueErrorMessage],
);
// Effect to reset completion if history navigation just occurred and set the text
useEffect(() => {
if (suppressCompletion) {
@@ -402,6 +456,12 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
} else {
const textToInsert = await clipboardy.read();
buffer.insert(textToInsert, { paste: true });
if (isLargePaste(textToInsert)) {
appEvents.emit(AppEvent.TransientMessage, {
message: 'Press Ctrl+O to expand pasted text',
type: TransientMessageType.Hint,
});
}
}
} catch (error) {
debugLogger.error('Error handling paste:', error);
@@ -455,6 +515,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
logicalPos.row,
logicalPos.col,
buffer.transformationsByLine,
{ includeEdge: true },
);
if (transform?.type === 'paste' && transform.id) {
buffer.togglePasteExpansion(
@@ -591,6 +652,12 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
}
// Ensure we never accidentally interpret paste as regular input.
buffer.handleInput(key);
if (key.sequence && isLargePaste(key.sequence)) {
appEvents.emit(AppEvent.TransientMessage, {
message: 'Press Ctrl+O to expand pasted text',
type: TransientMessageType.Hint,
});
}
return true;
}
@@ -632,6 +699,12 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
}
}
// Ctrl+O to expand/collapse paste placeholders
if (keyMatchers[Command.EXPAND_PASTE](key)) {
const handled = tryTogglePasteExpansion(buffer);
if (handled) return true;
}
if (
key.sequence === '!' &&
buffer.text === '' &&
@@ -780,7 +853,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
showSuggestions && activeSuggestionIndex > -1
? suggestions[activeSuggestionIndex].value
: buffer.text;
handleSubmitAndClear(textToSubmit);
handleSubmit(textToSubmit);
resetState();
setActive(false);
return true;
@@ -1077,7 +1150,6 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
setShellModeActive,
onClearScreen,
inputHistory,
handleSubmitAndClear,
handleSubmit,
shellHistory,
reverseSearchCompletion,
@@ -1330,12 +1402,12 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
/>
) : null}
<HalfLinePaddedBox
backgroundBaseColor={
isShellFocused && !isEmbeddedShellFocused
? theme.border.focused
: theme.border.default
backgroundBaseColor={theme.text.secondary}
backgroundOpacity={
showCursor
? DEFAULT_INPUT_BACKGROUND_OPACITY
: DEFAULT_BACKGROUND_OPACITY
}
backgroundOpacity={DEFAULT_BACKGROUND_OPACITY}
useBackgroundColor={useBackgroundColor}
>
<Box
@@ -57,12 +57,12 @@ describe('<LoadingIndicator />', () => {
elapsedTime: 5,
};
it('should not render when streamingState is Idle and no loading phrase or thought', () => {
it('should render blank when streamingState is Idle and no loading phrase or thought', () => {
const { lastFrame } = renderWithContext(
<LoadingIndicator elapsedTime={5} />,
StreamingState.Idle,
);
expect(lastFrame()).toBe('');
expect(lastFrame()?.trim()).toBe('');
});
it('should render spinner, phrase, and time when streamingState is Responding', () => {
@@ -146,7 +146,7 @@ describe('<LoadingIndicator />', () => {
<LoadingIndicator elapsedTime={5} />,
StreamingState.Idle,
);
expect(lastFrame()).toBe(''); // Initial: Idle (no loading phrase)
expect(lastFrame()?.trim()).toBe(''); // Initial: Idle (no loading phrase)
// Transition to Responding
rerender(
@@ -183,7 +183,7 @@ describe('<LoadingIndicator />', () => {
<LoadingIndicator elapsedTime={5} />
</StreamingContext.Provider>,
);
expect(lastFrame()).toBe(''); // Idle with no loading phrase
expect(lastFrame()?.trim()).toBe(''); // Idle with no loading phrase and no spinner
unmount();
});
@@ -217,6 +217,7 @@ describe('<LoadingIndicator />', () => {
const output = lastFrame();
expect(output).toBeDefined();
if (output) {
expect(output).toContain('💬');
expect(output).toContain('Thinking about something...');
expect(output).not.toContain('and other stuff.');
}
@@ -237,11 +238,24 @@ describe('<LoadingIndicator />', () => {
StreamingState.Responding,
);
const output = lastFrame();
expect(output).toContain('💬');
expect(output).toContain('This should be displayed');
expect(output).not.toContain('This should not be displayed');
unmount();
});
it('should not display thought icon for non-thought loading phrases', () => {
const { lastFrame, unmount } = renderWithContext(
<LoadingIndicator
currentLoadingPhrase="some random tip..."
elapsedTime={3}
/>,
StreamingState.Responding,
);
expect(lastFrame()).not.toContain('💬');
unmount();
});
it('should truncate long primary text instead of wrapping', () => {
const { lastFrame, unmount } = renderWithContext(
<LoadingIndicator
@@ -22,6 +22,7 @@ interface LoadingIndicatorProps {
inline?: boolean;
rightContent?: React.ReactNode;
thought?: ThoughtSummary | null;
thoughtLabel?: string;
showCancelAndTimer?: boolean;
}
@@ -31,6 +32,7 @@ export const LoadingIndicator: React.FC<LoadingIndicatorProps> = ({
inline = false,
rightContent,
thought,
thoughtLabel,
showCancelAndTimer = true,
}) => {
const streamingState = useStreamingContext();
@@ -50,7 +52,13 @@ export const LoadingIndicator: React.FC<LoadingIndicatorProps> = ({
const primaryText =
currentLoadingPhrase === INTERACTIVE_SHELL_WAITING_PHRASE
? currentLoadingPhrase
: thought?.subject || currentLoadingPhrase;
: thought?.subject
? (thoughtLabel ?? thought.subject)
: currentLoadingPhrase;
const hasThoughtIndicator =
currentLoadingPhrase !== INTERACTIVE_SHELL_WAITING_PHRASE &&
Boolean(thought?.subject?.trim());
const thinkingIndicator = hasThoughtIndicator ? '💬 ' : '';
const cancelAndTimerContent =
showCancelAndTimer &&
@@ -71,7 +79,8 @@ export const LoadingIndicator: React.FC<LoadingIndicatorProps> = ({
/>
</Box>
{primaryText && (
<Text color={theme.text.accent} wrap="truncate-end">
<Text color={theme.text.primary} italic wrap="truncate-end">
{thinkingIndicator}
{primaryText}
</Text>
)}
@@ -104,7 +113,8 @@ export const LoadingIndicator: React.FC<LoadingIndicatorProps> = ({
/>
</Box>
{primaryText && (
<Text color={theme.text.accent} wrap="truncate-end">
<Text color={theme.text.primary} italic wrap="truncate-end">
{thinkingIndicator}
{primaryText}
</Text>
)}
@@ -16,6 +16,20 @@ import { SHELL_COMMAND_NAME } from '../constants.js';
import type { UIState } from '../contexts/UIStateContext.js';
// Mock dependencies
vi.mock('../contexts/SettingsContext.js', async () => {
const actual = await vi.importActual('../contexts/SettingsContext.js');
return {
...actual,
useSettings: () => ({
merged: {
ui: {
inlineThinkingMode: 'off',
},
},
}),
};
});
vi.mock('../contexts/AppContext.js', async () => {
const actual = await vi.importActual('../contexts/AppContext.js');
return {
@@ -68,12 +82,15 @@ describe('MainContent', () => {
availableTerminalHeight: 24,
slashCommands: [],
constrainHeight: false,
thought: null,
isEditorDialogOpen: false,
activePtyId: undefined,
embeddedShellFocused: false,
historyRemountKey: 0,
bannerData: { defaultText: '', warningText: '' },
bannerVisible: false,
copyModeEnabled: false,
terminalWidth: 100,
};
beforeEach(() => {
@@ -158,6 +175,7 @@ describe('MainContent', () => {
vi.mocked(useAlternateBuffer).mockReturnValue(isAlternateBuffer);
const ptyId = 123;
const uiState = {
...defaultMockUiState,
history: [],
pendingHistoryItems: [
{
@@ -185,6 +203,7 @@ describe('MainContent', () => {
terminalHeight: 50,
terminalWidth: 100,
mainAreaWidth: 100,
thought: null,
embeddedShellFocused,
activePtyId: embeddedShellFocused ? ptyId : undefined,
constrainHeight,
@@ -4,11 +4,11 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { render } from 'ink-testing-library';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { act } from 'react';
import { ModelDialog } from './ModelDialog.js';
import { ConfigContext } from '../contexts/ConfigContext.js';
import { KeypressProvider } from '../contexts/KeypressContext.js';
import { renderWithProviders } from '../../test-utils/render.js';
import { waitFor } from '../../test-utils/async.js';
import {
DEFAULT_GEMINI_MODEL,
DEFAULT_GEMINI_MODEL_AUTO,
@@ -47,12 +47,14 @@ describe('<ModelDialog />', () => {
setModel: (model: string, isTemporary?: boolean) => void;
getModel: () => string;
getHasAccessToPreviewModel: () => boolean;
getIdeMode: () => boolean;
}
const mockConfig: MockConfig = {
setModel: mockSetModel,
getModel: mockGetModel,
getHasAccessToPreviewModel: mockGetHasAccessToPreviewModel,
getIdeMode: () => false,
};
beforeEach(() => {
@@ -68,17 +70,10 @@ describe('<ModelDialog />', () => {
});
});
const renderComponent = (contextValue = mockConfig as Config) =>
render(
<KeypressProvider>
<ConfigContext.Provider value={contextValue}>
<ModelDialog onClose={mockOnClose} />
</ConfigContext.Provider>
</KeypressProvider>,
);
const waitForUpdate = () =>
new Promise((resolve) => setTimeout(resolve, 150));
const renderComponent = (configValue = mockConfig as Config) =>
renderWithProviders(<ModelDialog onClose={mockOnClose} />, {
config: configValue,
});
it('renders the initial "main" view correctly', () => {
const { lastFrame } = renderComponent();
@@ -93,48 +88,60 @@ describe('<ModelDialog />', () => {
// Select "Manual" (index 1)
// Press down arrow to move to "Manual"
stdin.write('\u001B[B'); // Arrow Down
await waitForUpdate();
await act(async () => {
stdin.write('\u001B[B'); // Arrow Down
});
// Press enter to select
stdin.write('\r');
await waitForUpdate();
await act(async () => {
stdin.write('\r');
});
// Should now show manual options
expect(lastFrame()).toContain(DEFAULT_GEMINI_MODEL);
expect(lastFrame()).toContain(DEFAULT_GEMINI_FLASH_MODEL);
expect(lastFrame()).toContain(DEFAULT_GEMINI_FLASH_LITE_MODEL);
await waitFor(() => {
expect(lastFrame()).toContain(DEFAULT_GEMINI_MODEL);
expect(lastFrame()).toContain(DEFAULT_GEMINI_FLASH_MODEL);
expect(lastFrame()).toContain(DEFAULT_GEMINI_FLASH_LITE_MODEL);
});
});
it('sets model and closes when a model is selected in "main" view', async () => {
const { stdin } = renderComponent();
// Select "Auto" (index 0)
stdin.write('\r');
await waitForUpdate();
await act(async () => {
stdin.write('\r');
});
expect(mockSetModel).toHaveBeenCalledWith(
DEFAULT_GEMINI_MODEL_AUTO,
true, // Session only by default
);
expect(mockOnClose).toHaveBeenCalled();
await waitFor(() => {
expect(mockSetModel).toHaveBeenCalledWith(
DEFAULT_GEMINI_MODEL_AUTO,
true, // Session only by default
);
expect(mockOnClose).toHaveBeenCalled();
});
});
it('sets model and closes when a model is selected in "manual" view', async () => {
const { stdin } = renderComponent();
// Navigate to Manual (index 1) and select
stdin.write('\u001B[B');
await waitForUpdate();
stdin.write('\r');
await waitForUpdate();
await act(async () => {
stdin.write('\u001B[B');
});
await act(async () => {
stdin.write('\r');
});
// Now in manual view. Default selection is first item (DEFAULT_GEMINI_MODEL)
stdin.write('\r');
await waitForUpdate();
await act(async () => {
stdin.write('\r');
});
expect(mockSetModel).toHaveBeenCalledWith(DEFAULT_GEMINI_MODEL, true);
expect(mockOnClose).toHaveBeenCalled();
await waitFor(() => {
expect(mockSetModel).toHaveBeenCalledWith(DEFAULT_GEMINI_MODEL, true);
expect(mockOnClose).toHaveBeenCalled();
});
});
it('toggles persist mode with Tab key', async () => {
@@ -143,48 +150,64 @@ describe('<ModelDialog />', () => {
expect(lastFrame()).toContain('Remember model for future sessions: false');
// Press Tab to toggle persist mode
stdin.write('\t');
await waitForUpdate();
await act(async () => {
stdin.write('\t');
});
expect(lastFrame()).toContain('Remember model for future sessions: true');
await waitFor(() => {
expect(lastFrame()).toContain('Remember model for future sessions: true');
});
// Select "Auto" (index 0)
stdin.write('\r');
await waitForUpdate();
await act(async () => {
stdin.write('\r');
});
expect(mockSetModel).toHaveBeenCalledWith(
DEFAULT_GEMINI_MODEL_AUTO,
false, // Persist enabled
);
expect(mockOnClose).toHaveBeenCalled();
await waitFor(() => {
expect(mockSetModel).toHaveBeenCalledWith(
DEFAULT_GEMINI_MODEL_AUTO,
false, // Persist enabled
);
expect(mockOnClose).toHaveBeenCalled();
});
});
it('closes dialog on escape in "main" view', async () => {
const { stdin } = renderComponent();
stdin.write('\u001B'); // Escape
await waitForUpdate();
await act(async () => {
stdin.write('\u001B'); // Escape
});
expect(mockOnClose).toHaveBeenCalled();
await waitFor(() => {
expect(mockOnClose).toHaveBeenCalled();
});
});
it('goes back to "main" view on escape in "manual" view', async () => {
const { lastFrame, stdin } = renderComponent();
// Go to manual view
stdin.write('\u001B[B');
await waitForUpdate();
stdin.write('\r');
await waitForUpdate();
await act(async () => {
stdin.write('\u001B[B');
});
await act(async () => {
stdin.write('\r');
});
expect(lastFrame()).toContain(DEFAULT_GEMINI_MODEL);
await waitFor(() => {
expect(lastFrame()).toContain(DEFAULT_GEMINI_MODEL);
});
// Press Escape
stdin.write('\u001B');
await waitForUpdate();
await act(async () => {
stdin.write('\u001B');
});
expect(mockOnClose).not.toHaveBeenCalled();
// Should be back to main view (Manual option visible)
expect(lastFrame()).toContain('Manual');
await waitFor(() => {
expect(mockOnClose).not.toHaveBeenCalled();
// Should be back to main view (Manual option visible)
expect(lastFrame()).toContain('Manual');
});
});
});
@@ -1,6 +1,6 @@
/**
* @license
* Copyright 2025 Google LLC
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
@@ -33,7 +33,11 @@ vi.mock('../contexts/SettingsContext.js', async (importOriginal) => {
const useSessionStatsMock = vi.mocked(SessionContext.useSessionStats);
const useSettingsMock = vi.mocked(SettingsContext.useSettings);
const renderWithMockedStats = (metrics: SessionMetrics, width?: number) => {
const renderWithMockedStats = (
metrics: SessionMetrics,
width?: number,
currentModel: string = 'gemini-2.5-pro',
) => {
useSessionStatsMock.mockReturnValue({
stats: {
sessionId: 'test-session',
@@ -55,7 +59,7 @@ const renderWithMockedStats = (metrics: SessionMetrics, width?: number) => {
},
} as unknown as LoadedSettings);
return render(<ModelStatsDisplay />, width);
return render(<ModelStatsDisplay currentModel={currentModel} />, width);
};
describe('<ModelStatsDisplay />', () => {
@@ -380,6 +384,7 @@ describe('<ModelStatsDisplay />', () => {
},
},
80,
'auto-gemini-3',
);
const output = lastFrame();
@@ -1,6 +1,6 @@
/**
* @license
* Copyright 2025 Google LLC
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
@@ -16,6 +16,9 @@ import {
import { useSessionStats } from '../contexts/SessionContext.js';
import { Table, type Column } from './Table.js';
import { useSettings } from '../contexts/SettingsContext.js';
import { getDisplayString, isAutoModel } from '@google/gemini-cli-core';
import type { QuotaStats } from '../types.js';
import { QuotaStatsInfo } from './QuotaStatsInfo.js';
interface StatRowData {
metric: string;
@@ -29,14 +32,23 @@ interface ModelStatsDisplayProps {
selectedAuthType?: string;
userEmail?: string;
tier?: string;
currentModel?: string;
quotaStats?: QuotaStats;
}
export const ModelStatsDisplay: React.FC<ModelStatsDisplayProps> = ({
selectedAuthType,
userEmail,
tier,
currentModel,
quotaStats,
}) => {
const { stats } = useSessionStats();
const pooledRemaining = quotaStats?.remaining;
const pooledLimit = quotaStats?.limit;
const pooledResetTime = quotaStats?.resetTime;
const { models } = stats.metrics;
const settings = useSettings();
const showUserIdentity = settings.merged.ui.showUserIdentity;
@@ -49,7 +61,7 @@ export const ModelStatsDisplay: React.FC<ModelStatsDisplayProps> = ({
<Box
borderStyle="round"
borderColor={theme.border.default}
paddingY={1}
paddingTop={1}
paddingX={2}
>
<Text color={theme.text.primary}>
@@ -223,16 +235,21 @@ export const ModelStatsDisplay: React.FC<ModelStatsDisplayProps> = ({
})),
];
const isAuto = currentModel && isAutoModel(currentModel);
const statsTitle = isAuto
? `${getDisplayString(currentModel)} Stats For Nerds`
: 'Model Stats For Nerds';
return (
<Box
borderStyle="round"
borderColor={theme.border.default}
flexDirection="column"
paddingY={1}
paddingTop={1}
paddingX={2}
>
<Text bold color={theme.text.accent}>
Model Stats For Nerds
{statsTitle}
</Text>
<Box height={1} />
@@ -258,7 +275,17 @@ export const ModelStatsDisplay: React.FC<ModelStatsDisplayProps> = ({
<Text color={theme.text.primary}>{tier}</Text>
</Box>
)}
{showUserIdentity && (selectedAuthType || tier) && <Box height={1} />}
{isAuto &&
pooledRemaining !== undefined &&
pooledLimit !== undefined &&
pooledLimit > 0 && (
<QuotaStatsInfo
remaining={pooledRemaining}
limit={pooledLimit}
resetTime={pooledResetTime}
/>
)}
{(showUserIdentity || isAuto) && <Box height={1} />}
<Table data={rows} columns={columns} />
</Box>
@@ -12,6 +12,15 @@ import { useUIState, type UIState } from '../contexts/UIStateContext.js';
import { useTerminalSize } from '../hooks/useTerminalSize.js';
vi.mock('../contexts/UIStateContext.js');
vi.mock('../contexts/SettingsContext.js', () => ({
useSettings: () => ({
merged: {
ui: {
inlineThinkingMode: 'off',
},
},
}),
}));
vi.mock('../hooks/useTerminalSize.js');
vi.mock('./HistoryItemDisplay.js', async () => {
const { Text } = await vi.importActual('ink');
@@ -0,0 +1,73 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { render } from '../../test-utils/render.js';
import { describe, it, expect } from 'vitest';
import { QuotaDisplay } from './QuotaDisplay.js';
describe('QuotaDisplay', () => {
it('should not render when remaining is undefined', () => {
const { lastFrame } = render(
<QuotaDisplay remaining={undefined} limit={100} />,
);
expect(lastFrame()).toBe('');
});
it('should not render when limit is undefined', () => {
const { lastFrame } = render(
<QuotaDisplay remaining={100} limit={undefined} />,
);
expect(lastFrame()).toBe('');
});
it('should not render when limit is 0', () => {
const { lastFrame } = render(<QuotaDisplay remaining={100} limit={0} />);
expect(lastFrame()).toBe('');
});
it('should not render when usage > 20%', () => {
const { lastFrame } = render(<QuotaDisplay remaining={85} limit={100} />);
expect(lastFrame()).toBe('');
});
it('should render yellow when usage < 20%', () => {
const { lastFrame } = render(<QuotaDisplay remaining={15} limit={100} />);
expect(lastFrame()).toMatchSnapshot();
});
it('should render red when usage < 5%', () => {
const { lastFrame } = render(<QuotaDisplay remaining={4} limit={100} />);
expect(lastFrame()).toMatchSnapshot();
});
it('should render with reset time when provided', () => {
const resetTime = new Date(Date.now() + 3600000).toISOString(); // 1 hour from now
const { lastFrame } = render(
<QuotaDisplay remaining={15} limit={100} resetTime={resetTime} />,
);
expect(lastFrame()).toMatchSnapshot();
});
it('should NOT render reset time when terse is true', () => {
const resetTime = new Date(Date.now() + 3600000).toISOString();
const { lastFrame } = render(
<QuotaDisplay
remaining={15}
limit={100}
resetTime={resetTime}
terse={true}
/>,
);
expect(lastFrame()).toMatchSnapshot();
});
it('should render terse limit reached message', () => {
const { lastFrame } = render(
<QuotaDisplay remaining={0} limit={100} terse={true} />,
);
expect(lastFrame()).toMatchSnapshot();
});
});
@@ -0,0 +1,64 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { Text } from 'ink';
import {
getStatusColor,
QUOTA_THRESHOLD_HIGH,
QUOTA_THRESHOLD_MEDIUM,
} from '../utils/displayUtils.js';
import { formatResetTime } from '../utils/formatters.js';
interface QuotaDisplayProps {
remaining: number | undefined;
limit: number | undefined;
resetTime?: string;
terse?: boolean;
}
export const QuotaDisplay: React.FC<QuotaDisplayProps> = ({
remaining,
limit,
resetTime,
terse = false,
}) => {
if (remaining === undefined || limit === undefined || limit === 0) {
return null;
}
const percentage = (remaining / limit) * 100;
if (percentage > QUOTA_THRESHOLD_HIGH) {
return null;
}
const color = getStatusColor(percentage, {
green: QUOTA_THRESHOLD_HIGH,
yellow: QUOTA_THRESHOLD_MEDIUM,
});
const resetInfo =
!terse && resetTime ? `, ${formatResetTime(resetTime)}` : '';
if (remaining === 0) {
return (
<Text color={color}>
{terse
? 'Limit reached'
: `/stats Limit reached${resetInfo}${!terse && '. /auth to continue.'}`}
</Text>
);
}
return (
<Text color={color}>
{terse
? `${percentage.toFixed(0)}%`
: `/stats ${percentage.toFixed(0)}% usage remaining${resetInfo}`}
</Text>
);
};
@@ -0,0 +1,65 @@
/**
* @license
* Copyright 2026 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 { formatResetTime } from '../utils/formatters.js';
import {
getStatusColor,
QUOTA_THRESHOLD_HIGH,
QUOTA_THRESHOLD_MEDIUM,
} from '../utils/displayUtils.js';
interface QuotaStatsInfoProps {
remaining: number | undefined;
limit: number | undefined;
resetTime?: string;
showDetails?: boolean;
}
export const QuotaStatsInfo: React.FC<QuotaStatsInfoProps> = ({
remaining,
limit,
resetTime,
showDetails = true,
}) => {
if (remaining === undefined || limit === undefined || limit === 0) {
return null;
}
const percentage = (remaining / limit) * 100;
const color = getStatusColor(percentage, {
green: QUOTA_THRESHOLD_HIGH,
yellow: QUOTA_THRESHOLD_MEDIUM,
});
return (
<Box flexDirection="column" marginTop={0} marginBottom={0}>
<Text color={color}>
{remaining === 0
? `Limit reached`
: `${percentage.toFixed(0)}% usage remaining`}
{resetTime && `, ${formatResetTime(resetTime)}`}
</Text>
{showDetails && (
<>
<Text color={theme.text.primary}>
Usage limit: {limit.toLocaleString()}
</Text>
<Text color={theme.text.primary}>
Usage limits span all sessions and reset daily.
</Text>
{remaining === 0 && (
<Text color={theme.text.primary}>
Please /auth to upgrade or switch to an API key to continue.
</Text>
)}
</>
)}
</Box>
);
};
@@ -9,17 +9,21 @@ 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([
@@ -31,7 +31,7 @@ export const ShowMoreLines = ({ constrainHeight }: ShowMoreLinesProps) => {
}
return (
<Box>
<Box paddingX={1}>
<Text color={theme.text.secondary} wrap="truncate">
Press ctrl-o to show more lines
</Text>
@@ -1,6 +1,6 @@
/**
* @license
* Copyright 2025 Google LLC
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
@@ -421,6 +421,7 @@ describe('<StatsDisplay />', () => {
buckets: [
{
modelId: 'gemini-2.5-pro',
remainingAmount: '75',
remainingFraction: 0.75,
resetTime,
},
@@ -446,9 +447,64 @@ describe('<StatsDisplay />', () => {
);
const output = lastFrame();
expect(output).toContain('Usage left');
expect(output).toContain('Usage remaining');
expect(output).toContain('75.0%');
expect(output).toContain('(Resets in 1h 30m)');
expect(output).toContain('resets in 1h 30m');
expect(output).toMatchSnapshot();
vi.useRealTimers();
});
it('renders pooled quota information for auto mode', () => {
const now = new Date('2025-01-01T12:00:00Z');
vi.useFakeTimers();
vi.setSystemTime(now);
const metrics = createTestMetrics();
const quotas: RetrieveUserQuotaResponse = {
buckets: [
{
modelId: 'gemini-2.5-pro',
remainingAmount: '10',
remainingFraction: 0.1, // limit = 100
},
{
modelId: 'gemini-2.5-flash',
remainingAmount: '700',
remainingFraction: 0.7, // limit = 1000
},
],
};
useSessionStatsMock.mockReturnValue({
stats: {
sessionId: 'test-session-id',
sessionStartTime: new Date(),
metrics,
lastPromptTokenCount: 0,
promptCount: 5,
},
getPromptCount: () => 5,
startNewPrompt: vi.fn(),
});
const { lastFrame } = renderWithProviders(
<StatsDisplay
duration="1s"
quotas={quotas}
currentModel="auto"
quotaStats={{
remaining: 710,
limit: 1100,
}}
/>,
{ width: 100 },
);
const output = lastFrame();
// (10 + 700) / (100 + 1000) = 710 / 1100 = 64.5%
expect(output).toContain('65% usage remaining');
expect(output).toContain('Usage limit: 1,100');
expect(output).toMatchSnapshot();
vi.useRealTimers();
@@ -468,6 +524,7 @@ describe('<StatsDisplay />', () => {
buckets: [
{
modelId: 'gemini-2.5-flash',
remainingAmount: '50',
remainingFraction: 0.5,
resetTime,
},
@@ -495,7 +552,7 @@ describe('<StatsDisplay />', () => {
expect(output).toContain('gemini-2.5-flash');
expect(output).toContain('-'); // for requests
expect(output).toContain('50.0%');
expect(output).toContain('(Resets in 2h)');
expect(output).toContain('resets in 2h');
expect(output).toMatchSnapshot();
vi.useRealTimers();
+71 -44
View File
@@ -1,6 +1,6 @@
/**
* @license
* Copyright 2025 Google LLC
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
@@ -8,7 +8,7 @@ import type React from 'react';
import { Box, Text } from 'ink';
import { ThemedGradient } from './ThemedGradient.js';
import { theme } from '../semantic-colors.js';
import { formatDuration } from '../utils/formatters.js';
import { formatDuration, formatResetTime } from '../utils/formatters.js';
import type { ModelMetrics } from '../contexts/SessionContext.js';
import { useSessionStats } from '../contexts/SessionContext.js';
import {
@@ -24,8 +24,12 @@ import { computeSessionStats } from '../utils/computeStats.js';
import {
type RetrieveUserQuotaResponse,
VALID_GEMINI_MODELS,
getDisplayString,
isAutoModel,
} from '@google/gemini-cli-core';
import { useSettings } from '../contexts/SettingsContext.js';
import type { QuotaStats } from '../types.js';
import { QuotaStatsInfo } from './QuotaStatsInfo.js';
// A more flexible and powerful StatRow component
interface StatRowProps {
@@ -122,36 +126,25 @@ const buildModelRows = (
return [...activeRows, ...quotaRows];
};
const formatResetTime = (resetTime: string): string => {
const diff = new Date(resetTime).getTime() - Date.now();
if (diff <= 0) return '';
const totalMinutes = Math.ceil(diff / (1000 * 60));
const hours = Math.floor(totalMinutes / 60);
const minutes = totalMinutes % 60;
const fmt = (val: number, unit: 'hour' | 'minute') =>
new Intl.NumberFormat('en', {
style: 'unit',
unit,
unitDisplay: 'narrow',
}).format(val);
if (hours > 0 && minutes > 0) {
return `(Resets in ${fmt(hours, 'hour')} ${fmt(minutes, 'minute')})`;
} else if (hours > 0) {
return `(Resets in ${fmt(hours, 'hour')})`;
}
return `(Resets in ${fmt(minutes, 'minute')})`;
};
const ModelUsageTable: React.FC<{
models: Record<string, ModelMetrics>;
quotas?: RetrieveUserQuotaResponse;
cacheEfficiency: number;
totalCachedTokens: number;
}> = ({ models, quotas, cacheEfficiency, totalCachedTokens }) => {
currentModel?: string;
pooledRemaining?: number;
pooledLimit?: number;
pooledResetTime?: string;
}> = ({
models,
quotas,
cacheEfficiency,
totalCachedTokens,
currentModel,
pooledRemaining,
pooledLimit,
pooledResetTime,
}) => {
const rows = buildModelRows(models, quotas);
if (rows.length === 0) {
@@ -179,13 +172,43 @@ const ModelUsageTable: React.FC<{
? usageLimitWidth
: uncachedWidth + cachedWidth + outputTokensWidth);
const isAuto = currentModel && isAutoModel(currentModel);
const modelUsageTitle = isAuto
? `${getDisplayString(currentModel)} Usage`
: `Model Usage`;
return (
<Box flexDirection="column" marginTop={1}>
{/* Header */}
<Box alignItems="flex-end">
<Box width={nameWidth}>
<Text bold color={theme.text.primary} wrap="truncate-end">
Model Usage
{modelUsageTitle}
</Text>
</Box>
</Box>
{isAuto &&
showQuotaColumn &&
pooledRemaining !== undefined &&
pooledLimit !== undefined &&
pooledLimit > 0 && (
<Box flexDirection="column" marginTop={0} marginBottom={1}>
<QuotaStatsInfo
remaining={pooledRemaining}
limit={pooledLimit}
resetTime={pooledResetTime}
/>
<Text color={theme.text.primary}>
For a full token breakdown, run `/stats model`.
</Text>
</Box>
)}
<Box alignItems="flex-end">
<Box width={nameWidth}>
<Text bold color={theme.text.primary}>
Model
</Text>
</Box>
<Box
@@ -198,6 +221,7 @@ const ModelUsageTable: React.FC<{
Reqs
</Text>
</Box>
{!showQuotaColumn && (
<>
<Box
@@ -239,7 +263,7 @@ const ModelUsageTable: React.FC<{
alignItems="flex-end"
>
<Text bold color={theme.text.primary}>
Usage left
Usage remaining
</Text>
</Box>
)}
@@ -259,7 +283,10 @@ const ModelUsageTable: React.FC<{
{rows.map((row) => (
<Box key={row.key}>
<Box width={nameWidth}>
<Text color={theme.text.primary} wrap="truncate-end">
<Text
color={row.isActive ? theme.text.primary : theme.text.secondary}
wrap="truncate-end"
>
{row.modelName}
</Text>
</Box>
@@ -344,19 +371,6 @@ const ModelUsageTable: React.FC<{
</Text>
</Box>
)}
{showQuotaColumn && (
<>
<Box marginTop={1} marginBottom={2}>
<Text color={theme.text.primary}>
{`Usage limits span all sessions and reset daily.\n/auth to upgrade or switch to API key.`}
</Text>
</Box>
<Text color={theme.text.secondary}>
» Tip: For a full token breakdown, run `/stats model`.
</Text>
</>
)}
</Box>
);
};
@@ -368,6 +382,8 @@ interface StatsDisplayProps {
selectedAuthType?: string;
userEmail?: string;
tier?: string;
currentModel?: string;
quotaStats?: QuotaStats;
}
export const StatsDisplay: React.FC<StatsDisplayProps> = ({
@@ -377,12 +393,19 @@ export const StatsDisplay: React.FC<StatsDisplayProps> = ({
selectedAuthType,
userEmail,
tier,
currentModel,
quotaStats,
}) => {
const { stats } = useSessionStats();
const { metrics } = stats;
const { models, tools, files } = metrics;
const computed = computeSessionStats(metrics);
const settings = useSettings();
const pooledRemaining = quotaStats?.remaining;
const pooledLimit = quotaStats?.limit;
const pooledResetTime = quotaStats?.resetTime;
const showUserIdentity = settings.merged.ui.showUserIdentity;
const successThresholds = {
@@ -415,7 +438,7 @@ export const StatsDisplay: React.FC<StatsDisplayProps> = ({
borderStyle="round"
borderColor={theme.border.default}
flexDirection="column"
paddingY={1}
paddingTop={1}
paddingX={2}
overflow="hidden"
>
@@ -508,6 +531,10 @@ export const StatsDisplay: React.FC<StatsDisplayProps> = ({
quotas={quotas}
cacheEfficiency={computed.cacheEfficiency}
totalCachedTokens={computed.totalCachedTokens}
currentModel={currentModel}
pooledRemaining={pooledRemaining}
pooledLimit={pooledLimit}
pooledResetTime={pooledResetTime}
/>
</Box>
);
@@ -1,6 +1,6 @@
/**
* @license
* Copyright 2025 Google LLC
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
@@ -11,6 +11,8 @@ import { StatusDisplay } from './StatusDisplay.js';
import { UIStateContext, type UIState } from '../contexts/UIStateContext.js';
import { ConfigContext } from '../contexts/ConfigContext.js';
import { SettingsContext } from '../contexts/SettingsContext.js';
import type { Config } from '@google/gemini-cli-core';
import type { LoadedSettings } from '../../config/settings.js';
import { createMockSettings } from '../../test-utils/settings.js';
import type { TextBuffer } from './shared/text-buffer.js';
@@ -40,7 +42,7 @@ type UIStateOverrides = Partial<Omit<UIState, 'buffer'>> & {
const createMockUIState = (overrides: UIStateOverrides = {}): UIState =>
({
ctrlCPressedOnce: false,
warningMessage: null,
transientMessage: null,
ctrlDPressedOnce: false,
showEscapePrompt: false,
shortcutsHelpVisible: false,
@@ -67,7 +69,6 @@ const createMockConfig = (overrides = {}) => ({
...overrides,
});
/* eslint-disable @typescript-eslint/no-explicit-any */
const renderStatusDisplay = (
props: { hideContextSummary: boolean } = { hideContextSummary: false },
uiState: UIState = createMockUIState(),
@@ -75,15 +76,14 @@ const renderStatusDisplay = (
config = createMockConfig(),
) =>
render(
<ConfigContext.Provider value={config as any}>
<SettingsContext.Provider value={settings as any}>
<ConfigContext.Provider value={config as unknown as Config}>
<SettingsContext.Provider value={settings as unknown as LoadedSettings}>
<UIStateContext.Provider value={uiState}>
<StatusDisplay {...props} />
</UIStateContext.Provider>
</SettingsContext.Provider>
</ConfigContext.Provider>,
);
/* eslint-enable @typescript-eslint/no-explicit-any */
describe('StatusDisplay', () => {
const originalEnv = process.env;
@@ -91,6 +91,7 @@ describe('StatusDisplay', () => {
afterEach(() => {
process.env = { ...originalEnv };
delete process.env['GEMINI_SYSTEM_MD'];
vi.restoreAllMocks();
});
it('renders nothing by default if context summary is hidden via props', () => {
@@ -109,88 +110,6 @@ describe('StatusDisplay', () => {
expect(lastFrame()).toMatchSnapshot();
});
it('prioritizes Ctrl+C prompt over everything else (except system md)', () => {
const uiState = createMockUIState({
ctrlCPressedOnce: true,
warningMessage: 'Warning',
activeHooks: [{ name: 'hook', eventName: 'event' }],
});
const { lastFrame } = renderStatusDisplay(
{ hideContextSummary: false },
uiState,
);
expect(lastFrame()).toMatchSnapshot();
});
it('renders warning message', () => {
const uiState = createMockUIState({
warningMessage: 'This is a warning',
});
const { lastFrame } = renderStatusDisplay(
{ hideContextSummary: false },
uiState,
);
expect(lastFrame()).toMatchSnapshot();
});
it('prioritizes warning over Ctrl+D', () => {
const uiState = createMockUIState({
warningMessage: 'Warning',
ctrlDPressedOnce: true,
});
const { lastFrame } = renderStatusDisplay(
{ hideContextSummary: false },
uiState,
);
expect(lastFrame()).toMatchSnapshot();
});
it('renders Ctrl+D prompt', () => {
const uiState = createMockUIState({
ctrlDPressedOnce: true,
});
const { lastFrame } = renderStatusDisplay(
{ hideContextSummary: false },
uiState,
);
expect(lastFrame()).toMatchSnapshot();
});
it('renders Escape prompt when buffer is empty', () => {
const uiState = createMockUIState({
showEscapePrompt: true,
buffer: { text: '' },
});
const { lastFrame } = renderStatusDisplay(
{ hideContextSummary: false },
uiState,
);
expect(lastFrame()).toMatchSnapshot();
});
it('renders Escape prompt when buffer is NOT empty', () => {
const uiState = createMockUIState({
showEscapePrompt: true,
buffer: { text: 'some text' },
});
const { lastFrame } = renderStatusDisplay(
{ hideContextSummary: false },
uiState,
);
expect(lastFrame()).toMatchSnapshot();
});
it('renders Queue Error Message', () => {
const uiState = createMockUIState({
queueErrorMessage: 'Queue Error',
});
const { lastFrame } = renderStatusDisplay(
{ hideContextSummary: false },
uiState,
);
expect(lastFrame()).toMatchSnapshot();
});
it('renders HookStatusDisplay when hooks are active', () => {
const uiState = createMockUIState({
activeHooks: [{ name: 'hook', eventName: 'event' }],
@@ -28,41 +28,6 @@ export const StatusDisplay: React.FC<StatusDisplayProps> = ({
return <Text color={theme.status.error}>|_|</Text>;
}
if (uiState.ctrlCPressedOnce) {
return (
<Text color={theme.status.warning}>Press Ctrl+C again to exit.</Text>
);
}
if (uiState.warningMessage) {
return <Text color={theme.status.warning}>{uiState.warningMessage}</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.queueErrorMessage) {
return <Text color={theme.status.error}>{uiState.queueErrorMessage}</Text>;
}
if (
uiState.activeHooks.length > 0 &&
settings.merged.hooksConfig.notifications
@@ -0,0 +1,165 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, afterEach } from 'vitest';
import { renderWithProviders } from '../../test-utils/render.js';
import { ToastDisplay, shouldShowToast } from './ToastDisplay.js';
import { TransientMessageType } from '../../utils/events.js';
import { type UIState } from '../contexts/UIStateContext.js';
import { type TextBuffer } from './shared/text-buffer.js';
import { type HistoryItem } from '../types.js';
const renderToastDisplay = (uiState: Partial<UIState> = {}) =>
renderWithProviders(<ToastDisplay />, {
uiState: {
buffer: { text: '' } as TextBuffer,
history: [] as HistoryItem[],
...uiState,
},
});
describe('ToastDisplay', () => {
afterEach(() => {
vi.restoreAllMocks();
});
describe('shouldShowToast', () => {
const baseState: Partial<UIState> = {
ctrlCPressedOnce: false,
transientMessage: null,
ctrlDPressedOnce: false,
showEscapePrompt: false,
buffer: { text: '' } as TextBuffer,
history: [] as HistoryItem[],
queueErrorMessage: null,
};
it('returns false for default state', () => {
expect(shouldShowToast(baseState as UIState)).toBe(false);
});
it('returns true when ctrlCPressedOnce is true', () => {
expect(
shouldShowToast({ ...baseState, ctrlCPressedOnce: true } as UIState),
).toBe(true);
});
it('returns true when transientMessage is present', () => {
expect(
shouldShowToast({
...baseState,
transientMessage: { text: 'test', type: TransientMessageType.Hint },
} as UIState),
).toBe(true);
});
it('returns true when ctrlDPressedOnce is true', () => {
expect(
shouldShowToast({ ...baseState, ctrlDPressedOnce: true } as UIState),
).toBe(true);
});
it('returns true when showEscapePrompt is true and buffer is NOT empty', () => {
expect(
shouldShowToast({
...baseState,
showEscapePrompt: true,
buffer: { text: 'some text' } as TextBuffer,
} as UIState),
).toBe(true);
});
it('returns true when showEscapePrompt is true and history is NOT empty', () => {
expect(
shouldShowToast({
...baseState,
showEscapePrompt: true,
history: [{ id: '1' } as unknown as HistoryItem],
} as UIState),
).toBe(true);
});
it('returns false when showEscapePrompt is true but buffer and history are empty', () => {
expect(
shouldShowToast({
...baseState,
showEscapePrompt: true,
} as UIState),
).toBe(false);
});
it('returns true when queueErrorMessage is present', () => {
expect(
shouldShowToast({
...baseState,
queueErrorMessage: 'error',
} as UIState),
).toBe(true);
});
});
it('renders nothing by default', () => {
const { lastFrame } = renderToastDisplay();
expect(lastFrame()).toBe('');
});
it('renders Ctrl+C prompt', () => {
const { lastFrame } = renderToastDisplay({
ctrlCPressedOnce: true,
});
expect(lastFrame()).toMatchSnapshot();
});
it('renders warning message', () => {
const { lastFrame } = renderToastDisplay({
transientMessage: {
text: 'This is a warning',
type: TransientMessageType.Warning,
},
});
expect(lastFrame()).toMatchSnapshot();
});
it('renders hint message', () => {
const { lastFrame } = renderToastDisplay({
transientMessage: {
text: 'This is a hint',
type: TransientMessageType.Hint,
},
});
expect(lastFrame()).toMatchSnapshot();
});
it('renders Ctrl+D prompt', () => {
const { lastFrame } = renderToastDisplay({
ctrlDPressedOnce: true,
});
expect(lastFrame()).toMatchSnapshot();
});
it('renders Escape prompt when buffer is empty', () => {
const { lastFrame } = renderToastDisplay({
showEscapePrompt: true,
history: [{ id: 1, type: 'user', text: 'test' }] as HistoryItem[],
});
expect(lastFrame()).toMatchSnapshot();
});
it('renders Escape prompt when buffer is NOT empty', () => {
const { lastFrame } = renderToastDisplay({
showEscapePrompt: true,
buffer: { text: 'some text' } as TextBuffer,
});
expect(lastFrame()).toMatchSnapshot();
});
it('renders Queue Error Message', () => {
const { lastFrame } = renderToastDisplay({
queueErrorMessage: 'Queue Error',
});
expect(lastFrame()).toMatchSnapshot();
});
});
@@ -0,0 +1,77 @@
/**
* @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)
);
}
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>;
}
return null;
};
@@ -140,7 +140,7 @@ export const ToolConfirmationQueue: React.FC<ToolConfirmationQueueProps> = ({
/>
</Box>
<Box
height={0}
height={1}
width={mainAreaWidth}
borderLeft={true}
borderRight={true}
@@ -150,9 +150,7 @@ export const ToolConfirmationQueue: React.FC<ToolConfirmationQueueProps> = ({
borderStyle="round"
/>
</Box>
<Box paddingX={2} marginBottom={1}>
<ShowMoreLines constrainHeight={constrainHeight} />
</Box>
<ShowMoreLines constrainHeight={constrainHeight} />
</OverflowProvider>
);
};
@@ -1,6 +1,6 @@
/**
* @license
* Copyright 2025 Google LLC
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
@@ -64,7 +64,7 @@ export const ToolStatsDisplay: React.FC = () => {
<Box
borderStyle="round"
borderColor={theme.border.default}
paddingY={1}
paddingTop={1}
paddingX={2}
>
<Text color={theme.text.primary}>
@@ -98,7 +98,7 @@ export const ToolStatsDisplay: React.FC = () => {
borderStyle="round"
borderColor={theme.border.default}
flexDirection="column"
paddingY={1}
paddingTop={1}
paddingX={2}
width={70}
>
@@ -37,7 +37,7 @@ export const UserIdentity: React.FC<UserIdentityProps> = ({ config }) => {
}
return (
<Box marginY={1} flexDirection="column">
<Box marginTop={1} flexDirection="column">
<Box>
<Text color={theme.text.primary}>
{authType === AuthType.LOGIN_WITH_GOOGLE ? (
@@ -54,3 +54,14 @@ exports[`<BackgroundShellDisplay /> > scrolls to active shell when list opens 1`
│ ● 2. tail -f log.txt (PID: 1002) │
└──────────────────────────────────────────────────────────────────────────────────────────────────┘"
`;
exports[`<BackgroundShellDisplay /> > selects the current process and closes the list when Ctrl+L is pressed in list view 1`] = `
"┌──────────────────────────────────────────────────────────────────────────────────────────────────┐
│ 1: npm sta... (PID: 1001) (Focused) Close (Ctrl+B) | Kill (Ctrl+K) | List (Ctrl+L) │
│ │
│ Select Process (Enter to select, Ctrl+K to kill, Esc to cancel): │
│ │
│ ● 1. npm start (PID: 1001) │
│ 2. tail -f log.txt (PID: 1002) │
└──────────────────────────────────────────────────────────────────────────────────────────────────┘"
`;
@@ -1,11 +1,17 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`<Footer /> > footer configuration filtering (golden snapshots) > renders complete footer in narrow terminal (baseline narrow) > complete-footer-narrow 1`] = `" ...s/to/make/it/long no sandbox gemini-pro /model (100%)"`;
exports[`<Footer /> > displays "Limit reached" message when remaining is 0 1`] = `" ...directories/to/make/it/long no sandbox (see /docs) /model gemini-pro Limit reached"`;
exports[`<Footer /> > footer configuration filtering (golden snapshots) > renders complete footer with all sections visible (baseline) > complete-footer-wide 1`] = `" ...directories/to/make/it/long no sandbox (see /docs) gemini-pro /model (100% context left)"`;
exports[`<Footer /> > displays the usage indicator when usage is low 1`] = `" ...directories/to/make/it/long no sandbox (see /docs) /model gemini-pro 15%"`;
exports[`<Footer /> > footer configuration filtering (golden snapshots) > renders complete footer in narrow terminal (baseline narrow) > complete-footer-narrow 1`] = `" ...s/to/make/it/long no sandbox /model gemini-pro 100%"`;
exports[`<Footer /> > footer configuration filtering (golden snapshots) > renders complete footer with all sections visible (baseline) > complete-footer-wide 1`] = `" ...directories/to/make/it/long no sandbox (see /docs) /model gemini-pro 100% context left"`;
exports[`<Footer /> > footer configuration filtering (golden snapshots) > renders footer with CWD and model info hidden to test alignment (only sandbox visible) > footer-only-sandbox 1`] = `" no sandbox (see /docs)"`;
exports[`<Footer /> > footer configuration filtering (golden snapshots) > renders footer with all optional sections hidden (minimal footer) > footer-minimal 1`] = `""`;
exports[`<Footer /> > footer configuration filtering (golden snapshots) > renders footer with only model info hidden (partial filtering) > footer-no-model 1`] = `" ...directories/to/make/it/long no sandbox (see /docs)"`;
exports[`<Footer /> > hides the usage indicator when usage is not near limit 1`] = `" ...directories/to/make/it/long no sandbox (see /docs) /model gemini-pro"`;
@@ -385,3 +385,9 @@ exports[`<HistoryItemDisplay /> > renders InfoMessage for "info" type with multi
⚡ Line 2
⚡ Line 3"
`;
exports[`<HistoryItemDisplay /> > thinking items > renders thinking item when enabled 1`] = `
" Thinking
│ test
"
`;
@@ -28,7 +28,7 @@ AppHeader
│ Line 20 │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────╯
ShowMoreLines"
ShowMoreLines"
`;
exports[`MainContent > MainContent Tool Output Height Logic > 'ASB mode - Unfocused shell' 1`] = `
@@ -53,7 +53,7 @@ AppHeader
│ Line 19 █ │
│ Line 20 █ │
╰──────────────────────────────────────────────────────────────────────────────────────────────╯
ShowMoreLines"
ShowMoreLines"
`;
exports[`MainContent > MainContent Tool Output Height Logic > 'Normal mode - Constrained height' 1`] = `
@@ -77,7 +77,7 @@ exports[`MainContent > MainContent Tool Output Height Logic > 'Normal mode - Con
│ Line 19 │
│ Line 20 │
╰──────────────────────────────────────────────────────────────────────────────────────────────╯
ShowMoreLines"
ShowMoreLines"
`;
exports[`MainContent > MainContent Tool Output Height Logic > 'Normal mode - Unconstrained height' 1`] = `
@@ -101,15 +101,15 @@ exports[`MainContent > MainContent Tool Output Height Logic > 'Normal mode - Unc
│ Line 19 │
│ Line 20 │
╰──────────────────────────────────────────────────────────────────────────────────────────────╯
ShowMoreLines"
ShowMoreLines"
`;
exports[`MainContent > does not constrain height in alternate buffer mode 1`] = `
"ScrollableList
AppHeader
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
> Hello
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
✦ Hi there
ShowMoreLines
"
@@ -5,6 +5,7 @@ exports[`<ModelStatsDisplay /> > should display a single model correctly 1`] = `
│ │
│ Model Stats For Nerds │
│ │
│ │
│ Metric gemini-2.5-pro │
│ ────────────────────────────────────────────────────────────────────────────────────────────── │
│ API │
@@ -18,7 +19,6 @@ exports[`<ModelStatsDisplay /> > should display a single model correctly 1`] = `
│ ↳ Thoughts 2 │
│ ↳ Tool 1 │
│ ↳ Output 20 │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
@@ -27,6 +27,7 @@ exports[`<ModelStatsDisplay /> > should display conditional rows if at least one
│ │
│ Model Stats For Nerds │
│ │
│ │
│ Metric gemini-2.5-pro gemini-2.5-flash │
│ ────────────────────────────────────────────────────────────────────────────────────────────── │
│ API │
@@ -40,7 +41,6 @@ exports[`<ModelStatsDisplay /> > should display conditional rows if at least one
│ ↳ Thoughts 2 0 │
│ ↳ Tool 0 3 │
│ ↳ Output 20 10 │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
@@ -49,6 +49,7 @@ exports[`<ModelStatsDisplay /> > should display stats for multiple models correc
│ │
│ Model Stats For Nerds │
│ │
│ │
│ Metric gemini-2.5-pro gemini-2.5-flash │
│ ────────────────────────────────────────────────────────────────────────────────────────────── │
│ API │
@@ -62,7 +63,6 @@ exports[`<ModelStatsDisplay /> > should display stats for multiple models correc
│ ↳ Thoughts 10 20 │
│ ↳ Tool 5 10 │
│ ↳ Output 200 400 │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
@@ -71,6 +71,7 @@ exports[`<ModelStatsDisplay /> > should handle large values without wrapping or
│ │
│ Model Stats For Nerds │
│ │
│ │
│ Metric gemini-2.5-pro │
│ ────────────────────────────────────────────────────────────────────────────────────────────── │
│ API │
@@ -84,14 +85,14 @@ exports[`<ModelStatsDisplay /> > should handle large values without wrapping or
│ ↳ Thoughts 111,111,111 │
│ ↳ Tool 222,222,222 │
│ ↳ Output 123,456,789 │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`<ModelStatsDisplay /> > should handle models with long names (gemini-3-*-preview) without layout breaking 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
│ │
Model Stats For Nerds
Auto (Gemini 3) Stats For Nerds │
│ │
│ │
│ Metric gemini-3-pro-preview gemini-3-flash-preview │
│ ────────────────────────────────────────────────────────────────────────── │
@@ -106,7 +107,6 @@ exports[`<ModelStatsDisplay /> > should handle models with long names (gemini-3-
│ ↳ Thoughts 100 200 │
│ ↳ Tool 50 100 │
│ ↳ Output 4,000 8,000 │
│ │
╰──────────────────────────────────────────────────────────────────────────────╯"
`;
@@ -115,6 +115,7 @@ exports[`<ModelStatsDisplay /> > should not display conditional rows if no model
│ │
│ Model Stats For Nerds │
│ │
│ │
│ Metric gemini-2.5-pro │
│ ────────────────────────────────────────────────────────────────────────────────────────────── │
│ API │
@@ -125,7 +126,6 @@ exports[`<ModelStatsDisplay /> > should not display conditional rows if no model
│ Total 30 │
│ ↳ Input 10 │
│ ↳ Output 20 │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
@@ -133,6 +133,5 @@ exports[`<ModelStatsDisplay /> > should render "no API calls" message when there
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│ │
│ No API calls have been made in this session. │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
@@ -0,0 +1,11 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`QuotaDisplay > should NOT render reset time when terse is true 1`] = `"15%"`;
exports[`QuotaDisplay > should render red when usage < 5% 1`] = `"/stats 4% usage remaining"`;
exports[`QuotaDisplay > should render terse limit reached message 1`] = `"Limit reached"`;
exports[`QuotaDisplay > should render with reset time when provided 1`] = `"/stats 15% usage remaining, resets in 1h"`;
exports[`QuotaDisplay > should render yellow when usage < 20% 1`] = `"/stats 15% usage remaining"`;
@@ -18,11 +18,11 @@ exports[`<SessionSummaryDisplay /> > renders the summary display with a title 1`
│ » Tool Time: 0s (0.0%) │
│ │
│ │
│ Model Usage Reqs Input Tokens Cache Reads Output Tokens
│ Model Usage
│ Model Reqs Input Tokens Cache Reads Output Tokens │
│ ──────────────────────────────────────────────────────────────────────────── │
│ gemini-2.5-pro 10 500 500 2,000 │
│ │
│ Savings Highlight: 500 (50.0%) of input tokens were served from the cache, reducing costs. │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
@@ -13,6 +13,9 @@ exports[`SettingsDialog > Initial Rendering > should render settings list with v
│ ● Vim Mode false │
│ Enable Vim keybindings │
│ │
│ Default Approval Mode Default │
│ The default approval mode for tool execution. 'default' prompts for approval, 'au… │
│ │
│ Enable Auto Update true │
│ Enable automatic updates. │
│ │
@@ -31,9 +34,6 @@ exports[`SettingsDialog > Initial Rendering > should render settings list with v
│ Auto Theme Switching true │
│ Automatically switch between default light and dark themes based on terminal backgro… │
│ │
│ Terminal Background Polling Interval 60 │
│ Interval in seconds to poll the terminal background color. │
│ │
│ ▼ │
│ │
│ Apply To │
@@ -59,6 +59,9 @@ exports[`SettingsDialog > Snapshot Tests > should render 'accessibility settings
│ ● Vim Mode true* │
│ Enable Vim keybindings │
│ │
│ Default Approval Mode Default │
│ The default approval mode for tool execution. 'default' prompts for approval, 'au… │
│ │
│ Enable Auto Update true │
│ Enable automatic updates. │
│ │
@@ -77,9 +80,6 @@ exports[`SettingsDialog > Snapshot Tests > should render 'accessibility settings
│ Auto Theme Switching true │
│ Automatically switch between default light and dark themes based on terminal backgro… │
│ │
│ Terminal Background Polling Interval 60 │
│ Interval in seconds to poll the terminal background color. │
│ │
│ ▼ │
│ │
│ Apply To │
@@ -105,6 +105,9 @@ exports[`SettingsDialog > Snapshot Tests > should render 'all boolean settings d
│ ● Vim Mode false* │
│ Enable Vim keybindings │
│ │
│ Default Approval Mode Default │
│ The default approval mode for tool execution. 'default' prompts for approval, 'au… │
│ │
│ Enable Auto Update true* │
│ Enable automatic updates. │
│ │
@@ -123,9 +126,6 @@ exports[`SettingsDialog > Snapshot Tests > should render 'all boolean settings d
│ Auto Theme Switching true │
│ Automatically switch between default light and dark themes based on terminal backgro… │
│ │
│ Terminal Background Polling Interval 60 │
│ Interval in seconds to poll the terminal background color. │
│ │
│ ▼ │
│ │
│ Apply To │
@@ -151,6 +151,9 @@ exports[`SettingsDialog > Snapshot Tests > should render 'default state' correct
│ ● Vim Mode false │
│ Enable Vim keybindings │
│ │
│ Default Approval Mode Default │
│ The default approval mode for tool execution. 'default' prompts for approval, 'au… │
│ │
│ Enable Auto Update true │
│ Enable automatic updates. │
│ │
@@ -169,9 +172,6 @@ exports[`SettingsDialog > Snapshot Tests > should render 'default state' correct
│ Auto Theme Switching true │
│ Automatically switch between default light and dark themes based on terminal backgro… │
│ │
│ Terminal Background Polling Interval 60 │
│ Interval in seconds to poll the terminal background color. │
│ │
│ ▼ │
│ │
│ Apply To │
@@ -197,6 +197,9 @@ exports[`SettingsDialog > Snapshot Tests > should render 'file filtering setting
│ ● Vim Mode false │
│ Enable Vim keybindings │
│ │
│ Default Approval Mode Default │
│ The default approval mode for tool execution. 'default' prompts for approval, 'au… │
│ │
│ Enable Auto Update true │
│ Enable automatic updates. │
│ │
@@ -215,9 +218,6 @@ exports[`SettingsDialog > Snapshot Tests > should render 'file filtering setting
│ Auto Theme Switching true │
│ Automatically switch between default light and dark themes based on terminal backgro… │
│ │
│ Terminal Background Polling Interval 60 │
│ Interval in seconds to poll the terminal background color. │
│ │
│ ▼ │
│ │
│ Apply To │
@@ -243,6 +243,9 @@ exports[`SettingsDialog > Snapshot Tests > should render 'focused on scope selec
│ Vim Mode false │
│ Enable Vim keybindings │
│ │
│ Default Approval Mode Default │
│ The default approval mode for tool execution. 'default' prompts for approval, 'au… │
│ │
│ Enable Auto Update true │
│ Enable automatic updates. │
│ │
@@ -261,9 +264,6 @@ exports[`SettingsDialog > Snapshot Tests > should render 'focused on scope selec
│ Auto Theme Switching true │
│ Automatically switch between default light and dark themes based on terminal backgro… │
│ │
│ Terminal Background Polling Interval 60 │
│ Interval in seconds to poll the terminal background color. │
│ │
│ ▼ │
│ │
│ > Apply To │
@@ -289,6 +289,9 @@ exports[`SettingsDialog > Snapshot Tests > should render 'mixed boolean and numb
│ ● Vim Mode false* │
│ Enable Vim keybindings │
│ │
│ Default Approval Mode Default │
│ The default approval mode for tool execution. 'default' prompts for approval, 'au… │
│ │
│ Enable Auto Update false* │
│ Enable automatic updates. │
│ │
@@ -307,9 +310,6 @@ exports[`SettingsDialog > Snapshot Tests > should render 'mixed boolean and numb
│ Auto Theme Switching true │
│ Automatically switch between default light and dark themes based on terminal backgro… │
│ │
│ Terminal Background Polling Interval 60 │
│ Interval in seconds to poll the terminal background color. │
│ │
│ ▼ │
│ │
│ Apply To │
@@ -335,6 +335,9 @@ exports[`SettingsDialog > Snapshot Tests > should render 'tools and security set
│ ● Vim Mode false │
│ Enable Vim keybindings │
│ │
│ Default Approval Mode Default │
│ The default approval mode for tool execution. 'default' prompts for approval, 'au… │
│ │
│ Enable Auto Update true │
│ Enable automatic updates. │
│ │
@@ -353,9 +356,6 @@ exports[`SettingsDialog > Snapshot Tests > should render 'tools and security set
│ Auto Theme Switching true │
│ Automatically switch between default light and dark themes based on terminal backgro… │
│ │
│ Terminal Background Polling Interval 60 │
│ Interval in seconds to poll the terminal background color. │
│ │
│ ▼ │
│ │
│ Apply To │
@@ -381,6 +381,9 @@ exports[`SettingsDialog > Snapshot Tests > should render 'various boolean settin
│ ● Vim Mode true* │
│ Enable Vim keybindings │
│ │
│ Default Approval Mode Default │
│ The default approval mode for tool execution. 'default' prompts for approval, 'au… │
│ │
│ Enable Auto Update false* │
│ Enable automatic updates. │
│ │
@@ -399,9 +402,6 @@ exports[`SettingsDialog > Snapshot Tests > should render 'various boolean settin
│ Auto Theme Switching true │
│ Automatically switch between default light and dark themes based on terminal backgro… │
│ │
│ Terminal Background Polling Interval 60 │
│ Interval in seconds to poll the terminal background color. │
│ │
│ ▼ │
│ │
│ Apply To │
@@ -17,7 +17,6 @@ exports[`<StatsDisplay /> > Code Changes Display > displays Code Changes when li
│ » API Time: 0s (0.0%) │
│ » Tool Time: 100ms (100.0%) │
│ │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
@@ -37,7 +36,6 @@ exports[`<StatsDisplay /> > Code Changes Display > hides Code Changes when no li
│ » API Time: 0s (0.0%) │
│ » Tool Time: 100ms (100.0%) │
│ │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
@@ -57,7 +55,6 @@ exports[`<StatsDisplay /> > Conditional Color Tests > renders success rate in gr
│ » API Time: 0s (0.0%) │
│ » Tool Time: 0s (0.0%) │
│ │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
@@ -77,7 +74,6 @@ exports[`<StatsDisplay /> > Conditional Color Tests > renders success rate in re
│ » API Time: 0s (0.0%) │
│ » Tool Time: 0s (0.0%) │
│ │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
@@ -97,7 +93,6 @@ exports[`<StatsDisplay /> > Conditional Color Tests > renders success rate in ye
│ » API Time: 0s (0.0%) │
│ » Tool Time: 0s (0.0%) │
│ │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
@@ -118,10 +113,10 @@ exports[`<StatsDisplay /> > Conditional Rendering Tests > hides Efficiency secti
│ » Tool Time: 0s (0.0%) │
│ │
│ │
│ Model Usage Reqs Input Tokens Cache Reads Output Tokens
│ Model Usage
│ Model Reqs Input Tokens Cache Reads Output Tokens │
│ ──────────────────────────────────────────────────────────────────────────── │
│ gemini-2.5-pro 1 100 0 100 │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
@@ -141,7 +136,36 @@ exports[`<StatsDisplay /> > Conditional Rendering Tests > hides User Agreement w
│ » API Time: 0s (0.0%) │
│ » Tool Time: 123ms (100.0%) │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`<StatsDisplay /> > Quota Display > renders pooled quota information for auto mode 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│ │
│ Session Stats │
│ │
│ Interaction Summary │
│ Session ID: test-session-id │
│ Tool Calls: 0 ( ✓ 0 x 0 ) │
│ Success Rate: 0.0% │
│ │
│ Performance │
│ Wall Time: 1s │
│ Agent Active: 0s │
│ » API Time: 0s (0.0%) │
│ » Tool Time: 0s (0.0%) │
│ │
│ │
│ auto Usage │
│ 65% usage remaining │
│ Usage limit: 1,100 │
│ Usage limits span all sessions and reset daily. │
│ For a full token breakdown, run \`/stats model\`. │
│ │
│ Model Reqs Usage remaining │
│ ──────────────────────────────────────────────────────────── │
│ gemini-2.5-pro - │
│ gemini-2.5-flash - │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
@@ -162,16 +186,10 @@ exports[`<StatsDisplay /> > Quota Display > renders quota information for unused
│ » Tool Time: 0s (0.0%) │
│ │
│ │
│ Model Usage Reqs Usage left
│ Model Usage
│ Model Reqs Usage remaining │
│ ──────────────────────────────────────────────────────────── │
│ gemini-2.5-flash - 50.0% (Resets in 2h)
│ │
│ Usage limits span all sessions and reset daily. │
│ /auth to upgrade or switch to API key. │
│ │
│ │
│ » Tip: For a full token breakdown, run \`/stats model\`. │
│ │
│ gemini-2.5-flash - 50.0% resets in 2h │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
@@ -192,16 +210,10 @@ exports[`<StatsDisplay /> > Quota Display > renders quota information when quota
│ » Tool Time: 0s (0.0%) │
│ │
│ │
│ Model Usage Reqs Usage left
│ Model Usage
│ Model Reqs Usage remaining │
│ ──────────────────────────────────────────────────────────── │
│ gemini-2.5-pro 1 75.0% (Resets in 1h 30m)
│ │
│ Usage limits span all sessions and reset daily. │
│ /auth to upgrade or switch to API key. │
│ │
│ │
│ » Tip: For a full token breakdown, run \`/stats model\`. │
│ │
│ gemini-2.5-pro 1 75.0% resets in 1h 30m │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
@@ -221,7 +233,6 @@ exports[`<StatsDisplay /> > Title Rendering > renders the custom title when a ti
│ » API Time: 0s (0.0%) │
│ » Tool Time: 0s (0.0%) │
│ │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
@@ -241,7 +252,6 @@ exports[`<StatsDisplay /> > Title Rendering > renders the default title when no
│ » API Time: 0s (0.0%) │
│ » Tool Time: 0s (0.0%) │
│ │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
@@ -262,13 +272,13 @@ exports[`<StatsDisplay /> > renders a table with two models correctly 1`] = `
│ » Tool Time: 0s (0.0%) │
│ │
│ │
│ Model Usage Reqs Input Tokens Cache Reads Output Tokens
│ Model Usage
│ Model Reqs Input Tokens Cache Reads Output Tokens │
│ ──────────────────────────────────────────────────────────────────────────── │
│ gemini-2.5-pro 3 500 500 2,000 │
│ gemini-2.5-flash 5 15,000 10,000 15,000 │
│ │
│ Savings Highlight: 10,500 (40.4%) of input tokens were served from the cache, reducing costs. │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
@@ -290,12 +300,12 @@ exports[`<StatsDisplay /> > renders all sections when all data is present 1`] =
│ » Tool Time: 123ms (55.2%) │
│ │
│ │
│ Model Usage Reqs Input Tokens Cache Reads Output Tokens
│ Model Usage
│ Model Reqs Input Tokens Cache Reads Output Tokens │
│ ──────────────────────────────────────────────────────────────────────────── │
│ gemini-2.5-pro 1 50 50 100 │
│ │
│ Savings Highlight: 50 (50.0%) of input tokens were served from the cache, reducing costs. │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
@@ -315,6 +325,5 @@ exports[`<StatsDisplay /> > renders only the Performance section in its zero sta
│ » API Time: 0s (0.0%) │
│ » Tool Time: 0s (0.0%) │
│ │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
@@ -2,22 +2,8 @@
exports[`StatusDisplay > does NOT render HookStatusDisplay if notifications are disabled in settings 1`] = `"Mock Context Summary Display (Skills: 2, Shells: 0)"`;
exports[`StatusDisplay > prioritizes Ctrl+C prompt over everything else (except system md) 1`] = `"Press Ctrl+C again to exit."`;
exports[`StatusDisplay > prioritizes warning over Ctrl+D 1`] = `"Warning"`;
exports[`StatusDisplay > renders ContextSummaryDisplay by default 1`] = `"Mock Context Summary Display (Skills: 2, Shells: 0)"`;
exports[`StatusDisplay > renders Ctrl+D prompt 1`] = `"Press Ctrl+D again to exit."`;
exports[`StatusDisplay > renders Escape prompt when buffer is NOT empty 1`] = `"Press Esc again to clear prompt."`;
exports[`StatusDisplay > renders Escape prompt when buffer is empty 1`] = `"Press Esc again to rewind."`;
exports[`StatusDisplay > renders HookStatusDisplay when hooks are active 1`] = `"Mock Hook Status Display"`;
exports[`StatusDisplay > renders Queue Error Message 1`] = `"Queue Error"`;
exports[`StatusDisplay > renders system md indicator if env var is set 1`] = `"|⌐■_■|"`;
exports[`StatusDisplay > renders warning message 1`] = `"This is a warning"`;
@@ -0,0 +1,15 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`ToastDisplay > renders Ctrl+C prompt 1`] = `"Press Ctrl+C again to exit."`;
exports[`ToastDisplay > renders Ctrl+D prompt 1`] = `"Press Ctrl+D again to exit."`;
exports[`ToastDisplay > renders Escape prompt when buffer is NOT empty 1`] = `"Press Esc again to clear prompt."`;
exports[`ToastDisplay > renders Escape prompt when buffer is empty 1`] = `"Press Esc again to rewind."`;
exports[`ToastDisplay > renders Queue Error Message 1`] = `"Queue Error"`;
exports[`ToastDisplay > renders hint message 1`] = `"This is a hint"`;
exports[`ToastDisplay > renders warning message 1`] = `"This is a warning"`;
@@ -16,8 +16,7 @@ exports[`ToolConfirmationQueue > calculates availableContentHeight based on avai
│ 4. No, suggest changes (esc) │
│ │
╰──────────────────────────────────────────────────────────────────────────────╯
Press ctrl-o to show more lines
"
Press ctrl-o to show more lines"
`;
exports[`ToolConfirmationQueue > does not render expansion hint when constrainHeight is false 1`] = `
@@ -38,8 +37,7 @@ exports[`ToolConfirmationQueue > does not render expansion hint when constrainHe
│ 3. Modify with external editor │
│ 4. No, suggest changes (esc) │
│ │
╰──────────────────────────────────────────────────────────────────────────────╯
"
╰──────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`ToolConfirmationQueue > renders expansion hint when content is long and constrained 1`] = `
@@ -58,7 +56,7 @@ exports[`ToolConfirmationQueue > renders expansion hint when content is long and
│ 4. No, suggest changes (esc) │
│ │
╰──────────────────────────────────────────────────────────────────────────────╯
Press ctrl-o to show more lines
Press ctrl-o to show more lines
@@ -88,6 +86,5 @@ exports[`ToolConfirmationQueue > renders the confirming tool with progress indic
│ 2. Allow for this session │
│ 3. No, suggest changes (esc) │
│ │
╰──────────────────────────────────────────────────────────────────────────────╯
"
╰──────────────────────────────────────────────────────────────────────────────╯"
`;
@@ -16,7 +16,6 @@ exports[`<ToolStatsDisplay /> > should display stats for a single tool correctly
│ » Modified: 0 │
│ ──────────────────────────────────────────────────────────────── │
│ Overall Agreement Rate: 100.0% │
│ │
╰────────────────────────────────────────────────────────────────────╯"
`;
@@ -37,7 +36,6 @@ exports[`<ToolStatsDisplay /> > should display stats for multiple tools correctl
│ » Modified: 1 │
│ ──────────────────────────────────────────────────────────────── │
│ Overall Agreement Rate: 33.3% │
│ │
╰────────────────────────────────────────────────────────────────────╯"
`;
@@ -58,7 +56,6 @@ exports[`<ToolStatsDisplay /> > should handle large values without wrapping or o
│ » Modified: 12345 │
│ ──────────────────────────────────────────────────────────────── │
│ Overall Agreement Rate: 55.6% │
│ │
╰────────────────────────────────────────────────────────────────────╯"
`;
@@ -78,7 +75,6 @@ exports[`<ToolStatsDisplay /> > should handle zero decisions gracefully 1`] = `
│ » Modified: 0 │
│ ──────────────────────────────────────────────────────────────── │
│ Overall Agreement Rate: -- │
│ │
╰────────────────────────────────────────────────────────────────────╯"
`;
@@ -86,6 +82,5 @@ exports[`<ToolStatsDisplay /> > should render "no tool calls" message when there
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│ │
│ No tool calls have been made in this session. │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
`;
@@ -50,7 +50,10 @@ export const GeminiMessage: React.FC<GeminiMessageProps> = ({
terminalWidth={terminalWidth}
renderMarkdown={renderMarkdown}
/>
<Box marginBottom={1}>
<Box
marginTop={isAlternateBuffer ? 0 : 1}
marginBottom={isAlternateBuffer ? 1 : 0}
>
<ShowMoreLines
constrainHeight={availableTerminalHeight !== undefined}
/>
@@ -48,7 +48,10 @@ export const GeminiMessageContent: React.FC<GeminiMessageContentProps> = ({
terminalWidth={terminalWidth}
renderMarkdown={renderMarkdown}
/>
<Box marginBottom={1}>
<Box
marginTop={isAlternateBuffer ? 0 : 1}
marginBottom={isAlternateBuffer ? 1 : 0}
>
<ShowMoreLines
constrainHeight={availableTerminalHeight !== undefined}
/>
@@ -0,0 +1,78 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import { renderWithProviders } from '../../../test-utils/render.js';
import { ThinkingMessage } from './ThinkingMessage.js';
describe('ThinkingMessage', () => {
it('renders subject line', () => {
const { lastFrame } = renderWithProviders(
<ThinkingMessage
thought={{ subject: 'Planning', description: 'test' }}
/>,
);
expect(lastFrame()).toMatchSnapshot();
});
it('uses description when subject is empty', () => {
const { lastFrame } = renderWithProviders(
<ThinkingMessage
thought={{ subject: '', description: 'Processing details' }}
/>,
);
expect(lastFrame()).toMatchSnapshot();
});
it('renders full mode with left border and full text', () => {
const { lastFrame } = renderWithProviders(
<ThinkingMessage
thought={{
subject: 'Planning',
description: 'I am planning the solution.',
}}
/>,
);
expect(lastFrame()).toMatchSnapshot();
});
it('indents summary line correctly', () => {
const { lastFrame } = renderWithProviders(
<ThinkingMessage
thought={{
subject: 'Summary line',
description: 'First body line',
}}
/>,
);
expect(lastFrame()).toMatchSnapshot();
});
it('normalizes escaped newline tokens', () => {
const { lastFrame } = renderWithProviders(
<ThinkingMessage
thought={{
subject: 'Matching the Blocks',
description: '\\n\\nSome more text',
}}
/>,
);
expect(lastFrame()).toMatchSnapshot();
});
it('renders empty state gracefully', () => {
const { lastFrame } = renderWithProviders(
<ThinkingMessage thought={{ subject: '', description: '' }} />,
);
expect(lastFrame()).toBe('');
});
});
@@ -0,0 +1,80 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { useMemo } from 'react';
import { Box, Text } from 'ink';
import type { ThoughtSummary } from '@google/gemini-cli-core';
import { theme } from '../../semantic-colors.js';
import { normalizeEscapedNewlines } from '../../utils/textUtils.js';
interface ThinkingMessageProps {
thought: ThoughtSummary;
}
/**
* Renders a model's thought as a distinct bubble.
* Leverages Ink layout for wrapping and borders.
*/
export const ThinkingMessage: React.FC<ThinkingMessageProps> = ({
thought,
}) => {
const { summary, body } = useMemo(() => {
const subject = normalizeEscapedNewlines(thought.subject).trim();
const description = normalizeEscapedNewlines(thought.description).trim();
if (!subject && !description) {
return { summary: '', body: '' };
}
if (!subject) {
const lines = description
.split('\n')
.map((l) => l.trim())
.filter(Boolean);
return {
summary: lines[0] || '',
body: lines.slice(1).join('\n'),
};
}
return {
summary: subject,
body: description,
};
}, [thought]);
if (!summary && !body) {
return null;
}
return (
<Box width="100%" marginBottom={1} paddingLeft={1} flexDirection="column">
{summary && (
<Box paddingLeft={2}>
<Text color={theme.text.primary} bold italic>
{summary}
</Text>
</Box>
)}
{body && (
<Box
borderStyle="single"
borderLeft
borderRight={false}
borderTop={false}
borderBottom={false}
borderColor={theme.border.default}
paddingLeft={1}
>
<Text color={theme.text.secondary} italic>
{body}
</Text>
</Box>
)}
</Box>
);
};
@@ -260,9 +260,7 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
)
}
{(borderBottomOverride ?? true) && visibleToolCalls.length > 0 && (
<Box paddingX={1}>
<ShowMoreLines constrainHeight={constrainHeight} />
</Box>
<ShowMoreLines constrainHeight={constrainHeight} />
)}
</Box>
);
@@ -52,7 +52,7 @@ export const UserMessage: React.FC<UserMessageProps> = ({ text, width }) => {
return (
<HalfLinePaddedBox
backgroundBaseColor={theme.border.default}
backgroundBaseColor={theme.text.secondary}
backgroundOpacity={DEFAULT_BACKGROUND_OPACITY}
useBackgroundColor={useBackgroundColor}
>

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