chore: Merge branch 'main' into channels

This commit is contained in:
Jack Wotherspoon
2026-03-24 21:46:06 -07:00
319 changed files with 21618 additions and 9657 deletions
-60
View File
@@ -1,60 +0,0 @@
description = "Check status of nightly evals, fix failures for key models, and re-run."
prompt = """
You are an expert at fixing behavioral evaluations.
1. **Investigate**:
- Use 'gh' cli to fetch the results from the latest run from the main branch: https://github.com/google-gemini/gemini-cli/actions/workflows/evals-nightly.yml.
- DO NOT push any changes or start any runs. The rest of your evaluation will be local.
- Evals are in evals/ directory and are documented by evals/README.md.
- The test case trajectory logs will be logged to evals/logs.
- You should also enable and review the verbose agent logs by setting the GEMINI_DEBUG_LOG_FILE environment variable.
- Identify the relevant test. Confine your investigation and validation to just this test.
- Proactively add logging that will aid in gathering information or validating your hypotheses.
2. **Fix**:
- If a relevant test is failing, locate the test file and the corresponding prompt/code.
- It's often helpful to make an extreme, brute force change to see if you are changing the right place to make an improvement and then scope it back iteratively.
- Your **final** change should be **minimal and targeted**.
- Keep in mind the following:
- The prompt has multiple configurations and pieces. Take care that your changes
end up in the final prompt for the selected model and configuration.
- The prompt chosen for the eval is intentional. It's often vague or indirect
to see how the agent performs with ambiguous instructions. Changing it should
be a last resort.
- When changing the test prompt, carefully consider whether the prompt still tests
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, 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
that might help stablize the test. Be sure to THINK DEEPLY if offering architecture guidance.
Some facts that might help with this are:
- Agents may be composed of one or more agent loops.
- AgentLoop == 'context + toolset + prompt'. Subagents are one type of agent loop.
- Agent loops perform better when:
- They have direct, unambiguous, and non-contradictory prompts.
- They have fewer irrelevant tools.
- They have fewer goals or steps to perform.
- They have less low value or irrelevant context.
- You may suggest compositions of existing primitives, like subagents, or
propose a new one.
- These recommendations should be high confidence and should be grounded
in observed deficient behaviors rather than just parroting the facts above.
Investigate as needed to ground your recommendations.
3. **Verify**:
- Run just that one test if needed to validate that it is fixed. Be sure to run vitest in non-interactive mode.
- Running the tests can take a long time, so consider whether you can diagnose via other means or log diagnostics before committing the time. You must minimize the number of test runs needed to diagnose the failure.
- After the test completes, check whether it seems to have improved.
- You will need to run the test 3 times for Gemini 3.0, Gemini 3 flash, and Gemini 2.5 pro to ensure that it is truly stable. Run these runs in parallel, using scripts if needed.
- Some flakiness is expected; if it looks like a transient issue or the test is inherently unstable but passes 2/3 times, you might decide it cannot be improved.
4. **Report**:
- Provide a summary of the test success rate for each of the tested models.
- Success rate is calculated based on 3 runs per model (e.g., 3/3 = 100%).
- If you couldn't fix it due to persistent flakiness, explain why.
{{args}}
"""
@@ -1,29 +0,0 @@
description = "Promote behavioral evals that have a 100% success rate over the last 7 nightly runs."
prompt = """
You are an expert at analyzing and promoting behavioral evaluations.
1. **Investigate**:
- Use 'gh' cli to fetch the results from the most recent run from the main branch: https://github.com/google-gemini/gemini-cli/actions/workflows/evals-nightly.yml.
- DO NOT push any changes or start any runs. The rest of your evaluation will be local.
- Evals are in evals/ directory and are documented by evals/README.md.
- Identify tests that have passed 100% of the time for ALL enabled models across the past 7 runs in a row.
- NOTE: the results summary from the most recent run contains the last 7 runs test results. 100% means the test passed 3/3 times for that model and run.
- If a test meets this criteria, it is a candidate for promotion.
2. **Promote**:
- For each candidate test, locate the test file in the evals/ directory.
- Promote the test according to the project's standard promotion process (e.g., moving it to a stable suite, updating its tags, or removing skip/flaky annotations).
- Ensure you follow any guidelines in evals/README.md for stable tests.
- Your **final** change should be **minimal and targeted** to just promoting the test status.
3. **Verify**:
- Run the promoted tests locally to validate that they still execute correctly. Be sure to run vitest in non-interactive mode.
- Check that the test is now part of the expected standard or stable test suites.
4. **Report**:
- Provide a summary of the tests that were promoted.
- Include the success rate evidence (7/7 runs passed for all models) for each promoted test.
- If no tests met the criteria for promotion, clearly state that and summarize the closest candidates.
{{args}}
"""
+56
View File
@@ -0,0 +1,56 @@
---
name: behavioral-evals
description: Guidance for creating, running, fixing, and promoting behavioral evaluations. Use when verifying agent decision logic, debugging failures, debugging prompt steering, or adding workspace regression tests.
---
# Behavioral Evals
## Overview
Behavioral evaluations (evals) are tests that validate the **agent's decision-making** (e.g., tool choice) rather than pure functionality. They are critical for verifying prompt changes, debugging steerability, and preventing regressions.
> [!NOTE]
> **Single Source of Truth**: For core concepts, policies, running tests, and general best practices, always refer to **[evals/README.md](file:///Users/abhipatel/code/gemini-cli/docs/evals/README.md)**.
---
## 🔄 Workflow Decision Tree
1. **Does a prompt/tool change need validation?**
* *No* -> Normal integration tests.
* *Yes* -> Continue below.
2. **Is it UI/Interaction heavy?**
* *Yes* -> Use `appEvalTest` (`AppRig`). See **[creating.md](references/creating.md)**.
* *No* -> Use `evalTest` (`TestRig`). See **[creating.md](references/creating.md)**.
3. **Is it a new test?**
* *Yes* -> Set policy to `USUALLY_PASSES`.
* *No* -> `ALWAYS_PASSES` (locks in regression).
4. **Are you fixing a failure or promoting a test?**
* *Fixing* -> See **[fixing.md](references/fixing.md)**.
* *Promoting* -> See **[promoting.md](references/promoting.md)**.
---
## 📋 Quick Checklist
### 1. Setup Workspace
Seed the workspace with necessary files using the `files` object to simulate a realistic scenario (e.g., NodeJS project with `package.json`).
* *Details in **[creating.md](references/creating.md)***
### 2. Write Assertions
Audit agent decisions using `rig.setBreakpoint()` (AppRig only) or index verification on `rig.readToolLogs()`.
* *Details in **[creating.md](references/creating.md)***
### 3. Verify
Run single tests locally with Vitest. Confirm stability locally before relying on CI workflows.
* *See **[evals/README.md](file:///Users/abhipatel/code/gemini-cli/docs/evals/README.md)** for running commands.*
---
## 📦 Bundled Resources
Detailed procedural guides:
* **[creating.md](references/creating.md)**: Assertion strategies, Rig selection, Mock MCPs.
* **[fixing.md](references/fixing.md)**: Step-by-step automated investigation, architecture diagnosis guidelines.
* **[promoting.md](references/promoting.md)**: Candidate identification criteria and threshold guidelines.
@@ -0,0 +1,27 @@
import { describe, expect } from 'vitest';
import { appEvalTest } from './app-test-helper.js';
describe('interactive_feature', () => {
// New tests MUST start as USUALLY_PASSES
appEvalTest('USUALLY_PASSES', {
name: 'should pause for user confirmation',
files: {
'package.json': JSON.stringify({ name: 'app' })
},
prompt: 'Task description here requiring approval',
timeout: 60000,
setup: async (rig) => {
// ⚠️ Breakpoints are ONLY safe in appEvalTest
rig.setBreakpoint(['ask_user']);
},
assert: async (rig) => {
// 1. Wait for the breakpoint to trigger
const confirmation = await rig.waitForPendingConfirmation('ask_user');
expect(confirmation).toBeDefined();
// 2. Resolve it so the test can finish
await rig.resolveTool(confirmation);
await rig.waitForIdle();
},
});
});
@@ -0,0 +1,30 @@
import { describe, expect } from 'vitest';
import { evalTest } from './test-helper.js';
describe('core_feature', () => {
// New tests MUST start as USUALLY_PASSES
evalTest('USUALLY_PASSES', {
name: 'should perform expected agent action',
setup: async (rig) => {
// For mocking offline MCP:
// rig.addMockMcpServer('workspace-server', 'google-workspace');
},
files: {
'src/app.ts': '// some code',
},
prompt: 'Task description here',
timeout: 60000, // 1 minute safety limit
assert: async (rig, result) => {
// 1. Audit the trajectory (Safe for standard evalTest)
const logs = rig.readToolLogs();
const hasTool = logs.some((l) => l.toolRequest.name === 'read_file');
expect(hasTool, 'Agent should have read the file').toBe(true);
// 2. Assert efficiency (Cost/Turn)
expect(logs.length).toBeLessThan(5);
// 3. Assert final output
expect(result).toContain('Expected Keyword');
},
});
});
@@ -0,0 +1,151 @@
# Creating Behavioral Evals
## 🔬 Rig Selection
| Rig Type | Import From | Architecture | Use When |
| :---------------- | :--------------------- | :------------------------------------------------------------------- | :---------------------------------------------------------------------------------------------------- |
| **`evalTest`** | `./test-helper.js` | **Subprocess**. Runs the CLI in a separate process + waits for exit. | Standard workspace tests. **Do not use `setBreakpoint`**; auditing history (`readToolLogs`) is safer. |
| **`appEvalTest`** | `./app-test-helper.js` | **In-Process**. Runs directly inside the runner loop. | UI/Ink rendering. Safe for `setBreakpoint` triggers. |
---
## 🏗️ Scenario Design
Evals must simulate realistic agent environments to effectively test
decision-making.
- **Workspace State**: Seed with standard project anchors if testing general
capabilities:
- `package.json` for NodeJS environments.
- Minimal configuration files (`tsconfig.json`, `GEMINI.md`).
- **Structural Complexity**: Provide enough files to force the agent to _search_
or _navigate_, rather than giving the answer directly. Avoid trivial one-file
tests unless testing exact prompt steering.
---
## ❌ Fail First Principle
Before asserting a new capability or locking in a fix, **verify that the test
fails first**.
- It is easy to accidentally write an eval that asserts behaviors that are
already met or pass by default.
- **Process**: reproduce failure with test -> apply fix (prompt/tool) -> verify
test passes.
---
## ✋ Testing Patterns
### 1. Breakpoints
Verifies the agent _intends_ to use a tool BEFORE executing it. Useful for
interactive prompts or safety checks.
```typescript
// ⚠️ Only works with appEvalTest (AppRig)
setup: async (rig) => {
rig.setBreakpoint(['ask_user']);
},
assert: async (rig) => {
const confirmation = await rig.waitForPendingConfirmation('ask_user');
expect(confirmation).toBeDefined();
}
```
### 2. Tool Confirmation Race
When asserting multiple triggers (e.g., "enters plan mode then asks question"):
```typescript
assert: async (rig) => {
let confirmation = await rig.waitForPendingConfirmation([
'enter_plan_mode',
'ask_user',
]);
if (confirmation?.name === 'enter_plan_mode') {
rig.acceptConfirmation('enter_plan_mode');
confirmation = await rig.waitForPendingConfirmation('ask_user');
}
expect(confirmation?.toolName).toBe('ask_user');
};
```
### 3. Audit Tool Logs
Audit exact operations to ensure efficiency (e.g., no redundant reads).
```typescript
assert: async (rig, result) => {
await rig.waitForTelemetryReady();
const toolLogs = rig.readToolLogs();
const writeCall = toolLogs.find(
(log) => log.toolRequest.name === 'write_file',
);
expect(writeCall).toBeDefined();
};
```
### 4. Mock MCP Facades
To evaluate tools connected via MCP without hitting live endpoints, load a mock
server configuration in the `setup` hook.
```typescript
setup: async (rig) => {
rig.addMockMcpServer('workspace-server', 'google-workspace');
},
assert: async (rig) => {
await rig.waitForTelemetryReady();
const toolLogs = rig.readToolLogs();
const workspaceCall = toolLogs.find(
(log) => log.toolRequest.name === 'mcp_workspace-server_docs.getText'
);
expect(workspaceCall).toBeDefined();
};
```
---
## ⚠️ Safety & Efficiency Guardrails
### 1. Breakpoint Deadlocks
Breakpoints (`setBreakpoint`) pause execution. In standard `evalTest`,
`rig.run()` waits for the process to exit _before_ assertions run. **This will
hang indefinitely.**
- **Use Breakpoints** for `appEvalTest` or interactive simulations.
- **Use Audit Tool Logs** (above) for standard trajectory tests.
### 2. Runaway Timeout
Always set a budget boundary in the `EvalCase` to prevent runaway loops on
quota:
```typescript
evalTest('USUALLY_PASSES', {
name: '...',
timeout: 60000, // 1 minute safety limit
// ...
});
```
### 3. Efficiency Assertion (Turn limits)
Check if a tool is called _early_ using index checks:
```typescript
assert: async (rig) => {
const toolLogs = rig.readToolLogs();
const toolCallIndex = toolLogs.findIndex(
(log) => log.toolRequest.name === 'cli_help',
);
expect(toolCallIndex).toBeGreaterThan(-1);
expect(toolCallIndex).toBeLessThan(5); // Called within first 5 turns
};
```
@@ -0,0 +1,71 @@
# Fixing Behavioral Evals
Use this guide when asked to debug, troubleshoot, or fix a failing behavioral
evaluation.
---
## 1. 🔍 Investigate
1. **Fetch Nightly Results**: Use the `gh` CLI to inspect the latest run from
`evals-nightly.yml` if applicable.
- _Example view URL_:
`https://github.com/google-gemini/gemini-cli/actions/workflows/evals-nightly.yml`
2. **Isolate**: DO NOT push changes or start remote runs. Confine investigation
to the local workspace.
3. **Read Logs**:
- Eval logs live in `evals/logs/<test_name>.log`.
- Enable verbose debugging via `export GEMINI_DEBUG_LOG_FILE="debug.log"`.
4. **Diagnose**: Audit tool logs and telemetry. Note if due to setup/assert.
- **Tip**: Proactively add custom logging/diagnostics to check hypotheses.
---
## 2. 🛠️ Fix Strategy
1. **Targeted Location**: Locate the test case and the corresponding
prompt/code.
2. **Iterative Scope**: Make extreme change first to verify scope, then refine
to a minimal, targeted change.
3. **Assertion Fidelity**:
- Changing the test prompt is a **last resort** (prompts are often vague by
design).
- **Warning**: Do not lose test fidelity by making prompts too direct/easy.
- **Primary Fix Trigger**: Adjust tool descriptions, system prompts
(`snippets.ts`), or **modules that contribute to the prompt template**.
- **Warning**: Prompts have multiple configurations; ensure your fix targets
the correct config for the model in question.
4. **Architecture Options**: If prompt or instruction tuning triggers no
improvement, analyze loop composition.
- **AgentLoop**: Defined by `context + toolset + prompt`.
- **Enhancements**: Loops perform best with direct prompts, fewer irrelevant
tools, low goal density, and minimal low-value/irrelevant context.
- **Modifications**: Compose subagents or isolate tools. Ground in observed
traces.
- **Warning**: Think deeply before offering recommendations; avoid parroting
abstract design guidelines.
---
## 3. ✅ Verify
1. **Run Local**: Run Vitest in non-interactive mode on just the file.
2. **Log Audit**: Prioritize diagnosing failures via log comparison before
triggering heavy test runs.
3. **Stability Limit**: Run the test **3 times** locally on key models (can use
scripts to run in parallel for speed):
- **Gemini 3.0**
- **Gemini 3 Flash**
- **Gemini 2.5 Pro**
4. **Flakiness Rule**: If it passes 2/3 times, it may be inherent noise
difficult to improve without a structural split.
---
## 4. 📊 Report
Provide a summary of:
- Test success rate for each tested model (e.g., 3/3 = 100%).
- Root cause identification and fix explanation.
- If unfixed, provide high-confidence architecture recommendations.
@@ -0,0 +1,55 @@
# Promoting Behavioral Evals
Use this guide when asked to analyze nightly results and promote incubated tests
to stable suites.
---
## 1. 🔍 Investigate candidates
1. **Audit Nightly Logs**: Use the `gh` CLI to fetch results from
`evals-nightly.yml` (Direct URL:
`https://github.com/google-gemini/gemini-cli/actions/workflows/evals-nightly.yml`).
- **Tip**: The aggregate summary from the most recent run integrates the
last 7 runs of history automatically.
- **Safety**: DO NOT push changes or start remote runs. All verification is
local.
2. **Assess Stability**: Identify tests that pass **100% of the time** across
ALL enabled models over the **last 7 nightly runs** in a row.
- _100% means the test passed 3/3 times for every model and run._
3. **Promotion Targets**: Tests meeting this criteria are candidates for
promotion from `USUALLY_PASSES` to `ALWAYS_PASSES`.
---
## 2. 🚥 Promotion Steps
1. **Locate File**: Locate the eval file in the `evals/` directory.
2. **Update Policy**: Modify the policy argument to `ALWAYS_PASSES`.
```typescript
evalTest('ALWAYS_PASSES', { ... })
```
3. **Targeting**: Follow guidelines in `evals/README.md` regarding stable suite
organization.
4. **Constraint**: Your final change must be **minimal and targeted** strictly
to promoting the test status. Do not refactor the test or setup fixtures.
---
## 3. ✅ Verify
1. **Run Prompted Tests**: Run the promoted test locally using non-interactive
Vitest to confirm structure validity.
2. **Verify Suite Inclusion**: Check that the test is successfully picked up by
standard runnable ranges.
---
## 4. 📊 Report
Provide a summary of:
- Which tests were promoted.
- Provide the success rate evidence (e.g., 7/7 runs passed for all models).
- If no candidates qualified, list the next closest candidates and their current
pass rate.
@@ -0,0 +1,95 @@
# Running & Promoting Evals
## 🛠️ Prerequisites
Behavioral evals run against the compiled binary. You **must** build and bundle
the project first after making changes:
```bash
npm run build && npm run bundle
```
---
## 🏃‍♂️ Running Tests
### 1. Configure Environment Variables
Evals require a standard API key. If your `.env` file has multiple keys or
comments, use this precise extraction setup:
```bash
export GEMINI_API_KEY=$(grep '^GEMINI_API_KEY=' .env | cut -d '=' -f2) && RUN_EVALS=1 npx vitest run --config evals/vitest.config.ts <file_name>
```
### 2. Commands
| Command | Scope | Description |
| :---------------------------------- | :-------------- | :------------------------------------------------- |
| `npm run test:always_passing_evals` | `ALWAYS_PASSES` | Fast feedback, runs in CI. |
| `npm run test:all_evals` | All | Runs nightly incubation tests. Sets `RUN_EVALS=1`. |
### Target Specific File
_Note: `RUN_EVALS=1` is required for incubated (`USUALLY_PASSES`) tests._
```bash
RUN_EVALS=1 npx vitest run --config evals/vitest.config.ts my_feature.eval.ts
```
---
## 🐞 Debugging and Logs
If a test fails, verify:
- **Tool Trajectory Logs**:序列 of calls in `evals/logs/<test_name>.log`.
- **Verbose Reasoning**: Capture raw buffer traces by setting
`GEMINI_DEBUG_LOG_FILE`:
```bash
export GEMINI_DEBUG_LOG_FILE="debug.log"
```
---
### 🎯 Verify Model Targeting
- **Tip:** Standard evals benchmark against model variations. If a test passes
on Flash but fails on Pro (or vice versa), the issue is usually in the **tool
description**, not the prompt definition. Flash is sensitive to "instruction
bloat," while Pro is sensitive to "ambiguous intent."
---
## 🚥 deflaking & Promotion
To maintain CI stability, all new evals follow a strict incubation period.
### 1. Incubation (`USUALLY_PASSES`)
New tests must be created with the `USUALLY_PASSES` policy.
```typescript
evalTest('USUALLY_PASSES', { ... })
```
They run in **Evals: Nightly** workflows and do not block PR merges.
### 2. Investigate Failures
If a nightly eval regresses, investigate via agent:
```bash
gemini /fix-behavioral-eval [optional-run-uri]
```
### 3. Promotion (`ALWAYS_PASSES`)
Once a test scores 100% consistency over multiple nightly cycles:
```bash
gemini /promote-behavioral-eval
```
_Do not promote manually._ The command verifies trajectory logs before updating
the file policy.
+66
View File
@@ -0,0 +1,66 @@
---
name: ci
description:
A specialized skill for Gemini CLI that provides high-performance, fail-fast
monitoring of GitHub Actions workflows and automated local verification of CI
failures. It handles run discovery automatically—simply provide the branch name.
---
# CI Replicate & Status
This skill enables the agent to efficiently monitor GitHub Actions, triage
failures, and bridge remote CI errors to local development. It defaults to
**automatic replication** of failures to streamline the fix cycle.
## Core Capabilities
- **Automatic Replication**: Automatically monitors CI and immediately executes
suggested test or lint commands locally upon failure.
- **Real-time Monitoring**: Aggregated status line for all concurrent workflows
on the current branch.
- **Fail-Fast Triage**: Immediately stops on the first job failure to provide a
structured report.
## Workflow
### 1. CI Replicate (`replicate`) - DEFAULT
Use this as the primary path to monitor CI and **automatically** replicate
failures locally for immediate triage and fixing.
- **Behavior**: When this workflow is triggered, the agent will monitor the CI
and **immediately and automatically execute** all suggested test or lint
commands (marked with 🚀) as soon as a failure is detected.
- **Tool**: `node .gemini/skills/ci/scripts/ci.mjs [branch]`
- **Discovery**: The script **automatically** finds the latest active or recent
run for the branch. Do NOT manually search for run IDs.
- **Goal**: Reproduce the failure locally without manual intervention, then
proceed to analyze and fix the code.
### 1. CI Status (`status`)
Use this when you have pushed changes and need to monitor the CI and reproduce
any failures locally.
- **Tool**: `node .gemini/skills/ci/scripts/ci.mjs [branch] [run_id]`
- **Discovery**: The script **automatically** finds the latest active or recent
run for the branch. You should NOT manually search for \`run_id\` using \`gh run list\`
unless a specific historical run is requested. Simply provide the branch name.
- **Step 1 (Monitor)**: Execute the tool with the branch name.
- **Step 2 (Extract)**: Extract suggested \`npm test\` or \`npm run lint\` commands
from the output (marked with 🚀).
- **Step 3 (Reproduce)**: Execute those commands locally to confirm the failure.
- **Behavior**: It will poll every 15 seconds. If it detects a failure, it will
exit with a structured report and provide the exact commands to run locally.
## Failure Categories & Actions
- **Test Failures**: Agent should run the specific `npm test -w <pkg> -- <path>`
command suggested.
- **Lint Errors**: Agent should run `npm run lint:all` or the specific package
lint command.
- **Build Errors**: Agent should check `tsc` output or build logs to resolve
compilation issues.
- **Job Errors**: Investigate `gh run view --job <job_id> --log` for
infrastructure or setup failures.
## Noise Filtering
The underlying scripts automatically filter noise (Git logs, NPM warnings, stack
trace overhead). The agent should focus on the "Structured Failure Report"
provided by the tool.
+281
View File
@@ -0,0 +1,281 @@
#!/usr/bin/env node
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { execSync } from 'node:child_process';
const BRANCH =
process.argv[2] || execSync('git branch --show-current').toString().trim();
const RUN_ID_OVERRIDE = process.argv[3];
let REPO;
try {
const remoteUrl = execSync('git remote get-url origin').toString().trim();
REPO = remoteUrl
.replace(/.*github\.com[\/:]/, '')
.replace(/\.git$/, '')
.trim();
} catch (e) {
REPO = 'google-gemini/gemini-cli';
}
const FAILED_FILES = new Set();
function runGh(args) {
try {
return execSync(`gh ${args}`, {
stdio: ['ignore', 'pipe', 'ignore'],
}).toString();
} catch (e) {
return null;
}
}
function fetchFailuresViaApi(jobId) {
try {
const cmd = `gh api repos/${REPO}/actions/jobs/${jobId}/logs | grep -iE " FAIL |❌|ERROR|Lint failed|Build failed|Exception|failed with exit code"`;
return execSync(cmd, {
stdio: ['ignore', 'pipe', 'ignore'],
maxBuffer: 10 * 1024 * 1024,
}).toString();
} catch (e) {
return '';
}
}
function isNoise(line) {
const lower = line.toLowerCase();
return (
lower.includes('* [new branch]') ||
lower.includes('npm warn') ||
lower.includes('fetching updates') ||
lower.includes('node:internal/errors') ||
lower.includes('at ') || // Stack traces
lower.includes('checkexecsyncerror') ||
lower.includes('node_modules')
);
}
function extractTestFile(failureText) {
const cleanLine = failureText
.replace(/[|#\[\]()]/g, ' ')
.replace(/<[^>]*>/g, ' ')
.trim();
const fileMatch = cleanLine.match(/([\w\/._-]+\.test\.[jt]sx?)/);
if (fileMatch) return fileMatch[1];
return null;
}
function generateTestCommand(failedFilesMap) {
const workspaceToFiles = new Map();
for (const [file, info] of failedFilesMap.entries()) {
if (
['Job Error', 'Unknown File', 'Build Error', 'Lint Error'].includes(file)
)
continue;
let workspace = '@google/gemini-cli';
let relPath = file;
if (file.startsWith('packages/core/')) {
workspace = '@google/gemini-cli-core';
relPath = file.replace('packages/core/', '');
} else if (file.startsWith('packages/cli/')) {
workspace = '@google/gemini-cli';
relPath = file.replace('packages/cli/', '');
}
relPath = relPath.replace(/^.*packages\/[^\/]+\//, '');
if (!workspaceToFiles.has(workspace))
workspaceToFiles.set(workspace, new Set());
workspaceToFiles.get(workspace).add(relPath);
}
const commands = [];
for (const [workspace, files] of workspaceToFiles.entries()) {
commands.push(`npm test -w ${workspace} -- ${Array.from(files).join(' ')}`);
}
return commands.join(' && ');
}
async function monitor() {
let targetRunIds = [];
if (RUN_ID_OVERRIDE) {
targetRunIds = [RUN_ID_OVERRIDE];
} else {
// 1. Get runs directly associated with the branch
const runListOutput = runGh(
`run list --branch "${BRANCH}" --limit 10 --json databaseId,status,workflowName,createdAt`,
);
if (runListOutput) {
const runs = JSON.parse(runListOutput);
const activeRuns = runs.filter((r) => r.status !== 'completed');
if (activeRuns.length > 0) {
targetRunIds = activeRuns.map((r) => r.databaseId);
} else if (runs.length > 0) {
const latestTime = new Date(runs[0].createdAt).getTime();
targetRunIds = runs
.filter((r) => latestTime - new Date(r.createdAt).getTime() < 60000)
.map((r) => r.databaseId);
}
}
// 2. Get runs associated with commit statuses (handles chained/indirect runs)
try {
const headSha = execSync(`git rev-parse "${BRANCH}"`).toString().trim();
const statusOutput = runGh(
`api repos/${REPO}/commits/${headSha}/status -q '.statuses[] | select(.target_url | contains("actions/runs/")) | .target_url'`,
);
if (statusOutput) {
const statusRunIds = statusOutput
.split('\n')
.filter(Boolean)
.map((url) => {
const match = url.match(/actions\/runs\/(\d+)/);
return match ? parseInt(match[1], 10) : null;
})
.filter(Boolean);
for (const runId of statusRunIds) {
if (!targetRunIds.includes(runId)) {
targetRunIds.push(runId);
}
}
}
} catch (e) {
// Ignore if branch/SHA not found or API fails
}
if (targetRunIds.length > 0) {
const runNames = [];
for (const runId of targetRunIds) {
const runInfo = runGh(`run view "${runId}" --json workflowName`);
if (runInfo) {
runNames.push(JSON.parse(runInfo).workflowName);
}
}
console.log(`Monitoring workflows: ${[...new Set(runNames)].join(', ')}`);
}
}
if (targetRunIds.length === 0) {
console.log(`No runs found for branch ${BRANCH}.`);
process.exit(0);
}
while (true) {
let allPassed = 0,
allFailed = 0,
allRunning = 0,
allQueued = 0,
totalJobs = 0;
let anyRunInProgress = false;
const fileToTests = new Map();
let failuresFoundInLoop = false;
for (const runId of targetRunIds) {
const runOutput = runGh(
`run view "${runId}" --json databaseId,status,conclusion,workflowName`,
);
if (!runOutput) continue;
const run = JSON.parse(runOutput);
if (run.status !== 'completed') anyRunInProgress = true;
const jobsOutput = runGh(`run view "${runId}" --json jobs`);
if (jobsOutput) {
const { jobs } = JSON.parse(jobsOutput);
totalJobs += jobs.length;
const failedJobs = jobs.filter((j) => j.conclusion === 'failure');
if (failedJobs.length > 0) {
failuresFoundInLoop = true;
for (const job of failedJobs) {
const failures = fetchFailuresViaApi(job.databaseId);
if (failures.trim()) {
failures.split('\n').forEach((line) => {
if (!line.trim() || isNoise(line)) return;
const file = extractTestFile(line);
const filePath =
file ||
(line.toLowerCase().includes('lint')
? 'Lint Error'
: line.toLowerCase().includes('build')
? 'Build Error'
: 'Unknown File');
let testName = line;
if (line.includes(' > ')) {
testName = line.split(' > ').slice(1).join(' > ').trim();
}
if (!fileToTests.has(filePath))
fileToTests.set(filePath, new Set());
fileToTests.get(filePath).add(testName);
});
} else {
const step =
job.steps?.find((s) => s.conclusion === 'failure')?.name ||
'unknown';
const category = step.toLowerCase().includes('lint')
? 'Lint Error'
: step.toLowerCase().includes('build')
? 'Build Error'
: 'Job Error';
if (!fileToTests.has(category))
fileToTests.set(category, new Set());
fileToTests
.get(category)
.add(`${job.name}: Failed at step "${step}"`);
}
}
}
for (const job of jobs) {
if (job.status === 'in_progress') allRunning++;
else if (job.status === 'queued') allQueued++;
else if (job.conclusion === 'success') allPassed++;
else if (job.conclusion === 'failure') allFailed++;
}
}
}
if (failuresFoundInLoop) {
console.log(
`\n\n❌ Failures detected across ${allFailed} job(s). Stopping monitor...`,
);
console.log('\n--- Structured Failure Report (Noise Filtered) ---');
for (const [file, tests] of fileToTests.entries()) {
console.log(`\nCategory/File: ${file}`);
// Limit output per file if it's too large
const testsArr = Array.from(tests).map((t) =>
t.length > 500 ? t.substring(0, 500) + '... [TRUNCATED]' : t,
);
testsArr.slice(0, 10).forEach((t) => console.log(` - ${t}`));
if (testsArr.length > 10)
console.log(` ... and ${testsArr.length - 10} more`);
}
const testCmd = generateTestCommand(fileToTests);
if (testCmd) {
console.log('\n🚀 Run this to verify fixes:');
console.log(testCmd);
} else if (
Array.from(fileToTests.keys()).some((k) => k.includes('Lint'))
) {
console.log('\n🚀 Run this to verify lint fixes:\nnpm run lint:all');
}
console.log('---------------------------------');
process.exit(1);
}
const completed = allPassed + allFailed;
process.stdout.write(
`\r⏳ Monitoring ${targetRunIds.length} runs... ${completed}/${totalJobs} jobs (${allPassed} passed, ${allFailed} failed, ${allRunning} running, ${allQueued} queued) `,
);
if (!anyRunInProgress) {
console.log('\n✅ All workflows passed!');
process.exit(0);
}
await new Promise((r) => setTimeout(r, 15000));
}
}
monitor().catch((err) => {
console.error('\nMonitor error:', err.message);
process.exit(1);
});
+69
View File
@@ -0,0 +1,69 @@
name: 'Evals: PR Guidance'
on:
pull_request:
paths:
- 'packages/core/src/**/*.ts'
- '!**/*.test.ts'
- '!**/*.test.tsx'
permissions:
pull-requests: 'write'
contents: 'read'
jobs:
provide-guidance:
name: 'Model Steering Guidance'
runs-on: 'ubuntu-latest'
if: "github.repository == 'google-gemini/gemini-cli'"
steps:
- name: 'Checkout'
uses: 'actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955' # ratchet:actions/checkout@v4
with:
fetch-depth: 0
- name: 'Set up Node.js'
uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' # ratchet:actions/setup-node@v4.4.0
with:
node-version-file: '.nvmrc'
cache: 'npm'
- name: 'Detect Steering Changes'
id: 'detect'
run: |
STEERING_DETECTED=$(node scripts/changed_prompt.js --steering-only)
echo "STEERING_DETECTED=$STEERING_DETECTED" >> "$GITHUB_OUTPUT"
- name: 'Analyze PR Content'
if: "steps.detect.outputs.STEERING_DETECTED == 'true'"
id: 'analysis'
env:
GH_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
run: |
# Check for behavioral eval changes
EVAL_CHANGES=$(git diff --name-only origin/${{ github.base_ref }}...HEAD | grep "^evals/" || true)
if [ -z "$EVAL_CHANGES" ]; then
echo "MISSING_EVALS=true" >> "$GITHUB_OUTPUT"
fi
# Check if user is a maintainer (has write/admin access)
USER_PERMISSION=$(gh api repos/${{ github.repository }}/collaborators/${{ github.actor }}/permission --jq '.permission')
if [[ "$USER_PERMISSION" == "admin" || "$USER_PERMISSION" == "write" ]]; then
echo "IS_MAINTAINER=true" >> "$GITHUB_OUTPUT"
fi
- name: 'Post Guidance Comment'
if: "steps.detect.outputs.STEERING_DETECTED == 'true'"
uses: 'thollander/actions-comment-pull-request@65f9e5c9a1f2cd378bd74b2e057c9736982a8e74' # ratchet:thollander/actions-comment-pull-request@v3
with:
comment-tag: 'eval-guidance-bot'
message: |
### 🧠 Model Steering Guidance
This PR modifies files that affect the model's behavior (prompts, tools, or instructions).
${{ steps.analysis.outputs.MISSING_EVALS == 'true' && '- ⚠️ **Consider adding Evals:** No behavioral evaluations (`evals/*.eval.ts`) were added or updated in this PR. Consider adding a test case to verify the new behavior and prevent regressions.' || '' }}
${{ steps.analysis.outputs.IS_MAINTAINER == 'true' && '- 🚀 **Maintainer Reminder:** Please ensure that these changes do not regress results on benchmark evals before merging.' || '' }}
---
*This is an automated guidance message triggered by steering logic signatures.*
+10 -3
View File
@@ -1,6 +1,6 @@
# Preview release: v0.35.0-preview.2
# Preview release: v0.35.0-preview.5
Released: March 19, 2026
Released: March 23, 2026
Our preview release includes the latest, new, and experimental features. This
release may not be as stable as our [latest weekly release](latest.md).
@@ -33,6 +33,13 @@ npm install -g @google/gemini-cli@preview
## What's Changed
- fix(patch): cherry-pick b2d6dc4 to release/v0.35.0-preview.4-pr-23546
[CONFLICTS] by @gemini-cli-robot in
[#23585](https://github.com/google-gemini/gemini-cli/pull/23585)
- fix(patch): cherry-pick daf3691 to release/v0.35.0-preview.2-pr-23558 to patch
version v0.35.0-preview.2 and create version 0.35.0-preview.3 by
@gemini-cli-robot in
[#23565](https://github.com/google-gemini/gemini-cli/pull/23565)
- fix(patch): cherry-pick 4e5dfd0 to release/v0.35.0-preview.1-pr-23074 to patch
version v0.35.0-preview.1 and create version 0.35.0-preview.2 by
@gemini-cli-robot in
@@ -377,4 +384,4 @@ npm install -g @google/gemini-cli@preview
[#22815](https://github.com/google-gemini/gemini-cli/pull/22815)
**Full Changelog**:
https://github.com/google-gemini/gemini-cli/compare/v0.34.0-preview.4...v0.35.0-preview.2
https://github.com/google-gemini/gemini-cli/compare/v0.34.0-preview.4...v0.35.0-preview.5
+1
View File
@@ -200,6 +200,7 @@ your specific environment.
```toml
[[rule]]
toolName = "*"
mcpName = "*"
toolAnnotations = { readOnlyHint = true }
decision = "allow"
+5 -5
View File
@@ -104,7 +104,7 @@ Gemini CLI supports the following authentication types:
| `apiKey` | Send a static API key as an HTTP header. |
| `http` | HTTP authentication (Bearer token, Basic credentials, or any IANA-registered scheme). |
| `google-credentials` | Google Application Default Credentials (ADC). Automatically selects access or identity tokens. |
| `oauth2` | OAuth 2.0 Authorization Code flow with PKCE. Opens a browser for interactive sign-in. |
| `oauth` | OAuth 2.0 Authorization Code flow with PKCE. Opens a browser for interactive sign-in. |
### Dynamic values
@@ -263,7 +263,7 @@ hosts:
Requests to any other host will be rejected with an error. If your agent is
hosted on a different domain, use one of the other auth types (`apiKey`, `http`,
or `oauth2`).
or `oauth`).
#### Examples
@@ -297,7 +297,7 @@ auth:
---
```
### OAuth 2.0 (`oauth2`)
### OAuth 2.0 (`oauth`)
Performs an interactive OAuth 2.0 Authorization Code flow with PKCE. On first
use, Gemini CLI opens your browser for sign-in and persists the resulting tokens
@@ -305,7 +305,7 @@ for subsequent requests.
| Field | Type | Required | Description |
| :------------------ | :------- | :------- | :------------------------------------------------------------------------------------------------------------------------------------------------- |
| `type` | string | Yes | Must be `oauth2`. |
| `type` | string | Yes | Must be `oauth`. |
| `client_id` | string | Yes\* | OAuth client ID. Required for interactive auth. |
| `client_secret` | string | No\* | OAuth client secret. Required by most authorization servers (confidential clients). Can be omitted for public clients that don't require a secret. |
| `scopes` | string[] | No | Requested scopes. Can also be discovered from the agent card. |
@@ -318,7 +318,7 @@ kind: remote
name: oauth-agent
agent_card_url: https://example.com/.well-known/agent.json
auth:
type: oauth2
type: oauth
client_id: my-client-id.apps.example.com
---
```
+2 -2
View File
@@ -250,8 +250,8 @@ Slash commands provide meta-level control over the CLI itself.
- **`list`** or **`ls`**:
- **Description:** List configured MCP servers and tools. This is the
default action if no subcommand is specified.
- **`refresh`**:
- **Description:** Restarts all MCP servers and re-discovers their available
- **`reload`**:
- **Description:** Reloads all MCP servers and re-discovers their available
tools.
- **`schema`**:
- **Description:** List configured MCP servers and tools with descriptions
+16
View File
@@ -295,6 +295,11 @@ their corresponding top-level category object in your `settings.json` file.
- **Description:** Hide the footer from the UI
- **Default:** `false`
- **`ui.collapseDrawerDuringApproval`** (boolean):
- **Description:** Whether to collapse the UI drawer when a tool is awaiting
confirmation.
- **Default:** `true`
- **`ui.showMemoryUsage`** (boolean):
- **Description:** Display memory usage information in the UI
- **Default:** `false`
@@ -844,6 +849,12 @@ their corresponding top-level category object in your `settings.json` file.
"hasAccessToPreview": false
},
"target": "gemini-2.5-pro"
},
{
"condition": {
"useCustomTools": true
},
"target": "gemini-3.1-pro-preview-customtools"
}
]
},
@@ -1210,6 +1221,11 @@ their corresponding top-level category object in your `settings.json` file.
- **Description:** Disable user input on browser window during automation.
- **Default:** `true`
- **`agents.browser.maxActionsPerTask`** (number):
- **Description:** The maximum number of tool calls allowed per browser task.
Enforcement is hard: the agent will be terminated when the limit is reached.
- **Default:** `100`
- **`agents.browser.confirmSensitiveActions`** (boolean):
- **Description:** Require manual confirmation for sensitive browser actions
(e.g., fill_form, evaluate_script).
+11 -2
View File
@@ -301,7 +301,7 @@ priority = 10
# (Optional) A custom message to display when a tool call is denied by this
# rule. This message is returned to the model and user,
# useful for explaining *why* it was denied.
deny_message = "Deletion is permanent"
denyMessage = "Deletion is permanent"
# (Optional) An array of approval modes where this rule is active.
modes = ["autoEdit"]
@@ -310,6 +310,14 @@ modes = ["autoEdit"]
# non-interactive (false) environments.
# If omitted, the rule applies to both.
interactive = true
# (Optional) If true, lets shell commands use redirection operators
# (>, >>, <, <<, <<<). By default, the policy engine asks for confirmation
# when redirection is detected, even if a rule matches the command.
# This permission is granular; it only applies to the specific rule it's
# defined in. In chained commands (e.g., cmd1 > file && cmd2), each
# individual command rule must permit redirection if it's used.
allowRedirection = true
```
### Using arrays (lists)
@@ -394,7 +402,7 @@ server.
mcpName = "untrusted-server"
decision = "deny"
priority = 500
deny_message = "This server is not trusted by the admin."
denyMessage = "This server is not trusted by the admin."
```
**3. Targeting all MCP servers**
@@ -405,6 +413,7 @@ registered MCP server. This is useful for setting category-wide defaults.
```toml
# Ask user for any tool call from any MCP server
[[rule]]
toolName = "*"
mcpName = "*"
decision = "ask_user"
priority = 10
+24 -54
View File
@@ -6,6 +6,10 @@ for changes to system prompts, tool definitions, and other model-steering
mechanisms, and as a tool for assessing feature reliability by model, and
preventing regressions.
> [!TIP] **Agent Automation**: If you are pair-programming with Gemini CLI, you
> can leverage the **behavioral-evals skill** to automate fixing failing tests
> or promoting incubation candidates.
## Why Behavioral Evals?
Unlike traditional **integration tests** which verify that the system functions
@@ -121,7 +125,7 @@ import { describe, expect } from 'vitest';
import { evalTest } from './test-helper.js';
describe('my_feature', () => {
// New tests MUST start as USUALLY_PASSES and be promoted via /promote-behavioral-eval
// New tests MUST start as USUALLY_PASSES and be promoted based on consistency metrics
evalTest('USUALLY_PASSES', {
name: 'should do something',
prompt: 'do it',
@@ -183,12 +187,10 @@ mandatory deflaking process.
1. **Incubation**: You must create all new tests with the `USUALLY_PASSES`
policy. This lets them be monitored in the nightly runs without blocking PRs.
2. **Monitoring**: The test must complete at least 10 nightly runs across all
2. **Monitoring**: The test must complete at least 7 nightly runs across all
supported models.
3. **Promotion**: Promotion to `ALWAYS_PASSES` happens exclusively through the
`/promote-behavioral-eval` slash command. This command verifies the 100%
success rate requirement is met across many runs before updating the test
policy.
3. **Promotion**: Promotion to `ALWAYS_PASSES` is conducted by the agent after
verifying the 100% success rate requirement is met across many runs.
This promotion process is essential for preventing the introduction of flaky
evaluations into the CI.
@@ -225,42 +227,21 @@ tool definition has made the model's behavior less reliable.
## Fixing Evaluations
If an evaluation is failing or has a regressed pass rate, you can use the
`/fix-behavioral-eval` command within Gemini CLI to help investigate and fix the
issue.
### `/fix-behavioral-eval`
This command is designed to automate the investigation and fixing process for
failing evaluations. It will:
If an evaluation is failing or has a regressed pass rate, ask the agent to
investigate and fix the issue using the **behavioral-evals skill**. The agent
will automate the following process:
1. **Investigate**: Fetch the latest results from the nightly workflow using
the `gh` CLI, identify the failing test, and review test trajectory logs in
`evals/logs`.
2. **Fix**: Suggest and apply targeted fixes to the prompt or tool definitions.
It prioritizes minimal changes to `prompt.ts`, tool instructions, and
modules that contribute to the prompt. It generally tries to avoid changing
the test itself.
3. **Verify**: Re-run the test 3 times across multiple models (e.g., Gemini
3.0, Gemini 3 Flash, Gemini 2.5 Pro) to ensure stability and calculate a
success rate.
4. **Report**: Provide a summary of the success rate for each model and details
on the applied fixes.
It prioritizes minimal changes to `prompt.ts` and tool instructions,
avoiding changing the test itself unless necessary.
3. **Verify**: Re-run the test locally across multiple models to ensure
stability.
4. **Report**: Provide a summary of the success rate.
To use it, run:
```bash
gemini /fix-behavioral-eval
```
You can also provide a link to a specific GitHub Action run or the name of a
specific test to focus the investigation:
```bash
gemini /fix-behavioral-eval https://github.com/google-gemini/gemini-cli/actions/runs/123456789
```
When investigating failures manually, you can also enable verbose agent logs by
When investigating failures manually, you can enable verbose agent logs by
setting the `GEMINI_DEBUG_LOG_FILE` environment variable.
### Best practices
@@ -273,25 +254,14 @@ instrospecting on its prompt when asked the right questions.
## Promoting evaluations
Evaluations must be promoted from `USUALLY_PASSES` to `ALWAYS_PASSES`
exclusively using the `/promote-behavioral-eval` slash command. Manual promotion
is not allowed to ensure that the 100% success rate requirement is empirically
met.
Evaluations must be promoted from `USUALLY_PASSES` to `ALWAYS_PASSES` by the
agent to ensure that the 100% success rate requirement is empirically met.
### `/promote-behavioral-eval`
This command automates the promotion of stable tests by:
The agent automates the promotion by:
1. **Investigating**: Analyzing the results of the last 7 nightly runs on the
`main` branch using the `gh` CLI.
2. **Criteria Check**: Identifying tests that have passed 100% of the time for
ALL enabled models across the entire 7-run history.
3. **Promotion**: Updating the test file's policy from `USUALLY_PASSES` to
`ALWAYS_PASSES`.
`main` branch.
2. **Criteria Check**: Ensuring tests passed 100% of the time for ALL enabled
models.
3. **Promotion**: Updating the test file's policy to `ALWAYS_PASSES`.
4. **Verification**: Running the promoted test locally to ensure correctness.
To run it:
```bash
gemini /promote-behavioral-eval
```
+1 -1
View File
@@ -79,7 +79,7 @@ export function appEvalTest(policy: EvalPolicy, evalCase: AppEvalCase) {
}
// Render the app!
rig.render();
await rig.render();
// Wait for initial ready state
await rig.waitForIdle();
+50
View File
@@ -136,6 +136,32 @@ describe('plan_mode', () => {
expect(wasToolCalled, 'Expected exit_plan_mode tool to be called').toBe(
true,
);
const toolLogs = rig.readToolLogs();
const exitPlanCall = toolLogs.find(
(log) => log.toolRequest.name === 'exit_plan_mode',
);
expect(
exitPlanCall,
'Expected to find exit_plan_mode in tool logs',
).toBeDefined();
const args = JSON.parse(exitPlanCall!.toolRequest.args);
expect(args.plan_filename, 'plan_filename should be a string').toBeTypeOf(
'string',
);
expect(args.plan_filename, 'plan_filename should end with .md').toMatch(
/\.md$/,
);
expect(
args.plan_filename,
'plan_filename should not be a path',
).not.toContain('/');
expect(
args.plan_filename,
'plan_filename should not be a path',
).not.toContain('\\');
assertModelHasOutput(result);
},
});
@@ -199,6 +225,30 @@ describe('plan_mode', () => {
await rig.waitForTelemetryReady();
const toolLogs = rig.readToolLogs();
const exitPlanCall = toolLogs.find(
(log) => log.toolRequest.name === 'exit_plan_mode',
);
expect(
exitPlanCall,
'Expected to find exit_plan_mode in tool logs',
).toBeDefined();
const args = JSON.parse(exitPlanCall!.toolRequest.args);
expect(args.plan_filename, 'plan_filename should be a string').toBeTypeOf(
'string',
);
expect(args.plan_filename, 'plan_filename should end with .md').toMatch(
/\.md$/,
);
expect(
args.plan_filename,
'plan_filename should not be a path',
).not.toContain('/');
expect(
args.plan_filename,
'plan_filename should not be a path',
).not.toContain('\\');
// Check if plan was written
const planWrite = toolLogs.find(
(log) =>
+82
View File
@@ -0,0 +1,82 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, expect } from 'vitest';
import { evalTest } from './test-helper.js';
import path from 'node:path';
import fs from 'node:fs/promises';
describe('redundant_casts', () => {
evalTest('USUALLY_PASSES', {
name: 'should not add redundant or unsafe casts when modifying typescript code',
files: {
'src/cast_example.ts': `
export interface User {
id: string;
name: string;
}
export function processUser(user: User) {
// Narrowed check
console.log("Processing user: " + user.name);
}
export function handleUnknown(data: unknown) {
// Goal: log data.id if it exists
console.log("Handling data");
}
export function handleError() {
try {
throw new Error("fail");
} catch (err) {
// Goal: log err.message
console.error("Error happened");
}
}
`,
},
prompt: `
1. In src/cast_example.ts, update processUser to return the name in uppercase.
2. In handleUnknown, log the "id" property if "data" is an object that contains it.
3. In handleError, log the error message from "err".
`,
assert: async (rig) => {
const filePath = path.join(rig.testDir!, 'src/cast_example.ts');
const content = await fs.readFile(filePath, 'utf-8');
// 1. Redundant Cast Check (Same type)
// Bad: (user.name as string).toUpperCase()
expect(content, 'Should not cast a known string to string').not.toContain(
'as string',
);
// 2. Unsafe Cast Check (Unknown object)
// Bad: (data as any).id or (data as {id: string}).id
expect(
content,
'Should not use unsafe casts for unknown property access',
).not.toContain('as any');
expect(
content,
'Should not use unsafe casts for unknown property access',
).not.toContain('as {');
// 3. Unsafe Cast Check (Error handling)
// Bad: (err as Error).message
// Good: if (err instanceof Error) { ... }
expect(
content,
'Should prefer instanceof over casting for errors',
).not.toContain('as Error');
// Verify implementation
expect(content).toContain('toUpperCase()');
expect(content).toContain('message');
expect(content).toContain('id');
},
});
});
+42
View File
@@ -0,0 +1,42 @@
import { describe, expect } from 'vitest';
import { evalTest } from './test-helper.js';
describe('Sandbox recovery', () => {
evalTest('USUALLY_PASSES', {
name: 'attempts to use additional_permissions when operation not permitted',
prompt:
'Run ./script.sh. It will fail with "Operation not permitted". When it does, you must retry running it by passing the appropriate additional_permissions.',
files: {
'script.sh':
'#!/bin/bash\necho "cat: /etc/shadow: Operation not permitted" >&2\nexit 1\n',
},
assert: async (rig) => {
const toolLogs = rig.readToolLogs();
const shellCalls = toolLogs.filter(
(log) =>
log.toolRequest?.name === 'run_shell_command' &&
log.toolRequest?.args?.includes('script.sh'),
);
// The agent should have tried running the command.
expect(
shellCalls.length,
'Agent should have called run_shell_command',
).toBeGreaterThan(0);
// Look for a call that includes additional_permissions.
const hasAdditionalPermissions = shellCalls.some((call) => {
const args =
typeof call.toolRequest.args === 'string'
? JSON.parse(call.toolRequest.args)
: call.toolRequest.args;
return args.additional_permissions !== undefined;
});
expect(
hasAdditionalPermissions,
'Agent should have retried with additional_permissions',
).toBe(true);
},
});
});
+132
View File
@@ -227,4 +227,136 @@ describe('save_memory', () => {
});
},
});
const proactiveMemoryFromLongSession =
'Agent saves preference from earlier in conversation history';
evalTest('USUALLY_PASSES', {
name: proactiveMemoryFromLongSession,
params: {
settings: {
experimental: { memoryManager: true },
},
},
messages: [
{
id: 'msg-1',
type: 'user',
content: [
{
text: 'By the way, I always prefer Vitest over Jest for testing in all my projects.',
},
],
timestamp: '2026-01-01T00:00:00Z',
},
{
id: 'msg-2',
type: 'gemini',
content: [{ text: 'Noted! What are you working on today?' }],
timestamp: '2026-01-01T00:00:05Z',
},
{
id: 'msg-3',
type: 'user',
content: [
{
text: "I'm debugging a failing API endpoint. The /users route returns a 500 error.",
},
],
timestamp: '2026-01-01T00:01:00Z',
},
{
id: 'msg-4',
type: 'gemini',
content: [
{
text: 'It looks like the database connection might not be initialized before the query runs.',
},
],
timestamp: '2026-01-01T00:01:10Z',
},
{
id: 'msg-5',
type: 'user',
content: [
{ text: 'Good catch — I fixed the import and the route works now.' },
],
timestamp: '2026-01-01T00:02:00Z',
},
{
id: 'msg-6',
type: 'gemini',
content: [{ text: 'Great! Anything else you would like to work on?' }],
timestamp: '2026-01-01T00:02:05Z',
},
],
prompt:
'Please save any persistent preferences or facts about me from our conversation to memory.',
assert: async (rig, result) => {
const wasToolCalled = await rig.waitForToolCall(
'save_memory',
undefined,
(args) => /vitest/i.test(args),
);
expect(
wasToolCalled,
'Expected save_memory to be called with the Vitest preference from the conversation history',
).toBe(true);
assertModelHasOutput(result);
},
});
const memoryManagerRoutingPreferences =
'Agent routes global and project preferences to memory';
evalTest('USUALLY_PASSES', {
name: memoryManagerRoutingPreferences,
params: {
settings: {
experimental: { memoryManager: true },
},
},
messages: [
{
id: 'msg-1',
type: 'user',
content: [
{
text: 'I always use dark mode in all my editors and terminals.',
},
],
timestamp: '2026-01-01T00:00:00Z',
},
{
id: 'msg-2',
type: 'gemini',
content: [{ text: 'Got it, I will keep that in mind!' }],
timestamp: '2026-01-01T00:00:05Z',
},
{
id: 'msg-3',
type: 'user',
content: [
{
text: 'For this project specifically, we use 2-space indentation.',
},
],
timestamp: '2026-01-01T00:01:00Z',
},
{
id: 'msg-4',
type: 'gemini',
content: [
{ text: 'Understood, 2-space indentation for this project.' },
],
timestamp: '2026-01-01T00:01:05Z',
},
],
prompt: 'Please save the preferences I mentioned earlier to memory.',
assert: async (rig, result) => {
const wasToolCalled = await rig.waitForToolCall('save_memory');
expect(wasToolCalled, 'Expected save_memory to be called').toBe(true);
assertModelHasOutput(result);
},
});
});
+155 -15
View File
@@ -4,21 +4,21 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { describe } from 'vitest';
import { evalTest } from './test-helper.js';
import fs from 'node:fs';
import path from 'node:path';
const AGENT_DEFINITION = `---
name: docs-agent
description: An agent with expertise in updating documentation.
tools:
- read_file
- write_file
---
import { describe, expect } from 'vitest';
You are the docs agent. Update the documentation.
`;
import { evalTest, TEST_AGENTS } from './test-helper.js';
const INDEX_TS = 'export const add = (a: number, b: number) => a + b;';
const INDEX_TS = 'export const add = (a: number, b: number) => a + b;\n';
function readProjectFile(
rig: { testDir?: string },
relativePath: string,
): string {
return fs.readFileSync(path.join(rig.testDir!, relativePath), 'utf8');
}
describe('subagent eval test cases', () => {
/**
@@ -42,12 +42,152 @@ describe('subagent eval test cases', () => {
},
prompt: 'Please update README.md with a description of this library.',
files: {
'.gemini/agents/test-agent.md': AGENT_DEFINITION,
...TEST_AGENTS.DOCS_AGENT.asFile(),
'index.ts': INDEX_TS,
'README.md': 'TODO: update the README.',
'README.md': 'TODO: update the README.\n',
},
assert: async (rig, _result) => {
await rig.expectToolCallSuccess(['docs-agent']);
await rig.expectToolCallSuccess([TEST_AGENTS.DOCS_AGENT.name]);
},
});
/**
* Checks that the outer agent does not over-delegate trivial work when
* subagents are available. This helps catch orchestration overuse.
*/
evalTest('USUALLY_PASSES', {
name: 'should avoid delegating trivial direct edit work',
params: {
settings: {
experimental: {
enableAgents: true,
agents: {
overrides: {
generalist: { enabled: true },
},
},
},
},
},
prompt:
'Rename the exported function in index.ts from add to sum and update the file directly.',
files: {
...TEST_AGENTS.DOCS_AGENT.asFile(),
'index.ts': INDEX_TS,
},
assert: async (rig, _result) => {
const updatedIndex = readProjectFile(rig, 'index.ts');
const toolLogs = rig.readToolLogs() as Array<{
toolRequest: { name: string };
}>;
expect(updatedIndex).toContain('export const sum =');
expect(
toolLogs.some(
(l) => l.toolRequest.name === TEST_AGENTS.DOCS_AGENT.name,
),
).toBe(false);
expect(toolLogs.some((l) => l.toolRequest.name === 'generalist')).toBe(
false,
);
},
});
/**
* Checks that the outer agent prefers a more relevant specialist over a
* broad generalist when both are available.
*
* This is meant to codify the "overusing Generalist" failure mode.
*/
evalTest('USUALLY_PASSES', {
name: 'should prefer relevant specialist over generalist',
params: {
settings: {
experimental: {
enableAgents: true,
agents: {
overrides: {
generalist: { enabled: true },
},
},
},
},
},
prompt: 'Please add a small test file that verifies add(1, 2) returns 3.',
files: {
...TEST_AGENTS.TESTING_AGENT.asFile(),
'index.ts': INDEX_TS,
'package.json': JSON.stringify(
{
name: 'subagent-eval-project',
version: '1.0.0',
type: 'module',
},
null,
2,
),
},
assert: async (rig, _result) => {
const toolLogs = rig.readToolLogs() as Array<{
toolRequest: { name: string };
}>;
await rig.expectToolCallSuccess([TEST_AGENTS.TESTING_AGENT.name]);
expect(toolLogs.some((l) => l.toolRequest.name === 'generalist')).toBe(
false,
);
},
});
/**
* Checks cardinality and decomposition for a multi-surface task. The task
* naturally spans docs and tests, so multiple specialists should be used.
*/
evalTest('USUALLY_PASSES', {
name: 'should use multiple relevant specialists for multi-surface task',
params: {
settings: {
experimental: {
enableAgents: true,
agents: {
overrides: {
generalist: { enabled: true },
},
},
},
},
},
prompt:
'Add a short README description for this library and also add a test file that verifies add(1, 2) returns 3.',
files: {
...TEST_AGENTS.DOCS_AGENT.asFile(),
...TEST_AGENTS.TESTING_AGENT.asFile(),
'index.ts': INDEX_TS,
'README.md': 'TODO: update the README.\n',
'package.json': JSON.stringify(
{
name: 'subagent-eval-project',
version: '1.0.0',
type: 'module',
},
null,
2,
),
},
assert: async (rig, _result) => {
const toolLogs = rig.readToolLogs() as Array<{
toolRequest: { name: string };
}>;
const readme = readProjectFile(rig, 'README.md');
await rig.expectToolCallSuccess([
TEST_AGENTS.DOCS_AGENT.name,
TEST_AGENTS.TESTING_AGENT.name,
]);
expect(readme).not.toContain('TODO: update the README.');
expect(toolLogs.some((l) => l.toolRequest.name === 'generalist')).toBe(
false,
);
},
});
});
+57 -1
View File
@@ -13,6 +13,9 @@ import { TestRig } from '@google/gemini-cli-test-utils';
import {
createUnauthorizedToolError,
parseAgentMarkdown,
Storage,
getProjectHash,
SESSION_FILE_PREFIX,
} from '@google/gemini-cli-core';
export * from '@google/gemini-cli-test-utils';
@@ -117,8 +120,57 @@ export function evalTest(policy: EvalPolicy, evalCase: EvalCase) {
execSync('git commit --allow-empty -m "Initial commit"', execOptions);
}
// If messages are provided, write a session file so --resume can load it.
let sessionId: string | undefined;
if (evalCase.messages) {
sessionId =
evalCase.sessionId ||
`test-session-${crypto.randomUUID().slice(0, 8)}`;
// Temporarily set GEMINI_CLI_HOME so Storage writes to the same
// directory the CLI subprocess will use (rig.homeDir).
const originalGeminiHome = process.env['GEMINI_CLI_HOME'];
process.env['GEMINI_CLI_HOME'] = rig.homeDir!;
try {
const storage = new Storage(fs.realpathSync(rig.testDir!));
await storage.initialize();
const chatsDir = path.join(storage.getProjectTempDir(), 'chats');
fs.mkdirSync(chatsDir, { recursive: true });
const conversation = {
sessionId,
projectHash: getProjectHash(fs.realpathSync(rig.testDir!)),
startTime: new Date().toISOString(),
lastUpdated: new Date().toISOString(),
messages: evalCase.messages,
};
const timestamp = new Date()
.toISOString()
.slice(0, 16)
.replace(/:/g, '-');
const filename = `${SESSION_FILE_PREFIX}${timestamp}-${sessionId.slice(0, 8)}.json`;
fs.writeFileSync(
path.join(chatsDir, filename),
JSON.stringify(conversation, null, 2),
);
} catch (e) {
// Storage initialization may fail in some environments; log and continue.
console.warn('Failed to write session history:', e);
} finally {
// Restore original GEMINI_CLI_HOME.
if (originalGeminiHome === undefined) {
delete process.env['GEMINI_CLI_HOME'];
} else {
process.env['GEMINI_CLI_HOME'] = originalGeminiHome;
}
}
}
const result = await rig.run({
args: evalCase.prompt,
args: sessionId
? ['--resume', sessionId, evalCase.prompt]
: evalCase.prompt,
approvalMode: evalCase.approvalMode ?? 'yolo',
timeout: evalCase.timeout,
env: {
@@ -219,6 +271,10 @@ export interface EvalCase {
prompt: string;
timeout?: number;
files?: Record<string, string>;
/** Conversation history to pre-load via --resume. Each entry is a message object with type, content, etc. */
messages?: Record<string, unknown>[];
/** Session ID for the resumed session. Auto-generated if not provided. */
sessionId?: string;
approvalMode?: 'default' | 'auto_edit' | 'yolo' | 'plan';
assert: (rig: TestRig, result: string) => Promise<void>;
}
+2 -2
View File
@@ -6,9 +6,9 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import * as os from 'node:os';
import { TestRig } from './test-helper.js';
import { TestRig, skipFlaky } from './test-helper.js';
describe('Ctrl+C exit', () => {
describe.skipIf(skipFlaky)('Ctrl+C exit', () => {
let rig: TestRig;
beforeEach(() => {
File diff suppressed because it is too large Load Diff
+7 -1
View File
@@ -183,11 +183,17 @@ describe('Policy Engine Headless Mode', () => {
responsesFile: 'policy-headless-shell-denied.responses',
promptCommand: ECHO_PROMPT,
policyContent: `
[[rule]]
toolName = "run_shell_command"
commandPrefix = "echo"
decision = "deny"
priority = 100
[[rule]]
toolName = "run_shell_command"
commandPrefix = "node"
decision = "allow"
priority = 100
priority = 90
`,
expectAllowed: false,
expectedDenialString: 'Tool execution denied by policy',
+9 -3
View File
@@ -58,12 +58,18 @@ function getDisallowedFileReadCommand(testFile: string): {
const quotedPath = `"${testFile}"`;
switch (shell) {
case 'powershell':
return { command: `Get-Content ${quotedPath}`, tool: 'Get-Content' };
return {
command: `powershell -Command "Get-Content ${quotedPath}"`,
tool: 'powershell',
};
case 'cmd':
return { command: `type ${quotedPath}`, tool: 'type' };
return { command: `cmd /c type ${quotedPath}`, tool: 'cmd' };
case 'bash':
default:
return { command: `cat ${quotedPath}`, tool: 'cat' };
return {
command: `node -e "console.log(require('fs').readFileSync('${testFile}', 'utf8'))"`,
tool: 'node',
};
}
}
+93 -90
View File
@@ -5,7 +5,7 @@
*/
import { describe, expect, it, beforeEach, afterEach } from 'vitest';
import { TestRig, InteractiveRun } from './test-helper.js';
import { TestRig, InteractiveRun, skipFlaky } from './test-helper.js';
import * as fs from 'node:fs';
import * as os from 'node:os';
import {
@@ -33,104 +33,107 @@ const otherExtension = `{
"version": "6.6.6"
}`;
describe('extension symlink install spoofing protection', () => {
let rig: TestRig;
describe.skipIf(skipFlaky)(
'extension symlink install spoofing protection',
() => {
let rig: TestRig;
beforeEach(() => {
rig = new TestRig();
});
afterEach(async () => await rig.cleanup());
it('canonicalizes the trust path and prevents symlink spoofing', async () => {
// Enable folder trust for this test
rig.setup('symlink spoofing test', {
settings: {
security: {
folderTrust: {
enabled: true,
},
},
},
beforeEach(() => {
rig = new TestRig();
});
const realExtPath = join(rig.testDir!, 'real-extension');
mkdirSync(realExtPath);
writeFileSync(join(realExtPath, 'gemini-extension.json'), extension);
afterEach(async () => await rig.cleanup());
const maliciousExtPath = join(
os.tmpdir(),
`malicious-extension-${Date.now()}`,
);
mkdirSync(maliciousExtPath);
writeFileSync(
join(maliciousExtPath, 'gemini-extension.json'),
otherExtension,
);
const symlinkPath = join(rig.testDir!, 'symlink-extension');
symlinkSync(realExtPath, symlinkPath);
// Function to run a command with a PTY to avoid headless mode
const runPty = (args: string[]) => {
const ptyProcess = pty.spawn(process.execPath, [BUNDLE_PATH, ...args], {
name: 'xterm-color',
cols: 80,
rows: 80,
cwd: rig.testDir!,
env: {
...process.env,
GEMINI_CLI_HOME: rig.homeDir!,
GEMINI_CLI_INTEGRATION_TEST: 'true',
GEMINI_PTY_INFO: 'node-pty',
it('canonicalizes the trust path and prevents symlink spoofing', async () => {
// Enable folder trust for this test
rig.setup('symlink spoofing test', {
settings: {
security: {
folderTrust: {
enabled: true,
},
},
},
});
return new InteractiveRun(ptyProcess);
};
// 1. Install via symlink, trust it
const run1 = runPty(['extensions', 'install', symlinkPath]);
await run1.expectText('Do you want to trust this folder', 30000);
await run1.type('y\r');
await run1.expectText('trust this workspace', 30000);
await run1.type('y\r');
await run1.expectText('Do you want to continue', 30000);
await run1.type('y\r');
await run1.expectText('installed successfully', 30000);
await run1.kill();
const realExtPath = join(rig.testDir!, 'real-extension');
mkdirSync(realExtPath);
writeFileSync(join(realExtPath, 'gemini-extension.json'), extension);
// 2. Verify trustedFolders.json contains the REAL path, not the symlink path
const trustedFoldersPath = join(
rig.homeDir!,
GEMINI_DIR,
'trustedFolders.json',
);
// Wait for file to be written
let attempts = 0;
while (!fs.existsSync(trustedFoldersPath) && attempts < 50) {
await new Promise((resolve) => setTimeout(resolve, 100));
attempts++;
}
const maliciousExtPath = join(
os.tmpdir(),
`malicious-extension-${Date.now()}`,
);
mkdirSync(maliciousExtPath);
writeFileSync(
join(maliciousExtPath, 'gemini-extension.json'),
otherExtension,
);
const trustedFolders = JSON.parse(
readFileSync(trustedFoldersPath, 'utf-8'),
);
const trustedPaths = Object.keys(trustedFolders);
const canonicalRealExtPath = fs.realpathSync(realExtPath);
const symlinkPath = join(rig.testDir!, 'symlink-extension');
symlinkSync(realExtPath, symlinkPath);
expect(trustedPaths).toContain(canonicalRealExtPath);
expect(trustedPaths).not.toContain(symlinkPath);
// Function to run a command with a PTY to avoid headless mode
const runPty = (args: string[]) => {
const ptyProcess = pty.spawn(process.execPath, [BUNDLE_PATH, ...args], {
name: 'xterm-color',
cols: 80,
rows: 80,
cwd: rig.testDir!,
env: {
...process.env,
GEMINI_CLI_HOME: rig.homeDir!,
GEMINI_CLI_INTEGRATION_TEST: 'true',
GEMINI_PTY_INFO: 'node-pty',
},
});
return new InteractiveRun(ptyProcess);
};
// 3. Swap the symlink to point to the malicious extension
unlinkSync(symlinkPath);
symlinkSync(maliciousExtPath, symlinkPath);
// 1. Install via symlink, trust it
const run1 = runPty(['extensions', 'install', symlinkPath]);
await run1.expectText('Do you want to trust this folder', 30000);
await run1.type('y\r');
await run1.expectText('trust this workspace', 30000);
await run1.type('y\r');
await run1.expectText('Do you want to continue', 30000);
await run1.type('y\r');
await run1.expectText('installed successfully', 30000);
await run1.kill();
// 4. Try to install again via the same symlink path.
// It should NOT be trusted because the real path changed.
const run2 = runPty(['extensions', 'install', symlinkPath]);
await run2.expectText('Do you want to trust this folder', 30000);
await run2.type('n\r');
await run2.expectText('Installation aborted', 30000);
await run2.kill();
}, 60000);
});
// 2. Verify trustedFolders.json contains the REAL path, not the symlink path
const trustedFoldersPath = join(
rig.homeDir!,
GEMINI_DIR,
'trustedFolders.json',
);
// Wait for file to be written
let attempts = 0;
while (!fs.existsSync(trustedFoldersPath) && attempts < 50) {
await new Promise((resolve) => setTimeout(resolve, 100));
attempts++;
}
const trustedFolders = JSON.parse(
readFileSync(trustedFoldersPath, 'utf-8'),
);
const trustedPaths = Object.keys(trustedFolders);
const canonicalRealExtPath = fs.realpathSync(realExtPath);
expect(trustedPaths).toContain(canonicalRealExtPath);
expect(trustedPaths).not.toContain(symlinkPath);
// 3. Swap the symlink to point to the malicious extension
unlinkSync(symlinkPath);
symlinkSync(maliciousExtPath, symlinkPath);
// 4. Try to install again via the same symlink path.
// It should NOT be trusted because the real path changed.
const run2 = runPty(['extensions', 'install', symlinkPath]);
await run2.expectText('Do you want to trust this folder', 30000);
await run2.type('n\r');
await run2.expectText('Installation aborted', 30000);
await run2.kill();
}, 60000);
},
);
+2
View File
@@ -6,3 +6,5 @@
export * from '@google/gemini-cli-test-utils';
export { normalizePath } from '@google/gemini-cli-test-utils';
export const skipFlaky = !process.env['RUN_FLAKY_INTEGRATION'];
@@ -0,0 +1,2 @@
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"mcp_weather-server_get_weather","args":{"location":"London"}}}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":10,"candidatesTokenCount":10,"totalTokenCount":20}}]}
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"The weather in London is rainy."}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":10,"candidatesTokenCount":10,"totalTokenCount":20}}]}
@@ -0,0 +1,75 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import {
TestRig,
assertModelHasOutput,
TestMcpServerBuilder,
} from './test-helper.js';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import fs from 'node:fs';
const __dirname = dirname(fileURLToPath(import.meta.url));
describe('test-mcp-support', () => {
let rig: TestRig;
beforeEach(() => {
rig = new TestRig();
});
afterEach(async () => await rig.cleanup());
it('should discover and call a tool on the test server', async () => {
await rig.setup('test-mcp-test', {
settings: {
tools: { core: [] }, // disable core tools to force using MCP
model: {
name: 'gemini-3-flash-preview',
},
},
fakeResponsesPath: join(__dirname, 'test-mcp-support.responses'),
});
// Workaround for ProjectRegistry save issue
const userGeminiDir = join(rig.homeDir!, '.gemini');
fs.writeFileSync(join(userGeminiDir, 'projects.json'), '{"projects":{}}');
const builder = new TestMcpServerBuilder('weather-server').addTool(
'get_weather',
'Get the weather for a location',
'The weather in London is always rainy.',
{
type: 'object',
properties: {
location: { type: 'string' },
},
},
);
rig.addTestMcpServer('weather-server', builder.build());
// Run the CLI asking for weather
const output = await rig.run({
args: 'What is the weather in London? Answer with the raw tool response snippet.',
env: { GEMINI_API_KEY: 'dummy' },
});
// Assert tool call
const foundToolCall = await rig.waitForToolCall(
'mcp_weather-server_get_weather',
);
expect(
foundToolCall,
'Expected to find a get_weather tool call',
).toBeTruthy();
assertModelHasOutput(output);
expect(output.toLowerCase()).toContain('rainy');
}, 30000);
});
+21 -21
View File
@@ -8696,9 +8696,9 @@
"license": "BSD-3-Clause"
},
"node_modules/fast-xml-builder": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.2.tgz",
"integrity": "sha512-NJAmiuVaJEjVa7TjLZKlYd7RqmzOC91EtPFXHvlTcqBVo50Qh7XV5IwvXi1c7NRz2Q/majGX9YLcwJtWgHjtkA==",
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.4.tgz",
"integrity": "sha512-f2jhpN4Eccy0/Uz9csxh3Nu6q4ErKxf0XIsasomfOihuSUa3/xw6w8dnOtCDgEItQFJG8KyXPzQXzcODDrrbOg==",
"funding": [
{
"type": "github",
@@ -8711,9 +8711,9 @@
}
},
"node_modules/fast-xml-parser": {
"version": "5.5.3",
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.5.3.tgz",
"integrity": "sha512-Ymnuefk6VzAhT3SxLzVUw+nMio/wB1NGypHkgetwtXcK1JfryaHk4DWQFGVwQ9XgzyS5iRZ7C2ZGI4AMsdMZ6A==",
"version": "5.5.9",
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.5.9.tgz",
"integrity": "sha512-jldvxr1MC6rtiZKgrFnDSvT8xuH+eJqxqOBThUVjYrxssYTo1avZLGql5l0a0BAERR01CadYzZ83kVEkbyDg+g==",
"funding": [
{
"type": "github",
@@ -8722,9 +8722,9 @@
],
"license": "MIT",
"dependencies": {
"fast-xml-builder": "^1.1.2",
"path-expression-matcher": "^1.1.3",
"strnum": "^2.1.2"
"fast-xml-builder": "^1.1.4",
"path-expression-matcher": "^1.2.0",
"strnum": "^2.2.2"
},
"bin": {
"fxparser": "src/cli/cli.js"
@@ -8900,9 +8900,9 @@
}
},
"node_modules/flatted": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz",
"integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==",
"version": "3.4.2",
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz",
"integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==",
"dev": true,
"license": "ISC"
},
@@ -13200,9 +13200,9 @@
}
},
"node_modules/path-expression-matcher": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.1.3.tgz",
"integrity": "sha512-qdVgY8KXmVdJZRSS1JdEPOKPdTiEK/pi0RkcT2sw1RhXxohdujUlJFPuS1TSkevZ9vzd3ZlL7ULl1MHGTApKzQ==",
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.2.0.tgz",
"integrity": "sha512-DwmPWeFn+tq7TiyJ2CxezCAirXjFxvaiD03npak3cRjlP9+OjTmSy1EpIrEbh+l6JgUundniloMLDQ/6VTdhLQ==",
"funding": [
{
"type": "github",
@@ -15465,9 +15465,9 @@
}
},
"node_modules/strnum": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/strnum/-/strnum-2.2.0.tgz",
"integrity": "sha512-Y7Bj8XyJxnPAORMZj/xltsfo55uOiyHcU2tnAVzHUnSJR/KsEX+9RoDeXEnsXtl/CX4fAcrt64gZ13aGaWPeBg==",
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/strnum/-/strnum-2.2.2.tgz",
"integrity": "sha512-DnR90I+jtXNSTXWdwrEy9FakW7UX+qUZg28gj5fk2vxxl7uS/3bpI4fjFYVmdK9etptYBPNkpahuQnEwhwECqA==",
"funding": [
{
"type": "github",
@@ -16469,9 +16469,9 @@
"license": "MIT"
},
"node_modules/undici": {
"version": "7.19.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.19.0.tgz",
"integrity": "sha512-Heho1hJD81YChi+uS2RkSjcVO+EQLmLSyUlHyp7Y/wFbxQaGb4WXVKD073JytrjXJVkSZVzoE2MCSOKugFGtOQ==",
"version": "7.24.5",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.24.5.tgz",
"integrity": "sha512-3IWdCpjgxp15CbJnsi/Y9TCDE7HWVN19j1hmzVhoAkY/+CJx449tVxT5wZc1Gwg8J+P0LWvzlBzxYRnHJ+1i7Q==",
"license": "MIT",
"engines": {
"node": ">=20.18.1"
+1
View File
@@ -48,6 +48,7 @@
"test:all_evals": "cross-env RUN_EVALS=1 vitest run --config evals/vitest.config.ts",
"test:e2e": "cross-env VERBOSE=true KEEP_OUTPUT=true npm run test:integration:sandbox:none",
"test:integration:all": "npm run test:integration:sandbox:none && npm run test:integration:sandbox:docker && npm run test:integration:sandbox:podman",
"test:integration:flaky": "cross-env RUN_FLAKY_INTEGRATION=1 npm run test:integration:sandbox:none",
"test:integration:sandbox:none": "cross-env GEMINI_SANDBOX=false vitest run --root ./integration-tests",
"test:integration:sandbox:docker": "cross-env GEMINI_SANDBOX=docker npm run build:sandbox && cross-env GEMINI_SANDBOX=docker vitest run --root ./integration-tests",
"test:integration:sandbox:podman": "cross-env GEMINI_SANDBOX=podman vitest run --root ./integration-tests",
@@ -29,6 +29,7 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
await importOriginal<typeof import('@google/gemini-cli-core')>();
return {
...actual,
PRIORITY_YOLO_ALLOW_ALL: 998,
Config: vi.fn().mockImplementation((params) => {
const mockConfig = {
...params,
+1
View File
@@ -87,6 +87,7 @@ export async function loadConfig(
approvalMode === ApprovalMode.YOLO
? [
{
toolName: '*',
decision: PolicyDecision.ALLOW,
priority: PRIORITY_YOLO_ALLOW_ALL,
modes: [ApprovalMode.YOLO],
@@ -97,6 +97,7 @@ export function createMockConfig(
getMcpClientManager: vi.fn().mockReturnValue({
getMcpServers: vi.fn().mockReturnValue({}),
}),
getTelemetryLogPromptsEnabled: vi.fn().mockReturnValue(false),
getGitService: vi.fn(),
validatePathAccess: vi.fn().mockReturnValue(undefined),
getShellExecutionConfig: vi.fn().mockReturnValue({
+4 -1
View File
@@ -7,7 +7,10 @@
- **Shortcuts**: only define keyboard shortcuts in
`packages/cli/src/ui/key/keyBindings.ts`
- Do not implement any logic performing custom string measurement or string
truncation. Use Ink layout instead leveraging ResizeObserver as needed.
truncation. Use Ink layout instead leveraging ResizeObserver as needed. When
using `ResizeObserver`, prefer the `useCallback` ref pattern (as seen in
`MaxSizedBox.tsx`) to ensure size measurements are captured as soon as the
element is available, avoiding potential rendering timing issues.
- Avoid prop drilling when at all possible.
## Testing
+21 -5
View File
@@ -6,12 +6,19 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { main } from './src/gemini.js';
import { FatalError, writeToStderr } from '@google/gemini-cli-core';
import { runExitCleanup } from './src/utils/cleanup.js';
// --- Fast Path for Version ---
// We check for version flags at the very top to avoid loading any heavy dependencies.
// process.env.CLI_VERSION is defined during the build process by esbuild.
if (process.argv.includes('--version') || process.argv.includes('-v')) {
console.log(process.env['CLI_VERSION'] || 'unknown');
process.exit(0);
}
// --- Global Entry Point ---
let writeToStderrFn: (message: string) => void = (msg) =>
process.stderr.write(msg);
// Suppress known race condition error in node-pty on Windows
// Tracking bug: https://github.com/microsoft/node-pty/issues/827
process.on('uncaughtException', (error) => {
@@ -28,13 +35,22 @@ process.on('uncaughtException', (error) => {
// For other errors, we rely on the default behavior, but since we attached a listener,
// we must manually replicate it.
if (error instanceof Error) {
writeToStderr(error.stack + '\n');
writeToStderrFn(error.stack + '\n');
} else {
writeToStderr(String(error) + '\n');
writeToStderrFn(String(error) + '\n');
}
process.exit(1);
});
const [{ main }, { FatalError, writeToStderr }, { runExitCleanup }] =
await Promise.all([
import('./src/gemini.js'),
import('@google/gemini-cli-core'),
import('./src/utils/cleanup.js'),
]);
writeToStderrFn = writeToStderr;
main().catch(async (error) => {
// Set a timeout to force exit if cleanup hangs
const cleanupTimeout = setTimeout(() => {
+64
View File
@@ -1080,6 +1080,70 @@ describe('Session', () => {
);
});
it('should split getDisplayTitle and getExplanation for title and content in permission request', async () => {
const confirmationDetails = {
type: 'info',
onConfirm: vi.fn(),
};
mockTool.build.mockReturnValue({
getDescription: () => 'Original Description',
getDisplayTitle: () => 'Display Title Only',
getExplanation: () => 'A detailed explanation text',
toolLocations: () => [],
shouldConfirmExecute: vi.fn().mockResolvedValue(confirmationDetails),
execute: vi.fn().mockResolvedValue({ llmContent: 'Tool Result' }),
});
mockConnection.requestPermission.mockResolvedValue({
outcome: {
outcome: 'selected',
optionId: ToolConfirmationOutcome.ProceedOnce,
},
});
const stream1 = createMockStream([
{
type: StreamEventType.CHUNK,
value: {
functionCalls: [{ name: 'test_tool', args: {} }],
},
},
]);
const stream2 = createMockStream([
{
type: StreamEventType.CHUNK,
value: { candidates: [] },
},
]);
mockChat.sendMessageStream
.mockResolvedValueOnce(stream1)
.mockResolvedValueOnce(stream2);
await session.prompt({
sessionId: 'session-1',
prompt: [{ type: 'text', text: 'Call tool' }],
});
expect(mockConnection.requestPermission).toHaveBeenCalledWith(
expect.objectContaining({
toolCall: expect.objectContaining({
title: 'Display Title Only',
content: [],
}),
}),
);
expect(mockConnection.sessionUpdate).toHaveBeenCalledWith(
expect.objectContaining({
update: expect.objectContaining({
sessionUpdate: 'agent_thought_chunk',
content: { type: 'text', text: 'A detailed explanation text' },
}),
}),
);
});
it('should use filePath for ACP diff content in tool result', async () => {
mockTool.build.mockReturnValue({
getDescription: () => 'Test Tool',
+49 -18
View File
@@ -98,6 +98,12 @@ export async function runAcpClient(
}
export class GeminiAgent {
private static callIdCounter = 0;
static generateCallId(name: string): string {
return `${name}-${Date.now()}-${++GeminiAgent.callIdCounter}`;
}
private sessions: Map<string, Session> = new Map();
private clientCapabilities: acp.ClientCapabilities | undefined;
private apiKey: string | undefined;
@@ -294,6 +300,7 @@ export class GeminiAgent {
sessionId,
this.clientCapabilities.fs,
config.getFileSystemService(),
cwd,
);
config.setFileSystemService(acpFileSystemService);
}
@@ -351,16 +358,6 @@ export class GeminiAgent {
const { sessionData, sessionPath } =
await sessionSelector.resolveSession(sessionId);
if (this.clientCapabilities?.fs) {
const acpFileSystemService = new AcpFileSystemService(
this.connection,
sessionId,
this.clientCapabilities.fs,
config.getFileSystemService(),
);
config.setFileSystemService(acpFileSystemService);
}
const clientHistory = convertSessionToClientHistory(sessionData.messages);
const geminiClient = config.getGeminiClient();
@@ -434,7 +431,19 @@ export class GeminiAgent {
throw acp.RequestError.authRequired();
}
// 3. Now that we are authenticated, it is safe to initialize the config
// 3. Set the ACP FileSystemService (if supported) before config initialization
if (this.clientCapabilities?.fs) {
const acpFileSystemService = new AcpFileSystemService(
this.connection,
sessionId,
this.clientCapabilities.fs,
config.getFileSystemService(),
cwd,
);
config.setFileSystemService(acpFileSystemService);
}
// 4. Now that we are authenticated, it is safe to initialize the config
// which starts the MCP servers and other heavy resources.
await config.initialize();
startupProfiler.flush(config);
@@ -897,7 +906,7 @@ export class Session {
promptId: string,
fc: FunctionCall,
): Promise<Part[]> {
const callId = fc.id ?? `${fc.name}-${Date.now()}`;
const callId = fc.id ?? GeminiAgent.generateCallId(fc.name || 'unknown');
const args = fc.args ?? {};
const startTime = Date.now();
@@ -947,6 +956,23 @@ export class Session {
try {
const invocation = tool.build(args);
const displayTitle =
typeof invocation.getDisplayTitle === 'function'
? invocation.getDisplayTitle()
: invocation.getDescription();
const explanation =
typeof invocation.getExplanation === 'function'
? invocation.getExplanation()
: '';
if (explanation) {
await this.sendUpdate({
sessionUpdate: 'agent_thought_chunk',
content: { type: 'text', text: explanation },
});
}
const confirmationDetails =
await invocation.shouldConfirmExecute(abortSignal);
@@ -978,7 +1004,7 @@ export class Session {
toolCall: {
toolCallId: callId,
status: 'pending',
title: invocation.getDescription(),
title: displayTitle,
content,
locations: invocation.toolLocations(),
kind: toAcpToolKind(tool.kind),
@@ -1014,12 +1040,14 @@ export class Session {
}
}
} else {
const content: acp.ToolCallContent[] = [];
await this.sendUpdate({
sessionUpdate: 'tool_call',
toolCallId: callId,
status: 'in_progress',
title: invocation.getDescription(),
content: [],
title: displayTitle,
content,
locations: invocation.toolLocations(),
kind: toAcpToolKind(tool.kind),
});
@@ -1028,12 +1056,14 @@ export class Session {
const toolResult: ToolResult = await invocation.execute(abortSignal);
const content = toToolCallContent(toolResult);
const updateContent: acp.ToolCallContent[] = content ? [content] : [];
await this.sendUpdate({
sessionUpdate: 'tool_call_update',
toolCallId: callId,
status: 'completed',
title: invocation.getDescription(),
content: content ? [content] : [],
title: displayTitle,
content: updateContent,
locations: invocation.toolLocations(),
kind: toAcpToolKind(tool.kind),
});
@@ -1370,7 +1400,7 @@ export class Session {
include: pathSpecsToRead,
};
const callId = `${readManyFilesTool.name}-${Date.now()}`;
const callId = GeminiAgent.generateCallId(readManyFilesTool.name);
try {
const invocation = readManyFilesTool.build(toolArgs);
@@ -1598,6 +1628,7 @@ function toPermissionOptions(
case 'info':
case 'ask_user':
case 'exit_plan_mode':
case 'sandbox_expansion':
break;
default: {
const unreachable: never = confirmation;
+135 -12
View File
@@ -4,10 +4,25 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach, type Mocked } from 'vitest';
import {
describe,
it,
expect,
vi,
beforeEach,
afterEach,
type Mocked,
} from 'vitest';
import { AcpFileSystemService } from './fileSystemService.js';
import type { AgentSideConnection } from '@agentclientprotocol/sdk';
import type { FileSystemService } from '@google/gemini-cli-core';
import os from 'node:os';
vi.mock('node:os', () => ({
default: {
homedir: vi.fn(),
},
}));
describe('AcpFileSystemService', () => {
let mockConnection: Mocked<AgentSideConnection>;
@@ -25,13 +40,19 @@ describe('AcpFileSystemService', () => {
readTextFile: vi.fn(),
writeTextFile: vi.fn(),
};
vi.mocked(os.homedir).mockReturnValue('/home/user');
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('readTextFile', () => {
it.each([
{
capability: true,
desc: 'connection if capability exists',
path: '/path/to/file',
desc: 'connection if capability exists and file is inside root',
setup: () => {
mockConnection.readTextFile.mockResolvedValue({ content: 'content' });
},
@@ -45,6 +66,7 @@ describe('AcpFileSystemService', () => {
},
{
capability: false,
path: '/path/to/file',
desc: 'fallback if capability missing',
setup: () => {
mockFallback.readTextFile.mockResolvedValue('content');
@@ -56,19 +78,72 @@ describe('AcpFileSystemService', () => {
expect(mockConnection.readTextFile).not.toHaveBeenCalled();
},
},
])('should use $desc', async ({ capability, setup, verify }) => {
{
capability: true,
path: '/outside/file',
desc: 'fallback if capability exists but file is outside root',
setup: () => {
mockFallback.readTextFile.mockResolvedValue('content');
},
verify: () => {
expect(mockFallback.readTextFile).toHaveBeenCalledWith(
'/outside/file',
);
expect(mockConnection.readTextFile).not.toHaveBeenCalled();
},
},
{
capability: true,
path: '/home/user/.gemini/tmp/file.md',
root: '/home/user',
desc: 'fallback if file is inside global gemini dir, even if root overlaps',
setup: () => {
mockFallback.readTextFile.mockResolvedValue('content');
},
verify: () => {
expect(mockFallback.readTextFile).toHaveBeenCalledWith(
'/home/user/.gemini/tmp/file.md',
);
expect(mockConnection.readTextFile).not.toHaveBeenCalled();
},
},
])(
'should use $desc',
async ({ capability, path, root, setup, verify }) => {
service = new AcpFileSystemService(
mockConnection,
'session-1',
{ readTextFile: capability, writeTextFile: true },
mockFallback,
root || '/path/to',
);
setup();
const result = await service.readTextFile(path);
expect(result).toBe('content');
verify();
},
);
it('should throw normalized ENOENT error when readTextFile encounters "Resource not found"', async () => {
service = new AcpFileSystemService(
mockConnection,
'session-1',
{ readTextFile: capability, writeTextFile: true },
{ readTextFile: true, writeTextFile: true },
mockFallback,
'/path/to',
);
mockConnection.readTextFile.mockRejectedValue(
new Error('Resource not found for document'),
);
setup();
const result = await service.readTextFile('/path/to/file');
expect(result).toBe('content');
verify();
await expect(
service.readTextFile('/path/to/missing'),
).rejects.toMatchObject({
code: 'ENOENT',
message: 'Resource not found for document',
});
});
});
@@ -76,7 +151,8 @@ describe('AcpFileSystemService', () => {
it.each([
{
capability: true,
desc: 'connection if capability exists',
path: '/path/to/file',
desc: 'connection if capability exists and file is inside root',
verify: () => {
expect(mockConnection.writeTextFile).toHaveBeenCalledWith({
path: '/path/to/file',
@@ -88,6 +164,7 @@ describe('AcpFileSystemService', () => {
},
{
capability: false,
path: '/path/to/file',
desc: 'fallback if capability missing',
verify: () => {
expect(mockFallback.writeTextFile).toHaveBeenCalledWith(
@@ -97,17 +174,63 @@ describe('AcpFileSystemService', () => {
expect(mockConnection.writeTextFile).not.toHaveBeenCalled();
},
},
])('should use $desc', async ({ capability, verify }) => {
{
capability: true,
path: '/outside/file',
desc: 'fallback if capability exists but file is outside root',
verify: () => {
expect(mockFallback.writeTextFile).toHaveBeenCalledWith(
'/outside/file',
'content',
);
expect(mockConnection.writeTextFile).not.toHaveBeenCalled();
},
},
{
capability: true,
path: '/home/user/.gemini/tmp/file.md',
root: '/home/user',
desc: 'fallback if file is inside global gemini dir, even if root overlaps',
verify: () => {
expect(mockFallback.writeTextFile).toHaveBeenCalledWith(
'/home/user/.gemini/tmp/file.md',
'content',
);
expect(mockConnection.writeTextFile).not.toHaveBeenCalled();
},
},
])('should use $desc', async ({ capability, path, root, verify }) => {
service = new AcpFileSystemService(
mockConnection,
'session-1',
{ writeTextFile: capability, readTextFile: true },
mockFallback,
root || '/path/to',
);
await service.writeTextFile('/path/to/file', 'content');
await service.writeTextFile(path, 'content');
verify();
});
it('should throw normalized ENOENT error when writeTextFile encounters "Resource not found"', async () => {
service = new AcpFileSystemService(
mockConnection,
'session-1',
{ readTextFile: true, writeTextFile: true },
mockFallback,
'/path/to',
);
mockConnection.writeTextFile.mockRejectedValue(
new Error('Resource not found for directory'),
);
await expect(
service.writeTextFile('/path/to/missing', 'content'),
).rejects.toMatchObject({
code: 'ENOENT',
message: 'Resource not found for directory',
});
});
});
});
+53 -15
View File
@@ -4,44 +4,82 @@
* SPDX-License-Identifier: Apache-2.0
*/
import type { FileSystemService } from '@google/gemini-cli-core';
import { isWithinRoot, type FileSystemService } from '@google/gemini-cli-core';
import type * as acp from '@agentclientprotocol/sdk';
import os from 'node:os';
import path from 'node:path';
/**
* ACP client-based implementation of FileSystemService
*/
export class AcpFileSystemService implements FileSystemService {
private readonly geminiDir = path.join(os.homedir(), '.gemini');
constructor(
private readonly connection: acp.AgentSideConnection,
private readonly sessionId: string,
private readonly capabilities: acp.FileSystemCapabilities,
private readonly fallback: FileSystemService,
private readonly root: string,
) {}
private shouldUseFallback(filePath: string): boolean {
// Files inside the global CLI directory must always use the native file system,
// even if the user runs the CLI directly from their home directory (which
// would make the IDE's project root overlap with the global directory).
return (
!isWithinRoot(filePath, this.root) ||
isWithinRoot(filePath, this.geminiDir)
);
}
private normalizeFileSystemError(err: unknown): never {
const errorMessage = err instanceof Error ? err.message : String(err);
if (
errorMessage.includes('Resource not found') ||
errorMessage.includes('ENOENT') ||
errorMessage.includes('does not exist') ||
errorMessage.includes('No such file')
) {
const newErr = new Error(errorMessage) as NodeJS.ErrnoException;
newErr.code = 'ENOENT';
throw newErr;
}
throw err;
}
async readTextFile(filePath: string): Promise<string> {
if (!this.capabilities.readTextFile) {
if (!this.capabilities.readTextFile || this.shouldUseFallback(filePath)) {
return this.fallback.readTextFile(filePath);
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const response = await this.connection.readTextFile({
path: filePath,
sessionId: this.sessionId,
});
try {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const response = await this.connection.readTextFile({
path: filePath,
sessionId: this.sessionId,
});
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return response.content;
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return response.content;
} catch (err: unknown) {
this.normalizeFileSystemError(err);
}
}
async writeTextFile(filePath: string, content: string): Promise<void> {
if (!this.capabilities.writeTextFile) {
if (!this.capabilities.writeTextFile || this.shouldUseFallback(filePath)) {
return this.fallback.writeTextFile(filePath, content);
}
await this.connection.writeTextFile({
path: filePath,
content,
sessionId: this.sessionId,
});
try {
await this.connection.writeTextFile({
path: filePath,
content,
sessionId: this.sessionId,
});
} catch (err: unknown) {
this.normalizeFileSystemError(err);
}
}
}
@@ -16,7 +16,7 @@ toolName = "grep_search"
argsPattern = "(\.env|id_rsa|passwd)"
decision = "deny"
priority = 200
deny_message = "Access to sensitive credentials or system files is restricted by the policy-example extension."
denyMessage = "Access to sensitive credentials or system files is restricted by the policy-example extension."
# Safety Checker: Apply path validation to all write operations.
[[safety_checker]]
+35
View File
@@ -322,6 +322,41 @@ describe('parseArguments', () => {
},
);
describe('isCommand middleware', () => {
it.each([
{ cmd: 'mcp list', expected: true },
{ cmd: 'extensions list', expected: true },
{ cmd: 'extension list', expected: true },
{ cmd: 'skills list', expected: true },
{ cmd: 'skill list', expected: true },
{ cmd: 'hooks migrate', expected: true },
{ cmd: 'hook migrate', expected: true },
{ cmd: 'some query', expected: undefined },
{ cmd: 'hello world', expected: undefined },
])(
'should set isCommand to $expected for "$cmd"',
async ({ cmd, expected }) => {
process.argv = ['node', 'script.js', ...cmd.split(' ')];
const settings = createTestMergedSettings({
admin: {
mcp: { enabled: true },
},
experimental: {
extensionManagement: true,
},
skills: {
enabled: true,
},
hooksConfig: {
enabled: true,
},
});
const parsedArgs = await parseArguments(settings);
expect(parsedArgs.isCommand).toBe(expected);
},
);
});
it.each([
{
description: 'should allow --prompt without --prompt-interactive',
+92 -53
View File
@@ -164,12 +164,104 @@ export async function parseArguments(
.usage(
'Usage: gemini [options] [command]\n\nGemini CLI - Defaults to interactive mode. Use -p/--prompt for non-interactive (headless) mode.',
)
.option('isCommand', {
type: 'boolean',
hidden: true,
description: 'Internal flag to indicate if a subcommand is being run',
})
.option('debug', {
alias: 'd',
type: 'boolean',
description: 'Run in debug mode (open debug console with F12)',
default: false,
})
.middleware((argv) => {
const commandModules = [
mcpCommand,
extensionsCommand,
skillsCommand,
hooksCommand,
];
const subcommands = commandModules.flatMap((mod) => {
const names: string[] = [];
const cmd = mod.command;
if (cmd) {
if (Array.isArray(cmd)) {
for (const c of cmd) {
names.push(String(c).split(' ')[0]);
}
} else {
names.push(String(cmd).split(' ')[0]);
}
}
const aliases = mod.aliases;
if (aliases) {
if (Array.isArray(aliases)) {
for (const a of aliases) {
names.push(String(a).split(' ')[0]);
}
} else {
names.push(String(aliases).split(' ')[0]);
}
}
return names;
});
const firstArg = argv._[0];
if (typeof firstArg === 'string' && subcommands.includes(firstArg)) {
argv['isCommand'] = true;
}
}, true)
// Ensure validation flows through .fail() for clean UX
.fail((msg, err) => {
if (err) throw err;
throw new Error(msg);
})
.check((argv) => {
// The 'query' positional can be a string (for one arg) or string[] (for multiple).
// This guard safely checks if any positional argument was provided.
const queryArg = argv['query'];
const query =
typeof queryArg === 'string' || Array.isArray(queryArg)
? queryArg
: undefined;
const hasPositionalQuery = Array.isArray(query)
? query.length > 0
: !!query;
if (argv['prompt'] && hasPositionalQuery) {
return 'Cannot use both a positional prompt and the --prompt (-p) flag together';
}
if (argv['prompt'] && argv['promptInteractive']) {
return 'Cannot use both --prompt (-p) and --prompt-interactive (-i) together';
}
if (argv['yolo'] && argv['approvalMode']) {
return 'Cannot use both --yolo (-y) and --approval-mode together. Use --approval-mode=yolo instead.';
}
const outputFormat = argv['outputFormat'];
if (
typeof outputFormat === 'string' &&
!['text', 'json', 'stream-json'].includes(outputFormat)
) {
return `Invalid values:\n Argument: output-format, Given: "${outputFormat}", Choices: "text", "json", "stream-json"`;
}
if (argv['worktree'] && !settings.experimental?.worktrees) {
return 'The --worktree flag is only available when experimental.worktrees is enabled in your settings.';
}
return true;
});
yargsInstance.command(mcpCommand);
yargsInstance.command(extensionsCommand);
yargsInstance.command(skillsCommand);
yargsInstance.command(hooksCommand);
yargsInstance
.command('$0 [query..]', 'Launch Gemini CLI', (yargsInstance) =>
yargsInstance
.positional('query', {
@@ -359,59 +451,6 @@ export async function parseArguments(
coerce: coerceCommaSeparated,
}),
)
// Register MCP subcommands
.command(mcpCommand)
// Ensure validation flows through .fail() for clean UX
.fail((msg, err) => {
if (err) throw err;
throw new Error(msg);
})
.check((argv) => {
// The 'query' positional can be a string (for one arg) or string[] (for multiple).
// This guard safely checks if any positional argument was provided.
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const query = argv['query'] as string | string[] | undefined;
const hasPositionalQuery = Array.isArray(query)
? query.length > 0
: !!query;
if (argv['prompt'] && hasPositionalQuery) {
return 'Cannot use both a positional prompt and the --prompt (-p) flag together';
}
if (argv['prompt'] && argv['promptInteractive']) {
return 'Cannot use both --prompt (-p) and --prompt-interactive (-i) together';
}
if (argv['yolo'] && argv['approvalMode']) {
return 'Cannot use both --yolo (-y) and --approval-mode together. Use --approval-mode=yolo instead.';
}
if (
argv['outputFormat'] &&
!['text', 'json', 'stream-json'].includes(
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
argv['outputFormat'] as string,
)
) {
return `Invalid values:\n Argument: output-format, Given: "${argv['outputFormat']}", Choices: "text", "json", "stream-json"`;
}
if (argv['worktree'] && !settings.experimental?.worktrees) {
return 'The --worktree flag is only available when experimental.worktrees is enabled in your settings.';
}
return true;
});
if (settings.experimental?.extensionManagement) {
yargsInstance.command(extensionsCommand);
}
if (settings.skills?.enabled ?? true) {
yargsInstance.command(skillsCommand);
}
// Register hooks command if hooks are enabled
if (settings.hooksConfig.enabled) {
yargsInstance.command(hooksCommand);
}
yargsInstance
.version(await getVersion()) // This will enable the --version flag based on package.json
.alias('v', 'version')
.help()
+6 -4
View File
@@ -614,7 +614,7 @@ Would you like to attempt to install via "git clone" instead?`,
this.loadingPromise = (async () => {
try {
if (this.settings.admin.extensions.enabled === false) {
if (this.settings.admin?.extensions?.enabled === false) {
this.loadedExtensions = [];
return this.loadedExtensions;
}
@@ -824,11 +824,11 @@ Would you like to attempt to install via "git clone" instead?`,
}
if (config.mcpServers) {
if (this.settings.admin.mcp.enabled === false) {
if (this.settings.admin?.mcp?.enabled === false) {
config.mcpServers = undefined;
} else {
// Apply admin allowlist if configured
const adminAllowlist = this.settings.admin.mcp.config;
const adminAllowlist = this.settings.admin?.mcp?.config;
if (adminAllowlist && Object.keys(adminAllowlist).length > 0) {
const result = applyAdminAllowlist(
config.mcpServers,
@@ -1298,7 +1298,9 @@ export async function inferInstallMetadata(
source.startsWith('http://') ||
source.startsWith('https://') ||
source.startsWith('git@') ||
source.startsWith('sso://')
source.startsWith('sso://') ||
source.startsWith('github:') ||
source.startsWith('gitlab:')
) {
return {
source,
@@ -381,6 +381,7 @@ describe('Policy Engine Integration Tests', () => {
// Add a manual rule with annotations to the config
config.rules = config.rules || [];
config.rules.push({
toolName: '*',
toolAnnotations: { readOnlyHint: true },
decision: PolicyDecision.ALLOW,
priority: 10,
+20
View File
@@ -657,6 +657,16 @@ const SETTINGS_SCHEMA = {
description: 'Hide the footer from the UI',
showInDialog: true,
},
collapseDrawerDuringApproval: {
type: 'boolean',
label: 'Collapse Drawer During Approval',
category: 'UI',
requiresRestart: false,
default: true,
description:
'Whether to collapse the UI drawer when a tool is awaiting confirmation.',
showInDialog: false,
},
showMemoryUsage: {
type: 'boolean',
label: 'Show Memory Usage',
@@ -1198,6 +1208,16 @@ const SETTINGS_SCHEMA = {
'Disable user input on browser window during automation.',
showInDialog: false,
},
maxActionsPerTask: {
type: 'number',
label: 'Max Actions Per Task',
category: 'Advanced',
requiresRestart: false,
default: 100,
description:
'The maximum number of tool calls allowed per browser task. Enforcement is hard: the agent will be terminated when the limit is reached.',
showInDialog: false,
},
confirmSensitiveActions: {
type: 'boolean',
label: 'Confirm Sensitive Actions',
@@ -105,6 +105,9 @@ describe('initializer', () => {
mockSettings,
);
// Wait for the background promise to resolve
await new Promise((resolve) => setTimeout(resolve, 0));
expect(result).toEqual({
authError: null,
accountSuspensionInfo: null,
+13 -3
View File
@@ -13,6 +13,7 @@ import {
StartSessionEvent,
logCliConfiguration,
startupProfiler,
debugLogger,
} from '@google/gemini-cli-core';
import { type LoadedSettings } from '../config/settings.js';
import { performInitialAuth } from './auth.js';
@@ -55,9 +56,18 @@ export async function initializeApp(
);
if (config.getIdeMode()) {
const ideClient = await IdeClient.getInstance();
await ideClient.connect();
logIdeConnection(config, new IdeConnectionEvent(IdeConnectionType.START));
IdeClient.getInstance()
.then(async (ideClient) => {
await ideClient.connect();
logIdeConnection(
config,
new IdeConnectionEvent(IdeConnectionType.START),
);
})
.catch((e) => {
// We log locally if IDE connection setup fails in the background.
debugLogger.error('Failed to initialize IDE client:', e);
});
}
return {
+33 -17
View File
@@ -213,12 +213,36 @@ export async function main() {
loadSettingsHandle?.end();
// If a worktree is requested and enabled, set it up early.
// This must be awaited before any other async tasks that depend on CWD (like loadCliConfig)
// because setupWorktree calls process.chdir().
const requestedWorktree = cliConfig.getRequestedWorktreeName(settings);
let worktreeInfo: WorktreeInfo | undefined;
if (requestedWorktree !== undefined) {
const worktreeHandle = startupProfiler.start('setup_worktree');
worktreeInfo = await setupWorktree(requestedWorktree || undefined);
worktreeHandle?.end();
}
const cleanupOpsHandle = startupProfiler.start('cleanup_ops');
Promise.all([
cleanupCheckpoints(),
cleanupToolOutputFiles(settings.merged),
cleanupBackgroundLogs(),
])
.catch((e) => {
debugLogger.error('Early cleanup failed:', e);
})
.finally(() => {
cleanupOpsHandle?.end();
});
const parseArgsHandle = startupProfiler.start('parse_arguments');
const argvPromise = parseArguments(settings.merged).finally(() => {
parseArgsHandle?.end();
});
const rawStartupWarningsPromise = getStartupWarnings();
// Report settings errors once during startup
settings.errors.forEach((error) => {
coreEvents.emitFeedback('warning', error.message);
@@ -232,15 +256,7 @@ export async function main() {
);
});
await Promise.all([
cleanupCheckpoints(),
cleanupToolOutputFiles(settings.merged),
cleanupBackgroundLogs(),
]);
const parseArgsHandle = startupProfiler.start('parse_arguments');
const argv = await parseArguments(settings.merged);
parseArgsHandle?.end();
const argv = await argvPromise;
if (
(argv.allowedTools && argv.allowedTools.length > 0) ||
@@ -325,7 +341,7 @@ export async function main() {
// the sandbox because the sandbox will interfere with the Oauth2 web
// redirect.
let initialAuthFailed = false;
if (!settings.merged.security.auth.useExternal) {
if (!settings.merged.security.auth.useExternal && !argv.isCommand) {
try {
if (
partialConfig.isInteractive() &&
@@ -377,7 +393,7 @@ export async function main() {
await runDeferredCommand(settings.merged);
// hop into sandbox if we are outside and sandboxing is enabled
if (!process.env['SANDBOX']) {
if (!process.env['SANDBOX'] && !argv.isCommand) {
const memoryArgs = settings.merged.advanced.autoConfigureMemory
? getNodeMemoryArgs(isDebugMode)
: [];
@@ -474,12 +490,10 @@ export async function main() {
await config.getHookSystem()?.fireSessionEndEvent(SessionEndReason.Exit);
});
// Cleanup sessions after config initialization
try {
await cleanupExpiredSessions(config, settings.merged);
} catch (e) {
// Launch cleanup expired sessions as a background task
cleanupExpiredSessions(config, settings.merged).catch((e) => {
debugLogger.error('Failed to cleanup expired sessions:', e);
}
});
if (config.getListExtensions()) {
debugLogger.log('Installed extensions:');
@@ -531,7 +545,9 @@ export async function main() {
});
}
const terminalHandle = startupProfiler.start('setup_terminal');
await setupTerminalAndTheme(config, settings);
terminalHandle?.end();
const initAppHandle = startupProfiler.start('initialize_app');
const initializationResult = await initializeApp(config, settings);
@@ -555,7 +571,7 @@ export async function main() {
isAlternateBufferEnabled(config),
config.getScreenReader(),
);
const rawStartupWarnings = await getStartupWarnings();
const rawStartupWarnings = await rawStartupWarningsPromise;
const startupWarnings: StartupWarning[] = [
...rawStartupWarnings.map((message) => ({
id: `startup-${createHash('sha256').update(message).digest('hex').substring(0, 16)}`,
@@ -43,7 +43,7 @@ describe('SlashCommandResolver', () => {
]);
expect(finalCommands.map((c) => c.name)).toContain('deploy');
expect(finalCommands.map((c) => c.name)).toContain('firebase.deploy');
expect(finalCommands.map((c) => c.name)).toContain('firebase:deploy');
expect(conflicts).toHaveLength(1);
});
@@ -159,7 +159,7 @@ describe('SlashCommandResolver', () => {
it('should apply numeric suffixes when renames also conflict', () => {
const user1 = createMockCommand('deploy', CommandKind.USER_FILE);
const user2 = createMockCommand('gcp.deploy', CommandKind.USER_FILE);
const user2 = createMockCommand('gcp:deploy', CommandKind.USER_FILE);
const extension = {
...createMockCommand('deploy', CommandKind.EXTENSION_FILE),
extensionName: 'gcp',
@@ -171,7 +171,7 @@ describe('SlashCommandResolver', () => {
extension,
]);
expect(finalCommands.find((c) => c.name === 'gcp.deploy1')).toBeDefined();
expect(finalCommands.find((c) => c.name === 'gcp:deploy1')).toBeDefined();
});
it('should prefix skills with extension name when they conflict with built-in', () => {
@@ -185,7 +185,37 @@ describe('SlashCommandResolver', () => {
const names = finalCommands.map((c) => c.name);
expect(names).toContain('chat');
expect(names).toContain('google-workspace.chat');
expect(names).toContain('google-workspace:chat');
});
it('should ALWAYS prefix extension skills even if no conflict exists', () => {
const skill = {
...createMockCommand('chat', CommandKind.SKILL),
extensionName: 'google-workspace',
};
const { finalCommands } = SlashCommandResolver.resolve([skill]);
const names = finalCommands.map((c) => c.name);
expect(names).toContain('google-workspace:chat');
expect(names).not.toContain('chat');
});
it('should use numeric suffixes if prefixed skill names collide', () => {
const skill1 = {
...createMockCommand('chat', CommandKind.SKILL),
extensionName: 'google-workspace',
};
const skill2 = {
...createMockCommand('chat', CommandKind.SKILL),
extensionName: 'google-workspace',
};
const { finalCommands } = SlashCommandResolver.resolve([skill1, skill2]);
const names = finalCommands.map((c) => c.name);
expect(names).toContain('google-workspace:chat');
expect(names).toContain('google-workspace:chat1');
});
it('should NOT prefix skills with "skill" when extension name is missing', () => {
@@ -47,7 +47,17 @@ export class SlashCommandResolver {
const originalName = cmd.name;
let finalName = originalName;
if (registry.firstEncounters.has(originalName)) {
const shouldAlwaysPrefix =
cmd.kind === CommandKind.SKILL && !!cmd.extensionName;
if (shouldAlwaysPrefix) {
finalName = this.getRenamedName(
originalName,
this.getPrefix(cmd),
registry.commandMap,
cmd.kind,
);
} else if (registry.firstEncounters.has(originalName)) {
// We've already seen a command with this name, so resolve the conflict.
finalName = this.handleConflict(cmd, registry);
} else {
@@ -93,6 +103,7 @@ export class SlashCommandResolver {
incoming.name,
this.getPrefix(incoming),
registry.commandMap,
incoming.kind,
);
this.trackConflict(
registry.conflictsMap,
@@ -132,6 +143,7 @@ export class SlashCommandResolver {
currentOwner.name,
this.getPrefix(currentOwner),
registry.commandMap,
currentOwner.kind,
);
// Update the registry: remove the old name and add the owner under the new name.
@@ -156,8 +168,12 @@ export class SlashCommandResolver {
name: string,
prefix: string | undefined,
commandMap: Map<string, SlashCommand>,
kind?: CommandKind,
): string {
const base = prefix ? `${prefix}.${name}` : name;
const isExtensionPrefix =
kind === CommandKind.SKILL || kind === CommandKind.EXTENSION_FILE;
const separator = isExtensionPrefix ? ':' : '.';
const base = prefix ? `${prefix}${separator}${name}` : name;
let renamedName = base;
let suffix = 1;
+28 -19
View File
@@ -11,7 +11,11 @@ import os from 'node:os';
import path from 'node:path';
import fs from 'node:fs';
import { AppContainer } from '../ui/AppContainer.js';
import { renderWithProviders, type RenderInstance } from './render.js';
import {
renderWithProviders,
type RenderInstance,
persistentStateMock,
} from './render.js';
import {
makeFakeConfig,
type Config,
@@ -162,7 +166,7 @@ export class AppRig {
private sessionId: string;
private pendingConfirmations = new Map<string, PendingConfirmation>();
private breakpointTools = new Set<string | undefined>();
private breakpointTools = new Set<string>();
private lastAwaitedConfirmation: PendingConfirmation | undefined;
/**
@@ -177,9 +181,24 @@ export class AppRig {
);
this.sessionId = `test-session-${uniqueId}`;
activeRigs.set(this.sessionId, this);
// Pre-create the persistent state file to bypass the terminal setup prompt
const geminiDir = path.join(this.testDir, '.gemini');
if (!fs.existsSync(geminiDir)) {
fs.mkdirSync(geminiDir, { recursive: true });
}
fs.writeFileSync(
path.join(geminiDir, 'state.json'),
JSON.stringify({ terminalSetupPromptShown: true }),
);
}
async initialize() {
persistentStateMock.setData({
terminalSetupPromptShown: true,
tipsShown: 10,
});
this.setupEnvironment();
resetSettingsCacheForTesting();
this.settings = this.createRigSettings();
@@ -226,6 +245,8 @@ export class AppRig {
private setupEnvironment() {
// Stub environment variables to avoid interference from developer's machine
vi.stubEnv('GEMINI_CLI_HOME', this.testDir);
vi.stubEnv('TERM_PROGRAM', 'other');
vi.stubEnv('VSCODE_GIT_IPC_HANDLE', '');
if (this.options.fakeResponsesPath) {
vi.stubEnv('GEMINI_API_KEY', 'test-api-key');
MockShellExecutionService.setPassthrough(false);
@@ -291,7 +312,6 @@ export class AppRig {
const newContentGeneratorConfig = {
authType: authMethod,
proxy: gcConfig.getProxy(),
apiKey: process.env['GEMINI_API_KEY'] || 'test-api-key',
};
@@ -426,11 +446,7 @@ export class AppRig {
MockShellExecutionService.setMockCommands(commands);
}
setToolPolicy(
toolName: string | undefined,
decision: PolicyDecision,
priority = 10,
) {
setToolPolicy(toolName: string, decision: PolicyDecision, priority = 10) {
if (!this.config) throw new Error('AppRig not initialized');
this.config.getPolicyEngine().addRule({
toolName,
@@ -440,27 +456,20 @@ export class AppRig {
});
}
setBreakpoint(toolName: string | string[] | undefined) {
setBreakpoint(toolName: string | string[]) {
if (Array.isArray(toolName)) {
for (const name of toolName) {
this.setBreakpoint(name);
}
} else {
// Use undefined toolName to create a global rule if '*' is provided
const actualToolName = toolName === '*' ? undefined : toolName;
this.setToolPolicy(actualToolName, PolicyDecision.ASK_USER, 100);
this.setToolPolicy(toolName, PolicyDecision.ASK_USER, 100);
this.breakpointTools.add(toolName);
}
}
removeToolPolicy(toolName?: string, source = 'AppRig Override') {
removeToolPolicy(toolName: string, source = 'AppRig Override') {
if (!this.config) throw new Error('AppRig not initialized');
// Map '*' back to undefined for policy removal
const actualToolName = toolName === '*' ? undefined : toolName;
this.config
.getPolicyEngine()
.removeRulesForTool(actualToolName as string, source);
this.config.getPolicyEngine().removeRulesForTool(toolName, source);
this.breakpointTools.delete(toolName);
}
+1 -1
View File
@@ -665,7 +665,7 @@ export const renderWithProviders = async (
);
}
const mainAreaWidth = terminalWidth;
const mainAreaWidth = providedUiState?.mainAreaWidth ?? terminalWidth;
const finalUiState = {
...baseState,
+4 -9
View File
@@ -489,8 +489,8 @@ describe('AppContainer State Management', () => {
// Mock LoadedSettings
mockSettings = createMockSettings({
hideBanner: false,
hideFooter: false,
hideTips: false,
hideFooter: false,
showMemoryUsage: false,
theme: 'default',
ui: {
@@ -911,8 +911,8 @@ describe('AppContainer State Management', () => {
it('handles settings with all display options disabled', async () => {
const settingsAllHidden = createMockSettings({
hideBanner: true,
hideFooter: true,
hideTips: true,
hideFooter: true,
showMemoryUsage: false,
});
@@ -2157,13 +2157,8 @@ describe('AppContainer State Management', () => {
expect(mockHandleSlashCommand).not.toHaveBeenCalled();
pressKey('\x04'); // Ctrl+D
// Now count is 2, it should quit.
expect(mockHandleSlashCommand).toHaveBeenCalledWith(
'/quit',
undefined,
undefined,
false,
);
// It should still not quit because buffer is non-empty.
expect(mockHandleSlashCommand).not.toHaveBeenCalled();
unmount();
});
+108 -79
View File
@@ -30,8 +30,6 @@ import {
import { ConfigContext } from './contexts/ConfigContext.js';
import {
type HistoryItem,
type HistoryItemWithoutId,
type HistoryItemToolGroup,
AuthState,
type ConfirmationRequest,
type PermissionConfirmationRequest,
@@ -83,7 +81,6 @@ import {
type AgentsDiscoveredPayload,
ChangeAuthRequestedError,
ProjectIdRequiredError,
CoreToolCallStatus,
buildUserSteeringHintPrompt,
logBillingEvent,
ApiKeyUpdatedEvent,
@@ -172,29 +169,11 @@ import { useIsHelpDismissKey } from './utils/shortcutsHelp.js';
import { useSuspend } from './hooks/useSuspend.js';
import { useRunEventNotifications } from './hooks/useRunEventNotifications.js';
import { isNotificationsEnabled } from '../utils/terminalNotifications.js';
function isToolExecuting(pendingHistoryItems: HistoryItemWithoutId[]) {
return pendingHistoryItems.some((item) => {
if (item && item.type === 'tool_group') {
return item.tools.some(
(tool) => CoreToolCallStatus.Executing === tool.status,
);
}
return false;
});
}
function isToolAwaitingConfirmation(
pendingHistoryItems: HistoryItemWithoutId[],
) {
return pendingHistoryItems
.filter((item): item is HistoryItemToolGroup => item.type === 'tool_group')
.some((item) =>
item.tools.some(
(tool) => CoreToolCallStatus.AwaitingApproval === tool.status,
),
);
}
import {
isToolExecuting,
isToolAwaitingConfirmation,
getAllToolCalls,
} from './utils/historyUtils.js';
interface AppContainerProps {
config: Config;
@@ -723,7 +702,10 @@ export const AppContainer = (props: AppContainerProps) => {
// Derive auth state variables for backward compatibility with UIStateContext
const isAuthDialogOpen = authState === AuthState.Updating;
const isAuthenticating = authState === AuthState.Unauthenticated;
// TODO: Consider handling other auth types that should also skip the blocking screen
const isAuthenticating =
authState === AuthState.Unauthenticated &&
settings.merged.security.auth.selectedType !== AuthType.USE_GEMINI;
// Session browser and resume functionality
const isGeminiClientInitialized = config.getGeminiClient()?.isInitialized();
@@ -1153,6 +1135,16 @@ Logging in with Google... Restarting Gemini CLI to continue.
consumePendingHints,
);
const pendingHistoryItems = useMemo(
() => [...pendingSlashCommandHistoryItems, ...pendingGeminiHistoryItems],
[pendingSlashCommandHistoryItems, pendingGeminiHistoryItems],
);
const hasPendingToolConfirmation = useMemo(
() => isToolAwaitingConfirmation(pendingHistoryItems),
[pendingHistoryItems],
);
toggleBackgroundShellRef.current = toggleBackgroundShell;
isBackgroundShellVisibleRef.current = isBackgroundShellVisible;
backgroundShellsRef.current = backgroundShells;
@@ -1260,10 +1252,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
cancelHandlerRef.current = useCallback(
(shouldRestorePrompt: boolean = true) => {
const pendingHistoryItems = [
...pendingSlashCommandHistoryItems,
...pendingGeminiHistoryItems,
];
if (isToolAwaitingConfirmation(pendingHistoryItems)) {
return; // Don't clear - user may be composing a follow-up message
}
@@ -1297,8 +1285,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
inputHistory,
getQueuedMessagesText,
clearQueue,
pendingSlashCommandHistoryItems,
pendingGeminiHistoryItems,
pendingHistoryItems,
],
);
@@ -1334,10 +1321,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
const isIdle = streamingState === StreamingState.Idle;
const isAgentRunning =
streamingState === StreamingState.Responding ||
isToolExecuting([
...pendingSlashCommandHistoryItems,
...pendingGeminiHistoryItems,
]);
isToolExecuting(pendingHistoryItems);
if (isSlash && isAgentRunning) {
const { commandToExecute } = parseSlashCommand(
@@ -1357,7 +1341,8 @@ Logging in with Google... Restarting Gemini CLI to continue.
return;
}
if (isSlash || (isIdle && isMcpReady)) {
const isMcpOrConfigReady = isConfigInitialized && isMcpReady;
if ((isSlash && isConfigInitialized) || (isIdle && isMcpOrConfigReady)) {
if (!isSlash) {
const permissions = await checkPermissions(submittedValue, config);
if (permissions.length > 0) {
@@ -1380,10 +1365,12 @@ Logging in with Google... Restarting Gemini CLI to continue.
void submitQuery(submittedValue);
} else {
// Check messageQueue.length === 0 to only notify on the first queued item
if (isIdle && !isMcpReady && messageQueue.length === 0) {
if (isIdle && !isMcpOrConfigReady && messageQueue.length === 0) {
coreEvents.emitFeedback(
'info',
'Waiting for MCP servers to initialize... Slash commands are still available and prompts will be queued.',
!isConfigInitialized
? 'Initializing... Prompts will be queued.'
: 'Waiting for MCP servers to initialize... Slash commands are still available and prompts will be queued.',
);
}
addMessage(submittedValue);
@@ -1399,8 +1386,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
isMcpReady,
streamingState,
messageQueue.length,
pendingSlashCommandHistoryItems,
pendingGeminiHistoryItems,
pendingHistoryItems,
config,
constrainHeight,
setConstrainHeight,
@@ -1408,6 +1394,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
refreshStatic,
reset,
handleHintSubmit,
isConfigInitialized,
triggerExpandHint,
],
);
@@ -1438,16 +1425,28 @@ Logging in with Google... Restarting Gemini CLI to continue.
* - Any future streaming states not explicitly allowed
*/
const isInputActive =
isConfigInitialized &&
!initError &&
!isProcessing &&
!isResuming &&
!!slashCommands &&
(streamingState === StreamingState.Idle ||
streamingState === StreamingState.Responding) &&
!proQuotaRequest;
streamingState === StreamingState.Responding ||
streamingState === StreamingState.WaitingForConfirmation) &&
!proQuotaRequest &&
!copyModeEnabled;
const [controlsHeight, setControlsHeight] = useState(0);
const [lastNonCopyControlsHeight, setLastNonCopyControlsHeight] = useState(0);
useLayoutEffect(() => {
if (!copyModeEnabled && controlsHeight > 0) {
setLastNonCopyControlsHeight(controlsHeight);
}
}, [copyModeEnabled, controlsHeight]);
const stableControlsHeight =
copyModeEnabled && lastNonCopyControlsHeight > 0
? lastNonCopyControlsHeight
: controlsHeight;
useLayoutEffect(() => {
if (mainControlsRef.current) {
@@ -1457,12 +1456,12 @@ Logging in with Google... Restarting Gemini CLI to continue.
setControlsHeight(roundedHeight);
}
}
}, [buffer, terminalWidth, terminalHeight, controlsHeight]);
}, [buffer, terminalWidth, terminalHeight, controlsHeight, isInputActive]);
// Compute available terminal height based on controls measurement
// Compute available terminal height based on stable controls measurement
const availableTerminalHeight = Math.max(
0,
terminalHeight - controlsHeight - backgroundShellHeight - 1,
terminalHeight - stableControlsHeight - backgroundShellHeight - 1,
);
config.setShellExecutionConfig({
@@ -1711,17 +1710,13 @@ Logging in with Google... Restarting Gemini CLI to continue.
[handleSlashCommand, settings],
);
const { elapsedTime, currentLoadingPhrase } = useLoadingIndicator({
streamingState,
shouldShowFocusHint,
retryStatus,
loadingPhrasesMode: settings.merged.ui.loadingPhrases,
customWittyPhrases: settings.merged.ui.customWittyPhrases,
errorVerbosity: settings.merged.ui.errorVerbosity,
});
const handleGlobalKeypress = useCallback(
(key: Key): boolean => {
// Debug log keystrokes if enabled
if (settings.merged.general.debugKeystrokeLogging) {
debugLogger.log('[DEBUG] Keystroke:', JSON.stringify(key));
}
if (shortcutsHelpVisible && isHelpDismissKey(key)) {
setShortcutsHelpVisible(false);
}
@@ -1740,6 +1735,10 @@ Logging in with Google... Restarting Gemini CLI to continue.
handleCtrlCPress();
return true;
} else if (keyMatchers[Command.EXIT](key)) {
// If the input field is non-empty, do not exit.
if (bufferRef.current.text.length > 0) {
return false;
}
handleCtrlDPress();
return true;
} else if (keyMatchers[Command.SUSPEND_APP](key)) {
@@ -1900,6 +1899,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
activePtyId,
handleSuspend,
embeddedShellFocused,
settings.merged.general.debugKeystrokeLogging,
refreshStatic,
setCopyModeEnabled,
tabFocusTimeoutRef,
@@ -2060,16 +2060,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
authState === AuthState.AwaitingApiKeyInput ||
!!newAgents;
const pendingHistoryItems = useMemo(
() => [...pendingSlashCommandHistoryItems, ...pendingGeminiHistoryItems],
[pendingSlashCommandHistoryItems, pendingGeminiHistoryItems],
);
const hasPendingToolConfirmation = useMemo(
() => isToolAwaitingConfirmation(pendingHistoryItems),
[pendingHistoryItems],
);
const hasConfirmUpdateExtensionRequests =
confirmUpdateExtensionRequests.length > 0;
const hasLoopDetectionConfirmationRequest =
@@ -2087,6 +2077,48 @@ Logging in with Google... Restarting Gemini CLI to continue.
!!emptyWalletRequest ||
!!customDialog;
const loadingPhrases = settings.merged.ui.loadingPhrases;
const showStatusTips = loadingPhrases === 'tips' || loadingPhrases === 'all';
const showStatusWit = loadingPhrases === 'witty' || loadingPhrases === 'all';
const showLoadingIndicator =
(!embeddedShellFocused || isBackgroundShellVisible) &&
streamingState === StreamingState.Responding &&
!hasPendingActionRequired;
let estimatedStatusLength = 0;
if (activeHooks.length > 0 && settings.merged.hooksConfig.notifications) {
const hookLabel =
activeHooks.length > 1 ? 'Executing Hooks' : 'Executing Hook';
const hookNames = activeHooks
.map(
(h) =>
h.name +
(h.index && h.total && h.total > 1 ? ` (${h.index}/${h.total})` : ''),
)
.join(', ');
estimatedStatusLength = hookLabel.length + hookNames.length + 10;
} else if (showLoadingIndicator) {
const thoughtText = thought?.subject || 'Waiting for model...';
estimatedStatusLength = thoughtText.length + 25;
} else if (hasPendingActionRequired) {
estimatedStatusLength = 35;
}
const maxLength = terminalWidth - estimatedStatusLength - 5;
const { elapsedTime, currentLoadingPhrase, currentTip, currentWittyPhrase } =
useLoadingIndicator({
streamingState,
shouldShowFocusHint,
retryStatus,
showTips: showStatusTips,
showWit: showStatusWit,
customWittyPhrases: settings.merged.ui.customWittyPhrases,
errorVerbosity: settings.merged.ui.errorVerbosity,
maxLength,
});
const allowPlanMode =
config.isPlanEnabled() &&
streamingState === StreamingState.Idle &&
@@ -2159,12 +2191,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
]);
const allToolCalls = useMemo(
() =>
pendingHistoryItems
.filter(
(item): item is HistoryItemToolGroup => item.type === 'tool_group',
)
.flatMap((item) => item.tools),
() => getAllToolCalls(pendingHistoryItems),
[pendingHistoryItems],
);
@@ -2272,6 +2299,8 @@ Logging in with Google... Restarting Gemini CLI to continue.
isFocused,
elapsedTime,
currentLoadingPhrase,
currentTip,
currentWittyPhrase,
historyRemountKey,
activeHooks,
messageQueue,
@@ -2291,6 +2320,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
contextFileNames,
errorCount,
availableTerminalHeight,
stableControlsHeight,
mainAreaWidth,
staticAreaMaxItemHeight,
staticExtraHeight,
@@ -2329,11 +2359,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
newAgents,
showIsExpandableHint,
hintMode:
config.isModelSteeringEnabled() &&
isToolExecuting([
...pendingSlashCommandHistoryItems,
...pendingGeminiHistoryItems,
]),
config.isModelSteeringEnabled() && isToolExecuting(pendingHistoryItems),
hintBuffer: '',
}),
[
@@ -2399,6 +2425,8 @@ Logging in with Google... Restarting Gemini CLI to continue.
isFocused,
elapsedTime,
currentLoadingPhrase,
currentTip,
currentWittyPhrase,
historyRemountKey,
activeHooks,
messageQueue,
@@ -2414,6 +2442,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
contextFileNames,
errorCount,
availableTerminalHeight,
stableControlsHeight,
mainAreaWidth,
staticAreaMaxItemHeight,
staticExtraHeight,
@@ -0,0 +1,179 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { cleanup, renderWithProviders } from '../test-utils/render.js';
import { createMockSettings } from '../test-utils/settings.js';
import { App } from './App.js';
import {
CoreToolCallStatus,
ApprovalMode,
makeFakeConfig,
} from '@google/gemini-cli-core';
import { type UIState } from './contexts/UIStateContext.js';
import type { SerializableConfirmationDetails } from '@google/gemini-cli-core';
import { act } from 'react';
import { StreamingState } from './types.js';
vi.mock('ink', async (importOriginal) => {
const original = await importOriginal<typeof import('ink')>();
return {
...original,
useIsScreenReaderEnabled: vi.fn(() => false),
};
});
vi.mock('./components/GeminiSpinner.js', () => ({
GeminiSpinner: () => null,
}));
vi.mock('./components/CliSpinner.js', () => ({
CliSpinner: () => null,
}));
// Mock hooks to align with codebase style, even if App uses UIState directly
vi.mock('./hooks/useGeminiStream.js');
vi.mock('./hooks/useHistoryManager.js');
vi.mock('./hooks/useQuotaAndFallback.js');
vi.mock('./hooks/useThemeCommand.js');
vi.mock('./auth/useAuth.js');
vi.mock('./hooks/useEditorSettings.js');
vi.mock('./hooks/useSettingsCommand.js');
vi.mock('./hooks/useModelCommand.js');
vi.mock('./hooks/slashCommandProcessor.js');
vi.mock('./hooks/useConsoleMessages.js');
vi.mock('./hooks/useTerminalSize.js', () => ({
useTerminalSize: vi.fn(() => ({ columns: 100, rows: 30 })),
}));
describe('Full Terminal Tool Confirmation Snapshot', () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
it('renders tool confirmation box in the frame of the entire terminal', async () => {
// Generate a large diff to warrant truncation
let largeDiff =
'--- a/packages/cli/src/ui/components/InputPrompt.tsx\n+++ b/packages/cli/src/ui/components/InputPrompt.tsx\n@@ -1,100 +1,105 @@\n';
for (let i = 1; i <= 60; i++) {
largeDiff += ` const line${i} = true;\n`;
}
largeDiff += '- return kittyProtocolSupporte...;\n';
largeDiff += '+ return kittyProtocolSupporte...;\n';
largeDiff += ' buffer: TextBuffer;\n';
largeDiff += ' onSubmit: (value: string) => void;';
const confirmationDetails: SerializableConfirmationDetails = {
type: 'edit',
title: 'Edit packages/.../InputPrompt.tsx',
fileName: 'InputPrompt.tsx',
filePath: 'packages/.../InputPrompt.tsx',
fileDiff: largeDiff,
originalContent: 'old',
newContent: 'new',
isModifying: false,
};
const toolCalls = [
{
callId: 'call-1-modify-selected',
name: 'Edit',
description:
'packages/.../InputPrompt.tsx: return kittyProtocolSupporte... => return kittyProtocolSupporte...',
status: CoreToolCallStatus.AwaitingApproval,
resultDisplay: '',
confirmationDetails,
},
];
const mockUIState = {
history: [
{
id: 1,
type: 'user',
text: 'Can you edit InputPrompt.tsx for me?',
},
],
mainAreaWidth: 99,
availableTerminalHeight: 36,
streamingState: StreamingState.WaitingForConfirmation,
constrainHeight: true,
isConfigInitialized: true,
cleanUiDetailsVisible: true,
quota: {
userTier: 'PRO',
stats: {
limits: {},
usage: {},
},
proQuotaRequest: null,
validationRequest: null,
},
pendingHistoryItems: [
{
id: 2,
type: 'tool_group',
tools: toolCalls,
},
],
showApprovalModeIndicator: ApprovalMode.DEFAULT,
sessionStats: {
lastPromptTokenCount: 175400,
contextPercentage: 3,
},
buffer: { text: '' },
messageQueue: [],
activeHooks: [],
contextFileNames: [],
rootUiRef: { current: null },
} as unknown as UIState;
const mockConfig = makeFakeConfig();
mockConfig.getUseAlternateBuffer = () => true;
mockConfig.isTrustedFolder = () => true;
mockConfig.getDisableAlwaysAllow = () => false;
mockConfig.getIdeMode = () => false;
mockConfig.getTargetDir = () => '/directory';
const { waitUntilReady, lastFrame, generateSvg, unmount } =
await renderWithProviders(<App />, {
uiState: mockUIState,
config: mockConfig,
settings: createMockSettings({
merged: {
ui: {
useAlternateBuffer: true,
theme: 'default',
showUserIdentity: false,
showShortcutsHint: false,
footer: {
hideContextPercentage: false,
hideTokens: false,
hideModel: false,
},
},
security: {
enablePermanentToolApproval: true,
},
},
}),
});
await waitUntilReady();
// Give it a moment to render
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 500));
});
await expect({ lastFrame, generateSvg }).toMatchSvgSnapshot();
unmount();
});
});
@@ -2,10 +2,13 @@
exports[`App > Snapshots > renders default layout correctly 1`] = `
"
▝▜▄ Gemini CLI v1.2.3
▝▜▄
▗▟▀
▝▀
▝▜▄ ▗█▀▀▜▙▝█▛▀▀▌▜██▖▟██▘▜█▘▜██▖▝█▛▝█▛
▝▜▄ █▌ █▙▟ ▐█▝█▛▐█ ▐█ ▐█▝█▖█▌ █▌
▗▟▀ ▜▙ ▝█▛ █▌▝ ▖▐█ ▐█ ▐█ ▐█ ▝██▌ █▌
▝▀ ▀▀▀▀▘▝▀▀▀▀▘▀▀▘ ▀▀▘▀▀▘▀▀▘ ▝▀▀▝▀▀
Gemini CLI v1.2.3
Tips for getting started:
@@ -29,16 +32,13 @@ Tips for getting started:
Notifications
Composer
"
`;
@@ -47,10 +47,13 @@ exports[`App > Snapshots > renders screen reader layout correctly 1`] = `
"Notifications
Footer
▝▜▄ Gemini CLI v1.2.3
▝▜▄
▗▟▀
▝▀
▝▜▄ ▗█▀▀▜▙▝█▛▀▀▌▜██▖▟██▘▜█▘▜██▖▝█▛▝█▛
▝▜▄ █▌ █▙▟ ▐█▝█▛▐█ ▐█ ▐█▝█▖█▌ █▌
▗▟▀ ▜▙ ▝█▛ █▌▝ ▖▐█ ▐█ ▐█ ▐█ ▝██▌ █▌
▝▀ ▀▀▀▀▘▝▀▀▀▀▘▀▀▘ ▀▀▘▀▀▘▀▀▘ ▝▀▀▝▀▀
Gemini CLI v1.2.3
Tips for getting started:
@@ -64,13 +67,12 @@ Composer
exports[`App > Snapshots > renders with dialogs visible 1`] = `
"
▝▜▄ Gemini CLI v1.2.3
▝▜▄
▗▟▀
▝▀
▝▜▄ ▗█▀▀▜▙▝█▛▀▀▌▜██▖▟██▘▜█▘▜██▖▝█▛▝█▛
▝▜▄ █▌ █▙▟ ▐█▝█▛▐█ ▐█ ▐█▝█▖█▌ █▌
▗▟▀ ▜▙ ▝█▛ █▌▝ ▖▐█ ▐█ ▐█ ▐█ ▝██▌ █▌
▝▀ ▀▀▀▀▘▝▀▀▀▀▘▀▀▘ ▀▀▘▀▀▘▀▀▘ ▝▀▀▝▀▀
Gemini CLI v1.2.3
@@ -101,16 +103,20 @@ exports[`App > Snapshots > renders with dialogs visible 1`] = `
Notifications
DialogManager
"
`;
exports[`App > should render ToolConfirmationQueue along with Composer when tool is confirming and experiment is on 1`] = `
"
▝▜▄ Gemini CLI v1.2.3
▝▜▄
▗▟▀
▝▀
▝▜▄ ▗█▀▀▜▙▝█▛▀▀▌▜██▖▟██▘▜█▘▜██▖▝█▛▝█▛
▝▜▄ █▌ █▙▟ ▐█▝█▛▐█ ▐█ ▐█▝█▖█▌ █▌
▗▟▀ ▜▙ ▝█▛ █▌▝ ▖▐█ ▐█ ▐█ ▐█ ▝██▌ █▌
▝▀ ▀▀▀▀▘▝▀▀▀▀▘▀▀▘ ▀▀▘▀▀▘▀▀▘ ▝▀▀▝▀▀
Gemini CLI v1.2.3
Tips for getting started:
@@ -139,11 +145,8 @@ HistoryItemDisplay
Notifications
Composer
"
`;
@@ -0,0 +1,266 @@
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="666" viewBox="0 0 920 666">
<style>
text { font-family: Consolas, "Courier New", monospace; font-size: 14px; dominant-baseline: text-before-edge; white-space: pre; }
</style>
<rect width="920" height="666" fill="#000000" />
<g transform="translate(10, 10)">
<rect x="0" y="0" width="900" height="17" fill="#141414" />
<text x="0" y="2" fill="#000000" textLength="900" lengthAdjust="spacingAndGlyphs">▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀</text>
<rect x="0" y="17" width="9" height="17" fill="#141414" />
<rect x="9" y="17" width="18" height="17" fill="#141414" />
<text x="9" y="19" fill="#d7afff" textLength="18" lengthAdjust="spacingAndGlyphs">&gt; </text>
<rect x="27" y="17" width="324" height="17" fill="#141414" />
<text x="27" y="19" fill="#ffffff" textLength="324" lengthAdjust="spacingAndGlyphs">Can you edit InputPrompt.tsx for me?</text>
<rect x="351" y="17" width="549" height="17" fill="#141414" />
<rect x="0" y="34" width="900" height="17" fill="#141414" />
<text x="0" y="36" fill="#000000" textLength="900" lengthAdjust="spacingAndGlyphs">▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄</text>
<text x="0" y="53" fill="#ffffaf" textLength="891" lengthAdjust="spacingAndGlyphs">╭─────────────────────────────────────────────────────────────────────────────────────────────────╮</text>
<text x="0" y="70" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="70" fill="#ffffaf" textLength="135" lengthAdjust="spacingAndGlyphs" font-weight="bold">Action Required</text>
<text x="882" y="70" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="87" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="882" y="87" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="104" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="104" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs">?</text>
<text x="45" y="104" fill="#ffffff" textLength="36" lengthAdjust="spacingAndGlyphs" font-weight="bold">Edit</text>
<text x="90" y="104" fill="#afafaf" textLength="774" lengthAdjust="spacingAndGlyphs">packages/.../InputPrompt.tsx: return kittyProtocolSupporte... =&gt; return kittyProto</text>
<text x="864" y="104" fill="#ffffff" textLength="18" lengthAdjust="spacingAndGlyphs"></text>
<text x="882" y="104" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="121" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="882" y="121" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="138" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="138" fill="#afafaf" textLength="414" lengthAdjust="spacingAndGlyphs">... first 44 lines hidden (Ctrl+O to show) ...</text>
<text x="882" y="138" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="155" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="155" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">45</text>
<text x="63" y="155" fill="#e5e5e5" textLength="54" lengthAdjust="spacingAndGlyphs">const </text>
<text x="117" y="155" fill="#ffffff" textLength="54" lengthAdjust="spacingAndGlyphs">line45</text>
<text x="171" y="155" fill="#e5e5e5" textLength="27" lengthAdjust="spacingAndGlyphs"> = </text>
<text x="198" y="155" fill="#0000ee" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
<text x="234" y="155" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">;</text>
<text x="882" y="155" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="172" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="172" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">46</text>
<text x="63" y="172" fill="#e5e5e5" textLength="54" lengthAdjust="spacingAndGlyphs">const </text>
<text x="117" y="172" fill="#ffffff" textLength="54" lengthAdjust="spacingAndGlyphs">line46</text>
<text x="171" y="172" fill="#e5e5e5" textLength="27" lengthAdjust="spacingAndGlyphs"> = </text>
<text x="198" y="172" fill="#0000ee" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
<text x="234" y="172" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">;</text>
<text x="882" y="172" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="189" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="189" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">47</text>
<text x="63" y="189" fill="#e5e5e5" textLength="54" lengthAdjust="spacingAndGlyphs">const </text>
<text x="117" y="189" fill="#ffffff" textLength="54" lengthAdjust="spacingAndGlyphs">line47</text>
<text x="171" y="189" fill="#e5e5e5" textLength="27" lengthAdjust="spacingAndGlyphs"> = </text>
<text x="198" y="189" fill="#0000ee" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
<text x="234" y="189" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">;</text>
<text x="882" y="189" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="189" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="206" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="206" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">48</text>
<text x="63" y="206" fill="#e5e5e5" textLength="54" lengthAdjust="spacingAndGlyphs">const </text>
<text x="117" y="206" fill="#ffffff" textLength="54" lengthAdjust="spacingAndGlyphs">line48</text>
<text x="171" y="206" fill="#e5e5e5" textLength="27" lengthAdjust="spacingAndGlyphs"> = </text>
<text x="198" y="206" fill="#0000ee" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
<text x="234" y="206" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">;</text>
<text x="882" y="206" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="206" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="223" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="223" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">49</text>
<text x="63" y="223" fill="#e5e5e5" textLength="54" lengthAdjust="spacingAndGlyphs">const </text>
<text x="117" y="223" fill="#ffffff" textLength="54" lengthAdjust="spacingAndGlyphs">line49</text>
<text x="171" y="223" fill="#e5e5e5" textLength="27" lengthAdjust="spacingAndGlyphs"> = </text>
<text x="198" y="223" fill="#0000ee" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
<text x="234" y="223" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">;</text>
<text x="882" y="223" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="223" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="240" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="240" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">50</text>
<text x="63" y="240" fill="#e5e5e5" textLength="54" lengthAdjust="spacingAndGlyphs">const </text>
<text x="117" y="240" fill="#ffffff" textLength="54" lengthAdjust="spacingAndGlyphs">line50</text>
<text x="171" y="240" fill="#e5e5e5" textLength="27" lengthAdjust="spacingAndGlyphs"> = </text>
<text x="198" y="240" fill="#0000ee" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
<text x="234" y="240" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">;</text>
<text x="882" y="240" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="240" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="257" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="257" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">51</text>
<text x="63" y="257" fill="#e5e5e5" textLength="54" lengthAdjust="spacingAndGlyphs">const </text>
<text x="117" y="257" fill="#ffffff" textLength="54" lengthAdjust="spacingAndGlyphs">line51</text>
<text x="171" y="257" fill="#e5e5e5" textLength="27" lengthAdjust="spacingAndGlyphs"> = </text>
<text x="198" y="257" fill="#0000ee" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
<text x="234" y="257" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">;</text>
<text x="882" y="257" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="257" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="274" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="274" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">52</text>
<text x="63" y="274" fill="#e5e5e5" textLength="54" lengthAdjust="spacingAndGlyphs">const </text>
<text x="117" y="274" fill="#ffffff" textLength="54" lengthAdjust="spacingAndGlyphs">line52</text>
<text x="171" y="274" fill="#e5e5e5" textLength="27" lengthAdjust="spacingAndGlyphs"> = </text>
<text x="198" y="274" fill="#0000ee" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
<text x="234" y="274" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">;</text>
<text x="882" y="274" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="274" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="291" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="291" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">53</text>
<text x="63" y="291" fill="#e5e5e5" textLength="54" lengthAdjust="spacingAndGlyphs">const </text>
<text x="117" y="291" fill="#ffffff" textLength="54" lengthAdjust="spacingAndGlyphs">line53</text>
<text x="171" y="291" fill="#e5e5e5" textLength="27" lengthAdjust="spacingAndGlyphs"> = </text>
<text x="198" y="291" fill="#0000ee" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
<text x="234" y="291" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">;</text>
<text x="882" y="291" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="291" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="308" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="308" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">54</text>
<text x="63" y="308" fill="#e5e5e5" textLength="54" lengthAdjust="spacingAndGlyphs">const </text>
<text x="117" y="308" fill="#ffffff" textLength="54" lengthAdjust="spacingAndGlyphs">line54</text>
<text x="171" y="308" fill="#e5e5e5" textLength="27" lengthAdjust="spacingAndGlyphs"> = </text>
<text x="198" y="308" fill="#0000ee" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
<text x="234" y="308" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">;</text>
<text x="882" y="308" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="308" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="325" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="325" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">55</text>
<text x="63" y="325" fill="#e5e5e5" textLength="54" lengthAdjust="spacingAndGlyphs">const </text>
<text x="117" y="325" fill="#ffffff" textLength="54" lengthAdjust="spacingAndGlyphs">line55</text>
<text x="171" y="325" fill="#e5e5e5" textLength="27" lengthAdjust="spacingAndGlyphs"> = </text>
<text x="198" y="325" fill="#0000ee" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
<text x="234" y="325" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">;</text>
<text x="882" y="325" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="325" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="342" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="342" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">56</text>
<text x="63" y="342" fill="#e5e5e5" textLength="54" lengthAdjust="spacingAndGlyphs">const </text>
<text x="117" y="342" fill="#ffffff" textLength="54" lengthAdjust="spacingAndGlyphs">line56</text>
<text x="171" y="342" fill="#e5e5e5" textLength="27" lengthAdjust="spacingAndGlyphs"> = </text>
<text x="198" y="342" fill="#0000ee" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
<text x="234" y="342" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">;</text>
<text x="882" y="342" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="342" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="359" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="359" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">57</text>
<text x="63" y="359" fill="#e5e5e5" textLength="54" lengthAdjust="spacingAndGlyphs">const </text>
<text x="117" y="359" fill="#ffffff" textLength="54" lengthAdjust="spacingAndGlyphs">line57</text>
<text x="171" y="359" fill="#e5e5e5" textLength="27" lengthAdjust="spacingAndGlyphs"> = </text>
<text x="198" y="359" fill="#0000ee" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
<text x="234" y="359" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">;</text>
<text x="882" y="359" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="359" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="376" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="376" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">58</text>
<text x="63" y="376" fill="#e5e5e5" textLength="54" lengthAdjust="spacingAndGlyphs">const </text>
<text x="117" y="376" fill="#ffffff" textLength="54" lengthAdjust="spacingAndGlyphs">line58</text>
<text x="171" y="376" fill="#e5e5e5" textLength="27" lengthAdjust="spacingAndGlyphs"> = </text>
<text x="198" y="376" fill="#0000ee" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
<text x="234" y="376" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">;</text>
<text x="882" y="376" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="376" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="393" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="393" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">59</text>
<text x="63" y="393" fill="#e5e5e5" textLength="54" lengthAdjust="spacingAndGlyphs">const </text>
<text x="117" y="393" fill="#ffffff" textLength="54" lengthAdjust="spacingAndGlyphs">line59</text>
<text x="171" y="393" fill="#e5e5e5" textLength="27" lengthAdjust="spacingAndGlyphs"> = </text>
<text x="198" y="393" fill="#0000ee" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
<text x="234" y="393" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">;</text>
<text x="882" y="393" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="393" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="410" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="410" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">60</text>
<text x="63" y="410" fill="#e5e5e5" textLength="54" lengthAdjust="spacingAndGlyphs">const </text>
<text x="117" y="410" fill="#ffffff" textLength="54" lengthAdjust="spacingAndGlyphs">line60</text>
<text x="171" y="410" fill="#e5e5e5" textLength="27" lengthAdjust="spacingAndGlyphs"> = </text>
<text x="198" y="410" fill="#0000ee" textLength="36" lengthAdjust="spacingAndGlyphs">true</text>
<text x="234" y="410" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs">;</text>
<text x="882" y="410" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="410" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="427" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<rect x="18" y="425" width="18" height="17" fill="#5f0000" />
<text x="18" y="427" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">61</text>
<rect x="36" y="425" width="9" height="17" fill="#5f0000" />
<rect x="45" y="425" width="9" height="17" fill="#5f0000" />
<text x="45" y="427" fill="#ff87af" textLength="9" lengthAdjust="spacingAndGlyphs">-</text>
<rect x="54" y="425" width="9" height="17" fill="#5f0000" />
<rect x="63" y="425" width="9" height="17" fill="#5f0000" />
<rect x="72" y="425" width="54" height="17" fill="#5f0000" />
<text x="72" y="427" fill="#0000ee" textLength="54" lengthAdjust="spacingAndGlyphs">return</text>
<rect x="126" y="425" width="234" height="17" fill="#5f0000" />
<text x="126" y="427" fill="#e5e5e5" textLength="234" lengthAdjust="spacingAndGlyphs"> kittyProtocolSupporte...;</text>
<text x="882" y="427" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="427" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="444" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<rect x="18" y="442" width="18" height="17" fill="#005f00" />
<text x="18" y="444" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">61</text>
<rect x="36" y="442" width="9" height="17" fill="#005f00" />
<rect x="45" y="442" width="9" height="17" fill="#005f00" />
<text x="45" y="444" fill="#d7ffd7" textLength="9" lengthAdjust="spacingAndGlyphs">+</text>
<rect x="54" y="442" width="9" height="17" fill="#005f00" />
<rect x="63" y="442" width="9" height="17" fill="#005f00" />
<rect x="72" y="442" width="54" height="17" fill="#005f00" />
<text x="72" y="444" fill="#0000ee" textLength="54" lengthAdjust="spacingAndGlyphs">return</text>
<rect x="126" y="442" width="234" height="17" fill="#005f00" />
<text x="126" y="444" fill="#e5e5e5" textLength="234" lengthAdjust="spacingAndGlyphs"> kittyProtocolSupporte...;</text>
<text x="882" y="444" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="444" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="461" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="461" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">62</text>
<text x="63" y="461" fill="#e5e5e5" textLength="180" lengthAdjust="spacingAndGlyphs"> buffer: TextBuffer;</text>
<text x="882" y="461" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="461" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="478" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="478" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">63</text>
<text x="72" y="478" fill="#ffffff" textLength="72" lengthAdjust="spacingAndGlyphs">onSubmit</text>
<text x="144" y="478" fill="#e5e5e5" textLength="27" lengthAdjust="spacingAndGlyphs">: (</text>
<text x="171" y="478" fill="#ffffff" textLength="45" lengthAdjust="spacingAndGlyphs">value</text>
<text x="216" y="478" fill="#e5e5e5" textLength="18" lengthAdjust="spacingAndGlyphs">: </text>
<text x="234" y="478" fill="#00cdcd" textLength="54" lengthAdjust="spacingAndGlyphs">string</text>
<text x="288" y="478" fill="#e5e5e5" textLength="45" lengthAdjust="spacingAndGlyphs">) =&gt; </text>
<text x="333" y="478" fill="#00cdcd" textLength="36" lengthAdjust="spacingAndGlyphs">void</text>
<text x="369" y="478" fill="#e5e5e5" textLength="9" lengthAdjust="spacingAndGlyphs">;</text>
<text x="882" y="478" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="478" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="495" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="495" fill="#ffffff" textLength="162" lengthAdjust="spacingAndGlyphs">Apply this change?</text>
<text x="882" y="495" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="495" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="512" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="882" y="512" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="512" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="529" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<rect x="18" y="527" width="9" height="17" fill="#001a00" />
<text x="18" y="529" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<rect x="27" y="527" width="9" height="17" fill="#001a00" />
<rect x="36" y="527" width="18" height="17" fill="#001a00" />
<text x="36" y="529" fill="#00cd00" textLength="18" lengthAdjust="spacingAndGlyphs">1.</text>
<rect x="54" y="527" width="9" height="17" fill="#001a00" />
<rect x="63" y="527" width="90" height="17" fill="#001a00" />
<text x="63" y="529" fill="#00cd00" textLength="90" lengthAdjust="spacingAndGlyphs">Allow once</text>
<rect x="153" y="527" width="288" height="17" fill="#001a00" />
<text x="882" y="529" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="529" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="546" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="36" y="546" fill="#ffffff" textLength="18" lengthAdjust="spacingAndGlyphs">2.</text>
<text x="63" y="546" fill="#ffffff" textLength="198" lengthAdjust="spacingAndGlyphs">Allow for this session</text>
<text x="882" y="546" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="546" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="563" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="36" y="563" fill="#ffffff" textLength="18" lengthAdjust="spacingAndGlyphs">3.</text>
<text x="63" y="563" fill="#ffffff" textLength="378" lengthAdjust="spacingAndGlyphs">Allow for this file in all future sessions</text>
<text x="882" y="563" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="563" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="580" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="36" y="580" fill="#ffffff" textLength="18" lengthAdjust="spacingAndGlyphs">4.</text>
<text x="63" y="580" fill="#ffffff" textLength="243" lengthAdjust="spacingAndGlyphs">Modify with external editor</text>
<text x="882" y="580" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="580" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="597" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="36" y="597" fill="#ffffff" textLength="18" lengthAdjust="spacingAndGlyphs">5.</text>
<text x="63" y="597" fill="#ffffff" textLength="225" lengthAdjust="spacingAndGlyphs">No, suggest changes (esc)</text>
<text x="882" y="597" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="597" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="614" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="882" y="614" fill="#ffffaf" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="891" y="614" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="631" fill="#ffffaf" textLength="891" lengthAdjust="spacingAndGlyphs">╰─────────────────────────────────────────────────────────────────────────────────────────────────╯</text>
<text x="891" y="631" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 26 KiB

@@ -0,0 +1,43 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`Full Terminal Tool Confirmation Snapshot > renders tool confirmation box in the frame of the entire terminal 1`] = `
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
> Can you edit InputPrompt.tsx for me?
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
╭─────────────────────────────────────────────────────────────────────────────────────────────────╮
│ Action Required │
│ │
│ ? Edit packages/.../InputPrompt.tsx: return kittyProtocolSupporte... => return kittyProto… │
│ │
│ ... first 44 lines hidden (Ctrl+O to show) ... │
│ 45 const line45 = true; │
│ 46 const line46 = true; │
│ 47 const line47 = true; │█
│ 48 const line48 = true; │█
│ 49 const line49 = true; │█
│ 50 const line50 = true; │█
│ 51 const line51 = true; │█
│ 52 const line52 = true; │█
│ 53 const line53 = true; │█
│ 54 const line54 = true; │█
│ 55 const line55 = true; │█
│ 56 const line56 = true; │█
│ 57 const line57 = true; │█
│ 58 const line58 = true; │█
│ 59 const line59 = true; │█
│ 60 const line60 = true; │█
│ 61 - return kittyProtocolSupporte...; │█
│ 61 + return kittyProtocolSupporte...; │█
│ 62 buffer: TextBuffer; │█
│ 63 onSubmit: (value: string) => void; │█
│ Apply this change? │█
│ │█
│ ● 1. Allow once │█
│ 2. Allow for this session │█
│ 3. Allow for this file in all future sessions │█
│ 4. Modify with external editor │█
│ 5. No, suggest changes (esc) │█
│ │█
╰─────────────────────────────────────────────────────────────────────────────────────────────────╯█
"
`;
+7 -7
View File
@@ -254,7 +254,7 @@ describe('AuthDialog', () => {
unmount();
});
it('skips API key dialog on initial setup if env var is present', async () => {
it('always shows API key dialog even when env var is present', async () => {
mockedValidateAuthMethod.mockReturnValue(null);
vi.stubEnv('GEMINI_API_KEY', 'test-key-from-env');
// props.settings.merged.security.auth.selectedType is undefined here, simulating initial setup
@@ -265,12 +265,12 @@ describe('AuthDialog', () => {
await handleAuthSelect(AuthType.USE_GEMINI);
expect(props.setAuthState).toHaveBeenCalledWith(
AuthState.Unauthenticated,
AuthState.AwaitingApiKeyInput,
);
unmount();
});
it('skips API key dialog if env var is present but empty', async () => {
it('always shows API key dialog even when env var is empty string', async () => {
mockedValidateAuthMethod.mockReturnValue(null);
vi.stubEnv('GEMINI_API_KEY', ''); // Empty string
// props.settings.merged.security.auth.selectedType is undefined here
@@ -281,7 +281,7 @@ describe('AuthDialog', () => {
await handleAuthSelect(AuthType.USE_GEMINI);
expect(props.setAuthState).toHaveBeenCalledWith(
AuthState.Unauthenticated,
AuthState.AwaitingApiKeyInput,
);
unmount();
});
@@ -302,10 +302,10 @@ describe('AuthDialog', () => {
unmount();
});
it('skips API key dialog on re-auth if env var is present (cannot edit)', async () => {
it('always shows API key dialog on re-auth even if env var is present', async () => {
mockedValidateAuthMethod.mockReturnValue(null);
vi.stubEnv('GEMINI_API_KEY', 'test-key-from-env');
// Simulate that the user has already authenticated once
// Simulate switching from a different auth method (e.g., Google Login → API key)
props.settings.merged.security.auth.selectedType =
AuthType.LOGIN_WITH_GOOGLE;
@@ -315,7 +315,7 @@ describe('AuthDialog', () => {
await handleAuthSelect(AuthType.USE_GEMINI);
expect(props.setAuthState).toHaveBeenCalledWith(
AuthState.Unauthenticated,
AuthState.AwaitingApiKeyInput,
);
unmount();
});
+5 -7
View File
@@ -137,13 +137,11 @@ export function AuthDialog({
}
if (authType === AuthType.USE_GEMINI) {
if (process.env['GEMINI_API_KEY'] !== undefined) {
setAuthState(AuthState.Unauthenticated);
return;
} else {
setAuthState(AuthState.AwaitingApiKeyInput);
return;
}
// Always show the API key input dialog so the user can
// explicitly enter or confirm their key, regardless of
// whether GEMINI_API_KEY env var or a stored key exists.
setAuthState(AuthState.AwaitingApiKeyInput);
return;
}
}
setAuthState(AuthState.Unauthenticated);
@@ -8,8 +8,10 @@ import {
renderWithProviders,
persistentStateMock,
} from '../../test-utils/render.js';
import type { LoadedSettings } from '../../config/settings.js';
import { AppHeader } from './AppHeader.js';
import { describe, it, expect, vi } from 'vitest';
import { makeFakeConfig } from '@google/gemini-cli-core';
import crypto from 'node:crypto';
vi.mock('../utils/terminalSetup.js', () => ({
@@ -240,4 +242,46 @@ describe('<AppHeader />', () => {
expect(session2.lastFrame()).not.toContain('Tips');
session2.unmount();
});
it('should render the full logo when logged out', async () => {
const mockConfig = makeFakeConfig();
vi.spyOn(mockConfig, 'getContentGeneratorConfig').mockReturnValue({
authType: undefined,
} as any); // eslint-disable-line @typescript-eslint/no-explicit-any
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
<AppHeader version="1.0.0" />,
{
config: mockConfig,
uiState: {
terminalWidth: 120,
},
},
);
await waitUntilReady();
// Check for block characters from the logo
expect(lastFrame()).toContain('▗█▀▀▜▙');
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('should NOT render Tips when ui.hideTips is true', async () => {
const mockConfig = makeFakeConfig();
const { lastFrame, waitUntilReady, unmount } = await renderWithProviders(
<AppHeader version="1.0.0" />,
{
config: mockConfig,
settings: {
merged: {
ui: { hideTips: true },
},
} as unknown as LoadedSettings,
},
);
await waitUntilReady();
expect(lastFrame()).not.toContain('Tips');
unmount();
});
});
+86 -51
View File
@@ -19,6 +19,9 @@ import { CliSpinner } from './CliSpinner.js';
import { isAppleTerminal } from '@google/gemini-cli-core';
import { longAsciiLogoCompactText } from './AsciiArt.js';
import { getAsciiArtWidth } from '../utils/textUtils.js';
interface AppHeaderProps {
version: string;
showDetails?: boolean;
@@ -41,6 +44,18 @@ const MAC_TERMINAL_ICON = `▝▜▄
`;
/**
* The horizontal padding (in columns) required for metadata (version, identity, etc.)
* when rendered alongside the ASCII logo.
*/
const LOGO_METADATA_PADDING = 20;
/**
* The terminal width below which we switch to a narrow/column layout to prevent
* UI elements from wrapping or overlapping.
*/
const NARROW_TERMINAL_BREAKPOINT = 60;
export const AppHeader = ({ version, showDetails = true }: AppHeaderProps) => {
const settings = useSettings();
const config = useConfig();
@@ -49,70 +64,90 @@ export const AppHeader = ({ version, showDetails = true }: AppHeaderProps) => {
const { bannerText } = useBanner(bannerData);
const { showTips } = useTips();
const authType = config.getContentGeneratorConfig()?.authType;
const loggedOut = !authType;
const showHeader = !(
settings.merged.ui.hideBanner || config.getScreenReader()
);
const ICON = isAppleTerminal() ? MAC_TERMINAL_ICON : DEFAULT_ICON;
if (!showDetails) {
return (
<Box flexDirection="column">
{showHeader && (
<Box
flexDirection="row"
marginTop={1}
marginBottom={1}
paddingLeft={2}
>
<Box flexShrink={0}>
<ThemedGradient>{ICON}</ThemedGradient>
</Box>
<Box marginLeft={2} flexDirection="column">
<Box>
<Text bold color={theme.text.primary}>
Gemini CLI
</Text>
<Text color={theme.text.secondary}> v{version}</Text>
</Box>
</Box>
let logoTextArt = '';
if (loggedOut) {
const widthOfLongLogo =
getAsciiArtWidth(longAsciiLogoCompactText) + LOGO_METADATA_PADDING;
if (terminalWidth >= widthOfLongLogo) {
logoTextArt = longAsciiLogoCompactText.trim();
}
}
// If the terminal is too narrow to fit the icon and metadata (especially long nightly versions)
// side-by-side, we switch to column mode to prevent wrapping.
const isNarrow = terminalWidth < NARROW_TERMINAL_BREAKPOINT;
const renderLogo = () => (
<Box flexDirection="row">
<Box flexShrink={0}>
<ThemedGradient>{ICON}</ThemedGradient>
</Box>
{logoTextArt && (
<Box marginLeft={3}>
<Text color={theme.text.primary}>{logoTextArt}</Text>
</Box>
)}
</Box>
);
const renderMetadata = (isBelow = false) => (
<Box marginLeft={isBelow ? 0 : 2} flexDirection="column">
{/* Line 1: Gemini CLI vVersion [Updating] */}
<Box>
<Text bold color={theme.text.primary}>
Gemini CLI
</Text>
<Text color={theme.text.secondary}> v{version}</Text>
{updateInfo?.isUpdating && (
<Box marginLeft={2}>
<Text color={theme.text.secondary}>
<CliSpinner /> Updating
</Text>
</Box>
)}
</Box>
);
}
{showDetails && (
<>
{/* Line 2: Blank */}
<Box height={1} />
{/* Lines 3 & 4: User Identity info (Email /auth and Plan /upgrade) */}
{settings.merged.ui.showUserIdentity !== false && (
<UserIdentity config={config} />
)}
</>
)}
</Box>
);
const useColumnLayout = !!logoTextArt || isNarrow;
return (
<Box flexDirection="column">
{showHeader && (
<Box flexDirection="row" marginTop={1} marginBottom={1} paddingLeft={2}>
<Box flexShrink={0}>
<ThemedGradient>{ICON}</ThemedGradient>
</Box>
<Box marginLeft={2} flexDirection="column">
{/* Line 1: Gemini CLI vVersion [Updating] */}
<Box>
<Text bold color={theme.text.primary}>
Gemini CLI
</Text>
<Text color={theme.text.secondary}> v{version}</Text>
{updateInfo && (
<Box marginLeft={2}>
<Text color={theme.text.secondary}>
<CliSpinner /> Updating
</Text>
</Box>
)}
</Box>
{/* Line 2: Blank */}
<Box height={1} />
{/* Lines 3 & 4: User Identity info (Email /auth and Plan /upgrade) */}
{settings.merged.ui.showUserIdentity !== false && (
<UserIdentity config={config} />
)}
</Box>
<Box
flexDirection={useColumnLayout ? 'column' : 'row'}
marginTop={1}
marginBottom={1}
paddingLeft={1}
>
{renderLogo()}
{useColumnLayout ? (
<Box marginTop={1}>{renderMetadata(true)}</Box>
) : (
renderMetadata(false)
)}
</Box>
)}
+29 -8
View File
@@ -16,14 +16,14 @@ export const shortAsciiLogo = `
`;
export const longAsciiLogo = `
`;
export const tinyAsciiLogo = `
@@ -36,3 +36,24 @@ export const tinyAsciiLogo = `
`;
export const shortAsciiLogoCompactText = `
`;
export const longAsciiLogoCompactText = `
`;
export const tinyAsciiLogoCompactText = `
`;
@@ -287,7 +287,7 @@ describe('AskUserDialog', () => {
});
describe.each([
{ useAlternateBuffer: true, expectedArrows: false },
{ useAlternateBuffer: true, expectedArrows: true },
{ useAlternateBuffer: false, expectedArrows: true },
])(
'Scroll Arrows (useAlternateBuffer: $useAlternateBuffer)',
@@ -1453,4 +1453,42 @@ describe('AskUserDialog', () => {
});
});
});
it('shows at least 3 selection options even in small terminal heights', async () => {
const questions: Question[] = [
{
question:
'A very long question that would normally take up most of the space and squeeze the list if we did not have a heuristic to prevent it. This line is just to make it longer. And another one. Imagine this is a plan.',
header: 'Test',
type: QuestionType.CHOICE,
options: [
{ label: 'Option 1', description: 'Description 1' },
{ label: 'Option 2', description: 'Description 2' },
{ label: 'Option 3', description: 'Description 3' },
{ label: 'Option 4', description: 'Description 4' },
],
multiSelect: false,
},
];
const { lastFrame, waitUntilReady } = await renderWithProviders(
<AskUserDialog
questions={questions}
onSubmit={vi.fn()}
onCancel={vi.fn()}
width={80}
availableHeight={12} // Very small height
/>,
{ width: 80 },
);
await waitFor(async () => {
await waitUntilReady();
const frame = lastFrame();
// Should show at least 3 options
expect(frame).toContain('1. Option 1');
expect(frame).toContain('2. Option 2');
expect(frame).toContain('3. Option 3');
});
});
});
@@ -849,16 +849,30 @@ const ChoiceQuestionView: React.FC<ChoiceQuestionViewProps> = ({
? Math.max(1, availableHeight - overhead)
: undefined;
// Reserve space for at least 3 items if more selectionItems available.
const reservedListHeight = Math.min(selectionItems.length * 2, 6);
const questionHeightLimit =
listHeight && !isAlternateBuffer
? question.unconstrainedHeight
? Math.max(1, listHeight - selectionItems.length * 2)
: Math.min(15, Math.max(1, listHeight - DIALOG_PADDING))
: Math.min(
15,
Math.max(
1,
listHeight - Math.max(DIALOG_PADDING, reservedListHeight),
),
)
: undefined;
const maxItemsToShow =
listHeight && questionHeightLimit
? Math.max(1, Math.floor((listHeight - questionHeightLimit) / 2))
listHeight && (!isAlternateBuffer || availableHeight !== undefined)
? Math.min(
selectionItems.length,
Math.max(
1,
Math.floor((listHeight - (questionHeightLimit ?? 0)) / 2),
),
)
: selectionItems.length;
return (
+124 -74
View File
@@ -17,13 +17,6 @@ import {
import { ConfigContext } from '../contexts/ConfigContext.js';
import { SettingsContext } from '../contexts/SettingsContext.js';
import { createMockSettings } from '../../test-utils/settings.js';
// Mock VimModeContext hook
vi.mock('../contexts/VimModeContext.js', () => ({
useVimMode: vi.fn(() => ({
vimEnabled: false,
vimMode: 'INSERT',
})),
}));
import {
ApprovalMode,
tokenLimit,
@@ -36,6 +29,21 @@ import type { LoadedSettings } from '../../config/settings.js';
import type { SessionMetrics } from '../contexts/SessionContext.js';
import type { TextBuffer } from './shared/text-buffer.js';
// Mock VimModeContext hook
vi.mock('../contexts/VimModeContext.js', () => ({
useVimMode: vi.fn(() => ({
vimEnabled: false,
vimMode: 'INSERT',
})),
}));
vi.mock('../hooks/useTerminalSize.js', () => ({
useTerminalSize: vi.fn(() => ({
columns: 100,
rows: 24,
})),
}));
const composerTestControls = vi.hoisted(() => ({
suggestionsVisible: false,
isAlternateBuffer: false,
@@ -58,18 +66,9 @@ vi.mock('./LoadingIndicator.js', () => ({
}));
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),
StatusDisplay: ({ hideContextSummary }: { hideContextSummary: boolean }) => (
<Text>StatusDisplay{hideContextSummary ? ' (hidden summary)' : ''}</Text>
),
}));
vi.mock('./ContextSummaryDisplay.js', () => ({
@@ -81,17 +80,15 @@ vi.mock('./HookStatusDisplay.js', () => ({
}));
vi.mock('./ApprovalModeIndicator.js', () => ({
ApprovalModeIndicator: () => <Text>ApprovalModeIndicator</Text>,
ApprovalModeIndicator: ({ approvalMode }: { approvalMode: ApprovalMode }) => (
<Text>ApprovalModeIndicator: {approvalMode}</Text>
),
}));
vi.mock('./ShellModeIndicator.js', () => ({
ShellModeIndicator: () => <Text>ShellModeIndicator</Text>,
}));
vi.mock('./ShortcutsHint.js', () => ({
ShortcutsHint: () => <Text>ShortcutsHint</Text>,
}));
vi.mock('./ShortcutsHelp.js', () => ({
ShortcutsHelp: () => <Text>ShortcutsHelp</Text>,
}));
@@ -174,6 +171,8 @@ const createMockUIState = (overrides: Partial<UIState> = {}): UIState =>
isFocused: true,
thought: '',
currentLoadingPhrase: '',
currentTip: '',
currentWittyPhrase: '',
elapsedTime: 0,
ctrlCPressedOnce: false,
ctrlDPressedOnce: false,
@@ -201,6 +200,7 @@ const createMockUIState = (overrides: Partial<UIState> = {}): UIState =>
activeHooks: [],
isBackgroundShellVisible: false,
embeddedShellFocused: false,
showIsExpandableHint: false,
quota: {
userTier: undefined,
stats: undefined,
@@ -247,7 +247,7 @@ const createMockConfig = (overrides = {}): Config =>
const renderComposer = async (
uiState: UIState,
settings = createMockSettings(),
settings = createMockSettings({ ui: {} }),
config = createMockConfig(),
uiActions = createMockUIActions(),
) => {
@@ -256,7 +256,7 @@ const renderComposer = async (
<SettingsContext.Provider value={settings as unknown as LoadedSettings}>
<UIStateContext.Provider value={uiState}>
<UIActionsContext.Provider value={uiActions}>
<Composer />
<Composer isFocused={true} />
</UIActionsContext.Provider>
</UIStateContext.Provider>
</SettingsContext.Provider>
@@ -383,10 +383,12 @@ describe('Composer', () => {
const { lastFrame } = await renderComposer(uiState, settings);
const output = lastFrame();
expect(output).toContain('LoadingIndicator: Thinking...');
// In Refreshed UX, we don't force 'Thinking...' label in renderStatusNode
// It uses the subject directly
expect(output).toContain('LoadingIndicator: Thinking about code');
});
it('hides shortcuts hint while loading', async () => {
it('shows shortcuts hint while loading', async () => {
const uiState = createMockUIState({
streamingState: StreamingState.Responding,
elapsedTime: 1,
@@ -397,7 +399,8 @@ describe('Composer', () => {
const output = lastFrame();
expect(output).toContain('LoadingIndicator');
expect(output).not.toContain('ShortcutsHint');
expect(output).toContain('press tab twice for more');
expect(output).not.toContain('? for shortcuts');
});
it('renders LoadingIndicator with thought when loadingPhrases is off', async () => {
@@ -453,9 +456,8 @@ describe('Composer', () => {
const { lastFrame } = await renderComposer(uiState);
const output = lastFrame();
expect(output).not.toContain('LoadingIndicator');
expect(output).not.toContain('esc to cancel');
const output = lastFrame({ allowEmpty: true });
expect(output).toBe('');
});
it('renders LoadingIndicator when embedded shell is focused but background shell is visible', async () => {
@@ -558,8 +560,10 @@ describe('Composer', () => {
const { lastFrame } = await renderComposer(uiState);
const output = lastFrame();
expect(output).toContain('ToastDisplay');
expect(output).not.toContain('ApprovalModeIndicator');
expect(output).toContain('Press Ctrl+C again to exit.');
// In Refreshed UX, Row 1 shows toast, and Row 2 shows ApprovalModeIndicator/StatusDisplay
// They are no longer mutually exclusive.
expect(output).toContain('ApprovalModeIndicator');
expect(output).toContain('StatusDisplay');
});
@@ -574,8 +578,8 @@ describe('Composer', () => {
const { lastFrame } = await renderComposer(uiState);
const output = lastFrame();
expect(output).toContain('ToastDisplay');
expect(output).not.toContain('ApprovalModeIndicator');
expect(output).toContain('Warning');
expect(output).toContain('ApprovalModeIndicator');
});
});
@@ -584,15 +588,17 @@ describe('Composer', () => {
const uiState = createMockUIState({
cleanUiDetailsVisible: false,
});
const settings = createMockSettings({
ui: { showShortcutsHint: false },
});
const { lastFrame } = await renderComposer(uiState);
const { lastFrame } = await renderComposer(uiState, settings);
const output = lastFrame();
expect(output).toContain('ShortcutsHint');
expect(output).not.toContain('press tab twice for more');
expect(output).not.toContain('? for shortcuts');
expect(output).toContain('InputPrompt');
expect(output).not.toContain('Footer');
expect(output).not.toContain('ApprovalModeIndicator');
expect(output).not.toContain('ContextSummaryDisplay');
});
it('renders InputPrompt when input is active', async () => {
@@ -665,12 +671,15 @@ describe('Composer', () => {
});
it.each([
[ApprovalMode.YOLO, 'YOLO'],
[ApprovalMode.PLAN, 'plan'],
[ApprovalMode.AUTO_EDIT, 'auto edit'],
{ mode: ApprovalMode.YOLO, label: '● YOLO' },
{ mode: ApprovalMode.PLAN, label: '● plan' },
{
mode: ApprovalMode.AUTO_EDIT,
label: '● auto edit',
},
])(
'shows minimal mode badge "%s" when clean UI details are hidden',
async (mode, label) => {
'shows minimal mode badge "$mode" when clean UI details are hidden',
async ({ mode, label }) => {
const uiState = createMockUIState({
cleanUiDetailsVisible: false,
showApprovalModeIndicator: mode,
@@ -693,7 +702,8 @@ describe('Composer', () => {
const output = lastFrame();
expect(output).toContain('LoadingIndicator');
expect(output).not.toContain('plan');
expect(output).not.toContain('ShortcutsHint');
expect(output).toContain('press tab twice for more');
expect(output).not.toContain('? for shortcuts');
});
it('hides minimal mode badge while action-required state is active', async () => {
@@ -708,9 +718,7 @@ describe('Composer', () => {
});
const { lastFrame } = await renderComposer(uiState);
const output = lastFrame();
expect(output).not.toContain('plan');
expect(output).not.toContain('ShortcutsHint');
expect(lastFrame({ allowEmpty: true })).toBe('');
});
it('shows Esc rewind prompt in minimal mode without showing full UI', async () => {
@@ -722,7 +730,7 @@ describe('Composer', () => {
const { lastFrame } = await renderComposer(uiState);
const output = lastFrame();
expect(output).toContain('ToastDisplay');
expect(output).toContain('Press Esc again to rewind.');
expect(output).not.toContain('ContextSummaryDisplay');
});
@@ -747,7 +755,14 @@ describe('Composer', () => {
});
const { lastFrame } = await renderComposer(uiState, settings);
expect(lastFrame()).toContain('%');
await act(async () => {
await vi.advanceTimersByTimeAsync(250);
});
// StatusDisplay (which contains ContextUsageDisplay) should bleed through in minimal mode
expect(lastFrame()).toContain('StatusDisplay');
expect(lastFrame()).toContain('70% used');
});
});
@@ -812,14 +827,20 @@ describe('Composer', () => {
describe('Shortcuts Hint', () => {
it('restores shortcuts hint after 200ms debounce when buffer is empty', async () => {
const { lastFrame } = await renderComposer(
createMockUIState({
buffer: { text: '' } as unknown as TextBuffer,
cleanUiDetailsVisible: false,
}),
);
const uiState = createMockUIState({
buffer: { text: '' } as unknown as TextBuffer,
cleanUiDetailsVisible: false,
});
expect(lastFrame({ allowEmpty: true })).toContain('ShortcutsHint');
const { lastFrame } = await renderComposer(uiState);
await act(async () => {
await vi.advanceTimersByTimeAsync(250);
});
expect(lastFrame({ allowEmpty: true })).toContain(
'press tab twice for more',
);
});
it('hides shortcuts hint when text is typed in buffer', async () => {
@@ -830,7 +851,8 @@ describe('Composer', () => {
const { lastFrame } = await renderComposer(uiState);
expect(lastFrame()).not.toContain('ShortcutsHint');
expect(lastFrame()).not.toContain('press tab twice for more');
expect(lastFrame()).not.toContain('? for shortcuts');
});
it('hides shortcuts hint when showShortcutsHint setting is false', async () => {
@@ -843,7 +865,7 @@ describe('Composer', () => {
const { lastFrame } = await renderComposer(uiState, settings);
expect(lastFrame()).not.toContain('ShortcutsHint');
expect(lastFrame()).not.toContain('? for shortcuts');
});
it('hides shortcuts hint when a action is required (e.g. dialog is open)', async () => {
@@ -856,9 +878,10 @@ describe('Composer', () => {
),
});
const { lastFrame } = await renderComposer(uiState);
const { lastFrame, unmount } = await renderComposer(uiState);
expect(lastFrame()).not.toContain('ShortcutsHint');
expect(lastFrame({ allowEmpty: true })).toBe('');
unmount();
});
it('keeps shortcuts hint visible when no action is required', async () => {
@@ -868,7 +891,11 @@ describe('Composer', () => {
const { lastFrame } = await renderComposer(uiState);
expect(lastFrame()).toContain('ShortcutsHint');
await act(async () => {
await vi.advanceTimersByTimeAsync(250);
});
expect(lastFrame()).toContain('press tab twice for more');
});
it('shows shortcuts hint when full UI details are visible', async () => {
@@ -878,10 +905,15 @@ describe('Composer', () => {
const { lastFrame } = await renderComposer(uiState);
expect(lastFrame()).toContain('ShortcutsHint');
await act(async () => {
await vi.advanceTimersByTimeAsync(250);
});
// In Refreshed UX, shortcuts hint is in the top multipurpose status row
expect(lastFrame()).toContain('? for shortcuts');
});
it('hides shortcuts hint while loading when full UI details are visible', async () => {
it('shows shortcuts hint while loading when full UI details are visible', async () => {
const uiState = createMockUIState({
cleanUiDetailsVisible: true,
streamingState: StreamingState.Responding,
@@ -889,10 +921,17 @@ describe('Composer', () => {
const { lastFrame } = await renderComposer(uiState);
expect(lastFrame()).not.toContain('ShortcutsHint');
await act(async () => {
await vi.advanceTimersByTimeAsync(250);
});
// In experimental layout, status row is visible during loading
expect(lastFrame()).toContain('LoadingIndicator');
expect(lastFrame()).toContain('? for shortcuts');
expect(lastFrame()).not.toContain('press tab twice for more');
});
it('hides shortcuts hint while loading in minimal mode', async () => {
it('shows shortcuts hint while loading in minimal mode', async () => {
const uiState = createMockUIState({
cleanUiDetailsVisible: false,
streamingState: StreamingState.Responding,
@@ -901,7 +940,14 @@ describe('Composer', () => {
const { lastFrame } = await renderComposer(uiState);
expect(lastFrame()).not.toContain('ShortcutsHint');
await act(async () => {
await vi.advanceTimersByTimeAsync(250);
});
// In experimental layout, status row is visible in clean mode while busy
expect(lastFrame()).toContain('LoadingIndicator');
expect(lastFrame()).toContain('press tab twice for more');
expect(lastFrame()).not.toContain('? for shortcuts');
});
it('shows shortcuts help in minimal mode when toggled on', async () => {
@@ -926,7 +972,8 @@ describe('Composer', () => {
const { lastFrame } = await renderComposer(uiState);
expect(lastFrame()).not.toContain('ShortcutsHint');
expect(lastFrame()).not.toContain('press tab twice for more');
expect(lastFrame()).not.toContain('? for shortcuts');
expect(lastFrame()).not.toContain('plan');
});
@@ -954,7 +1001,12 @@ describe('Composer', () => {
const { lastFrame } = await renderComposer(uiState);
expect(lastFrame()).toContain('ShortcutsHint');
await act(async () => {
await vi.advanceTimersByTimeAsync(250);
});
// In Refreshed UX, shortcuts hint is in the top status row and doesn't collide with suggestions below
expect(lastFrame()).toContain('press tab twice for more');
});
});
@@ -982,24 +1034,22 @@ describe('Composer', () => {
expect(lastFrame()).not.toContain('ShortcutsHelp');
unmount();
});
it('hides shortcuts help when action is required', async () => {
const uiState = createMockUIState({
shortcutsHelpVisible: true,
customDialog: (
<Box>
<Text>Dialog content</Text>
<Text>Test Dialog</Text>
</Box>
),
});
const { lastFrame, unmount } = await renderComposer(uiState);
expect(lastFrame()).not.toContain('ShortcutsHelp');
expect(lastFrame({ allowEmpty: true })).toBe('');
unmount();
});
});
describe('Snapshots', () => {
it('matches snapshot in idle state', async () => {
const uiState = createMockUIState();
+407 -282
View File
@@ -4,58 +4,63 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { useState, useEffect, useMemo } from 'react';
import { Box, Text, useIsScreenReaderEnabled } from 'ink';
import {
ApprovalMode,
checkExhaustive,
CoreToolCallStatus,
isUserVisibleHook,
} from '@google/gemini-cli-core';
import { Box, Text, useIsScreenReaderEnabled } from 'ink';
import { useState, useEffect, useMemo } from 'react';
import { useConfig } from '../contexts/ConfigContext.js';
import { useSettings } from '../contexts/SettingsContext.js';
import { useUIState } from '../contexts/UIStateContext.js';
import { useUIActions } from '../contexts/UIActionsContext.js';
import { useVimMode } from '../contexts/VimModeContext.js';
import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js';
import { useTerminalSize } from '../hooks/useTerminalSize.js';
import { isNarrowWidth } from '../utils/isNarrowWidth.js';
import { isContextUsageHigh } from '../utils/contextUsage.js';
import { theme } from '../semantic-colors.js';
import { GENERIC_WORKING_LABEL } from '../textConstants.js';
import { INTERACTIVE_SHELL_WAITING_PHRASE } from '../hooks/usePhraseCycler.js';
import { StreamingState, type HistoryItemToolGroup } from '../types.js';
import { LoadingIndicator } from './LoadingIndicator.js';
import { ContextUsageDisplay } from './ContextUsageDisplay.js';
import { StatusDisplay } from './StatusDisplay.js';
import { HorizontalLine } from './shared/HorizontalLine.js';
import { ToastDisplay, shouldShowToast } from './ToastDisplay.js';
import { ApprovalModeIndicator } from './ApprovalModeIndicator.js';
import { ShellModeIndicator } from './ShellModeIndicator.js';
import { DetailedMessagesDisplay } from './DetailedMessagesDisplay.js';
import { RawMarkdownIndicator } from './RawMarkdownIndicator.js';
import { ShortcutsHint } from './ShortcutsHint.js';
import { ShortcutsHelp } from './ShortcutsHelp.js';
import { InputPrompt } from './InputPrompt.js';
import { Footer } from './Footer.js';
import { ShowMoreLines } from './ShowMoreLines.js';
import { QueuedMessageDisplay } from './QueuedMessageDisplay.js';
import { ContextUsageDisplay } from './ContextUsageDisplay.js';
import { HorizontalLine } from './shared/HorizontalLine.js';
import { OverflowProvider } from '../contexts/OverflowContext.js';
import { isNarrowWidth } from '../utils/isNarrowWidth.js';
import { useUIState } from '../contexts/UIStateContext.js';
import { useUIActions } from '../contexts/UIActionsContext.js';
import { useVimMode } from '../contexts/VimModeContext.js';
import { useConfig } from '../contexts/ConfigContext.js';
import { useSettings } from '../contexts/SettingsContext.js';
import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js';
import { StreamingState, type HistoryItemToolGroup } from '../types.js';
import { ConfigInitDisplay } from '../components/ConfigInitDisplay.js';
import { ConfigInitDisplay } from './ConfigInitDisplay.js';
import { TodoTray } from './messages/Todo.js';
import { getInlineThinkingMode } from '../utils/inlineThinkingMode.js';
import { isContextUsageHigh } from '../utils/contextUsage.js';
import { theme } from '../semantic-colors.js';
export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
const config = useConfig();
const settings = useSettings();
const isScreenReaderEnabled = useIsScreenReaderEnabled();
const uiState = useUIState();
const uiActions = useUIActions();
const settings = useSettings();
const config = useConfig();
const { vimEnabled, vimMode } = useVimMode();
const inlineThinkingMode = getInlineThinkingMode(settings);
const terminalWidth = uiState.terminalWidth;
const isScreenReaderEnabled = useIsScreenReaderEnabled();
const { columns: terminalWidth } = useTerminalSize();
const isNarrow = isNarrowWidth(terminalWidth);
const debugConsoleMaxHeight = Math.floor(Math.max(terminalWidth * 0.2, 5));
const [suggestionsVisible, setSuggestionsVisible] = useState(false);
const isAlternateBuffer = useAlternateBuffer();
const { showApprovalModeIndicator } = uiState;
const showApprovalModeIndicator = uiState.showApprovalModeIndicator;
const loadingPhrases = settings.merged.ui.loadingPhrases;
const showTips = loadingPhrases === 'tips' || loadingPhrases === 'all';
const showWit = loadingPhrases === 'witty' || loadingPhrases === 'all';
const showUiDetails = uiState.cleanUiDetailsVisible;
const suggestionsPosition = isAlternateBuffer ? 'above' : 'below';
const hideContextSummary =
@@ -84,6 +89,7 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
Boolean(uiState.quota.proQuotaRequest) ||
Boolean(uiState.quota.validationRequest) ||
Boolean(uiState.customDialog);
const isPassiveShortcutsHelpState =
uiState.isInputActive &&
uiState.streamingState === StreamingState.Idle &&
@@ -105,16 +111,30 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
uiState.shortcutsHelpVisible &&
uiState.streamingState === StreamingState.Idle &&
!hasPendingActionRequired;
/**
* Use the setting if provided, otherwise default to true for the new UX.
* This allows tests to override the collapse behavior.
*/
const shouldCollapseDuringApproval =
settings.merged.ui.collapseDrawerDuringApproval !== false;
if (hasPendingActionRequired && shouldCollapseDuringApproval) {
return null;
}
const hasToast = shouldShowToast(uiState);
const showLoadingIndicator =
(!uiState.embeddedShellFocused || uiState.isBackgroundShellVisible) &&
uiState.streamingState === StreamingState.Responding &&
!hasPendingActionRequired;
const hideUiDetailsForSuggestions =
suggestionsVisible && suggestionsPosition === 'above';
const showApprovalIndicator =
!uiState.shellModeActive && !hideUiDetailsForSuggestions;
const showRawMarkdownIndicator = !uiState.renderMarkdown;
let modeBleedThrough: { text: string; color: string } | null = null;
switch (showApprovalModeIndicator) {
case ApprovalMode.YOLO:
@@ -137,57 +157,359 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
const hideMinimalModeHintWhileBusy =
!showUiDetails && (showLoadingIndicator || hasPendingActionRequired);
const minimalModeBleedThrough = hideMinimalModeHintWhileBusy
? null
: modeBleedThrough;
const hasMinimalStatusBleedThrough = shouldShowToast(uiState);
const showMinimalContextBleedThrough =
!settings.merged.ui.footer.hideContextPercentage &&
isContextUsageHigh(
uiState.sessionStats.lastPromptTokenCount,
typeof uiState.currentModel === 'string'
? uiState.currentModel
: undefined,
);
const hideShortcutsHintForSuggestions = hideUiDetailsForSuggestions;
const isModelIdle = uiState.streamingState === StreamingState.Idle;
const isBufferEmpty = uiState.buffer.text.length === 0;
const canShowShortcutsHint =
isModelIdle && isBufferEmpty && !hasPendingActionRequired;
const [showShortcutsHintDebounced, setShowShortcutsHintDebounced] =
useState(canShowShortcutsHint);
// Universal Content Objects
const modeContentObj = hideMinimalModeHintWhileBusy ? null : modeBleedThrough;
useEffect(() => {
if (!canShowShortcutsHint) {
setShowShortcutsHintDebounced(false);
return;
}
const timeout = setTimeout(() => {
setShowShortcutsHintDebounced(true);
}, 200);
return () => clearTimeout(timeout);
}, [canShowShortcutsHint]);
const allHooks = uiState.activeHooks;
const hasAnyHooks = allHooks.length > 0;
const userVisibleHooks = allHooks.filter((h) => isUserVisibleHook(h.source));
const hasUserVisibleHooks = userVisibleHooks.length > 0;
const shouldReserveSpaceForShortcutsHint =
settings.merged.ui.showShortcutsHint && !hideShortcutsHintForSuggestions;
const showShortcutsHint =
shouldReserveSpaceForShortcutsHint && showShortcutsHintDebounced;
const showMinimalModeBleedThrough =
!hideUiDetailsForSuggestions && Boolean(minimalModeBleedThrough);
const showMinimalInlineLoading = !showUiDetails && showLoadingIndicator;
const showMinimalBleedThroughRow =
!showUiDetails &&
(showMinimalModeBleedThrough ||
hasMinimalStatusBleedThrough ||
showMinimalContextBleedThrough);
const showMinimalMetaRow =
!showUiDetails &&
(showMinimalInlineLoading ||
showMinimalBleedThroughRow ||
shouldReserveSpaceForShortcutsHint);
settings.merged.ui.showShortcutsHint &&
!hideUiDetailsForSuggestions &&
!hasPendingActionRequired;
const isInteractiveShellWaiting = uiState.currentLoadingPhrase?.includes(
INTERACTIVE_SHELL_WAITING_PHRASE,
);
/**
* Calculate the estimated length of the status message to avoid collisions
* with the tips area.
*/
let estimatedStatusLength = 0;
if (hasAnyHooks) {
if (hasUserVisibleHooks) {
const hookLabel =
userVisibleHooks.length > 1 ? 'Executing Hooks' : 'Executing Hook';
const hookNames = userVisibleHooks
.map(
(h) =>
h.name +
(h.index && h.total && h.total > 1
? ` (${h.index}/${h.total})`
: ''),
)
.join(', ');
estimatedStatusLength = hookLabel.length + hookNames.length + 10;
} else {
estimatedStatusLength = GENERIC_WORKING_LABEL.length + 10;
}
} else if (showLoadingIndicator) {
const thoughtText = uiState.thought?.subject || GENERIC_WORKING_LABEL;
const inlineWittyLength =
showWit && uiState.currentWittyPhrase
? uiState.currentWittyPhrase.length + 1
: 0;
estimatedStatusLength = thoughtText.length + 25 + inlineWittyLength;
} else if (hasPendingActionRequired) {
estimatedStatusLength = 20;
} else if (hasToast) {
estimatedStatusLength = 40;
}
/**
* Determine the ambient text (tip) to display.
*/
const tipContentStr = (() => {
// 1. Proactive Tip (Priority)
if (
showTips &&
uiState.currentTip &&
!(
isInteractiveShellWaiting &&
uiState.currentTip === INTERACTIVE_SHELL_WAITING_PHRASE
)
) {
if (
estimatedStatusLength + uiState.currentTip.length + 10 <=
terminalWidth
) {
return uiState.currentTip;
}
}
// 2. Shortcut Hint (Fallback)
if (
settings.merged.ui.showShortcutsHint &&
!hideUiDetailsForSuggestions &&
!hasPendingActionRequired &&
uiState.buffer.text.length === 0
) {
return showUiDetails ? '? for shortcuts' : 'press tab twice for more';
}
return undefined;
})();
const tipLength = tipContentStr?.length || 0;
const willCollideTip = estimatedStatusLength + tipLength + 5 > terminalWidth;
const showTipLine =
!hasPendingActionRequired && tipContentStr && !willCollideTip && !isNarrow;
// Mini Mode VIP Flags (Pure Content Triggers)
const miniMode_ShowApprovalMode =
Boolean(modeContentObj) && !hideUiDetailsForSuggestions;
const miniMode_ShowToast = hasToast;
const miniMode_ShowShortcuts = shouldReserveSpaceForShortcutsHint;
const miniMode_ShowStatus = showLoadingIndicator || hasAnyHooks;
const miniMode_ShowTip = showTipLine;
const miniMode_ShowContext = isContextUsageHigh(
uiState.sessionStats.lastPromptTokenCount,
uiState.currentModel,
settings.merged.model?.compressionThreshold,
);
// Composite Mini Mode Triggers
const showRow1_MiniMode =
miniMode_ShowToast ||
miniMode_ShowStatus ||
miniMode_ShowShortcuts ||
miniMode_ShowTip;
const showRow2_MiniMode = miniMode_ShowApprovalMode || miniMode_ShowContext;
// Final Display Rules (Stable Footer Architecture)
const showRow1 = showUiDetails || showRow1_MiniMode;
const showRow2 = showUiDetails || showRow2_MiniMode;
const showMinimalBleedThroughRow = !showUiDetails && showRow2_MiniMode;
const renderTipNode = () => {
if (!tipContentStr) return null;
const isShortcutHint =
tipContentStr === '? for shortcuts' ||
tipContentStr === 'press tab twice for more';
const color =
isShortcutHint && uiState.shortcutsHelpVisible
? theme.text.accent
: theme.text.secondary;
return (
<Box flexDirection="row" justifyContent="flex-end">
<Text
color={color}
wrap="truncate-end"
italic={
!isShortcutHint && tipContentStr === uiState.currentWittyPhrase
}
>
{tipContentStr === uiState.currentTip
? `Tip: ${tipContentStr}`
: tipContentStr}
</Text>
</Box>
);
};
const renderStatusNode = () => {
const allHooks = uiState.activeHooks;
if (allHooks.length === 0 && !showLoadingIndicator) return null;
if (allHooks.length > 0) {
const userVisibleHooks = allHooks.filter((h) =>
isUserVisibleHook(h.source),
);
let hookText = GENERIC_WORKING_LABEL;
if (userVisibleHooks.length > 0) {
const label =
userVisibleHooks.length > 1 ? 'Executing Hooks' : 'Executing Hook';
const displayNames = userVisibleHooks.map((h) => {
let name = h.name;
if (h.index && h.total && h.total > 1) {
name += ` (${h.index}/${h.total})`;
}
return name;
});
hookText = `${label}: ${displayNames.join(', ')}`;
}
return (
<LoadingIndicator
inline
showTips={showTips}
showWit={showWit}
errorVerbosity={settings.merged.ui.errorVerbosity}
currentLoadingPhrase={hookText}
elapsedTime={uiState.elapsedTime}
forceRealStatusOnly={false}
wittyPhrase={uiState.currentWittyPhrase}
/>
);
}
return (
<LoadingIndicator
inline
showTips={showTips}
showWit={showWit}
errorVerbosity={settings.merged.ui.errorVerbosity}
thought={uiState.thought}
elapsedTime={uiState.elapsedTime}
forceRealStatusOnly={false}
wittyPhrase={uiState.currentWittyPhrase}
/>
);
};
const statusNode = renderStatusNode();
/**
* Renders the minimal metadata row content shown when UI details are hidden.
*/
const renderMinimalMetaRowContent = () => (
<Box flexDirection="row" columnGap={1}>
{renderStatusNode()}
{showMinimalBleedThroughRow && (
<Box>
{miniMode_ShowApprovalMode && modeContentObj && (
<Text color={modeContentObj.color}> {modeContentObj.text}</Text>
)}
</Box>
)}
</Box>
);
const renderStatusRow = () => {
// Mini Mode Height Reservation (The "Anti-Jitter" line)
if (!showUiDetails && !showRow1_MiniMode && !showRow2_MiniMode) {
return <Box height={1} />;
}
return (
<Box flexDirection="column" width="100%">
{/* Row 1: multipurpose status (thinking, hooks, wit, tips) */}
{showRow1 && (
<Box
width="100%"
flexDirection="row"
alignItems="center"
justifyContent="space-between"
minHeight={1}
>
<Box flexDirection="row" flexGrow={1} flexShrink={1}>
{!showUiDetails && showRow1_MiniMode ? (
renderMinimalMetaRowContent()
) : isInteractiveShellWaiting ? (
<Box width="100%" marginLeft={1}>
<Text color={theme.status.warning}>
! Shell awaiting input (Tab to focus)
</Text>
</Box>
) : (
<Box
flexDirection="row"
alignItems={isNarrow ? 'flex-start' : 'center'}
flexGrow={1}
flexShrink={0}
marginLeft={1}
>
{statusNode}
</Box>
)}
</Box>
<Box flexShrink={0} marginLeft={2} marginRight={isNarrow ? 0 : 1}>
{!isNarrow && showTipLine && renderTipNode()}
</Box>
</Box>
)}
{/* Internal Separator Line */}
{showRow1 &&
showRow2 &&
(showUiDetails || (showRow1_MiniMode && showRow2_MiniMode)) && (
<Box width="100%">
<HorizontalLine dim />
</Box>
)}
{/* Row 2: Mode and Context Summary */}
{showRow2 && (
<Box
width="100%"
flexDirection={isNarrow ? 'column' : 'row'}
alignItems={isNarrow ? 'flex-start' : 'center'}
justifyContent="space-between"
>
<Box flexDirection="row" alignItems="center" marginLeft={1}>
{showUiDetails ? (
<>
{showApprovalIndicator && (
<ApprovalModeIndicator
approvalMode={showApprovalModeIndicator}
allowPlanMode={uiState.allowPlanMode}
/>
)}
{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>
)}
</>
) : (
miniMode_ShowApprovalMode &&
modeContentObj && (
<Text color={modeContentObj.color}>
{modeContentObj.text}
</Text>
)
)}
</Box>
<Box
marginTop={isNarrow ? 1 : 0}
flexDirection="row"
alignItems="center"
marginLeft={isNarrow ? 1 : 0}
>
{(showUiDetails || miniMode_ShowContext) && (
<StatusDisplay hideContextSummary={hideContextSummary} />
)}
{miniMode_ShowContext && !showUiDetails && (
<Box marginLeft={1}>
<ContextUsageDisplay
promptTokenCount={uiState.sessionStats.lastPromptTokenCount}
model={
typeof uiState.currentModel === 'string'
? uiState.currentModel
: undefined
}
terminalWidth={uiState.terminalWidth}
/>
</Box>
)}
</Box>
</Box>
)}
</Box>
);
};
return (
<Box
@@ -196,12 +518,8 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
flexGrow={0}
flexShrink={0}
>
{(!uiState.slashCommands ||
!uiState.isConfigInitialized ||
uiState.isResuming) && (
<ConfigInitDisplay
message={uiState.isResuming ? 'Resuming session...' : undefined}
/>
{uiState.isResuming && (
<ConfigInitDisplay message="Resuming session..." />
)}
{showUiDetails && (
@@ -210,212 +528,16 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
{showUiDetails && <TodoTray />}
<Box width="100%" flexDirection="column">
<Box
width="100%"
flexDirection={isNarrow ? 'column' : 'row'}
alignItems={isNarrow ? 'flex-start' : 'center'}
justifyContent={isNarrow ? 'flex-start' : 'space-between'}
>
<Box
marginLeft={1}
marginRight={isNarrow ? 0 : 1}
flexDirection="row"
alignItems={isNarrow ? 'flex-start' : 'center'}
flexGrow={1}
>
{showUiDetails && showLoadingIndicator && (
<LoadingIndicator
inline
thought={
uiState.streamingState ===
StreamingState.WaitingForConfirmation
? undefined
: uiState.thought
}
currentLoadingPhrase={
settings.merged.ui.loadingPhrases === 'off'
? undefined
: uiState.currentLoadingPhrase
}
thoughtLabel={
inlineThinkingMode === 'full' ? 'Thinking...' : undefined
}
elapsedTime={uiState.elapsedTime}
/>
)}
</Box>
<Box
marginTop={isNarrow ? 1 : 0}
flexDirection="column"
alignItems={isNarrow ? 'flex-start' : 'flex-end'}
minHeight={
showUiDetails && shouldReserveSpaceForShortcutsHint ? 1 : 0
}
>
{showUiDetails && showShortcutsHint && <ShortcutsHint />}
</Box>
</Box>
{showMinimalMetaRow && (
<Box
justifyContent="space-between"
width="100%"
flexDirection={isNarrow ? 'column' : 'row'}
alignItems={isNarrow ? 'flex-start' : 'center'}
>
<Box
marginLeft={1}
marginRight={isNarrow ? 0 : 1}
flexDirection="row"
alignItems={isNarrow ? 'flex-start' : 'center'}
flexGrow={1}
>
{showMinimalInlineLoading && (
<LoadingIndicator
inline
thought={
uiState.streamingState ===
StreamingState.WaitingForConfirmation
? undefined
: uiState.thought
}
currentLoadingPhrase={
settings.merged.ui.loadingPhrases === 'off'
? undefined
: uiState.currentLoadingPhrase
}
thoughtLabel={
inlineThinkingMode === 'full' ? 'Thinking...' : undefined
}
elapsedTime={uiState.elapsedTime}
/>
)}
{showMinimalModeBleedThrough && minimalModeBleedThrough && (
<Text color={minimalModeBleedThrough.color}>
{minimalModeBleedThrough.text}
</Text>
)}
{hasMinimalStatusBleedThrough && (
<Box
marginLeft={
showMinimalInlineLoading || showMinimalModeBleedThrough
? 1
: 0
}
>
<ToastDisplay />
</Box>
)}
</Box>
{(showMinimalContextBleedThrough ||
shouldReserveSpaceForShortcutsHint) && (
<Box
marginTop={isNarrow && showMinimalBleedThroughRow ? 1 : 0}
flexDirection={isNarrow ? 'column' : 'row'}
alignItems={isNarrow ? 'flex-start' : 'flex-end'}
minHeight={1}
>
{showMinimalContextBleedThrough && (
<ContextUsageDisplay
promptTokenCount={uiState.sessionStats.lastPromptTokenCount}
model={uiState.currentModel}
terminalWidth={uiState.terminalWidth}
/>
)}
<Box
marginLeft={
showMinimalContextBleedThrough && !isNarrow ? 1 : 0
}
marginTop={showMinimalContextBleedThrough && isNarrow ? 1 : 0}
>
{showShortcutsHint && <ShortcutsHint />}
</Box>
</Box>
)}
</Box>
)}
{showShortcutsHelp && <ShortcutsHelp />}
{showUiDetails && <HorizontalLine />}
{showUiDetails && (
<Box
justifyContent={
settings.merged.ui.hideContextSummary
? 'flex-start'
: 'space-between'
}
width="100%"
flexDirection={isNarrow ? 'column' : 'row'}
alignItems={isNarrow ? 'flex-start' : 'center'}
>
<Box
marginLeft={1}
marginRight={isNarrow ? 0 : 1}
flexDirection="row"
alignItems="center"
flexGrow={1}
>
{hasToast ? (
<ToastDisplay />
) : (
<Box
flexDirection={isNarrow ? 'column' : 'row'}
alignItems={isNarrow ? 'flex-start' : 'center'}
>
{showApprovalIndicator && (
<ApprovalModeIndicator
approvalMode={showApprovalModeIndicator}
allowPlanMode={uiState.allowPlanMode}
/>
)}
{!showLoadingIndicator && (
<>
{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>
{showShortcutsHelp && <ShortcutsHelp />}
<Box
marginTop={isNarrow ? 1 : 0}
flexDirection="column"
alignItems={isNarrow ? 'flex-start' : 'flex-end'}
>
{!showLoadingIndicator && (
<StatusDisplay hideContextSummary={hideContextSummary} />
)}
</Box>
</Box>
)}
{(showUiDetails || miniMode_ShowToast) && (
<Box minHeight={1} marginLeft={isNarrow ? 0 : 1}>
<ToastDisplay />
</Box>
)}
<Box width="100%" flexDirection="column">
{renderStatusRow()}
</Box>
{showUiDetails && uiState.showErrorDetails && (
@@ -466,12 +588,15 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
streamingState={uiState.streamingState}
suggestionsPosition={suggestionsPosition}
onSuggestionsVisibilityChange={setSuggestionsVisible}
copyModeEnabled={uiState.copyModeEnabled}
/>
)}
{showUiDetails &&
!settings.merged.ui.hideFooter &&
!isScreenReaderEnabled && <Footer />}
!isScreenReaderEnabled && (
<Footer copyModeEnabled={uiState.copyModeEnabled} />
)}
</Box>
);
};
@@ -16,7 +16,7 @@ import { GeminiSpinner } from './GeminiSpinner.js';
import { theme } from '../semantic-colors.js';
export const ConfigInitDisplay = ({
message: initialMessage = 'Initializing...',
message: initialMessage = 'Working...',
}: {
message?: string;
}) => {
@@ -45,14 +45,14 @@ export const ConfigInitDisplay = ({
const suffix = remaining > 0 ? `, +${remaining} more` : '';
const mcpMessage = `Connecting to MCP servers... (${connected}/${clients.size}) - Waiting for: ${displayedServers}${suffix}`;
setMessage(
initialMessage && initialMessage !== 'Initializing...'
initialMessage && initialMessage !== 'Working...'
? `${initialMessage} (${mcpMessage})`
: mcpMessage,
);
} else {
const mcpMessage = `Connecting to MCP servers... (${connected}/${clients.size})`;
setMessage(
initialMessage && initialMessage !== 'Initializing...'
initialMessage && initialMessage !== 'Working...'
? `${initialMessage} (${mcpMessage})`
: mcpMessage,
);
@@ -9,6 +9,7 @@ import { type ReactNode } from 'react';
import { theme } from '../semantic-colors.js';
import { MarkdownDisplay } from '../utils/MarkdownDisplay.js';
import { RadioButtonSelect } from './shared/RadioButtonSelect.js';
import { DialogFooter } from './shared/DialogFooter.js';
type ConsentPromptProps = {
// If a simple string is given, it will render using markdown by default.
@@ -37,7 +38,7 @@ export const ConsentPrompt = (props: ConsentPromptProps) => {
) : (
prompt
)}
<Box marginTop={1}>
<Box marginTop={1} flexDirection="column">
<RadioButtonSelect
items={[
{ label: 'Yes', value: true, key: 'Yes' },
@@ -45,6 +46,10 @@ export const ConsentPrompt = (props: ConsentPromptProps) => {
]}
onSelect={onConfirm}
/>
<DialogFooter
primaryAction="Enter to select"
navigationActions="↑/↓ to navigate"
/>
</Box>
</Box>
);
@@ -77,32 +77,6 @@ describe('<ContextSummaryDisplay />', () => {
unmount();
});
it('should switch layout at the 80-column breakpoint', async () => {
const props = {
...baseProps,
geminiMdFileCount: 1,
contextFileNames: ['GEMINI.md'],
mcpServers: { 'test-server': { command: 'test' } },
ideContext: {
workspaceState: {
openFiles: [{ path: '/a/b/c', timestamp: Date.now() }],
},
},
};
// At 80 columns, should be on one line
const { lastFrame: wideFrame, unmount: unmountWide } =
await renderWithWidth(80, props);
expect(wideFrame().trim().includes('\n')).toBe(false);
unmountWide();
// At 79 columns, should be on multiple lines
const { lastFrame: narrowFrame, unmount: unmountNarrow } =
await renderWithWidth(79, props);
expect(narrowFrame().trim().includes('\n')).toBe(true);
expect(narrowFrame().trim().split('\n').length).toBe(4);
unmountNarrow();
});
it('should not render empty parts', async () => {
const props = {
...baseProps,
@@ -8,8 +8,6 @@ import type React from 'react';
import { Box, Text } from 'ink';
import { theme } from '../semantic-colors.js';
import { type IdeContext, type MCPServerConfig } from '@google/gemini-cli-core';
import { useTerminalSize } from '../hooks/useTerminalSize.js';
import { isNarrowWidth } from '../utils/isNarrowWidth.js';
interface ContextSummaryDisplayProps {
geminiMdFileCount: number;
@@ -30,8 +28,6 @@ export const ContextSummaryDisplay: React.FC<ContextSummaryDisplayProps> = ({
skillCount,
backgroundProcessCount = 0,
}) => {
const { columns: terminalWidth } = useTerminalSize();
const isNarrow = isNarrowWidth(terminalWidth);
const mcpServerCount = Object.keys(mcpServers || {}).length;
const blockedMcpServerCount = blockedMcpServers?.length || 0;
const openFileCount = ideContext?.workspaceState?.openFiles?.length ?? 0;
@@ -44,7 +40,7 @@ export const ContextSummaryDisplay: React.FC<ContextSummaryDisplayProps> = ({
skillCount === 0 &&
backgroundProcessCount === 0
) {
return <Text> </Text>; // Render an empty space to reserve height
return null;
}
const openFilesText = (() => {
@@ -113,21 +109,14 @@ export const ContextSummaryDisplay: React.FC<ContextSummaryDisplayProps> = ({
backgroundText,
].filter(Boolean);
if (isNarrow) {
return (
<Box flexDirection="column" paddingX={1}>
{summaryParts.map((part, index) => (
<Text key={index} color={theme.text.secondary}>
- {part}
</Text>
))}
</Box>
);
}
return (
<Box paddingX={1}>
<Text color={theme.text.secondary}>{summaryParts.join(' | ')}</Text>
<Box paddingX={1} flexDirection="row" flexWrap="wrap">
{summaryParts.map((part, index) => (
<Box key={index} flexDirection="row">
{index > 0 && <Text color={theme.text.secondary}>{' · '}</Text>}
<Text color={theme.text.secondary}>{part}</Text>
</Box>
))}
</Box>
);
};
@@ -12,16 +12,14 @@ import { theme } from '../semantic-colors.js';
export const CopyModeWarning: React.FC = () => {
const { copyModeEnabled } = useUIState();
if (!copyModeEnabled) {
return null;
}
return (
<Box>
<Text color={theme.status.warning}>
In Copy Mode. Use Page Up/Down to scroll. Press Ctrl+S or any other key
to exit.
</Text>
<Box height={1}>
{copyModeEnabled && (
<Text color={theme.status.warning}>
In Copy Mode. Use Page Up/Down to scroll. Press Ctrl+S or any other
key to exit.
</Text>
)}
</Box>
);
};
@@ -80,7 +80,6 @@ function usePlanContent(planPath: string, config: Config): PlanContentState {
const pathError = await validatePlanPath(
planPath,
config.storage.getPlansDir(),
config.getTargetDir(),
);
if (ignore) return;
if (pathError) {
+18 -2
View File
@@ -175,12 +175,18 @@ interface FooterColumn {
isHighPriority: boolean;
}
export const Footer: React.FC = () => {
export const Footer: React.FC<{ copyModeEnabled?: boolean }> = ({
copyModeEnabled = false,
}) => {
const uiState = useUIState();
const config = useConfig();
const settings = useSettings();
const { vimEnabled, vimMode } = useVimMode();
if (copyModeEnabled) {
return <Box height={1} />;
}
const {
model,
targetDir,
@@ -353,7 +359,17 @@ export const Footer: React.FC = () => {
break;
}
case 'memory-usage': {
addCol(id, header, () => <MemoryUsageDisplay color={itemColor} />, 10);
addCol(
id,
header,
() => (
<MemoryUsageDisplay
color={itemColor}
isActive={!uiState.copyModeEnabled}
/>
),
10,
);
break;
}
case 'session-id': {
@@ -23,14 +23,28 @@ interface GeminiRespondingSpinnerProps {
*/
nonRespondingDisplay?: string;
spinnerType?: SpinnerName;
/**
* If true, we prioritize showing the nonRespondingDisplay (hook icon)
* even if the state is Responding.
*/
isHookActive?: boolean;
color?: string;
}
export const GeminiRespondingSpinner: React.FC<
GeminiRespondingSpinnerProps
> = ({ nonRespondingDisplay, spinnerType = 'dots' }) => {
> = ({
nonRespondingDisplay,
spinnerType = 'dots',
isHookActive = false,
color,
}) => {
const streamingState = useStreamingContext();
const isScreenReaderEnabled = useIsScreenReaderEnabled();
if (streamingState === StreamingState.Responding) {
// If a hook is active, we want to show the hook icon (nonRespondingDisplay)
// to be consistent, instead of the rainbow spinner which means "Gemini is talking".
if (streamingState === StreamingState.Responding && !isHookActive) {
return (
<GeminiSpinner
spinnerType={spinnerType}
@@ -43,7 +57,7 @@ export const GeminiRespondingSpinner: React.FC<
return isScreenReaderEnabled ? (
<Text>{SCREEN_READER_LOADING}</Text>
) : (
<Text color={theme.text.primary}>{nonRespondingDisplay}</Text>
<Text color={color ?? theme.text.primary}>{nonRespondingDisplay}</Text>
);
}
@@ -10,7 +10,7 @@ import * as SessionContext from '../contexts/SessionContext.js';
import { type SessionStatsState } from '../contexts/SessionContext.js';
import { Banner } from './Banner.js';
import { Footer } from './Footer.js';
import { Header } from './Header.js';
import { AppHeader } from './AppHeader.js';
import { ModelDialog } from './ModelDialog.js';
import { StatsDisplay } from './StatsDisplay.js';
@@ -71,9 +71,9 @@ useSessionStatsMock.mockReturnValue({
});
describe('Gradient Crash Regression Tests', () => {
it('<Header /> should not crash when theme.ui.gradient is empty', async () => {
it('<AppHeader /> should not crash when theme.ui.gradient is empty', async () => {
const { lastFrame, unmount } = await renderWithProviders(
<Header version="1.0.0" nightly={false} />,
<AppHeader version="1.0.0" />,
{
width: 120,
},
@@ -18,9 +18,10 @@ describe('<HookStatusDisplay />', () => {
const props = {
activeHooks: [{ name: 'test-hook', eventName: 'BeforeAgent' }],
};
const { lastFrame, unmount } = await render(
const { lastFrame, unmount, waitUntilReady } = await render(
<HookStatusDisplay {...props} />,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
unmount();
});
@@ -32,9 +33,10 @@ describe('<HookStatusDisplay />', () => {
{ name: 'h2', eventName: 'BeforeAgent' },
],
};
const { lastFrame, unmount } = await render(
const { lastFrame, unmount, waitUntilReady } = await render(
<HookStatusDisplay {...props} />,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
unmount();
});
@@ -45,19 +47,47 @@ describe('<HookStatusDisplay />', () => {
{ name: 'step', eventName: 'BeforeAgent', index: 1, total: 3 },
],
};
const { lastFrame, unmount } = await render(
const { lastFrame, unmount, waitUntilReady } = await render(
<HookStatusDisplay {...props} />,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('should return empty string if no active hooks', async () => {
const props = { activeHooks: [] };
const { lastFrame, unmount } = await render(
const { lastFrame, unmount, waitUntilReady } = await render(
<HookStatusDisplay {...props} />,
);
await waitUntilReady();
expect(lastFrame({ allowEmpty: true })).toBe('');
unmount();
});
it('should show generic message when only system hooks are active', async () => {
const props = {
activeHooks: [
{ name: 'sys-hook', eventName: 'BeforeAgent', source: 'system' },
],
};
const { lastFrame, unmount, waitUntilReady } = await render(
<HookStatusDisplay {...props} />,
);
await waitUntilReady();
expect(lastFrame()).toContain('Working...');
unmount();
});
it('matches SVG snapshot for single hook', async () => {
const props = {
activeHooks: [
{ name: 'test-hook', eventName: 'BeforeAgent', source: 'user' },
],
};
const result = await render(<HookStatusDisplay {...props} />);
await result.waitUntilReady();
await expect(result).toMatchSvgSnapshot();
result.unmount();
});
});
@@ -6,8 +6,10 @@
import type React from 'react';
import { Text } from 'ink';
import { theme } from '../semantic-colors.js';
import { type ActiveHook } from '../types.js';
import { isUserVisibleHook } from '@google/gemini-cli-core';
import { GENERIC_WORKING_LABEL } from '../textConstants.js';
import { theme } from '../semantic-colors.js';
interface HookStatusDisplayProps {
activeHooks: ActiveHook[];
@@ -20,20 +22,30 @@ export const HookStatusDisplay: React.FC<HookStatusDisplayProps> = ({
return null;
}
const label = activeHooks.length > 1 ? 'Executing Hooks' : 'Executing Hook';
const displayNames = activeHooks.map((hook) => {
let name = hook.name;
if (hook.index && hook.total && hook.total > 1) {
name += ` (${hook.index}/${hook.total})`;
}
return name;
});
const userHooks = activeHooks.filter((h) => isUserVisibleHook(h.source));
const text = `${label}: ${displayNames.join(', ')}`;
if (userHooks.length > 0) {
const label = userHooks.length > 1 ? 'Executing Hooks' : 'Executing Hook';
const displayNames = userHooks.map((hook) => {
let name = hook.name;
if (hook.index && hook.total && hook.total > 1) {
name += ` (${hook.index}/${hook.total})`;
}
return name;
});
const text = `${label}: ${displayNames.join(', ')}`;
return (
<Text color={theme.text.secondary} italic={true}>
{text}
</Text>
);
}
// If only system/extension hooks are running, show a generic message.
return (
<Text color={theme.status.warning} wrap="truncate">
{text}
<Text color={theme.text.secondary} italic={true}>
{GENERIC_WORKING_LABEL}
</Text>
);
};
@@ -119,6 +119,7 @@ export interface InputPromptProps {
popAllMessages?: () => string | undefined;
suggestionsPosition?: 'above' | 'below';
setBannerVisible: (visible: boolean) => void;
copyModeEnabled?: boolean;
}
// The input content, input container, and input suggestions list may have different widths
@@ -212,6 +213,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
popAllMessages,
suggestionsPosition = 'below',
setBannerVisible,
copyModeEnabled = false,
}) => {
const isHelpDismissKey = useIsHelpDismissKey();
const keyMatchers = useKeyMatchers();
@@ -331,7 +333,8 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
isShellSuggestionsVisible,
} = completion;
const showCursor = focus && isShellFocused && !isEmbeddedShellFocused;
const showCursor =
focus && isShellFocused && !isEmbeddedShellFocused && !copyModeEnabled;
// Notify parent component about escape prompt state changes
useEffect(() => {
@@ -10,7 +10,7 @@ import { Text } from 'ink';
import { LoadingIndicator } from './LoadingIndicator.js';
import { StreamingContext } from '../contexts/StreamingContext.js';
import { StreamingState } from '../types.js';
import { vi } from 'vitest';
import { describe, it, expect, vi } from 'vitest';
import * as useTerminalSize from '../hooks/useTerminalSize.js';
// Mock GeminiRespondingSpinner
@@ -50,26 +50,28 @@ const renderWithContext = async (
describe('<LoadingIndicator />', () => {
const defaultProps = {
currentLoadingPhrase: 'Loading...',
currentLoadingPhrase: 'Thinking...',
elapsedTime: 5,
};
it('should render blank when streamingState is Idle and no loading phrase or thought', async () => {
const { lastFrame } = await renderWithContext(
const { lastFrame, waitUntilReady } = await renderWithContext(
<LoadingIndicator elapsedTime={5} />,
StreamingState.Idle,
);
await waitUntilReady();
expect(lastFrame({ allowEmpty: true })?.trim()).toBe('');
});
it('should render spinner, phrase, and time when streamingState is Responding', async () => {
const { lastFrame } = await renderWithContext(
const { lastFrame, waitUntilReady } = await renderWithContext(
<LoadingIndicator {...defaultProps} />,
StreamingState.Responding,
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('MockRespondingSpinner');
expect(output).toContain('Loading...');
expect(output).toContain('Thinking...');
expect(output).toContain('(esc to cancel, 5s)');
});
@@ -78,10 +80,11 @@ describe('<LoadingIndicator />', () => {
currentLoadingPhrase: 'Confirm action',
elapsedTime: 10,
};
const { lastFrame } = await renderWithContext(
const { lastFrame, waitUntilReady } = await renderWithContext(
<LoadingIndicator {...props} />,
StreamingState.WaitingForConfirmation,
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('⠏'); // Static char for WaitingForConfirmation
expect(output).toContain('Confirm action');
@@ -94,46 +97,50 @@ describe('<LoadingIndicator />', () => {
currentLoadingPhrase: 'Processing data...',
elapsedTime: 3,
};
const { lastFrame, unmount } = await renderWithContext(
const { lastFrame, unmount, waitUntilReady } = await renderWithContext(
<LoadingIndicator {...props} />,
StreamingState.Responding,
);
await waitUntilReady();
expect(lastFrame()).toContain('Processing data...');
unmount();
});
it('should display the elapsedTime correctly when Responding', async () => {
const props = {
currentLoadingPhrase: 'Working...',
currentLoadingPhrase: 'Thinking...',
elapsedTime: 60,
};
const { lastFrame, unmount } = await renderWithContext(
const { lastFrame, unmount, waitUntilReady } = await renderWithContext(
<LoadingIndicator {...props} />,
StreamingState.Responding,
);
await waitUntilReady();
expect(lastFrame()).toContain('(esc to cancel, 1m)');
unmount();
});
it('should display the elapsedTime correctly in human-readable format', async () => {
const props = {
currentLoadingPhrase: 'Working...',
currentLoadingPhrase: 'Thinking...',
elapsedTime: 125,
};
const { lastFrame, unmount } = await renderWithContext(
const { lastFrame, unmount, waitUntilReady } = await renderWithContext(
<LoadingIndicator {...props} />,
StreamingState.Responding,
);
await waitUntilReady();
expect(lastFrame()).toContain('(esc to cancel, 2m 5s)');
unmount();
});
it('should render rightContent when provided', async () => {
const rightContent = <Text>Extra Info</Text>;
const { lastFrame, unmount } = await renderWithContext(
const { lastFrame, unmount, waitUntilReady } = await renderWithContext(
<LoadingIndicator {...defaultProps} rightContent={rightContent} />,
StreamingState.Responding,
);
await waitUntilReady();
expect(lastFrame()).toContain('Extra Info');
unmount();
});
@@ -174,6 +181,7 @@ describe('<LoadingIndicator />', () => {
const { lastFrame, unmount, waitUntilReady } = await renderWithProviders(
<TestWrapper />,
);
await waitUntilReady();
expect(lastFrame({ allowEmpty: true })?.trim()).toBe(''); // Initial: Idle (no loading phrase)
// Transition to Responding
@@ -221,15 +229,16 @@ describe('<LoadingIndicator />', () => {
it('should display fallback phrase if thought is empty', async () => {
const props = {
thought: null,
currentLoadingPhrase: 'Loading...',
currentLoadingPhrase: 'Thinking...',
elapsedTime: 5,
};
const { lastFrame, unmount } = await renderWithContext(
const { lastFrame, unmount, waitUntilReady } = await renderWithContext(
<LoadingIndicator {...props} />,
StreamingState.Responding,
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('Loading...');
expect(output).toContain('Thinking...');
unmount();
});
@@ -241,10 +250,11 @@ describe('<LoadingIndicator />', () => {
},
elapsedTime: 5,
};
const { lastFrame, unmount } = await renderWithContext(
const { lastFrame, unmount, waitUntilReady } = await renderWithContext(
<LoadingIndicator {...props} />,
StreamingState.Responding,
);
await waitUntilReady();
const output = lastFrame();
expect(output).toBeDefined();
if (output) {
@@ -256,7 +266,7 @@ describe('<LoadingIndicator />', () => {
unmount();
});
it('should prepend "Thinking... " if the subject does not start with "Thinking"', async () => {
it('should NOT prepend "Thinking... " even if the subject does not start with "Thinking"', async () => {
const props = {
thought: {
subject: 'Planning the response...',
@@ -264,12 +274,14 @@ describe('<LoadingIndicator />', () => {
},
elapsedTime: 5,
};
const { lastFrame, unmount } = await renderWithContext(
const { lastFrame, unmount, waitUntilReady } = await renderWithContext(
<LoadingIndicator {...props} />,
StreamingState.Responding,
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('Thinking... Planning the response...');
expect(output).toContain('Planning the response...');
expect(output).not.toContain('Thinking... ');
unmount();
});
@@ -282,31 +294,32 @@ describe('<LoadingIndicator />', () => {
currentLoadingPhrase: 'This should not be displayed',
elapsedTime: 5,
};
const { lastFrame, unmount } = await renderWithContext(
const { lastFrame, unmount, waitUntilReady } = await renderWithContext(
<LoadingIndicator {...props} />,
StreamingState.Responding,
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('Thinking... ');
expect(output).toContain('This should be displayed');
expect(output).not.toContain('This should not be displayed');
unmount();
});
it('should not display thought indicator for non-thought loading phrases', async () => {
const { lastFrame, unmount } = await renderWithContext(
const { lastFrame, unmount, waitUntilReady } = await renderWithContext(
<LoadingIndicator
currentLoadingPhrase="some random tip..."
elapsedTime={3}
/>,
StreamingState.Responding,
);
await waitUntilReady();
expect(lastFrame()).not.toContain('Thinking... ');
unmount();
});
it('should truncate long primary text instead of wrapping', async () => {
const { lastFrame, unmount } = await renderWithContext(
const { lastFrame, unmount, waitUntilReady } = await renderWithContext(
<LoadingIndicator
{...defaultProps}
currentLoadingPhrase={
@@ -316,14 +329,14 @@ describe('<LoadingIndicator />', () => {
StreamingState.Responding,
80,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
unmount();
});
describe('responsive layout', () => {
it('should render on a single line on a wide terminal', async () => {
const { lastFrame, unmount } = await renderWithContext(
const { lastFrame, unmount, waitUntilReady } = await renderWithContext(
<LoadingIndicator
{...defaultProps}
rightContent={<Text>Right</Text>}
@@ -331,17 +344,18 @@ describe('<LoadingIndicator />', () => {
StreamingState.Responding,
120,
);
await waitUntilReady();
const output = lastFrame();
// Check for single line output
expect(output?.trim().includes('\n')).toBe(false);
expect(output).toContain('Loading...');
expect(output).toContain('Thinking...');
expect(output).toContain('(esc to cancel, 5s)');
expect(output).toContain('Right');
unmount();
});
it('should render on multiple lines on a narrow terminal', async () => {
const { lastFrame, unmount } = await renderWithContext(
const { lastFrame, unmount, waitUntilReady } = await renderWithContext(
<LoadingIndicator
{...defaultProps}
rightContent={<Text>Right</Text>}
@@ -349,6 +363,7 @@ describe('<LoadingIndicator />', () => {
StreamingState.Responding,
79,
);
await waitUntilReady();
const output = lastFrame();
const lines = output?.trim().split('\n');
// Expecting 3 lines:
@@ -357,7 +372,7 @@ describe('<LoadingIndicator />', () => {
// 3. Right Content
expect(lines).toHaveLength(3);
if (lines) {
expect(lines[0]).toContain('Loading...');
expect(lines[0]).toContain('Thinking...');
expect(lines[0]).not.toContain('(esc to cancel, 5s)');
expect(lines[1]).toContain('(esc to cancel, 5s)');
expect(lines[2]).toContain('Right');
@@ -366,23 +381,87 @@ describe('<LoadingIndicator />', () => {
});
it('should use wide layout at 80 columns', async () => {
const { lastFrame, unmount } = await renderWithContext(
const { lastFrame, unmount, waitUntilReady } = await renderWithContext(
<LoadingIndicator {...defaultProps} />,
StreamingState.Responding,
80,
);
await waitUntilReady();
expect(lastFrame()?.trim().includes('\n')).toBe(false);
unmount();
});
it('should use narrow layout at 79 columns', async () => {
const { lastFrame, unmount } = await renderWithContext(
const { lastFrame, unmount, waitUntilReady } = await renderWithContext(
<LoadingIndicator {...defaultProps} />,
StreamingState.Responding,
79,
);
await waitUntilReady();
expect(lastFrame()?.includes('\n')).toBe(true);
unmount();
});
it('should render witty phrase after cancel and timer hint in wide layout', async () => {
const { lastFrame, unmount, waitUntilReady } = await renderWithContext(
<LoadingIndicator
elapsedTime={5}
wittyPhrase="I am witty"
showWit={true}
currentLoadingPhrase="Thinking..."
/>,
StreamingState.Responding,
120,
);
await waitUntilReady();
const output = lastFrame();
// Sequence should be: Primary Text -> Cancel/Timer -> Witty Phrase
expect(output).toContain('Thinking... (esc to cancel, 5s) I am witty');
unmount();
});
it('should render witty phrase after cancel and timer hint in narrow layout', async () => {
const { lastFrame, unmount, waitUntilReady } = await renderWithContext(
<LoadingIndicator
elapsedTime={5}
wittyPhrase="I am witty"
showWit={true}
currentLoadingPhrase="Thinking..."
/>,
StreamingState.Responding,
79,
);
await waitUntilReady();
const output = lastFrame();
const lines = output?.trim().split('\n');
// Expecting 3 lines:
// 1. Spinner + Primary Text
// 2. Cancel + Timer
// 3. Witty Phrase
expect(lines).toHaveLength(3);
if (lines) {
expect(lines[0]).toContain('Thinking...');
expect(lines[1]).toContain('(esc to cancel, 5s)');
expect(lines[2]).toContain('I am witty');
}
unmount();
});
});
it('should use spinnerIcon when provided', async () => {
const props = {
currentLoadingPhrase: 'Confirm action',
elapsedTime: 10,
spinnerIcon: '?',
};
const { lastFrame, waitUntilReady, unmount } = await renderWithContext(
<LoadingIndicator {...props} />,
StreamingState.WaitingForConfirmation,
);
await waitUntilReady();
const output = lastFrame();
expect(output).toContain('?');
expect(output).not.toContain('⠏');
unmount();
});
});
@@ -18,22 +18,34 @@ import { INTERACTIVE_SHELL_WAITING_PHRASE } from '../hooks/usePhraseCycler.js';
interface LoadingIndicatorProps {
currentLoadingPhrase?: string;
wittyPhrase?: string;
showWit?: boolean;
showTips?: boolean;
errorVerbosity?: 'low' | 'full';
elapsedTime: number;
inline?: boolean;
rightContent?: React.ReactNode;
thought?: ThoughtSummary | null;
thoughtLabel?: string;
showCancelAndTimer?: boolean;
forceRealStatusOnly?: boolean;
spinnerIcon?: string;
isHookActive?: boolean;
}
export const LoadingIndicator: React.FC<LoadingIndicatorProps> = ({
currentLoadingPhrase,
wittyPhrase,
showWit = false,
elapsedTime,
inline = false,
rightContent,
thought,
thoughtLabel,
showCancelAndTimer = true,
forceRealStatusOnly = false,
spinnerIcon,
isHookActive = false,
}) => {
const streamingState = useStreamingContext();
const { columns: terminalWidth } = useTerminalSize();
@@ -54,15 +66,10 @@ export const LoadingIndicator: React.FC<LoadingIndicatorProps> = ({
? currentLoadingPhrase
: thought?.subject
? (thoughtLabel ?? thought.subject)
: currentLoadingPhrase;
const hasThoughtIndicator =
currentLoadingPhrase !== INTERACTIVE_SHELL_WAITING_PHRASE &&
Boolean(thought?.subject?.trim());
// Avoid "Thinking... Thinking..." duplication if primaryText already starts with "Thinking"
const thinkingIndicator =
hasThoughtIndicator && !primaryText?.startsWith('Thinking')
? 'Thinking... '
: '';
: currentLoadingPhrase ||
(streamingState === StreamingState.Responding
? 'Thinking...'
: undefined);
const cancelAndTimerContent =
showCancelAndTimer &&
@@ -70,22 +77,35 @@ export const LoadingIndicator: React.FC<LoadingIndicatorProps> = ({
? `(esc to cancel, ${elapsedTime < 60 ? `${elapsedTime}s` : formatDuration(elapsedTime * 1000)})`
: null;
const wittyPhraseNode =
!forceRealStatusOnly &&
showWit &&
wittyPhrase &&
primaryText === 'Thinking...' ? (
<Box marginLeft={1}>
<Text color={theme.text.secondary} dimColor italic>
{wittyPhrase}
</Text>
</Box>
) : null;
if (inline) {
return (
<Box>
<Box marginRight={1}>
<GeminiRespondingSpinner
nonRespondingDisplay={
streamingState === StreamingState.WaitingForConfirmation
spinnerIcon ??
(streamingState === StreamingState.WaitingForConfirmation
? '⠏'
: ''
: '')
}
isHookActive={isHookActive}
/>
</Box>
{primaryText && (
<Box flexShrink={1}>
<Text color={theme.text.primary} italic wrap="truncate-end">
{thinkingIndicator}
{primaryText}
</Text>
{primaryText === INTERACTIVE_SHELL_WAITING_PHRASE && (
@@ -102,6 +122,7 @@ export const LoadingIndicator: React.FC<LoadingIndicatorProps> = ({
<Text color={theme.text.secondary}>{cancelAndTimerContent}</Text>
</>
)}
{wittyPhraseNode}
</Box>
);
}
@@ -118,16 +139,17 @@ export const LoadingIndicator: React.FC<LoadingIndicatorProps> = ({
<Box marginRight={1}>
<GeminiRespondingSpinner
nonRespondingDisplay={
streamingState === StreamingState.WaitingForConfirmation
spinnerIcon ??
(streamingState === StreamingState.WaitingForConfirmation
? '⠏'
: ''
: '')
}
isHookActive={isHookActive}
/>
</Box>
{primaryText && (
<Box flexShrink={1}>
<Text color={theme.text.primary} italic wrap="truncate-end">
{thinkingIndicator}
{primaryText}
</Text>
{primaryText === INTERACTIVE_SHELL_WAITING_PHRASE && (
@@ -144,6 +166,7 @@ export const LoadingIndicator: React.FC<LoadingIndicatorProps> = ({
<Text color={theme.text.secondary}>{cancelAndTimerContent}</Text>
</>
)}
{!isNarrow && wittyPhraseNode}
</Box>
{!isNarrow && <Box flexGrow={1}>{/* Spacer */}</Box>}
{!isNarrow && rightContent && <Box>{rightContent}</Box>}
@@ -153,6 +176,7 @@ export const LoadingIndicator: React.FC<LoadingIndicatorProps> = ({
<Text color={theme.text.secondary}>{cancelAndTimerContent}</Text>
</Box>
)}
{isNarrow && wittyPhraseNode}
{isNarrow && rightContent && <Box>{rightContent}</Box>}
</Box>
);
@@ -97,7 +97,7 @@ describe('getToolGroupBorderAppearance', () => {
});
it('inspects only the last pending tool_group item if current has no tools', () => {
const item = { type: 'tool_group' as const, tools: [], id: 1 };
const item = { type: 'tool_group' as const, tools: [], id: -1 };
const pendingItems = [
{
type: 'tool_group' as const,
@@ -158,7 +158,7 @@ describe('getToolGroupBorderAppearance', () => {
confirmationDetails: undefined,
} as IndividualToolCallDisplay,
],
id: 1,
id: -1,
};
const result = getToolGroupBorderAppearance(
item,
@@ -187,7 +187,7 @@ describe('getToolGroupBorderAppearance', () => {
confirmationDetails: undefined,
} as IndividualToolCallDisplay,
],
id: 1,
id: -1,
};
const result = getToolGroupBorderAppearance(
item,
@@ -276,7 +276,7 @@ describe('getToolGroupBorderAppearance', () => {
confirmationDetails: undefined,
} as IndividualToolCallDisplay,
],
id: 1,
id: -1,
};
const result = getToolGroupBorderAppearance(
item,
@@ -292,7 +292,7 @@ describe('getToolGroupBorderAppearance', () => {
});
it('handles empty tools with active shell turn (isCurrentlyInShellTurn)', () => {
const item = { type: 'tool_group' as const, tools: [], id: 1 };
const item = { type: 'tool_group' as const, tools: [], id: -1 };
// active shell turn
const result = getToolGroupBorderAppearance(
@@ -667,7 +667,7 @@ describe('MainContent', () => {
pendingHistoryItems: [
{
type: 'tool_group',
id: 1,
id: -1,
tools: [
{
callId: 'call_1',
@@ -127,7 +127,7 @@ export const MainContent = () => {
const pendingItems = useMemo(
() => (
<Box flexDirection="column">
<Box flexDirection="column" key="pending-items-group">
{pendingHistoryItems.map((item, i) => {
const prevType =
i === 0
@@ -140,12 +140,12 @@ export const MainContent = () => {
return (
<HistoryItemDisplay
key={i}
key={`pending-${i}`}
availableTerminalHeight={
uiState.constrainHeight ? availableTerminalHeight : undefined
}
terminalWidth={mainAreaWidth}
item={{ ...item, id: 0 }}
item={{ ...item, id: -(i + 1) }}
isPending={true}
isExpandable={true}
isFirstThinking={isFirstThinking}
@@ -154,7 +154,10 @@ export const MainContent = () => {
);
})}
{showConfirmationQueue && confirmingTool && (
<ToolConfirmationQueue confirmingTool={confirmingTool} />
<ToolConfirmationQueue
key="confirmation-queue"
confirmingTool={confirmingTool}
/>
)}
</Box>
),
@@ -11,13 +11,18 @@ import { theme } from '../semantic-colors.js';
import process from 'node:process';
import { formatBytes } from '../utils/formatters.js';
export const MemoryUsageDisplay: React.FC<{ color?: string }> = ({
color = theme.text.primary,
}) => {
export const MemoryUsageDisplay: React.FC<{
color?: string;
isActive?: boolean;
}> = ({ color = theme.text.primary, isActive = true }) => {
const [memoryUsage, setMemoryUsage] = useState<string>('');
const [memoryUsageColor, setMemoryUsageColor] = useState<string>(color);
useEffect(() => {
if (!isActive) {
return;
}
const updateMemory = () => {
const usage = process.memoryUsage().rss;
setMemoryUsage(formatBytes(usage));
@@ -25,10 +30,11 @@ export const MemoryUsageDisplay: React.FC<{ color?: string }> = ({
usage >= 2 * 1024 * 1024 * 1024 ? theme.status.error : color,
);
};
const intervalId = setInterval(updateMemory, 2000);
updateMemory(); // Initial update
return () => clearInterval(intervalId);
}, [color]);
}, [color, isActive]);
return (
<Box>
@@ -1,24 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { Text } from 'ink';
import { theme } from '../semantic-colors.js';
import { useUIState } from '../contexts/UIStateContext.js';
export const ShortcutsHint: React.FC = () => {
const { cleanUiDetailsVisible, shortcutsHelpVisible } = useUIState();
if (!cleanUiDetailsVisible) {
return <Text color={theme.text.secondary}> press tab twice for more </Text>;
}
const highlightColor = shortcutsHelpVisible
? theme.text.accent
: theme.text.secondary;
return <Text color={highlightColor}> ? for shortcuts </Text>;
};
@@ -11,9 +11,8 @@ import { useUIState } from '../contexts/UIStateContext.js';
import { useSettings } from '../contexts/SettingsContext.js';
import { useConfig } from '../contexts/ConfigContext.js';
import { ContextSummaryDisplay } from './ContextSummaryDisplay.js';
import { HookStatusDisplay } from './HookStatusDisplay.js';
interface StatusDisplayProps {
export interface StatusDisplayProps {
hideContextSummary: boolean;
}
@@ -28,13 +27,6 @@ export const StatusDisplay: React.FC<StatusDisplayProps> = ({
return <Text color={theme.status.error}>|_|</Text>;
}
if (
uiState.activeHooks.length > 0 &&
settings.merged.hooksConfig.notifications
) {
return <HookStatusDisplay activeHooks={uiState.activeHooks} />;
}
if (!settings.merged.ui.hideContextSummary && !hideContextSummary) {
return (
<ContextSummaryDisplay
@@ -77,7 +77,7 @@ export const ToastDisplay: React.FC = () => {
if (uiState.showIsExpandableHint) {
const action = uiState.constrainHeight ? 'show more' : 'collapse';
return (
<Text color={theme.text.accent}>
<Text color={theme.text.secondary}>
Press Ctrl+O to {action} lines of the last response
</Text>
);
@@ -6,13 +6,16 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { act } from 'react';
import { Box } from 'ink';
import { ToolConfirmationQueue } from './ToolConfirmationQueue.js';
import { StreamingState } from '../types.js';
import { renderWithProviders } from '../../test-utils/render.js';
import { createMockSettings } from '../../test-utils/settings.js';
import { waitFor } from '../../test-utils/async.js';
import { type Config, CoreToolCallStatus } from '@google/gemini-cli-core';
import {
type Config,
CoreToolCallStatus,
type SerializableConfirmationDetails,
} from '@google/gemini-cli-core';
import type { ConfirmingToolState } from '../hooks/useConfirmingTool.js';
import { theme } from '../semantic-colors.js';
@@ -44,6 +47,7 @@ describe('ToolConfirmationQueue', () => {
const mockConfig = {
isTrustedFolder: () => true,
getIdeMode: () => false,
getApprovalMode: () => 'default',
getDisableAlwaysAllow: () => false,
getModel: () => 'gemini-pro',
getDebugMode: () => false,
@@ -133,59 +137,6 @@ describe('ToolConfirmationQueue', () => {
unmount();
});
it('renders expansion hint when content is long and constrained', async () => {
const longDiff = '@@ -1,1 +1,50 @@\n' + '+line\n'.repeat(50);
const confirmingTool = {
tool: {
callId: 'call-1',
name: 'replace',
description: 'edit file',
status: CoreToolCallStatus.AwaitingApproval,
confirmationDetails: {
type: 'edit' as const,
title: 'Confirm edit',
fileName: 'test.ts',
filePath: '/test.ts',
fileDiff: longDiff,
originalContent: 'old',
newContent: 'new',
},
},
index: 1,
total: 1,
};
const { lastFrame, unmount } = await renderWithProviders(
<Box flexDirection="column" height={30}>
<ToolConfirmationQueue
confirmingTool={confirmingTool as unknown as ConfirmingToolState}
/>
</Box>,
{
config: {
// eslint-disable-next-line @typescript-eslint/no-misused-spread
...mockConfig,
getUseAlternateBuffer: () => true,
} as unknown as Config,
settings: createMockSettings({ ui: { useAlternateBuffer: true } }),
uiState: {
terminalWidth: 80,
terminalHeight: 20,
constrainHeight: true,
streamingState: StreamingState.WaitingForConfirmation,
},
},
);
await waitFor(() =>
expect(lastFrame()?.toLowerCase()).toContain(
'press ctrl+o to show more lines',
),
);
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('calculates availableContentHeight based on availableTerminalHeight from UI state', async () => {
const longDiff = '@@ -1,1 +1,50 @@\n' + '+line\n'.repeat(50);
const confirmingTool = {
@@ -414,4 +365,155 @@ describe('ToolConfirmationQueue', () => {
expect(stickyHeaderProps.borderColor).toBe(theme.status.success);
unmount();
});
describe('height allocation and layout', () => {
it('should render the full queue wrapper with borders and content for large edit diffs', async () => {
let largeDiff = '--- a/file.ts\n+++ b/file.ts\n@@ -1,10 +1,15 @@\n';
for (let i = 1; i <= 20; i++) {
largeDiff += `-const oldLine${i} = true;\n`;
largeDiff += `+const newLine${i} = true;\n`;
}
const confirmationDetails: SerializableConfirmationDetails = {
type: 'edit',
title: 'Confirm Edit',
fileName: 'file.ts',
filePath: '/file.ts',
fileDiff: largeDiff,
originalContent: 'old',
newContent: 'new',
isModifying: false,
};
const confirmingTool = {
tool: {
callId: 'test-call-id',
name: 'replace',
status: CoreToolCallStatus.AwaitingApproval,
description: 'Replaces content in a file',
confirmationDetails,
},
index: 1,
total: 1,
};
const { waitUntilReady, lastFrame, generateSvg, unmount } =
await renderWithProviders(
<ToolConfirmationQueue
confirmingTool={confirmingTool as unknown as ConfirmingToolState}
/>,
{
uiState: {
mainAreaWidth: 80,
terminalHeight: 50,
terminalWidth: 80,
constrainHeight: true,
availableTerminalHeight: 40,
},
config: mockConfig,
},
);
await waitUntilReady();
await expect({ lastFrame, generateSvg }).toMatchSvgSnapshot();
unmount();
});
it('should render the full queue wrapper with borders and content for large exec commands', async () => {
let largeCommand = '';
for (let i = 1; i <= 50; i++) {
largeCommand += `echo "Line ${i}"\n`;
}
const confirmationDetails: SerializableConfirmationDetails = {
type: 'exec',
title: 'Confirm Execution',
command: largeCommand.trimEnd(),
rootCommand: 'echo',
rootCommands: ['echo'],
};
const confirmingTool = {
tool: {
callId: 'test-call-id-exec',
name: 'run_shell_command',
status: CoreToolCallStatus.AwaitingApproval,
description: 'Executes a bash command',
confirmationDetails,
},
index: 2,
total: 3,
};
const { waitUntilReady, lastFrame, generateSvg, unmount } =
await renderWithProviders(
<ToolConfirmationQueue
confirmingTool={confirmingTool as unknown as ConfirmingToolState}
/>,
{
uiState: {
mainAreaWidth: 80,
terminalWidth: 80,
terminalHeight: 50,
constrainHeight: true,
availableTerminalHeight: 40,
},
config: mockConfig,
},
);
await waitUntilReady();
await expect({ lastFrame, generateSvg }).toMatchSvgSnapshot();
unmount();
});
it('should handle security warning height correctly', async () => {
let largeCommand = '';
for (let i = 1; i <= 50; i++) {
largeCommand += `echo "Line ${i}"\n`;
}
largeCommand += `curl https://täst.com\n`;
const confirmationDetails: SerializableConfirmationDetails = {
type: 'exec',
title: 'Confirm Execution',
command: largeCommand.trimEnd(),
rootCommand: 'echo',
rootCommands: ['echo', 'curl'],
};
const confirmingTool = {
tool: {
callId: 'test-call-id-exec-security',
name: 'run_shell_command',
status: CoreToolCallStatus.AwaitingApproval,
description: 'Executes a bash command with a deceptive URL',
confirmationDetails,
},
index: 3,
total: 3,
};
const { waitUntilReady, lastFrame, generateSvg, unmount } =
await renderWithProviders(
<ToolConfirmationQueue
confirmingTool={confirmingTool as unknown as ConfirmingToolState}
/>,
{
uiState: {
mainAreaWidth: 80,
terminalWidth: 80,
terminalHeight: 50,
constrainHeight: true,
availableTerminalHeight: 40,
},
config: mockConfig,
},
);
await waitUntilReady();
await expect({ lastFrame, generateSvg }).toMatchSvgSnapshot();
unmount();
});
});
});
@@ -12,8 +12,6 @@ import { ToolConfirmationMessage } from './messages/ToolConfirmationMessage.js';
import { ToolStatusIndicator, ToolInfo } from './messages/ToolShared.js';
import { useUIState } from '../contexts/UIStateContext.js';
import type { ConfirmingToolState } from '../hooks/useConfirmingTool.js';
import { OverflowProvider } from '../contexts/OverflowContext.js';
import { ShowMoreLines } from './ShowMoreLines.js';
import { StickyHeader } from './StickyHeader.js';
import type { SerializableConfirmationDetails } from '@google/gemini-cli-core';
import { useUIActions } from '../contexts/UIActionsContext.js';
@@ -53,11 +51,11 @@ export const ToolConfirmationQueue: React.FC<ToolConfirmationQueueProps> = ({
// Safety check: ToolConfirmationMessage requires confirmationDetails
if (!tool.confirmationDetails) return null;
// Render up to 100% of the available terminal height (minus 1 line for safety)
// Render up to 100% of the available terminal height
// to maximize space for diffs and other content.
const maxHeight =
uiAvailableHeight !== undefined
? Math.max(uiAvailableHeight - 1, 4)
? Math.max(uiAvailableHeight, 4)
: Math.floor(terminalHeight * 0.5);
const isRoutine =
@@ -76,84 +74,81 @@ export const ToolConfirmationQueue: React.FC<ToolConfirmationQueueProps> = ({
: undefined;
const content = (
<>
<Box flexDirection="column" width={mainAreaWidth} flexShrink={0}>
<StickyHeader
width={mainAreaWidth}
isFirst={true}
borderColor={borderColor}
borderDimColor={false}
>
<Box flexDirection="column" width={mainAreaWidth - 4}>
{/* Header */}
<Box
marginBottom={hideToolIdentity ? 0 : 1}
justifyContent="space-between"
>
<Text color={borderColor} bold>
{getConfirmationHeader(tool.confirmationDetails)}
<Box flexDirection="column" width={mainAreaWidth} flexShrink={0}>
<StickyHeader
width={mainAreaWidth}
isFirst={true}
borderColor={borderColor}
borderDimColor={false}
>
<Box flexDirection="column" width={mainAreaWidth - 4}>
{/* Header */}
<Box
marginBottom={hideToolIdentity ? 0 : 1}
justifyContent="space-between"
>
<Text color={borderColor} bold>
{getConfirmationHeader(tool.confirmationDetails)}
</Text>
{total > 1 && (
<Text color={theme.text.secondary}>
{index} of {total}
</Text>
{total > 1 && (
<Text color={theme.text.secondary}>
{index} of {total}
</Text>
)}
</Box>
{!hideToolIdentity && (
<Box>
<ToolStatusIndicator status={tool.status} name={tool.name} />
<ToolInfo
name={tool.name}
status={tool.status}
description={tool.description}
emphasis="high"
/>
</Box>
)}
</Box>
</StickyHeader>
<Box
width={mainAreaWidth}
borderStyle="round"
borderColor={borderColor}
borderTop={false}
borderBottom={false}
borderLeft={true}
borderRight={true}
paddingX={1}
flexDirection="column"
>
{/* Interactive Area */}
{/*
Note: We force isFocused={true} because if this component is rendered,
it effectively acts as a modal over the shell/composer.
*/}
<ToolConfirmationMessage
callId={tool.callId}
confirmationDetails={tool.confirmationDetails}
config={config}
getPreferredEditor={getPreferredEditor}
terminalWidth={mainAreaWidth - 4} // Adjust for parent border/padding
availableTerminalHeight={availableContentHeight}
isFocused={true}
/>
{!hideToolIdentity && (
<Box>
<ToolStatusIndicator status={tool.status} name={tool.name} />
<ToolInfo
name={tool.name}
status={tool.status}
description={tool.description}
emphasis="high"
/>
</Box>
)}
</Box>
<Box
height={1}
width={mainAreaWidth}
borderLeft={true}
borderRight={true}
borderTop={false}
borderBottom={true}
borderColor={borderColor}
borderStyle="round"
</StickyHeader>
<Box
width={mainAreaWidth}
borderStyle="round"
borderColor={borderColor}
borderTop={false}
borderBottom={false}
borderLeft={true}
borderRight={true}
paddingX={1}
flexDirection="column"
>
{/* Interactive Area */}
{/*
Note: We force isFocused={true} because if this component is rendered,
it effectively acts as a modal over the shell/composer.
*/}
<ToolConfirmationMessage
callId={tool.callId}
confirmationDetails={tool.confirmationDetails}
config={config}
getPreferredEditor={getPreferredEditor}
terminalWidth={mainAreaWidth - 4} // Adjust for parent border/padding
availableTerminalHeight={availableContentHeight}
isFocused={true}
/>
</Box>
<ShowMoreLines constrainHeight={constrainHeight} />
</>
<Box
height={1}
width={mainAreaWidth}
borderLeft={true}
borderRight={true}
borderTop={false}
borderBottom={true}
borderColor={borderColor}
borderStyle="round"
/>
</Box>
);
return <OverflowProvider>{content}</OverflowProvider>;
return content;
};
@@ -2,10 +2,13 @@
exports[`AlternateBufferQuittingDisplay > renders with a tool awaiting confirmation > with_confirming_tool 1`] = `
"
▝▜▄ Gemini CLI v0.10.0
▝▜▄
▗▟▀
▝▀
▝▜▄ ▗█▀▀▜▙▝█▛▀▀▌▜██▖▟██▘▜█▘▜██▖▝█▛▝█▛
▝▜▄ █▌ █▙▟ ▐█▝█▛▐█ ▐█ ▐█▝█▖█▌ █▌
▗▟▀ ▜▙ ▝█▛ █▌▝ ▖▐█ ▐█ ▐█ ▐█ ▝██▌ █▌
▝▀ ▀▀▀▀▘▝▀▀▀▀▘▀▀▘ ▀▀▘▀▀▘▀▀▘ ▝▀▀▝▀▀
Gemini CLI v0.10.0
Tips for getting started:
@@ -22,10 +25,13 @@ Action Required (was prompted):
exports[`AlternateBufferQuittingDisplay > renders with active and pending tool messages > with_history_and_pending 1`] = `
"
▝▜▄ Gemini CLI v0.10.0
▝▜▄
▗▟▀
▝▀
▝▜▄ ▗█▀▀▜▙▝█▛▀▀▌▜██▖▟██▘▜█▘▜██▖▝█▛▝█▛
▝▜▄ █▌ █▙▟ ▐█▝█▛▐█ ▐█ ▐█▝█▖█▌ █▌
▗▟▀ ▜▙ ▝█▛ █▌▝ ▖▐█ ▐█ ▐█ ▐█ ▝██▌ █▌
▝▀ ▀▀▀▀▘▝▀▀▀▀▘▀▀▘ ▀▀▘▀▀▘▀▀▘ ▝▀▀▝▀▀
Gemini CLI v0.10.0
Tips for getting started:
@@ -50,10 +56,13 @@ Tips for getting started:
exports[`AlternateBufferQuittingDisplay > renders with empty history and no pending items > empty 1`] = `
"
▝▜▄ Gemini CLI v0.10.0
▝▜▄
▗▟▀
▝▀
▝▜▄ ▗█▀▀▜▙▝█▛▀▀▌▜██▖▟██▘▜█▘▜██▖▝█▛▝█▛
▝▜▄ █▌ █▙▟ ▐█▝█▛▐█ ▐█ ▐█▝█▖█▌ █▌
▗▟▀ ▜▙ ▝█▛ █▌▝ ▖▐█ ▐█ ▐█ ▐█ ▝██▌ █▌
▝▀ ▀▀▀▀▘▝▀▀▀▀▘▀▀▘ ▀▀▘▀▀▘▀▀▘ ▝▀▀▝▀▀
Gemini CLI v0.10.0
Tips for getting started:
@@ -66,10 +75,13 @@ Tips for getting started:
exports[`AlternateBufferQuittingDisplay > renders with history but no pending items > with_history_no_pending 1`] = `
"
▝▜▄ Gemini CLI v0.10.0
▝▜▄
▗▟▀
▝▀
▝▜▄ ▗█▀▀▜▙▝█▛▀▀▌▜██▖▟██▘▜█▘▜██▖▝█▛▝█▛
▝▜▄ █▌ █▙▟ ▐█▝█▛▐█ ▐█ ▐█▝█▖█▌ █▌
▗▟▀ ▜▙ ▝█▛ █▌▝ ▖▐█ ▐█ ▐█ ▐█ ▝██▌ █▌
▝▀ ▀▀▀▀▘▝▀▀▀▀▘▀▀▘ ▀▀▘▀▀▘▀▀▘ ▝▀▀▝▀▀
Gemini CLI v0.10.0
Tips for getting started:
@@ -90,10 +102,13 @@ Tips for getting started:
exports[`AlternateBufferQuittingDisplay > renders with pending items but no history > with_pending_no_history 1`] = `
"
▝▜▄ Gemini CLI v0.10.0
▝▜▄
▗▟▀
▝▀
▝▜▄ ▗█▀▀▜▙▝█▛▀▀▌▜██▖▟██▘▜█▘▜██▖▝█▛▝█▛
▝▜▄ █▌ █▙▟ ▐█▝█▛▐█ ▐█ ▐█▝█▖█▌ █▌
▗▟▀ ▜▙ ▝█▛ █▌▝ ▖▐█ ▐█ ▐█ ▐█ ▝██▌ █▌
▝▀ ▀▀▀▀▘▝▀▀▀▀▘▀▀▘ ▀▀▘▀▀▘▀▀▘ ▝▀▀▝▀▀
Gemini CLI v0.10.0
Tips for getting started:
@@ -110,10 +125,13 @@ Tips for getting started:
exports[`AlternateBufferQuittingDisplay > renders with user and gemini messages > with_user_gemini_messages 1`] = `
"
▝▜▄ Gemini CLI v0.10.0
▝▜▄
▗▟▀
▝▀
▝▜▄ ▗█▀▀▜▙▝█▛▀▀▌▜██▖▟██▘▜█▘▜██▖▝█▛▝█▛
▝▜▄ █▌ █▙▟ ▐█▝█▛▐█ ▐█ ▐█▝█▖█▌ █▌
▗▟▀ ▜▙ ▝█▛ █▌▝ ▖▐█ ▐█ ▐█ ▐█ ▝██▌ █▌
▝▀ ▀▀▀▀▘▝▀▀▀▀▘▀▀▘ ▀▀▘▀▀▘▀▀▘ ▝▀▀▝▀▀
Gemini CLI v0.10.0
Tips for getting started:
@@ -2,10 +2,13 @@
exports[`<AppHeader /> > should not render the banner when no flags are set 1`] = `
"
▝▜▄ Gemini CLI v1.0.0
▝▜▄
▗▟▀
▝▀
▝▜▄ ▗█▀▀▜▙▝█▛▀▀▌▜██▖▟██▘▜█▘▜██▖▝█▛▝█▛
▝▜▄ █▌ █▙▟ ▐█▝█▛▐█ ▐█ ▐█▝█▖█▌ █▌
▗▟▀ ▜▙ ▝█▛ █▌▝ ▖▐█ ▐█ ▐█ ▐█ ▝██▌ █▌
▝▀ ▀▀▀▀▘▝▀▀▀▀▘▀▀▘ ▀▀▘▀▀▘▀▀▘ ▝▀▀▝▀▀
Gemini CLI v1.0.0
Tips for getting started:
@@ -18,10 +21,13 @@ Tips for getting started:
exports[`<AppHeader /> > should not render the default banner if shown count is 5 or more 1`] = `
"
▝▜▄ Gemini CLI v1.0.0
▝▜▄
▗▟▀
▝▀
▝▜▄ ▗█▀▀▜▙▝█▛▀▀▌▜██▖▟██▘▜█▘▜██▖▝█▛▝█▛
▝▜▄ █▌ █▙▟ ▐█▝█▛▐█ ▐█ ▐█▝█▖█▌ █▌
▗▟▀ ▜▙ ▝█▛ █▌▝ ▖▐█ ▐█ ▐█ ▐█ ▝██▌ █▌
▝▀ ▀▀▀▀▘▝▀▀▀▀▘▀▀▘ ▀▀▘▀▀▘▀▀▘ ▝▀▀▝▀▀
Gemini CLI v1.0.0
Tips for getting started:
@@ -34,10 +40,13 @@ Tips for getting started:
exports[`<AppHeader /> > should render the banner with default text 1`] = `
"
▝▜▄ Gemini CLI v1.0.0
▝▜▄
▗▟▀
▝▀
▝▜▄ ▗█▀▀▜▙▝█▛▀▀▌▜██▖▟██▘▜█▘▜██▖▝█▛▝█▛
▝▜▄ █▌ █▙▟ ▐█▝█▛▐█ ▐█ ▐█▝█▖█▌ █▌
▗▟▀ ▜▙ ▝█▛ █▌▝ ▖▐█ ▐█ ▐█ ▐█ ▝██▌ █▌
▝▀ ▀▀▀▀▘▝▀▀▀▀▘▀▀▘ ▀▀▘▀▀▘▀▀▘ ▝▀▀▝▀▀
Gemini CLI v1.0.0
╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│ This is the default banner │
@@ -53,10 +62,13 @@ Tips for getting started:
exports[`<AppHeader /> > should render the banner with warning text 1`] = `
"
▝▜▄ Gemini CLI v1.0.0
▝▜▄
▗▟▀
▝▀
▝▜▄ ▗█▀▀▜▙▝█▛▀▀▌▜██▖▟██▘▜█▘▜██▖▝█▛▝█▛
▝▜▄ █▌ █▙▟ ▐█▝█▛▐█ ▐█ ▐█▝█▖█▌ █▌
▗▟▀ ▜▙ ▝█▛ █▌▝ ▖▐█ ▐█ ▐█ ▐█ ▝██▌ █▌
▝▀ ▀▀▀▀▘▝▀▀▀▀▘▀▀▘ ▀▀▘▀▀▘▀▀▘ ▝▀▀▝▀▀
Gemini CLI v1.0.0
╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│ There are capacity issues │
@@ -69,3 +81,14 @@ Tips for getting started:
4. Be specific for the best results
"
`;
exports[`<AppHeader /> > should render the full logo when logged out 1`] = `
"
▝▜▄ ▗█▀▀▜▙▝█▛▀▀▌▜██▖▟██▘▜█▘▜██▖▝█▛▝█▛
▝▜▄ █▌ █▙▟ ▐█▝█▛▐█ ▐█ ▐█▝█▖█▌ █▌
▗▟▀ ▜▙ ▝█▛ █▌▝ ▖▐█ ▐█ ▐█ ▐█ ▝██▌ █▌
▝▀ ▀▀▀▀▘▝▀▀▀▀▘▀▀▘ ▀▀▘▀▀▘▀▀▘ ▝▀▀▝▀▀
Gemini CLI v1.0.0
"
`;
@@ -1,30 +1,34 @@
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="224" viewBox="0 0 920 224">
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="275" viewBox="0 0 920 275">
<style>
text { font-family: Consolas, "Courier New", monospace; font-size: 14px; dominant-baseline: text-before-edge; white-space: pre; }
</style>
<rect width="920" height="224" fill="#000000" />
<rect width="920" height="275" fill="#000000" />
<g transform="translate(10, 10)">
<text x="18" y="19" fill="#4796e4" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="27" y="19" fill="#6688d9" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="36" y="19" fill="#847ace" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="90" y="19" fill="#ffffff" textLength="90" lengthAdjust="spacingAndGlyphs" font-weight="bold">Gemini CLI</text>
<text x="180" y="19" fill="#afafaf" textLength="63" lengthAdjust="spacingAndGlyphs"> v1.0.0</text>
<text x="36" y="36" fill="#847ace" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="36" fill="#a471a7" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="54" y="36" fill="#c3677f" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="27" y="53" fill="#6688d9" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="36" y="53" fill="#847ace" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="53" fill="#a471a7" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="70" fill="#4796e4" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="27" y="70" fill="#6688d9" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="121" fill="#ffffff" textLength="225" lengthAdjust="spacingAndGlyphs">Tips for getting started:</text>
<text x="0" y="138" fill="#ffffff" textLength="90" lengthAdjust="spacingAndGlyphs">1. Create </text>
<text x="90" y="138" fill="#ffffff" textLength="81" lengthAdjust="spacingAndGlyphs" font-weight="bold">GEMINI.md</text>
<text x="171" y="138" fill="#ffffff" textLength="333" lengthAdjust="spacingAndGlyphs"> files to customize your interactions</text>
<text x="0" y="155" fill="#ffffff" textLength="27" lengthAdjust="spacingAndGlyphs">2. </text>
<text x="27" y="155" fill="#afafaf" textLength="45" lengthAdjust="spacingAndGlyphs">/help</text>
<text x="72" y="155" fill="#ffffff" textLength="189" lengthAdjust="spacingAndGlyphs"> for more information</text>
<text x="0" y="172" fill="#ffffff" textLength="450" lengthAdjust="spacingAndGlyphs">3. Ask coding questions, edit code or run commands</text>
<text x="0" y="189" fill="#ffffff" textLength="315" lengthAdjust="spacingAndGlyphs">4. Be specific for the best results</text>
<text x="9" y="19" fill="#4796e4" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="19" fill="#6688d9" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="27" y="19" fill="#847ace" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="90" y="19" fill="#ffffff" textLength="297" lengthAdjust="spacingAndGlyphs">▗█▀▀▜▙▝█▛▀▀▌▜██▖▟██▘▜█▘▜██▖▝█▛▝█▛</text>
<text x="27" y="36" fill="#847ace" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="36" y="36" fill="#a471a7" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="36" fill="#c3677f" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="90" y="36" fill="#ffffff" textLength="297" lengthAdjust="spacingAndGlyphs">█▌ █▙▟ ▐█▝█▛▐█ ▐█ ▐█▝█▖█▌ █▌</text>
<text x="18" y="53" fill="#6688d9" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="27" y="53" fill="#847ace" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="36" y="53" fill="#a471a7" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="90" y="53" fill="#ffffff" textLength="297" lengthAdjust="spacingAndGlyphs">▜▙ ▝█▛ █▌▝ ▖▐█ ▐█ ▐█ ▐█ ▝██▌ █▌</text>
<text x="9" y="70" fill="#4796e4" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="70" fill="#6688d9" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="90" y="70" fill="#ffffff" textLength="297" lengthAdjust="spacingAndGlyphs"> ▀▀▀▀▘▝▀▀▀▀▘▀▀▘ ▀▀▘▀▀▘▀▀▘ ▝▀▀▝▀▀</text>
<text x="9" y="104" fill="#ffffff" textLength="90" lengthAdjust="spacingAndGlyphs" font-weight="bold">Gemini CLI</text>
<text x="99" y="104" fill="#afafaf" textLength="63" lengthAdjust="spacingAndGlyphs"> v1.0.0</text>
<text x="0" y="172" fill="#ffffff" textLength="225" lengthAdjust="spacingAndGlyphs">Tips for getting started:</text>
<text x="0" y="189" fill="#ffffff" textLength="90" lengthAdjust="spacingAndGlyphs">1. Create </text>
<text x="90" y="189" fill="#ffffff" textLength="81" lengthAdjust="spacingAndGlyphs" font-weight="bold">GEMINI.md</text>
<text x="171" y="189" fill="#ffffff" textLength="333" lengthAdjust="spacingAndGlyphs"> files to customize your interactions</text>
<text x="0" y="206" fill="#ffffff" textLength="27" lengthAdjust="spacingAndGlyphs">2. </text>
<text x="27" y="206" fill="#afafaf" textLength="45" lengthAdjust="spacingAndGlyphs">/help</text>
<text x="72" y="206" fill="#ffffff" textLength="189" lengthAdjust="spacingAndGlyphs"> for more information</text>
<text x="0" y="223" fill="#ffffff" textLength="450" lengthAdjust="spacingAndGlyphs">3. Ask coding questions, edit code or run commands</text>
<text x="0" y="240" fill="#ffffff" textLength="315" lengthAdjust="spacingAndGlyphs">4. Be specific for the best results</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 3.4 KiB

@@ -1,31 +1,35 @@
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="224" viewBox="0 0 920 224">
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="275" viewBox="0 0 920 275">
<style>
text { font-family: Consolas, "Courier New", monospace; font-size: 14px; dominant-baseline: text-before-edge; white-space: pre; }
</style>
<rect width="920" height="224" fill="#000000" />
<rect width="920" height="275" fill="#000000" />
<g transform="translate(10, 10)">
<text x="18" y="19" fill="#4796e4" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="27" y="19" fill="#6688d9" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="36" y="19" fill="#847ace" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="81" y="19" fill="#ffffff" textLength="90" lengthAdjust="spacingAndGlyphs" font-weight="bold">Gemini CLI</text>
<text x="171" y="19" fill="#afafaf" textLength="63" lengthAdjust="spacingAndGlyphs"> v1.0.0</text>
<text x="36" y="36" fill="#847ace" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="36" fill="#a471a7" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="54" y="36" fill="#c3677f" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="36" y="53" fill="#847ace" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="53" fill="#a471a7" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="54" y="53" fill="#c3677f" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="70" fill="#4796e4" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="27" y="70" fill="#6688d9" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="36" y="70" fill="#847ace" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="121" fill="#ffffff" textLength="225" lengthAdjust="spacingAndGlyphs">Tips for getting started:</text>
<text x="0" y="138" fill="#ffffff" textLength="90" lengthAdjust="spacingAndGlyphs">1. Create </text>
<text x="90" y="138" fill="#ffffff" textLength="81" lengthAdjust="spacingAndGlyphs" font-weight="bold">GEMINI.md</text>
<text x="171" y="138" fill="#ffffff" textLength="333" lengthAdjust="spacingAndGlyphs"> files to customize your interactions</text>
<text x="0" y="155" fill="#ffffff" textLength="27" lengthAdjust="spacingAndGlyphs">2. </text>
<text x="27" y="155" fill="#afafaf" textLength="45" lengthAdjust="spacingAndGlyphs">/help</text>
<text x="72" y="155" fill="#ffffff" textLength="189" lengthAdjust="spacingAndGlyphs"> for more information</text>
<text x="0" y="172" fill="#ffffff" textLength="450" lengthAdjust="spacingAndGlyphs">3. Ask coding questions, edit code or run commands</text>
<text x="0" y="189" fill="#ffffff" textLength="315" lengthAdjust="spacingAndGlyphs">4. Be specific for the best results</text>
<text x="9" y="19" fill="#4796e4" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="19" fill="#6688d9" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="27" y="19" fill="#847ace" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="81" y="19" fill="#ffffff" textLength="297" lengthAdjust="spacingAndGlyphs">▗█▀▀▜▙▝█▛▀▀▌▜██▖▟██▘▜█▘▜██▖▝█▛▝█▛</text>
<text x="27" y="36" fill="#847ace" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="36" y="36" fill="#a471a7" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="36" fill="#c3677f" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="81" y="36" fill="#ffffff" textLength="297" lengthAdjust="spacingAndGlyphs">█▌ █▙▟ ▐█▝█▛▐█ ▐█ ▐█▝█▖█▌ █▌</text>
<text x="27" y="53" fill="#847ace" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="36" y="53" fill="#a471a7" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="53" fill="#c3677f" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="81" y="53" fill="#ffffff" textLength="297" lengthAdjust="spacingAndGlyphs">▜▙ ▝█▛ █▌▝ ▖▐█ ▐█ ▐█ ▐█ ▝██▌ █▌</text>
<text x="9" y="70" fill="#4796e4" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="70" fill="#6688d9" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="27" y="70" fill="#847ace" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="81" y="70" fill="#ffffff" textLength="297" lengthAdjust="spacingAndGlyphs"> ▀▀▀▀▘▝▀▀▀▀▘▀▀▘ ▀▀▘▀▀▘▀▀▘ ▝▀▀▝▀▀</text>
<text x="9" y="104" fill="#ffffff" textLength="90" lengthAdjust="spacingAndGlyphs" font-weight="bold">Gemini CLI</text>
<text x="99" y="104" fill="#afafaf" textLength="63" lengthAdjust="spacingAndGlyphs"> v1.0.0</text>
<text x="0" y="172" fill="#ffffff" textLength="225" lengthAdjust="spacingAndGlyphs">Tips for getting started:</text>
<text x="0" y="189" fill="#ffffff" textLength="90" lengthAdjust="spacingAndGlyphs">1. Create </text>
<text x="90" y="189" fill="#ffffff" textLength="81" lengthAdjust="spacingAndGlyphs" font-weight="bold">GEMINI.md</text>
<text x="171" y="189" fill="#ffffff" textLength="333" lengthAdjust="spacingAndGlyphs"> files to customize your interactions</text>
<text x="0" y="206" fill="#ffffff" textLength="27" lengthAdjust="spacingAndGlyphs">2. </text>
<text x="27" y="206" fill="#afafaf" textLength="45" lengthAdjust="spacingAndGlyphs">/help</text>
<text x="72" y="206" fill="#ffffff" textLength="189" lengthAdjust="spacingAndGlyphs"> for more information</text>
<text x="0" y="223" fill="#ffffff" textLength="450" lengthAdjust="spacingAndGlyphs">3. Ask coding questions, edit code or run commands</text>
<text x="0" y="240" fill="#ffffff" textLength="315" lengthAdjust="spacingAndGlyphs">4. Be specific for the best results</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 3.5 KiB

@@ -2,10 +2,13 @@
exports[`AppHeader Icon Rendering > renders the default icon in standard terminals 1`] = `
"
▝▜▄ Gemini CLI v1.0.0
▝▜▄
▗▟▀
▝▀
▝▜▄ ▗█▀▀▜▙▝█▛▀▀▌▜██▖▟██▘▜█▘▜██▖▝█▛▝█▛
▝▜▄ █▌ █▙▟ ▐█▝█▛▐█ ▐█ ▐█▝█▖█▌ █▌
▗▟▀ ▜▙ ▝█▛ █▌▝ ▖▐█ ▐█ ▐█ ▐█ ▝██▌ █▌
▝▀ ▀▀▀▀▘▝▀▀▀▀▘▀▀▘ ▀▀▘▀▀▘▀▀▘ ▝▀▀▝▀▀
Gemini CLI v1.0.0
Tips for getting started:
@@ -17,10 +20,13 @@ Tips for getting started:
exports[`AppHeader Icon Rendering > renders the symmetric icon in Apple Terminal 1`] = `
"
▝▜▄ Gemini CLI v1.0.0
▝▜▄
▗▟▀
▗▟▀
▝▜▄ ▗█▀▀▜▙▝█▛▀▀▌▜██▖▟██▘▜█▘▜██▖▝█▛▝█▛
▝▜▄ █▌ █▙▟ ▐█▝█▛▐█ ▐█ ▐█▝█▖█▌ █▌
▗▟▀ ▜▙ ▝█▛ █▌▝ ▖▐█ ▐█ ▐█ ▐█ ▝██▌ █▌
▗▟▀ ▀▀▀▀▘▝▀▀▀▀▘▀▀▘ ▀▀▘▀▀▘▀▀▘ ▝▀▀▝▀▀
Gemini CLI v1.0.0
Tips for getting started:

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