Compare commits

..

45 Commits

Author SHA1 Message Date
Spencer Tang 90fda1400e fix(core): add timeout to tree-sitter initialization to prevent hanging 2026-04-08 17:06:50 -04:00
Sri Pasumarthi 4ebc43bc66 feat(test-utils): add memory usage integration test harness (#24876) 2026-04-08 17:42:18 +00:00
ruomeng 34b4f1c6e4 refactor(plan): simplify policy priorities and consolidate read-only rules (#24849) 2026-04-08 15:58:29 +00:00
Gaurav e77b22e638 fix: isolate concurrent browser agent instances (#24794) 2026-04-08 14:31:10 +00:00
Adam Weidman 1b3e7d674f docs: update MCP server OAuth redirect port documentation (#24844) 2026-04-08 14:06:30 +00:00
Gaurav Ghosh e7f8d9cf1a Revert "feat: Introduce an AI-driven interactive shell mode with new"
This reverts commit 651ad63ed6.
2026-04-08 07:31:17 -07:00
Gaurav Ghosh 651ad63ed6 feat: Introduce an AI-driven interactive shell mode with new
`read-shell` and `write-to-shell` tools and a configurable mode setting.
2026-04-08 07:27:28 -07:00
Jacob Richman cbacdc67d0 feat(cli): switch to ctrl+g from ctrl-x (#24861) 2026-04-08 06:22:45 +00:00
Jacob Richman 7e1938c1bc fix(cli): switch default back to terminalBuffer=false and fix regressions introduced for that mode (#24873) 2026-04-08 05:47:54 +00:00
Anjaligarhwal b9f1d832c8 fix(core): dispose Scheduler to prevent McpProgress listener leak (#24870) 2026-04-08 03:05:53 +00:00
Dev Randalpura 47c5d25d93 Added flag for ept size and increased default size (#24859) 2026-04-08 03:03:36 +00:00
Jacob Richman 28efab483f fix(cli): always show shell command description or actual command (#24774) 2026-04-08 01:52:33 +00:00
gemini-cli-robot 9fd92c0eea Changelog for v0.37.0-preview.2 (#24848)
Co-authored-by: gemini-cli-robot <224641728+gemini-cli-robot@users.noreply.github.com>
2026-04-08 00:13:06 +00:00
Michael Bleigh 16768c08f2 refactor(core): make LegacyAgentSession dependencies optional (#24287)
Co-authored-by: Adam Weidman <adamfweidman@gmail.com>
Co-authored-by: Adam Weidman <adamfweidman@google.com>
2026-04-07 23:45:22 +00:00
JAYADITYA 1aa798dd18 refactor(cli): remove duplication in interactive shell awaiting input hint (#24801) 2026-04-07 23:36:44 +00:00
Christian Gunderman f96d5f98fe Revert "fix(ui): improve narration suppression and reduce flicker (#2… (#24857) 2026-04-07 22:45:40 +00:00
Yuna Seol 3c5b5db034 feat(core): use experiment flags for default fetch timeouts (#24261) 2026-04-07 22:35:04 +00:00
Michael Bleigh 986293bd38 feat(core): add agent protocol UI types and experimental flag (#24275)
Co-authored-by: Adam Weidman <adamfweidman@gmail.com>
Co-authored-by: Adam Weidman <adamfweidman@google.com>
2026-04-07 21:45:18 +00:00
David Pierce adf7b3b717 Improve sandbox error matching and caching (#24550) 2026-04-07 21:08:18 +00:00
Jack Wotherspoon 9637fb3990 fix(core): remove tmux alternate buffer warning (#24852) 2026-04-07 21:01:14 +00:00
Sri Pasumarthi 06fcdc231c feat(acp): add /help command (#24839) 2026-04-07 20:01:44 +00:00
Sehoon Shon d29da15427 fix(cli): prevent multiple banner increments on remount (#24843) 2026-04-07 19:44:09 +00:00
Enjoy Kumawat ab3075feb9 fix: use directory junctions on Windows for skill linking (#24823) 2026-04-07 19:28:43 +00:00
Abhi 5588000e93 chore: fix formatting for behavioral eval skill reference file (#24846) 2026-04-07 19:26:53 +00:00
krishdef7 68fef8745e fix(core): propagate BeforeModel hook model override end-to-end (#24784)
Signed-off-by: krishdef7 <gargkrish06@gmail.com>
Co-authored-by: Sandy Tao <sandytao520@icloud.com>
2026-04-07 17:49:26 +00:00
Michael Bleigh e432f7c009 feat(hooks): display hook system messages in UI (#24616) 2026-04-07 17:42:39 +00:00
Alisa 846051f716 Alisa/approve button (#24645) 2026-04-07 16:44:07 +00:00
Tommaso Sciortino 1c22c5b37b Remove flakey test (#24837) 2026-04-07 09:39:15 -07:00
Emily Hedlund 1762c9c509 temporarily disable sandbox integration test on windows (#24786) 2026-04-07 15:33:40 +00:00
Abhijit Balaji 0025978d76 feat(cli): support selective topic expansion and click-to-expand (#24793) 2026-04-07 15:00:40 +00:00
Gaurav 4c5e887732 feat(telemetry): add browser agent clearcut metrics (#24688) 2026-04-07 07:48:38 +00:00
Abhi 83096c68b0 fix(policy): allow complete_task in plan mode (#24771) 2026-04-07 03:43:42 +00:00
Christian Gunderman d2b775f9a7 Add an eval for and fix unsafe cloning behavior. (#24457) 2026-04-07 03:17:44 +00:00
Spencer 0a8da988ed fix(cli): ensure skills list outputs to stdout in non-interactive environments (#24566) 2026-04-07 02:40:23 +00:00
David Pierce 984f02c180 relax tool sandboxing overrides for plan mode to match defaults. (#24762) 2026-04-06 22:18:10 +00:00
Tommaso Sciortino df67f973ed fix(cli): respect global environment variable allowlist (#24767) 2026-04-06 22:17:55 +00:00
Christian Gunderman 7872d6d7fe fix(ui): improve narration suppression and reduce flicker (#24635) 2026-04-06 21:18:59 +00:00
Gaurav e116aa34f4 fix(browser): remove premature browser cleanup after subagent invocation (#24753) 2026-04-06 21:17:31 +00:00
Abhijit Balaji ad98294352 Revert "feat(core,cli): prioritize summary for topics (#24608)" (#24777) 2026-04-06 20:33:18 +00:00
Dev Randalpura 2353a6d253 fix(ui): fixed auth race condition causing logo to flicker (#24652) 2026-04-06 20:17:05 +00:00
krishdef7 8ac560d2c9 fix(core): handle partial llm_request in BeforeModel hook override (#22326) 2026-04-06 20:11:38 +00:00
Tommaso Sciortino c6a9d3de13 fix(cli): remove -S from shebang to fix Windows and BSD execution (#24756) 2026-04-06 19:06:56 +00:00
Christian Gunderman 8f131ffef7 Fix issue where topic headers can be posted back to back (#24759) 2026-04-06 18:36:22 +00:00
Jacob Richman 70f6d6a992 split context (#24623) 2026-04-06 17:20:38 +00:00
cynthialong0-0 c96cb09e09 feat(cli): add role specific metrics to /stats (#24659) 2026-04-06 16:20:48 +00:00
180 changed files with 5228 additions and 2634 deletions
@@ -33,6 +33,35 @@ evaluation.
- **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**.
- Fixes should generally try to improve the prompt
`@packages/core/src/prompts/snippets.ts` first.
- **Instructional Generality**: Changes to the system prompt should aim to
be as general as possible while still accomplishing the goal. Specificity
should be added only as needed.
- **Principle**: Instead of creating "forbidden lists" for specific syntax
(e.g., "Don't use `Object.create()`"), formulate a broader engineering
principle that covers the underlying issue (e.g., "Prioritize explicit
composition over hidden prototype manipulation"). This improves
steerability across a wider range of similar scenarios.
- _Low Specificity_: "Follow ecosystem best practices"
- _Medium Specificity_: "Utilize OOP and functional best practices, as
applicable"
- _High Specificity_: Provide ecosystem-specific hints as examples of a
broader principle rather than direct instructions. e.g., "NEVER use
hacks like bypassing the type system or employing 'hidden' logic (e.g.:
reflection, prototype manipulation). Instead, use explicit and idiomatic
language features (e.g.: type guards, explicit class instantiation, or
object spread) that maintain structural integrity."
- **Prompt Simplification**: Once the test is passing, use `ask_user` to
determine if prompt simplification is desired.
- **Criteria**: Simplification should be attempted only if there are
related clauses that can be de-duplicated or reparented under a single
heading.
- **Verification**: As part of simplification, you MUST identify and run
any behavioral eval tests that might be affected by the changes to
ensure no regressions are introduced.
- Test fixes should not "cheat" by changing a test's `GEMINI.md` file or by
updating the test's prompt to instruct it to not repro the bug.
- **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
+86 -19
View File
@@ -1,7 +1,7 @@
name: 'Evals: PR Evaluation & Regression'
on:
pull_request:
pull_request_target:
types: ['opened', 'synchronize', 'reopened', 'ready_for_review']
paths:
- 'packages/core/src/prompts/**'
@@ -23,13 +23,73 @@ permissions:
actions: 'read'
jobs:
detect-changes:
name: 'Detect Steering Changes'
runs-on: 'gemini-cli-ubuntu-16-core'
# Security: pull_request_target allows secrets, so we must gate carefully.
# Detection should not run code from the fork.
if: "github.repository == 'google-gemini/gemini-cli' && github.event.pull_request.draft == false"
outputs:
SHOULD_RUN: '${{ steps.detect.outputs.SHOULD_RUN }}'
STEERING_DETECTED: '${{ steps.detect.outputs.STEERING_DETECTED }}'
steps:
- name: 'Checkout'
uses: 'actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955' # ratchet:actions/checkout@v5
with:
# Check out the trusted code from main for detection
fetch-depth: 0
- name: 'Detect Steering Changes'
id: 'detect'
env:
# Use the PR's head SHA for comparison without checking it out
PR_HEAD_SHA: '${{ github.event.pull_request.head.sha }}'
run: |
# Fetch the fork's PR branch for analysis
git fetch origin pull/${{ github.event.pull_request.number }}/head:pr-head
# Run the trusted script from main
SHOULD_RUN=$(node scripts/changed_prompt.js)
STEERING_DETECTED=$(node scripts/changed_prompt.js --steering-only)
echo "SHOULD_RUN=$SHOULD_RUN" >> "$GITHUB_OUTPUT"
echo "STEERING_DETECTED=$STEERING_DETECTED" >> "$GITHUB_OUTPUT"
- name: 'Notify Approval Required'
if: "steps.detect.outputs.SHOULD_RUN == 'true'"
env:
GH_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
run: |
RUN_URL="https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}"
COMMENT_BODY="### 🛑 Action Required: Evaluation Approval
Steering changes have been detected in this PR. To prevent regressions, a maintainer must approve the evaluation run before this PR can be merged.
**Maintainers:**
1. Go to the [**Workflow Run Summary**]($RUN_URL).
2. Click the yellow **'Review deployments'** button.
3. Select the **'eval-gate'** environment and click **'Approve'**.
Once approved, the evaluation results will be posted here automatically.
<!-- eval-approval-notification -->"
# Check if comment already exists to avoid spamming
COMMENT_ID=$(gh pr view ${{ github.event.pull_request.number }} --json comments --jq '.comments[] | select(.body | contains("<!-- eval-approval-notification -->")) | .url' | grep -oE "[0-9]+$" | head -n 1)
if [ -z "$COMMENT_ID" ]; then
gh pr comment ${{ github.event.pull_request.number }} --body "$COMMENT_BODY"
else
echo "Updating existing notification comment $COMMENT_ID..."
gh api -X PATCH "repos/${{ github.repository }}/issues/comments/$COMMENT_ID" -F body="$COMMENT_BODY"
fi
pr-evaluation:
name: 'Evaluate Steering & Regressions'
needs: 'detect-changes'
if: "needs.detect-changes.outputs.SHOULD_RUN == 'true'"
# Manual approval gate via environment
environment: 'eval-gate'
runs-on: 'gemini-cli-ubuntu-16-core'
if: "github.repository == 'google-gemini/gemini-cli' && (github.event_name != 'pull_request' || (github.event.pull_request.draft == false && github.event.pull_request.head.repo.full_name == github.repository))"
# External contributors' PRs will wait for approval in this environment
environment: |-
${{ (github.event.pull_request.head.repo.full_name == github.repository) && 'internal' || 'external-evals' }}
env:
# CENTRALIZED MODEL LIST
MODEL_LIST: 'gemini-3-flash-preview'
@@ -38,32 +98,40 @@ jobs:
- name: 'Checkout'
uses: 'actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955' # ratchet:actions/checkout@v5
with:
# Check out the fork's PR code for the actual evaluation
# This only runs AFTER manual approval
ref: '${{ github.event.pull_request.head.sha }}'
fetch-depth: 0
- name: 'Remove Approval Notification'
# Run even if other steps fail, to ensure we clean up the "Action Required" message
if: 'always()'
env:
GH_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
PR_NUMBER: '${{ github.event.pull_request.number }}'
run: |
echo "Debug: PR_NUMBER is '$PR_NUMBER'"
# Search for the notification comment by its hidden tag
COMMENT_ID=$(gh pr view "$PR_NUMBER" --json comments --jq '.comments[] | select(.body | contains("<!-- eval-approval-notification -->")) | .url' | grep -oE "[0-9]+$" | head -n 1)
if [ -n "$COMMENT_ID" ]; then
echo "Removing notification comment $COMMENT_ID now that run is approved..."
gh api -X DELETE "repos/${{ github.repository }}/issues/comments/$COMMENT_ID"
fi
- 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: |
SHOULD_RUN=$(node scripts/changed_prompt.js)
STEERING_DETECTED=$(node scripts/changed_prompt.js --steering-only)
echo "SHOULD_RUN=$SHOULD_RUN" >> "$GITHUB_OUTPUT"
echo "STEERING_DETECTED=$STEERING_DETECTED" >> "$GITHUB_OUTPUT"
- name: 'Install dependencies'
if: "steps.detect.outputs.SHOULD_RUN == 'true'"
run: 'npm ci'
- name: 'Build project'
if: "steps.detect.outputs.SHOULD_RUN == 'true'"
run: 'npm run build'
- name: 'Analyze PR Content (Guidance)'
if: "steps.detect.outputs.STEERING_DETECTED == 'true'"
if: "needs.detect-changes.outputs.STEERING_DETECTED == 'true'"
id: 'analysis'
env:
GH_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
@@ -81,7 +149,6 @@ jobs:
fi
- name: 'Execute Regression Check'
if: "steps.detect.outputs.SHOULD_RUN == 'true'"
env:
GEMINI_API_KEY: '${{ secrets.GEMINI_API_KEY }}'
GH_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
@@ -96,7 +163,7 @@ jobs:
fi
- name: 'Post or Update PR Comment'
if: "always() && (steps.detect.outputs.STEERING_DETECTED == 'true' || env.REPORT_FILE != '')"
if: "always() && (needs.detect-changes.outputs.STEERING_DETECTED == 'true' || env.REPORT_FILE != '')"
env:
GH_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
run: |
@@ -107,7 +174,7 @@ jobs:
echo ""
fi
if [[ "${{ steps.detect.outputs.STEERING_DETECTED }}" == "true" ]]; then
if [[ "${{ needs.detect-changes.outputs.STEERING_DETECTED }}" == "true" ]]; then
echo "### 🧠 Model Steering Guidance"
echo ""
echo "This PR modifies files that affect the model's behavior (prompts, tools, or instructions)."
+33
View File
@@ -0,0 +1,33 @@
name: 'Memory Tests: Nightly'
on:
schedule:
- cron: '0 2 * * *' # Runs at 2 AM every day
workflow_dispatch: # Allow manual trigger
permissions:
contents: 'read'
jobs:
memory-test:
name: 'Run Memory Usage Tests'
runs-on: 'gemini-cli-ubuntu-16-core'
if: "github.repository == 'google-gemini/gemini-cli'"
steps:
- name: 'Checkout'
uses: 'actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8' # ratchet:actions/checkout@v5
- name: 'Set up Node.js'
uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' # ratchet:actions/setup-node@v4
with:
node-version-file: '.nvmrc'
cache: 'npm'
- name: 'Install dependencies'
run: 'npm ci'
- name: 'Build project'
run: 'npm run build'
- name: 'Run Memory Tests'
run: 'npm run test:memory'
+2
View File
@@ -44,6 +44,8 @@ powerful tool for developers.
- **Test Commands:**
- **Unit (All):** `npm run test`
- **Integration (E2E):** `npm run test:e2e`
- **Memory (Nightly):** `npm run test:memory` (Runs memory regression tests
against baselines. Excluded from `preflight`, run nightly.)
- **Workspace-Specific:** `npm test -w <pkg> -- <path>` (Note: `<path>` must
be relative to the workspace root, e.g.,
`-w @google/gemini-cli-core -- src/routing/modelRouterService.test.ts`)
-438
View File
@@ -1,438 +0,0 @@
# Architectural Design: Gemini CLI to ADK Migration
| Authors: [Adam Weidman](mailto:adamfweidman@google.com) Contributors: Reviewers: *See section [Status of this document](#status-of-this-document).* | Status: Draft Last revised: Apr 7, 2026 Visibility: Confidential |
| :--- | :--- |
---
# Goal
To migrate the Gemini CLI backend execution engine from its legacy fragmented loop structure to the Agent Development Kit (ADK). This migration will unify how agents and subagents are orchestrated, simplify state persistence, and expose a standard `AgentSession` interface for the CLI, future SDK surfaces, and subagent execution.
---
# Context
Over time, Gemini CLI has accumulated complex runtime behaviors: multi-tier tool scheduling, policy-driven approvals, payload masking, dynamic routing, and fine-grained telemetry. Integrating these with ADK requires a clean boundary that preserves Gemini CLI semantics without forking ADK core behavior.
The key migration boundary is:
- ADK runtime semantics -> Gemini CLI `AgentProtocol` / `AgentSession`
- ADK `Event` stream -> Gemini CLI `AgentEvent` stream
That boundary, not the model wrapper alone, is the architectural center of this design.
---
# Current State and Proposed Mappings
The following analysis maps existing Gemini CLI components onto ADK capabilities, citing both repositories (`gemini-cli` and `adk-js`).
## Core Runtime Architecture
The migration uses one shared ADK-backed runtime core. Every orchestrated agent, including subagents, is exposed through the same external session contract:
- **Top-level CLI agent** -> `AgentSession`
- **Future SDK entry point** -> `AgentSession`
- **Subagent execution** -> `AgentSession`
The runtime owns:
- ADK runner/session lifecycle
- tool execution
- policy integration
- routing, availability, compaction, and masking hooks
- persistence integration
The adapters own:
- `streamId` timing guarantees
- replay / reattach behavior
- translation from ADK `Event` to Gemini CLI `AgentEvent`
- top-level versus subagent event projection
- projection of a child `AgentSession` into parent-facing tool or thread events when a subagent is embedded inside another agent
The minimum shape is:
```typescript
interface PipelineServices {
run(
request: LlmRequest,
baseModel: BaseLlm,
stream: boolean,
): AsyncGenerator<LlmResponse, void>;
connect(
request: LlmRequest,
baseModel: BaseLlm,
): Promise<BaseLlmConnection>;
}
class GcliAgentModel extends BaseLlm {
constructor(
private baseModel: BaseLlm,
private pipeline: PipelineServices,
) {
super({model: 'gcli-consolidated'});
}
async *generateContentAsync(
request: LlmRequest,
stream = false,
): AsyncGenerator<LlmResponse, void> {
yield* this.pipeline.run(request, this.baseModel, stream);
}
async connect(request: LlmRequest): Promise<BaseLlmConnection> {
return this.pipeline.connect(request, this.baseModel);
}
}
```
Design rule:
- request mutation stays in the pipeline
- all orchestrated agents expose the same `AgentSession` contract
- session lifecycle, replay, approvals, and subagent projection stay in the runtime/adapters
`AdkAgentService` is the composition root for this architecture. It creates and resumes both top-level and subagent `AgentSession`s, builds scoped registries and message-bus instances, wires policy and approval bridges, and embeds child sessions through projection adapters rather than through a separate subagent runtime. See Appendix A for the intended initialization shape.
Composition rule:
- stateful tools are cloned or reinstantiated per session when needed; stateless tools may be shared
- MCP discovery is shared at the manager layer but registered into session-local tool, prompt, and resource registries
- `AgentLoopContext` is decomposed into pipeline config, tool/subagent config, session services, callback bridges, and UI projection rather than passed through as a single runtime object
- file persistence remains Gemini CLI-owned through `GcliFileSessionService extends BaseSessionService`
Persistence rule:
- persisted history is an append-only event log plus derived app/user/session state
- the session service provides atomic append, crash-safe recovery, and single-writer enforcement
- rewind truncates the event log, recomputes derived state, and invalidates confirmation/resumption state past the rewind point
## 3.1 Authentication Flexibility
The CLI resolves distinct authentication flows (OAuth, ADC, Compute metadata) using standard Google libraries.
* **Current State:** Resolved in `packages/core/src/code_assist/oauth2.ts` based on `AuthType`.
* OAuth (`LOGIN_WITH_GOOGLE`)
* Compute Metadata Server (`COMPUTE_ADC`)
* **Constraint:** Standard `Gemini` construction in `adk-js/core/src/models/google_llm.ts` still selects backend from constructor-level `apiKey` or Vertex config. It does **not** natively accept Gemini CLI's `AuthClient`-driven auth shape.
* **Proposed ADK Mapping:** Phase 1 keeps auth in `GcliAgentModel`. The pipeline resolves refreshed credentials and injects them through `request.config.httpOptions.headers` for unary requests and `request.liveConnectConfig.httpOptions.headers` for live connections.
* **Design position:** This is a **bridge**, not native ADK auth parity. Long-term cleanup is either:
* a `CodeAssistLlm extends BaseLlm`, or
* an upstream ADK auth-provider abstraction.
```typescript
request.config ??= {};
request.config.httpOptions ??= {};
request.config.httpOptions.headers = {
...request.config.httpOptions.headers,
Authorization: `Bearer ${await auth.getAccessToken()}`,
};
```
## 3.2 Model Steering and Mid-Stream Injection
User interjections (hints) course-correct the loop mid-turn.
* **Current State:** Steering today is tied to the legacy loop and injection services.
* **Proposed ADK Mapping (Next-Step Steering):** Supported in Phase 1. `beforeModelCallback` can read queued hints and mutate the next outbound request.
* **Proposed ADK Mapping (True Real-Time Interrupt):** Still blocked. TypeScript ADK live runtime is not complete yet, and input-stream semantics are not a stable dependency.
* **Phase 1 behavior:** user interjections are queued and prepended to the next model request at tool or turn boundaries. True in-place turn interruption remains out of scope.
## 3.3 State Management and Token Compaction
The CLI truncates large tool responses and summarizes older history to protect token budgets.
* **Current State:** `ChatCompressionService` in `packages/core/src/context/chatCompressionService.ts` implements reverse token budgeting and a two-phase verification loop.
* **Proposed ADK Mapping:** Compaction remains a Gemini CLI-owned history processor invoked by the request pipeline.
* **Design position:** Phase 1 does **not** force this onto ADK `BaseContextCompactor`. Gemini CLI compression is currently an outbound-history projection with truncation plus summary/verification sub-calls, while ADK's native compactor path is event-log-centric and mutates session history.
* **Design choice:** In Phase 1, compaction mutates **outgoing request history only**. The persisted session event log remains canonical.
* **Utility calls:** Compaction sub-calls use the same routing, auth, and availability pipeline as primary model calls.
## 3.4 Model Configuration and Hierarchical Overrides
Dynamic aliasing (for example, temperature scoped to specific sub-commands).
* **Current State:** Managed by `ModelConfigService`.
* **Proposed ADK Mapping:** Resolution stays **request-scoped**, not session-init-scoped.
* **Design choice:** The session stores requested model and override state. The runtime resolves the concrete model and temperature on each request, including retries, subagents, and utility calls.
## 3.5 Universal Policy Enforcement (TOML Rules)
Tiered workspace restrictions (for example, read-only tools in untrusted folders).
* **Current State:** Intercepted at tool scheduling time in legacy scheduler loops.
* **Proposed ADK Mapping (Container):** Standardize on ADK `SecurityPlugin`.
* **Proposed ADK Mapping (Decision Brain):** Implement `GcliPolicyEngineAdapter implements BasePolicyEngine`.
* **Richer context:** The adapter is fed session/mode/subagent/MCP metadata from the runtime so current Gemini CLI policy semantics are preserved as closely as possible.
* **Phase 1 suspension flow:** current tool approvals use existing Gemini CLI callbacks. This is intentionally **non-native** and **non-resumable across process death**.
* **Long-term target:** move the same policy decisions onto native ADK confirmation/resumption semantics once the broader live/elicitation surface is ready.
```typescript
interface GcliPolicyBridge {
evaluate(context: ToolCallPolicyContext): Promise<PolicyCheckResult>;
}
export class GcliPolicyEngineAdapter implements BasePolicyEngine {
constructor(private readonly policyBridge: GcliPolicyBridge) {}
async evaluate(context: ToolCallPolicyContext): Promise<PolicyCheckResult> {
return this.policyBridge.evaluate(context);
}
}
```
## 3.6 Telemetry and Observability (Clearcut Tracking)
Hardware metrics, token counts, and step durations.
* **Current State:** `ClearcutLogger` reads system metrics and relies on deep scheduler hooks for latency accounting.
* **Proposed ADK Mapping:** Use a combination of:
* passive event-stream observation, and
* explicit runtime instrumentation where passive ADK events are insufficient.
* **Correlation rule:** tool timing is keyed by `functionCall.id`, **not** by `event.id`.
* **Design position:** Passive stream interception alone is not assumed to provide full parity.
## 3.7 Dynamic Model Routing and Configurability
Banning a model mid-turn, auto-routing via classifiers, and falling back dynamically without reinitializing the session.
* **Current State:** Managed by `ModelRouterService` and a chain of `RoutingStrategy` implementations which require the full `RoutingContext` (`history`, `request`, `AbortSignal`).
* **Proposed ADK Mapping:** Mostly implementable now, but not “100% possible today” without bridge logic.
* **Design choice:** The runtime constructs a proper `RoutingContext` from:
* canonical session history
* the pending user request
* requested model
* abort signal
* **Execution point:** routing remains request-scoped and runs before final dispatch.
* **Model banning:** treated as routing/fallback selection, not as a synthetic terminal model error.
## 3.8 Fallbacks and Availability Management
Ensuring availability by retrying or switching models when rate limits (429s) or terminal faults occur.
* **Current State:** Managed by `ModelAvailabilityService` and `ModelPool`.
* **Proposed ADK Mapping (Preflight):** availability and fallback selection run before dispatch and before routing commits to a concrete model.
* **Proposed ADK Mapping (Post-failure):** handled separately by the runtime. Availability state mutation and retry decisions are not treated as pure request preprocessing.
* **Global Application:** utility calls use the same availability/fallback services as primary model calls.
* **Retry safety rule:** automatic full-turn replay is allowed only before any side-effecting tool has executed. After side effects, the runtime surfaces the failure and requires explicit user action.
* **Phase 1 transition path:** approvals and fallback prompts continue to use existing Gemini CLI callbacks. The doc treats this as an internal bridge, not as an existing standard ADK elicitation API.
## 3.9 State-Driven Mode Switching (Plan Mode)
Dynamically shifting system prompts and active tools when users switch interaction tiers (for example, Chat Mode to Plan Mode).
* **Current State:** Toggled via `/plan`, which changes `ApprovalMode` and related legacy scheduler behavior.
* **Proposed ADK Mapping (Dynamic Prompt):** Use `InstructionProvider` in `LlmAgentConfig.instruction`.
* **Proposed ADK Mapping (Dynamic Tooling):** Use a custom `BaseToolset` whose filter is derived from session mode.
* **Single source of truth:** mode is session/runtime state derived from current approval mode; prompt, toolset, policy, routing, and UI all consume the same state.
```typescript
export class GcliModeAwareToolset extends BaseToolset {
constructor(
private readonly chatTools: BaseTool[],
private readonly planTools: BaseTool[],
) {
super(() => true);
}
async getTools(context?: ReadonlyContext): Promise<BaseTool[]> {
const isPlan = context?.state.get('plan_mode') === true;
return isPlan ? this.planTools : this.chatTools;
}
async close(): Promise<void> {}
}
```
## 3.10 Tool Output Masking
Managing context window efficiency by offloading bulky tool outputs (for example, shell logs and large file reads) to files.
* **Current State:** `ToolOutputMaskingService` in `packages/core/src/context/toolOutputMaskingService.ts`.
* **Proposed ADK Mapping:** masking runs on **outgoing request history only**, after compaction.
* **Design choice:** persisted session history remains the canonical event log. Masking artifacts are session-scoped files referenced from the outbound request projection.
---
# 5. Known Gaps in ADK (Gating Blockers)
This section highlights existing gaps in standard ADK that prevent a seamless cutover without bridge logic or upstream changes.
## 5.1 Real-Time User Message Injections (Aborted Turns)
While next-step steering is possible today using `beforeModelCallback`, true real-time interruption requires:
- stable input-stream support, and
- a complete TypeScript live runtime
Until then, the supported behavior is queued next-step steering at model boundaries, not mid-stream interruption.
## 5.2 Conversation Rewind and State Reversal
Translating manual trajectory drops to ADK runtime state is cumbersome. While Python ADK supports rollback, TypeScript ADK does not yet support it natively.
* **Resolution Strategy:** Gemini CLI implements rewind in `GcliFileSessionService`, but **not** as a shallow JSON edit. Rewind truncates the event log, recomputes derived state, and invalidates any confirmation/resumption state past the rewind point.
## 5.3 Phase 1 Non-Goals
To keep the migration surface bounded, Phase 1 intentionally excludes:
- non-interactive surfacing of `elicitation_request` / `elicitation_response`
- true live/bidi interruption semantics
- native ADK confirmation/resumption parity for approvals and fallback prompts
---
# Long-Term Vision: Unification of Agents and Subagents
The long-term vision is that subagents and the primary agent share:
- the same runtime core
- the same tool definitions
- the same policy constraints
- the same configuration schemas
- the same orchestration contract: `AgentSession`
What may differ is the **embedding adapter**:
- standalone/top-level agent -> consumed directly as `AgentSession`
- subagent embedded by a parent agent -> projected from its `AgentSession` into parent-facing tool or child-thread events
For SDK-first unification, Gemini CLI orchestration targets `AgentSession`, not a specific ADK tool abstraction. When a subagent must be exposed to a parent model as a tool, Gemini CLI wraps that child `AgentSession` with a `FunctionTool` or custom `BaseTool` projection. `AgentTool` remains the native ADK nested-agent option if we later want full ADK-native nested-agent semantics.
---
# Migration Sequence and Unification Checklist
The migration remains non-sequential, but each phase has an explicit success condition:
- [ ] **Non-interactive session parity** `#22699`
output/error parity, basic replay correctness, feature-flagged rollout
- [ ] **Interactive session parity** `#22701`
projected event parity, approval bridge wiring, no live-interrupt claim
- [ ] **Subagent adapter parity** `#22700`
tool isolation, activity projection, policy/routing parity for child runs
- [ ] **ADK session conformance** `#22974`
`AgentSession` timing/replay guarantees preserved by the adapter
- [ ] **Skills parity** `#22966`
skills work through the shared runtime without special legacy paths
- [ ] **Policy and confirmation parity** `#22964`
phase-1 callback bridge stable; native confirmation migration scoped separately
- [ ] **Compaction quality parity** `#22979`
comparable summarization and truncation quality to current behavior
---
# Appendix A: Initialization Sketch
The main agent and subagents share the same composition root. The difference is whether the resulting `AgentSession` is consumed directly or projected into a parent session.
```typescript
shared = {
baseModel,
modelConfigService,
availabilityService,
router,
authService,
policyEngine,
mcpClientManager,
skillCatalog,
baseToolCatalog,
sessionService,
}
agentService = new AdkAgentService(shared)
function createSession(definition, parentSessionId?) {
sessionRecord = sessionService.create({ definition, parentSessionId })
registries = buildScopedRegistries(definition, parentSessionId)
pipeline = createPipeline({
sessionId: sessionRecord.id,
agentId: definition.name,
parentSessionId,
})
model = new GcliAgentModel(baseModel, pipeline)
runtime = createAdkRuntime({
definition,
model,
sessionRecord,
registries,
policyEngine,
})
return new AgentSession(new AdkAgentProtocolAdapter(runtime))
}
function createSubagentTool(definition) {
return new FunctionTool(async (input, toolContext) => {
child = createSession(definition, toolContext.sessionId)
stream = await child.send(userMessage(input))
for await (event of child.stream(stream)) {
projectChildEventToParent(toolContext, event)
}
return collectFinalText()
})
}
```
Child session projection shape:
```typescript
emit(tool_request({ requestId, name: definition.name, args: input }))
for await (event of childSession.stream({ streamId })) {
if (event.type === 'message' || event.type === 'tool_update') {
emit(
tool_update({
requestId,
content: projectDisplayContent(event),
}),
)
continue
}
if (event.type === 'error') {
emit(
tool_response({
requestId,
name: definition.name,
isError: true,
content: projectErrorContent(event),
}),
)
return
}
if (event.type === 'agent_end') {
emit(
tool_response({
requestId,
name: definition.name,
content: collectFinalChildResult(),
}),
)
return
}
}
```
Only the final `tool_response` is returned to the parent model. `tool_update` remains a progress/UI projection surface.
Service lifetime split:
- shared across sessions: model config, availability, routing, auth, policy engine, MCP manager, skill catalog
- scoped per agent/session: `AgentSession`, pipeline instance, tool/prompt/resource registries, derived message bus
- scoped per invocation: shell process, MCP request, confirmation continuation, tool progress updates
---
# Status of this document approvals table {#status-of-this-document}
| #begin-approvals-addon-section See [go/g3a-approvals](http://goto.google.com/g3a-approvals) for instructions on adding reviewers. |
| :---: |
.
-170
View File
@@ -1,170 +0,0 @@
# M5 Implementation Details
Companion to `replatforming_design.md`. Per-flow walkthroughs, the routing
and masking integration recipes, and the seam-level acceptance criteria
for the early scaffolding PRs. Anything strategic goes back into the
design doc; this file is the mechanical reference.
## Surface Phases
M5 lands by runnable surface, not by isolated subsystem. Each phase wires
one call site end-to-end before the next starts.
**Phase A — Non-interactive.** Wires the ADK runtime through the
non-interactive AgentSession path. Translator, `GcliAgentModel` dispatch
through `ContentGenerator`, `GcliRoutingProcessor`, 429 retry +
`handleFallback` + `ModelAvailabilityService` (silent-policy branch swaps
models without a UI prompt; non-silent intents are unreachable until the
TUI handler lands), abort, scheduler-backed tool execution,
`ToolOutputMaskingProcessor`, `LoopDetectionAdkPlugin`, native
`MCPToolset` (`tools/list` + `tools/call`).
**Phase B — Local subagents.** Same `AdkAgentProtocol` as Phase A; new
work is subagent-only behavior: factory wiring at
[`subagent-tool-wrapper.ts:97`](https://github.com/google-gemini/gemini-cli/blob/main/packages/core/src/agents/subagent-tool-wrapper.ts#L97), `complete_task` terminator, grace-period
recovery turn, scoped execution wrappers (workspace, memory-inbox,
auto-memory-extraction), and the `onWaitingForConfirmation` activity
signal. Event propagation to the parent is already an M4 property — no
new wiring.
**Phase C — Interactive.** Wires the TUI on top of the proven runtime.
Stream rendering parity, `session_update`, `HookBridgePlugin` + lifecycle
hooks + `Notification`, plan mode (`InstructionProvider` + mode-aware
tools), user steering injection, the TUI-registered
`fallbackModelHandler` callback (unlocks `retry_once`/`stop`/`upgrade`),
and `/rewind`. **All `/slash commands` live outside the protocol
layer** — `/rewind` is a slash-command/runtime-adapter concern.
## Walkthroughs
**Typical message:** user input → `AdkAgentProtocol.send` → emit `agent_start` (`BeforeAgent` hook fires) → emit deterministic `session_update{model}` once `config.getModel()` is resolved (do NOT wait on first translator output — first output may be `error` or `usage`) → `Runner.runAsync` → request processors run in one explicit ordered list (ADK defaults through `CONTENT_REQUEST_PROCESSOR`, then `ToolOutputMaskingProcessor`, then `GcliRoutingProcessor`, then the remaining ADK defaults; supplying `config.requestProcessors` replaces ADK's default list, so we must include the defaults ourselves) → tool preprocessing → `beforeModelCallback` runs guards + `BeforeModel` / `BeforeToolSelection` hooks → `GcliAgentModel.generateContentAsync` dispatches through `config.getContentGenerator().generateContentStream(...)` → translator emits `message` + `tool_request` events → `toAdkTool.execute` calls `scheduler.schedule` (`BeforeTool` hook, approval, exec, per-call truncation at 40k chars via [`tool-executor.ts:196-292`](https://github.com/google-gemini/gemini-cli/blob/main/packages/core/src/scheduler/tool-executor.ts#L196-L292), `AfterTool` hook) → feeds back → loop iterates → final `message` events → `agent_end` (`AfterAgent` hook fires). ADK currently dispatches function calls sequentially within a turn ([`functions.ts:345-491`](https://github.com/google/adk-js/blob/main/core/src/agents/functions.ts#L345-L491)), and `scheduler.schedule` queues concurrent callers — no interleaving risk.
**429 fallback:** `GcliAgentModel` owns the same retry loop shape that `GeminiChat` owns today: the content-generator call is inside a `retryWithBackoff` `apiCall` closure, `onPersistent429` calls `handleFallback(config, currentModel, authType, error)`, the handler applies the existing `ModelAvailabilityService` transition and may call `config.activateFallbackMode(...)` for `retry_always`, then the retry loop resets attempts and re-runs the same closure. Do not throw to ADK Runner for retry; `LlmAgent.runAndHandleError` converts thrown model errors into error responses/events. Fallback retry must re-resolve the same current model/config path used by `GcliRoutingProcessor`, and the parity test must cover a fallback that changes the concrete model so we do not dispatch with stale model-specific config or tool declarations. The TUI's `fallbackModelHandler` callback ([`handler.ts:89`](https://github.com/google-gemini/gemini-cli/blob/main/packages/core/src/fallback/handler.ts#L89)) is what unlocks the non-silent intents (`retry_once`, `stop`, `upgrade`); non-interactive runs without one and `handleFallback` returns `null` after silent transitions — that's by design, not a Phase A limitation.
**Loop detection:** `LoopDetectionAdkPlugin.onEventCallback` feeds each `partial:true` delta to `LoopDetectionService.addAndCheck`, and ignores the `partial:false` consolidated event so the accumulated buffer isn't double-counted. On terminate the plugin calls `protocol.abort()` with reason `LOOP_DETECTED`; the protocol emits a synthetic `error{_meta.code:'LOOP_DETECTED'}` event and the abort propagates through `InvocationContext.abortSignal` into `GcliAgentModel.generateContentAsync`. The plugin does NOT mutate the in-flight ADK event and does NOT abort inside `onEventCallback` — that would drop the event before yield ([`runner.ts:332-334`](https://github.com/google/adk-js/blob/main/core/src/runner/runner.ts#L332-L334)).
**`/rewind`:** slash command → slash-command/runtime-adapter (NOT an `AgentProtocol` method) → `ChatRecordingService.rewindTo(id)` truncates `ConversationRecord.messages` ([`chatRecordingService.ts:743-762`](https://github.com/google-gemini/gemini-cli/blob/main/packages/core/src/services/chatRecordingService.ts#L743-L762)) → `sessionService.deleteSession(...)` drops the ADK session → emits `session_update`. Next `send()` calls `sessionService.createSession(...)` then `appendEvent`s each retained `ConversationRecord` message converted to an ADK `Event` (`InMemorySessionService.createSession` has no seed-events parameter — [`in_memory_session_service.ts:54-67`](https://github.com/google/adk-js/blob/main/core/src/sessions/in_memory_session_service.ts#L54-L67)). Conversion fields: ISO timestamp → epoch ms, `type:'user'|'gemini'``author`, synthesized deterministic `invocationId` (ConversationRecord has none), `ToolCallRecord``FunctionCall` + `FunctionResponse` Parts, thoughts and `TokensSummary``actions.customMetadata`. Rewind-injected events are non-partial by construction. Wired in Phase C alongside the rest of the interactive surface.
**Subagent invocation:** Parent ADK `LlmAgent` dispatches an M4 subagent tool → [`subagent-tool-wrapper.ts:97`](https://github.com/google-gemini/gemini-cli/blob/main/packages/core/src/agents/subagent-tool-wrapper.ts#L97) calls the session factory (today it constructs `LocalSubagentInvocation` directly — [`local-invocation.ts:48`](https://github.com/google-gemini/gemini-cli/blob/main/packages/core/src/agents/local-invocation.ts#L48)) → factory returns an ADK-backed `AgentProtocol` when the flag is on, legacy `LocalSubagentInvocation` otherwise → child constructs its own independent `Runner` with its own `LlmAgent`, plugins, tools, and `GcliSessionService` → child events stream back through the M4 wrapper to the parent as `tool_update`s and a terminal `tool_response`. Both implementations satisfy `AgentProtocol`; the factory selects between them. Existing parent-side telemetry on [`subagent-tool.ts:224`](https://github.com/google-gemini/gemini-cli/blob/main/packages/core/src/agents/subagent-tool.ts#L224) / [`subagent-tool-wrapper.ts:75`](https://github.com/google-gemini/gemini-cli/blob/main/packages/core/src/agents/subagent-tool-wrapper.ts#L75) is the correlation point — no aggregation changes for M5.
## Model routing
ADK ships a `RoutedLlm` that can sit in front of a `BaseLlm` and pick a model
per call. M5 does not use it. Gemini CLI already owns the routing stack
(`ModelRouterService`, sequence-sticky model, `applyModelSelection`,
`ModelConfigService`, `ModelAvailabilityService`, fallback config), and that
behavior has to stay byte-for-byte the same through the deprecation window.
Bolting a second routing layer on top would not preserve behavior; it would
give us two places to keep in sync.
The integration is a custom ADK **request processor**, not a step inside
`BaseLlm`. ADK runs request processors at [`llm_agent.ts:731-743`](https://github.com/google/adk-js/blob/main/core/src/agents/llm_agent.ts#L731-L743) before
tool preprocessing ([`llm_agent.ts:746`](https://github.com/google/adk-js/blob/main/core/src/agents/llm_agent.ts#L746)) and before
`BaseLlm.generateContentAsync` ([`llm_agent.ts:1024`](https://github.com/google/adk-js/blob/main/core/src/agents/llm_agent.ts#L1024)). Selecting the model
inside `generateContentAsync` is too late — tool declarations and
generation config have already been materialized from whatever model was
on the `LlmRequest` when it arrived.
Per-call flow inside `GcliRoutingProcessor.runAsync(invocationContext, llmRequest)`:
1. Build a `RoutingContext` from the masked `llmRequest.contents` + `invocationContext.session` + `Config` + `abortSignal`. Same shape `GeminiClient.processTurn` constructs today.
2. If the current sequence model is set, keep using it for this tool-call sequence. Otherwise call `config.getModelRouterService().route(routingContext)`. Sequence-sticky state moves to `Config` (or its successor) in PR #14 with named reset points — today it lives on `GeminiClient` ([`client.ts:101`](https://github.com/google-gemini/gemini-cli/blob/main/packages/core/src/core/client.ts#L101), [`:339`](https://github.com/google-gemini/gemini-cli/blob/main/packages/core/src/core/client.ts#L339)).
3. Run the selection through the existing path: `applyModelSelection(...)`, `ModelConfigService`, `ModelAvailabilityService`. Active model, availability state, fallback config, and resolved generation config stay consistent with the legacy run loop. Set the current sequence model.
4. Rewrite `llmRequest.model`, `llmRequest.config` (system instructions, generation config), and the tool subset in place. ADK's tool preprocessing then sees the resolved model.
`GcliAgentModel.generateContentAsync` is the content-generator dispatcher plus the legacy-compatible fallback retry loop:
5. Check abort signal.
6. Map ADK `LlmRequest``GenerateContentParameters` and call `config.getContentGenerator().generateContentStream(...)`. Auth (login-with-Google, Gemini API key, Vertex, ADC/compute, gateway, fake responses, logging/recording wrappers) lives inside the content generator implementation — no header injection in this layer.
7. Stream the result back to ADK, propagating abort. On 429 / persistent quota errors, stay inside the local `retryWithBackoff` loop: call `handleFallback(...)`, let it apply the existing availability/fallback state transition, reset attempts when it returns a retry intent, and re-enter the content-generator call path. A thrown model error is terminal for this ADK iteration unless an ADK `onModelError` callback returns a replacement response; Runner does not automatically re-run request processors.
## Availability service integration
`ModelAvailabilityService` is a self-contained state machine — no Config in its constructor, no Config reads in its methods, just a `Map<ModelId, HealthState>` and the methods that mutate it ([`modelAvailabilityService.ts:41-137`](https://github.com/google-gemini/gemini-cli/blob/main/packages/core/src/availability/modelAvailabilityService.ts#L41-L137)). The class is reusable as-is under ADK. The integration *around* it is Config-driven, and that's what has to be ported.
What feeds the service: `handleFallback` reads the policy chain from Config (`resolvePolicyChain(config)`), takes the failed model as a parameter, and calls `availability.selectFirstAvailable(candidates)`. The failed model comes from [`client.ts:1080`](https://github.com/google-gemini/gemini-cli/blob/main/packages/core/src/core/client.ts#L1080): `const active = this.config.getActiveModel()`. The retry loop re-polls Config between attempts so any mid-loop fallback mutation is picked up cleanly.
What resets the service: two triggers, both outside the service. `reset()` fires from `config.setModel()` ([`config.ts:1813`](https://github.com/google-gemini/gemini-cli/blob/main/packages/core/src/config/config.ts#L1813)), itself triggered by `config.activateFallbackMode()` after a `retry_always` decision. `resetTurn()` fires at the turn boundary — call site not yet verified, probably `client.ts` or `GeminiChat`.
What `GcliAgentModel` has to do: replicate the [`client.ts:1072-1110`](https://github.com/google-gemini/gemini-cli/blob/main/packages/core/src/core/client.ts#L1072-L1110) retry pattern faithfully — pre-call read `config.getActiveModel()`, call `handleFallback(config, currentAttemptModel, authType, error)` on persistent 429, re-poll Config before the next attempt. The `resetTurn` trigger lands at whatever ADK boundary corresponds to "new turn started" — ADK doesn't know about it, so it's an explicit wire.
Subagent note: `AgentLoopContext.config` is shared by reference between parent and child, so parent and subagent share the same `ModelAvailabilityService.health` map and the same `_activeModel`. A subagent fallback mutates parent state. This is legacy behavior, not an ADK regression — flagging it as a documented property, not a Phase A task.
Open Questions: locate the `resetTurn` call site in legacy and map it to an ADK trigger.
## Tool output masking
Two mechanisms, only one is new under ADK:
**Per-call truncation (already done by the scheduler).** When a tool returns, [`tool-executor.ts:196-292`](https://github.com/google-gemini/gemini-cli/blob/main/packages/core/src/scheduler/tool-executor.ts#L196-L292) truncates content > 40k chars for shell and single-text-part MCP tools, writes the full output to `<projectTempDir>/.../<toolName>_<callId>_<random>.txt`, and returns a snippet + file path pointer. This rides for free in M5 because `toAdkTool` calls the scheduler.
**Per-turn batch masking (this is what's new).** Today [`client.ts:637`](https://github.com/google-gemini/gemini-cli/blob/main/packages/core/src/core/client.ts#L637) calls `tryMaskToolOutputs(getHistory())` inside `processTurn`, after compression and before the model call. `ToolOutputMaskingService.mask` walks the whole history, applies a 50k-protection + 30k-prunable-gate sliding window, writes full content to disk, replaces `functionResponse.response` with a `<tool_output_masked>...</tool_output_masked>` preview + pointer ([`toolOutputMaskingService.ts:70-272`](https://github.com/google-gemini/gemini-cli/blob/main/packages/core/src/context/toolOutputMaskingService.ts#L70-L272)). Then `setHistory` syncs to `ChatRecordingService.updateMessagesFromHistory` ([`chatRecordingService.ts:768`](https://github.com/google-gemini/gemini-cli/blob/main/packages/core/src/services/chatRecordingService.ts#L768)).
Under ADK this becomes a custom `RequestProcessor` (`ToolOutputMaskingProcessor`) registered with `LlmAgent` after ADK has built `llmRequest.contents` from `session.events` and before `GcliRoutingProcessor` reads those contents. The processor:
1. Reads `llmRequest.contents` (produced by ADK's `ContentRequestProcessor` from `session.events`).
2. Calls `ToolOutputMaskingService.mask(llmRequest.contents, config)`. The algorithm is unchanged.
3. Applies each masked `functionResponse` back to the matching live ADK `Session.events` part by stable event/part identity. Do not use `session.events[i]``llmRequest.contents[i]`; ADK filters, rearranges, and clone-deeps content while building the request ([`content_processor_utils.ts:72-84`](https://github.com/google/adk-js/blob/main/core/src/agents/processors/content_processor_utils.ts#L72-L84)).
4. Writes the masked `llmRequest.contents` back so the model call and downstream routing see the masked view.
5. Calls `ChatRecordingService.updateMessagesFromHistory(masked)` so the persisted transcript matches.
`isAlreadyMasked` keeps it idempotent across turns. The disk file path stays the same across runs once we drop the `Math.random()` suffix in the filename (also part of PR #21).
## GcliSessionService
`InMemorySessionService.getSession` does `cloneDeep(session)` ([`in_memory_session_service.ts:88-131`](https://github.com/google/adk-js/blob/main/core/src/sessions/in_memory_session_service.ts#L88-L131)), so the runner operates on a clone of the stored session. Any mutation to `invocationContext.session.events[i]` only persists for the duration of `runAsync`; the next turn fetches a fresh clone. Subclassing it is not enough: its `sessions` store is private ([`in_memory_session_service.ts:40`](https://github.com/google/adk-js/blob/main/core/src/sessions/in_memory_session_service.ts#L40)), and returning the stored reference from `getSession` would make inherited `appendEvent` push the same event twice ([`in_memory_session_service.ts:173-227`](https://github.com/google/adk-js/blob/main/core/src/sessions/in_memory_session_service.ts#L173-L227)).
`GcliSessionService` extends `BaseSessionService`, not `InMemorySessionService`. It owns an authoritative live-session map keyed by `appName/userId/sessionId`. Plugins and request processors that mutate session events (masking is the primary use case) get persistence across turns.
```ts
class GcliSessionService extends BaseSessionService {
private readonly liveSessions = new Map<string, Session>();
private key(appName: string, userId: string, sessionId: string): string {
return `${appName}\0${userId}\0${sessionId}`;
}
override async createSession(req: CreateSessionRequest): Promise<Session> {
const session = createSession({
id: req.sessionId ?? randomUUID(),
appName: req.appName,
userId: req.userId,
state: req.state ?? {},
events: [],
lastUpdateTime: Date.now(),
});
this.liveSessions.set(this.key(req.appName, req.userId, session.id), session);
return session;
}
override async getSession(req: GetSessionRequest): Promise<Session | undefined> {
const session = this.liveSessions.get(
this.key(req.appName, req.userId, req.sessionId),
);
if (!session || !req.config) return session;
// If a caller asks for a filtered view, return a copy so the caller cannot
// mutate a truncated live session.
return copyWithFilteredEvents(session, req.config);
}
override async appendEvent(req: AppendEventRequest): Promise<Event> {
const event = await super.appendEvent(req); // pushes once, skips partials
req.session.lastUpdateTime = event.timestamp;
return event;
}
}
```
`listSessions` and `deleteSession` operate on `liveSessions`; they do not delegate to an inner `InMemorySessionService` whose state would drift once `appendEvent` stops delegating. The runner calls `getSession` without `GetSessionConfig`, but preserving filtered-copy behavior for `numRecentEvents` / `afterTimestamp` keeps the service compatible with other ADK callers. If any M5 plugin uses ADK app/user state prefixes, PR #20 ports `InMemorySessionService`'s app-state/user-state handling too; otherwise the service intentionally supports only session-local state.
## PR seam summary
Seam names and acceptance criteria for the foundation PRs. Aside from the `GcliSessionService` code shape above, pseudo-code stubs are intentionally omitted because they drift from real code; the per-PR description owns the implementation specifics.
**PR #1 — Scaffold.** Add `packages/core/src/adk-agent/`. Export `AdkAgentProtocol` (skeleton implementing `AgentProtocol`), `GcliSessionService` (`BaseSessionService` implementation with live session storage), and named seam files for `GcliAgentModel`, `GcliRoutingProcessor`, `ToolOutputMaskingProcessor`, `HookBridgePlugin`, `LoopDetectionAdkPlugin`, `toAdkTool`. All bodies are TODO; compile green; no behavior change. Acceptance: module compiles, file structure matches the architectural sketch, all symbols importable.
**PR #2 — Flag + factory.** Add `experimental.adk.runtimeEnabled` to the settings schema. Implement the session factory: builds an `AdkAgentProtocol` when the runtime flag is on, otherwise the existing legacy implementation. Define precedence with `experimental.adk.agentSessionNoninteractiveEnabled` (the new flag is the runtime selector; the old one continues to gate the non-interactive code path). Acceptance: flag visible in settings, factory unit-tested with both flag states, no production caller wired yet.
**PR #3 — Event-type cleanup.** Delete unused `elicitation_request`, `elicitation_response`, and `elicitations` references that the protocol surface no longer uses.
(`/rewind` is intentionally NOT a foundation PR. It stays in the slash-command/runtime-adapter layer and is wired during Phase C — see Surface Phases above.)
-472
View File
@@ -1,472 +0,0 @@
# Gemini CLI Replatforming Design
## Goals
1. **Fast ADK Replatforming:** Transition the default Gemini CLI agent to use
the ADK execution engine to reduce maintenance overhead on the legacy run
loop.
2. **Reuse Existing Frameworks:** Minimize UI and internal API churn by keeping
existing callback mechanisms and frameworks. Specifically, we will _not_
rewrite the existing tool approval and model fallback mechanisms to use ADK's
`elicitation_request`/`elicitation_response` events. We will reuse the
existing message bus and scheduler integrations.
3. **Unified Core Engine:** Ensure the main interactive chat, non-interactive
mode, and subagents execute via the exact same core ADK engine.
## Non-Goals
1. **No Public SDK Readiness:** We are no longer refactoring the core codebase
to expose a polished, public-facing SDK.
2. **No BYOA / Multi-Agent Orchestration:** We are explicitly not building
extensibility for third-party agents or external runtime orchestration.
3. **No UI Modernization for Elicitations:** We will not refactor the React TUI
or underlying message bus to adopt standard ADK elicitation events. Tool
approvals and system interrupts will continue to use the legacy callback
bridges.
---
## Open Questions
Where we want feedback. Each links to the section with more context — please weigh in there.
**Milestone 3 — TUI behind `AgentSession`:** scope to be fleshed out by @Jacob Richman; tracked in [#22702](https://github.com/google-gemini/gemini-cli/issues/22702). Exemplar areas listed in [Transition Plan](#transition-plan).
**Milestone 5 — ADK Execution Engine:**
- Loop detection: which event stream feeds the detector, how does soft-recovery get into the next request, and which terminate mechanism (abort / throw / replacement response)? See [Loop detection](#loop-detection).
- MCP: upstream the missing surface into `MCPToolset`, or keep `McpClient` and pass its outputs as functional tools? See [MCP](#mcp).
- Slash commands: live outside the protocol layer — scope of the slash-command/runtime-adapter layer TBD. See [Slash commands](#slash-commands).
---
## Milestones
1. AgentSession Interface Create `AgentSession` abstraction flexible enough to
support the legacy runtime and ADK.
2. Adapting the Non-Interactive Runtime Adapt the existing non-interactive CLI
to conform to the new `AgentSession` API.
3. Initial TUI Adaptation & Session Creation Move the main agent behind the
`AgentSession` interface, adapting the TUI via `useAgentStream` and
`LegacyAgentProtocol` while reusing the message bus. **Includes implementing
the Agent Creation Factory/Function.**
4. Subagent Orchestration Decouple subagents from the legacy runtime by wrapping
them in the `AgentSession` interface.
5. ADK Execution Engine Build the unified ADK agent conforming to
`AgentSession`, handling all modes (Main, Non-Interactive, Subagents).
Adopts ADK's loop and request processors; retains gemini-cli's scheduler,
routing, fallback, masking, hooks, compression, recording, and loop
detection. See [Detailed Design §5](#5-milestone-5-adk-execution-engine).
## Progress
| Milestone | Owner | Status | Relevant PRs / Branches | Time Estimate |
| :------------------------------------------- | :------------------------------- | :---------- | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :------------ |
| 1. AgentSession Interface | Adam Weidman, Michael Bleigh | ✅ Complete | [PR #22270](https://github.com/google-gemini/gemini-cli/pull/22270), [PR #23159](https://github.com/google-gemini/gemini-cli/pull/23159), [PR #23548](https://github.com/google-gemini/gemini-cli/pull/23548) | - |
| 2. Adapting the Non-Interactive Runtime | Adam Weidman | ✅ Complete | [PR #22984](https://github.com/google-gemini/gemini-cli/pull/22984), [PR #22985](https://github.com/google-gemini/gemini-cli/pull/22985), [PR #22986](https://github.com/google-gemini/gemini-cli/pull/22986), [PR #24439](https://github.com/google-gemini/gemini-cli/pull/24439), [PR #22987](https://github.com/google-gemini/gemini-cli/pull/22987) | - |
| 3. Initial TUI Adaptation & Session Creation | Michael Bleigh | 🚧 WIP | [PR #24275](https://github.com/google-gemini/gemini-cli/pull/24275), [PR #24287](https://github.com/google-gemini/gemini-cli/pull/24287), [PR #24292](https://github.com/google-gemini/gemini-cli/pull/24292), [PR #24297](https://github.com/google-gemini/gemini-cli/pull/24297), [Issue #25046](https://github.com/google-gemini/gemini-cli/issues/25046) | 2-3 weeks |
| 4. Subagent Orchestration | Adam Weidman | 🚧 WIP | [PR #25302](https://github.com/google-gemini/gemini-cli/pull/25302), [PR #25303](https://github.com/google-gemini/gemini-cli/pull/25303)<br>Branches: `agent-session/local-invocation`, `agent-session/remote-invocation`, `agent-session/agent-tool` | 1 week |
| 5. ADK Execution Engine | Adam Weidman, Alexey Kalenkevich | 🚧 WIP | Alexey Prototype: `eeb9301`. See [M5 Execution Plan](#m5-execution-plan) below for the PR breakdown. | 4 weeks |
| 6. Testing / Validation / Bug fixing | Adam Weidman, Alexey Kalenkevich | ⏳ Upcoming | Folded into each phase below: non-interactive first, then subagents, then interactive. See [M5 Execution Plan](#m5-execution-plan). | 2 weeks |
_(Note: Previous milestones related to "Unified Elicitations" and "Adopt ADK
Primitives" have been removed as per the updated Non-Goals)._
---
## Detailed Design
### 1. Milestone 1: AgentSession Interface
The `AgentSession` interface is the core abstraction that decouples the TUI from
the specific agent implementation by proposing a purely event-driven loop
boundary (`AgentEvent` streams). _For full rationale and design, see the
[Gemini CLI Agents design document](https://docs.google.com/document/d/1Zv2_VuVNc-PtsFIU5HYApdmaC3EyK5_fNeivYUtL1fs/edit?tab=t.0#heading=h.ok5bx3z7fmr8)._
### 2. Milestone 2: Adapting the Non-Interactive Runtime
The non-interactive runtime conforms to the `AgentSession` API via a legacy
protocol adapter. This adapter translates internal message bus and scheduler
events into standard agent event streams. Because this mode does not require
user interaction, it bypasses complex UI features like tool approvals.
This capability is enabled via the following experimental setting in
`.gemini/settings.json`:
```json
{
"experimental": {
"adk": {
"agentSessionNoninteractiveEnabled": true
}
}
}
```
### 3. Milestone 3: Initial TUI Adaptation & Session Creation
Adapting the interactive TUI to consume the `AgentSession` interface is a
complex, multi-layered effort. The core mechanism is an event-streaming hook
that subscribes to the session.
#### Current Event Handling State (Legacy Mechanism)
The legacy system relies on a central `Scheduler` and a `MessageBus` to
orchestrate tool execution and approvals. This is the mechanism we are retaining
to avoid a full UI refactor:
- **Tool Approvals**: When a tool requires user confirmation, the scheduler
calls `resolveConfirmation`
([`scheduler.ts:L663`](https://github.com/google-gemini/gemini-cli/blob/main/packages/core/src/scheduler/scheduler.ts#L663))
and updates the tool's status to `AwaitingApproval` in `confirmation.ts`
([`confirmation.ts:L162`](https://github.com/google-gemini/gemini-cli/blob/main/packages/core/src/scheduler/confirmation.ts#L162)).
- **UI Notification**: The `SchedulerStateManager` publishes a
`TOOL_CALLS_UPDATE` event on the message bus
([`state-manager.ts:L254`](https://github.com/google-gemini/gemini-cli/blob/main/packages/core/src/scheduler/state-manager.ts#L254)).
The UI hook `useToolScheduler.ts` subscribes to this event to update the React
state
([`useToolScheduler.ts:L178`](https://github.com/google-gemini/gemini-cli/blob/main/packages/cli/src/ui/hooks/useToolScheduler.ts#L178)).
- **User Interaction**: If any tool requires approval, the TUI transitions to a
waiting state
([`useGeminiStream.ts:L174`](https://github.com/google-gemini/gemini-cli/blob/main/packages/cli/src/ui/hooks/useGeminiStream.ts#L174))
and renders the `ToolConfirmationQueue.tsx` component.
- **Response Loop**: Once the user makes a decision, `ToolActionsContext.tsx`
publishes a `TOOL_CONFIRMATION_RESPONSE`
([`ToolActionsContext.tsx:L150`](https://github.com/google-gemini/gemini-cli/blob/main/packages/cli/src/ui/contexts/ToolActionsContext.tsx#L150)).
The scheduler, which is blocked in `waitForConfirmation`
([`confirmation.ts:L168`](https://github.com/google-gemini/gemini-cli/blob/main/packages/core/src/scheduler/confirmation.ts#L168)),
receives the response via the message bus and resumes execution.
#### Transition Plan
The interactive `AgentSession` adapter continues to leverage the legacy message bus and scheduler rather than migrating tool approvals to ADK elicitations — minimizing UI churn. The adapter subscribes to the message bus and translates read-only observational events (`tool_update`, `message`, `usage`) into `AgentEvent` streams; interactive/blocking events (tool approvals) stay on the legacy callback path.
Work areas (scope is settled; implementation within each owned by @Jacob Richman in [#22702](https://github.com/google-gemini/gemini-cli/issues/22702)):
- **Event Parity:** standard tool calls, MCP tool calls, and subagent display.
- **UI Coalescence:** using `agent_start` / `agent_end` for robust UI state management.
- **Tool-Controlled Display:** per-tool render variants (`FileDiff`, `TodoList`, `AnsiOutput`, etc.) passing through `AgentEvent`, plus tool-triggered out-of-band display state (e.g., IDE diff overlay, plan-mode toggle).
- **Command Routing:** client-initiated commands (slash commands) routed down to the session; ensuring `abort` works properly.
- **System Notices:** generic notice events for system notifications.
### 4. Milestone 4: Subagent Orchestration
Subagents invoke both local and remote execution via a unified `AgentTool` that
wraps them in `AgentSession` instances. This pattern translates low-level
execution events into standard `AgentEvent`s for the parent session.
### 5. Milestone 5: ADK Execution Engine
Replaces the legacy `GeminiClient` + `Turn` loop with an ADK `Runner` + `LlmAgent`
behind the existing `AgentSession` interface. ADK contributes the agent loop;
gemini-cli contributes everything else.
Reference implementation:
[commit `eeb9301a`](https://github.com/google-gemini/gemini-cli/commit/eeb9301a489a1609f7d74e6c61569d82c5742821).
#### Approach: progressive integration
Start by landing the initial scaffolding: the `adk-agent/` module, the runtime
flag, the top-level session factory, and the event-type cleanup. These changes
should compile green, keep the flag off by default, and avoid behavior changes.
Once the initial scaffolding lands, M5 proceeds by **runnable surface**, not
by isolated subsystem. Each phase wires one call site end-to-end before
moving to the next:
1. **Non-interactive first.** Run the ADK loop end-to-end without UI. This phase brings up the translator, model dispatch through `GcliAgentModel`, routing, scheduler-backed tool execution, output masking, abort propagation, loop detection, and native MCP. The 429 retry path lands here too: `handleFallback` silently swaps to an available model when the policy allows. The TUI-side prompt that lets a user pick `retry_once` or `stop` is a separate callback registered in Phase C; non-interactive runs without it.
2. **Local subagents second.** Same ADK runtime — no second implementation. The new work is the subagent-only behavior that doesn't exist on the top-level loop: `complete_task` as a mandatory terminator, grace-period retry, scoped workspace/memory wrappers, and the confirmation-waiting activity signal. Event propagation back to the parent already works through the M4 wrapper.
3. **Interactive last.** Wire the TUI once the runtime is proven. Stream rendering, `session_update` behavior, hooks and notifications, plan mode, between-turn steering, the TUI fallback prompt callback, and `/rewind`. All slash commands live outside the protocol layer — `/rewind` is owned by the slash-command/runtime-adapter layer, not `AgentProtocol`.
#### Architectural sketch
```
User input
┌──────────────────────┐
│ AgentSession │ existing wrapper (agent-session.ts:18)
└──────────┬───────────┘ SessionStart / SessionEnd hooks
┌──────────────────────┐
│ AdkAgentProtocol │ send/subscribe/abort/events
│ │ agent_start/end → Before/AfterAgent hooks
└──────────┬───────────┘
┌──────────────────────┐
│ GeminiCliAgent │ extends BaseAgent
│ .runAsyncImpl(ctx) │ yields ADK Events
└──────────┬───────────┘
┌─────────────────────────────────────────────┐
│ ADK Runner with: │
│ • GcliSessionService (live event state) │
│ • RequestProcessors (ordered): │
│ ADK defaults through content build │
│ ToolOutputMaskingProcessor │
│ GcliRoutingProcessor │
│ remaining ADK defaults │
│ • Plugins: │
│ MaxTurns / TokenLimit / MaxTime │
│ HookBridgePlugin │
│ LoopDetectionAdkPlugin │
│ Steering injection │
│ │
│ LlmAgent loop: call → parse → dispatch → │
│ feed back → iterate │
└────────┬──────────────────────┬──────────────┘
│ │
┌────────▼─────────┐ ┌─────────▼──────────────┐
│ GcliAgentModel │ │ toAdkTool │
│ • dispatch │ │ scheduler.schedule({ │
│ • fallback retry │ │ callId, name, args │
│ • abort │ │ }, signal) │
│ │ │ │ → BeforeTool hook │
│ ▼ │ │ → MessageBus policy │
│ ContentGenerator │ │ → tool exec │
│ (auth lives here)│ │ → AfterTool hook │
└──────────────────┘ │ → CompletedToolCall[] │
└─────────────────────────┘
Runner yields Events → adk-event-translator → AgentEvent[]
→ AdkAgentProtocol._emit → subscribers (TUI, non-interactive, etc.)
└─ ChatRecordingService.recordMessage on final (non-partial) events:
user messages, consolidated model responses, usage, tool calls
```
Per-flow walkthroughs (typical message, 429 fallback, loop detection,
`/rewind`, subagent invocation) and scaffold sketches live in
[`implementation_details.md`](./implementation_details.md).
#### Session entry point
`AgentProtocol` is the interface ([`types.ts:11`](https://github.com/google-gemini/gemini-cli/blob/main/packages/core/src/agent/types.ts#L11)). `AgentSession` is the
existing wrapper class that implements `AgentProtocol`
([`agent-session.ts:18`](https://github.com/google-gemini/gemini-cli/blob/main/packages/core/src/agent/agent-session.ts#L18)). M5 adds `AdkAgentProtocol` as a second
implementation; the existing `AgentSession` wrapper composes either one.
The whole ADK runtime sits inside one constructor:
```ts
class AdkAgentProtocol implements AgentProtocol {
constructor(opts: {
config: Config; // model + workspace + services
// (auth lives inside the ContentGenerator
// returned by config.getContentGenerator())
instructionProvider: InstructionProvider; // system prompt (mode-aware)
tools: BaseTool[]; // gemini-cli tools wrapped via toAdkTool;
// MCP tools come from MCPToolset
plugins: BasePlugin[]; // HookBridge, LoopDetection, MaxTurns, ...
requestProcessors: RequestProcessor[]; // full ordered ADK + GCLI processor list
sessionService: BaseSessionService; // GcliSessionService (live in-memory events)
parentSessionId?: string; // present iff this is a subagent
});
send(input: AgentSend): Promise<{ streamId: string | null }>;
subscribe(cb: (e: AgentEvent) => void): Unsubscribe;
abort(): Promise<void>;
get events(): readonly AgentEvent[];
}
```
#### Hooks
Gemini CLI's `HookSystem` (`packages/core/src/hooks/`) ships 11 events
that user-defined hooks integrate against. We keep it as the user-facing
surface; exposing ADK plugins to users would force every existing hook
to rewrite against different signatures.
Eight of the 11 fire at boundaries our own code controls. Three fire
mid-loop where only ADK knows the timing. We fire the eight directly;
the three go through a small ADK plugin.
Owned — fire from our own code:
| Hook | Fires from |
| --- | --- |
| `BeforeTool` / `AfterTool` | `scheduler.schedule()` |
| `BeforeAgent` / `AfterAgent` | `AdkAgentProtocol` on `agent_start` / `agent_end` |
| `SessionStart` / `SessionEnd` | `AdkAgentProtocol` constructor / `.dispose()` |
| `PreCompress` | `ChatCompressionService` |
| `Notification` | `AdkAgentProtocol._emit` on tool-notification events |
Bridged — fire from an ADK plugin callback:
| Hook | Fires from |
| --- | --- |
| `BeforeToolSelection` | `HookBridgePlugin.beforeModelCallback` |
| `BeforeModel` | `HookBridgePlugin.beforeModelCallback` |
| `AfterModel` | `HookBridgePlugin.afterModelCallback` |
`HookBridgePlugin` is a `BasePlugin` registered with the runner. ADK
invokes its model callbacks; the body fires the matching gemini-cli
hook events. One-way, no state.
#### MCP
Today. `mcp-client.ts` is a full server-lifecycle layer: tool discovery, prompt and resource registries, OAuth refresh and 401 recovery, list-change notifications, stdio crash restart, and progress routing.
What ADK provides. `MCPToolset` is tool-scoped — tools/list and tools/call only, for stdio and streamable HTTP ([`mcp_toolset.ts:58`](https://github.com/google/adk-js/blob/main/core/src/tools/mcp/mcp_toolset.ts#L58), [`mcp_tool.ts:65`](https://github.com/google/adk-js/blob/main/core/src/tools/mcp/mcp_tool.ts#L65)), with HTTP auth material set at connection construction ([`mcp_session_manager.ts:38-50`](https://github.com/google/adk-js/blob/main/core/src/tools/mcp/mcp_session_manager.ts#L38-L50)). It builds its own session manager rather than reusing ours, and exposes no path to prompts or resources even though the underlying `@modelcontextprotocol/sdk` client supports them. Stored-token refresh, 401 recovery, list-change handlers, progress notifications, and stdio crash restart are absent.
Open Questions:
- Upstream the missing surface into `MCPToolset`, or keep `McpClient` and pass its outputs (tools, prompts, resources) into the ADK runtime as functional tools?
#### Subagents
A subagent is an `AgentProtocol` with a different system prompt, tool subset, and termination policy. Local subagents run through the same `AdkAgentProtocol` constructor as the top-level session; the session factory selects the runtime and picks which plugins ride along per session. The factory omits `LoopDetectionAdkPlugin` for subagents — they have no loop detection today and terminate via `complete_task` plus the executor's turn/time guards. `Config` and `MessageBus` pass through.
Remote subagents (`RemoteAgentInvocation`, [`remote-invocation.ts:41`](https://github.com/google-gemini/gemini-cli/blob/main/packages/core/src/agents/remote-invocation.ts#L41)) are unaffected — they run on our own A2A integration, and M4's `AgentTool` routes remote vs. local on `definition.kind`. The wiring change is at [`subagent-tool-wrapper.ts:97`](https://github.com/google-gemini/gemini-cli/blob/main/packages/core/src/agents/subagent-tool-wrapper.ts#L97), which today directly constructs `LocalSubagentInvocation` ([`local-invocation.ts:48`](https://github.com/google-gemini/gemini-cli/blob/main/packages/core/src/agents/local-invocation.ts#L48)) and instead delegates to the session factory.
Legacy reference points for the behaviors Phase B reimplements as plugins / wrappers:
- `complete_task` mandatory terminator ([`local-executor.ts:355`](https://github.com/google-gemini/gemini-cli/blob/main/packages/core/src/agents/local-executor.ts#L355)) — subagent must call this tool or it errors with `ERROR_NO_COMPLETE_TASK_CALL`.
- Grace-period recovery turn ([`local-executor.ts:460`](https://github.com/google-gemini/gemini-cli/blob/main/packages/core/src/agents/local-executor.ts#L460), [`:724`](https://github.com/google-gemini/gemini-cli/blob/main/packages/core/src/agents/local-executor.ts#L724)) — single final retry with an injected "you must call `complete_task` now" message on recoverable terminate reasons.
- Scoped execution wrappers ([`local-executor.ts:530-545`](https://github.com/google-gemini/gemini-cli/blob/main/packages/core/src/agents/local-executor.ts#L530-L545), memory injection at [`:624`](https://github.com/google-gemini/gemini-cli/blob/main/packages/core/src/agents/local-executor.ts#L624) and [`:1339`](https://github.com/google-gemini/gemini-cli/blob/main/packages/core/src/agents/local-executor.ts#L1339)) — workspace, memory-inbox, auto-memory-extraction wrap the run.
- `onWaitingForConfirmation` UI signal — propagated through the executor's activity callback today; maps to `BeforeTool` under ADK.
#### Loop detection
Today. `LoopDetectionService` runs two detectors against one strike counter: a sliding-window hash over text/tool-call chunks as they stream ([`loopDetectionService.ts:339-437`](https://github.com/google-gemini/gemini-cli/blob/main/packages/core/src/services/loopDetectionService.ts#L339-L437)), and a per-N-turn semantic check via a side LLM call ([`loopDetectionService.ts:261-311`](https://github.com/google-gemini/gemini-cli/blob/main/packages/core/src/services/loopDetectionService.ts#L261-L311)). Below threshold, `GeminiClient` prepends a "System: Potential loop detected..." nudge to the next request ([`client.ts:1246-1279`](https://github.com/google-gemini/gemini-cli/blob/main/packages/core/src/core/client.ts#L1246-L1279)); at threshold, it throws and the turn unwinds.
What ADK provides. Plugin callbacks at every event, around every model call, and on model error (the latter can return a replacement response to keep the loop going). Model output is double-emitted — partial deltas per chunk AND a single consolidated event for the same response — so anything reading the stream has to pick one to avoid double-counting. Exit mechanisms ([`llm_agent.js:634-686`](https://github.com/google/adk-js/blob/main/core/src/agents/llm_agent.ts#L634-L686)): a tripped abort signal returns the generator silently; a thrown Error from inside the model call yields a synthetic error event and ends the turn; a replacement response from `onModelErrorCallback` keeps the loop going. ADK has no direct equivalent of "prepend a system message to the next request"; the closest path is mutating the request contents inside `beforeModelCallback`.
Open Questions:
- Which event stream feeds the detector — partial deltas vs consolidated events?
- How does the soft-recovery nudge get into the next request?
- Which terminate mechanism — abort, throw, or replacement response ([`llm_agent.js:634-686`](https://github.com/google/adk-js/blob/main/core/src/agents/llm_agent.ts#L634-L686))?
#### Slash commands
Slash commands live outside the core `AgentProtocol` layer, in a slash-command/runtime-adapter layer wired during Phase C. Specific commands (`/clear`, `/help`, `/memory`, `/compress`, `/rewind`, `/agents`, ...) route differently — some are pure TUI commands, some need to talk to the session, some restart the engine. Implementation details for the adapter layer (per-command routing, abort propagation, and the `ConversationRecord → ADK Event` conversion that `/rewind` requires) are still being expanded.
#### What we reuse from gemini-cli
These existing services remain the source of truth under the ADK runtime.
M5 adapts to them rather than replacing them:
- `scheduler.schedule()` — tool execution, approval, telemetry, truncation/distillation, `BeforeTool`/`AfterTool` hooks all run inside it (masking is separate — see Tool output masking row)
- `MessageBus` + `PolicyEngine` — tool approval correlation; legacy approval UI continues to consume bus events
- `handleFallback` + `ModelAvailabilityService` — 429 fallback + model pool
- `ModelRouterService` — per-turn model selection (sequence-sticky), authoritative for routing
- `ModelConfigService` — model resolution per request
- `LoopDetectionService` — strike-tracked detector
- `ChatRecordingService` — product-facing conversation persistence + `rewindTo`
- `ChatCompressionService` — outgoing-history projection only; ADK `Session.events` stays as in-memory loop state, `ConversationRecord` stays authoritative for persistence and `/rewind`
- `InjectionService` — user steering queue
- `ToolOutputMaskingService` — masking
- `HookSystem` + 11 hook events — see Hooks section
- Existing tool catalog and slash commands
#### What old logic gets dropped under ADK
These are the legacy bits the ADK runtime actually replaces:
- `GeminiClient.processTurn` — replaced by ADK `Runner` + request processors.
- `GeminiChat` — replaced by ADK `Session` events.
- `Turn` loop — replaced by ADK `LlmAgent.runAsyncImpl`.
- `ServerGeminiStreamEvent` — replaced by ADK `Event` → translator → `AgentEvent`.
#### What we don't reuse from ADK
ADK is a framework; we adopt the loop and a few extension points. The
exclusions below would each be plausible mistakes without explicit
rejection:
- ADK's built-in `Gemini` (`BaseLlm`) — we run our own `BaseLlm` subclass (`GcliAgentModel`) that dispatches through `config.getContentGenerator()`. Auth (OAuth / Gemini API key / Vertex), fallback retry, recording/logging wrappers, and telemetry all live in our content generator; using ADK's bare client would bypass all of that.
- `BasePolicyEngine` / `SecurityPlugin` — legacy `MessageBus` + `PolicyEngine` stays. ADK's security plugin would force the approval UI to re-bind against ADK elicitations.
- `BaseContextCompactor``ChatCompressionService` stays. ADK's compactor doesn't know about our token-budget projection rules.
- ADK `RoutedLlm` — not the routing source of truth. Gemini CLI routing (`ModelRouterService` + `ModelConfigService` + `ModelAvailabilityService`, sequence-sticky model, model-specific tool/config updates) remains authoritative. Routing lives in a custom ADK `RequestProcessor` — see Model routing in implementation_details.md.
- `BaseSessionService` (file-backed) — `ChatRecordingService` stays. M5 uses a small `GcliSessionService` implementation for in-memory ADK event state.
- Most ADK plugin callbacks — see Hooks section.
#### Supported under ADK runtime
| Feature | Seam |
| --- | --- |
| Auth (OAuth / Gemini API key / Vertex) | `GcliAgentModel` maps ADK `LlmRequest` to `GenerateContentParameters` and dispatches through `config.getContentGenerator()`; auth lives in the content generator implementation |
| Model fallback (429) | `GcliAgentModel` ports the existing `retryWithBackoff` + `handleFallback` + `ModelAvailabilityService` path; retry stays inside the model wrapper, not ADK Runner |
| Dynamic routing & availability | `GcliRoutingProcessor` (a custom ADK `RequestProcessor`) consults `ModelRouterService`, `ModelConfigService`, `ModelAvailabilityService`, and the sequence-sticky model, then rewrites `LlmRequest.model` + tool/config before tool preprocessing. No ADK `RoutedLlm` — see Model routing in implementation_details.md |
| Tool execution | Existing `scheduler.schedule()` |
| Tool approval | Existing `MessageBus` + `ToolConfirmationQueue.tsx` |
| Tool output masking | `ToolOutputMaskingProcessor` (a custom ADK `RequestProcessor`) runs `ToolOutputMaskingService.mask`, writes the masked view back to `LlmRequest.contents`, and applies the same masked function responses back to the live ADK session events by event/part identity rather than request-content index; then calls `ChatRecordingService.updateMessagesFromHistory` to sync the transcript. Per-call truncation rides for free inside `scheduler.schedule`. |
| Chat compression | Existing `ChatCompressionService` (outgoing-history projection only) |
| Loop detection | `LoopDetectionAdkPlugin` + `LoopDetectionService` — see Loop detection section |
| Plan mode | `InstructionProvider` + mode-aware tools (mode-change side effects fire from `Config`, runtime-independent) |
| User steering (between turns) | `beforeModelCallback` consumes `InjectionService` queue |
| MCP `tools/list` + `tools/call` | Native `MCPToolset` — see MCP section |
| MCP OAuth | Scoping open — see MCP section |
| Subagents | M4 `AgentTool` + session factory — see Subagents section |
| `/rewind` and all `/slash commands` | Live outside the protocol layer. `/rewind` is a slash-command/runtime-adapter concern: the adapter truncates `ConversationRecord` via `ChatRecordingService.rewindTo` and drops the ADK session; next `send()` rebuilds via `appendEvent`. Wired in Phase C (interactive). |
| User hooks (11 events) | See Hooks section |
| Max turns / tokens / time | Custom plugins (`MaxTurnsAdkPlugin`, `TokenLimitAdkPlugin`, `MaxTimeAdkPlugin`) |
| Telemetry / Clearcut | Translator + plugin instrumentation; no new telemetry architecture |
| Session persistence | `ChatRecordingService` remains the persisted conversation record; ADK `Session.events` (held in `GcliSessionService`) is runtime state only. `streamId` parity only; `eventId` resume is preserved on the protocol interface, see Rewind PR |
| Skills, slash commands, ACP | Unchanged surfaces (ACP picks runtime by feature flag) |
| VSCode IDE companion | Unaffected — side-channel to CLI process |
#### Not supported under ADK runtime
| Feature | Status | Rationale |
| --- | --- | --- |
| MCP `/mcp prompts` (`prompts/list`, `prompts/get`) | Scoping open — see MCP section | `MCPToolset` exposes no prompts API today. |
| MCP `/mcp resources` (`resources/list`, `resources/read`) | Scoping open — see MCP section | Same gap as prompts. |
---
## M5 Execution Plan
**Foundation** — sequenced first:
| # | Title | Type |
| --- | --- | --- |
| 1 | `[AdkAgent] Scaffold adk-agent/ module: AdkAgentProtocol skeleton, GcliSessionService, seam names` | Feature |
| 2 | `[AdkAgent] Add experimental.adk.runtimeEnabled flag + session factory + define precedence vs existing agentSessionNoninteractiveEnabled` | Feature |
| 3 | `[AdkAgent] Delete unused elicitation_request/response from AgentEvent types` | Bug |
**Phase A — Non-interactive.** Wires the non-interactive AgentSession entry
point through the ADK runtime. Proves the core loop without TUI complexity.
| # | Title | Type |
| --- | --- | --- |
| A1 | `[AdkAgent] Translator: text + thought + functionCall + functionResponse + usage + error (with _meta.code) + partial/consolidation + agent_start/end; matches event-translator.test.ts shape` | Feature |
| A2 | `[AdkAgent] GcliAgentModel: dispatch ADK LlmRequest through config.getContentGenerator() + AbortSignal propagation` | Feature |
| A3 | `[AdkAgent] GcliAgentModel: 429 retry via retryWithBackoff + handleFallback + ModelAvailabilityService (retry inside the model wrapper); silent-policy branch covers non-interactive without a UI handler; covers concrete-model changes` | Feature |
| A4 | `[AdkAgent] GcliRoutingProcessor (custom ADK RequestProcessor) + move sequence-sticky model owner from GeminiClient to Config with named reset points` | Feature |
| A5 | `[AdkAgent] Invalid-stream retry + next-speaker continuation (client.ts:818, :845) — port or explicitly mark Not Supported` | Feature |
| A6 | `[AdkAgent] toAdkTool: route execution through existing scheduler.schedule()` | Feature |
| A7 | `[AdkAgent] GcliSessionService: BaseSessionService implementation with authoritative live session map and single-append semantics` | Feature |
| A8 | `[AdkAgent] ToolOutputMaskingProcessor: ToolOutputMaskingService.mask + apply masks back to live session events by event/part identity + sync ChatRecordingService.updateMessagesFromHistory` | Feature |
| A8a | `[Cleanup] Migrate toolDistillationService off GeminiClient onto BaseLlmClient` | Bug |
| A9 | `[AdkAgent] Plugins: MaxTurnsAdkPlugin + TokenLimitAdkPlugin + MaxTimeAdkPlugin (beforeModelCallback / before+after)` | Feature |
| A10 | `[AdkAgent] LoopDetectionAdkPlugin: feed partial:true deltas to detector; on terminate call protocol.abort() with LOOP_DETECTED; protocol emits synthetic error event` | Feature |
| A11 | `[AdkAgent] MCP: adopt native MCPToolset for stdio + HTTP (tools/list + tools/call only)` | Feature |
| A11ad | `[adk-js upstream] MCPToolset capability gaps: prompts/resources, auth lifecycle (token reuse/refresh/401), server lifecycle (list-change, stdio restart, progress, close), and wire upstream prompts/resources into PromptRegistry + ResourceRegistry` | Feature |
| A12 | `[AdkAgent] Wire non-interactive entry point through session factory to select ADK runtime` | Feature |
**Phase B — Local subagents.** Reuses the Phase-A runtime for child sessions
once Phase A is wired and runs cleanly.
| # | Title | Type |
| --- | --- | --- |
| B1 | `[AdkAgent] Route subagent-tool-wrapper.ts:97 constructor through session factory; return ADK-backed AgentProtocol when the parent runtime is ADK` | Feature |
| B2 | `[AdkAgent] Subagent: complete_task mandatory terminator plugin (afterModelCallback)` | Feature |
| B3 | `[AdkAgent] Subagent: grace-period recovery turn (inject "you must call complete_task now" + final retry)` | Feature |
| B4 | `[AdkAgent] Subagent: scoped execution wrappers (workspace, memory-inbox, auto-memory-extraction)` | Feature |
| B5 | `[AdkAgent] Subagent: onWaitingForConfirmation activity signal via BeforeTool` | Feature |
**Phase C — Interactive.** Wires the TUI last. By this point the runtime
loop, model path, tools, fallback, masking, and subagents are already proven
outside the TUI. **All `/slash commands` live outside the protocol layer.**
| # | Title | Type |
| --- | --- | --- |
| C1 | `[AdkAgent] Wire interactive AgentSession through session factory; TUI stream rendering parity` | Feature |
| C2 | `[AdkAgent] User steering injection via beforeModelCallback` | Feature |
| C3 | `[AdkAgent] Plan mode: InstructionProvider for system-prompt swapping + mode-aware tool filtering via BaseToolset` | Feature |
| C4 | `[AdkAgent] HookBridgePlugin: fire BeforeModel / AfterModel / BeforeToolSelection from beforeModelCallback / afterModelCallback` | Feature |
| C5 | `[AdkAgent] Wire SessionStart / SessionEnd / BeforeAgent / AfterAgent / Notification hooks (Notification fires from AdkAgentProtocol._emit on tool-notification events)` | Feature |
| C6 | `[AdkAgent] Register TUI fallbackModelHandler callback so non-silent intents (retry_once / stop / upgrade / retry_with_credits) work; handleFallback machinery is already wired in Phase A` | Feature |
| C7 | `[AdkAgent] /rewind: slash-command/runtime-adapter truncates ConversationRecord via ChatRecordingService.rewindTo, drops ADK session, next send() rebuilds via appendEvent (NOT an AgentProtocol method)` | Feature |
| C8 | `[AdkAgent] Make ADK runtime the default for all wired surfaces` | Feature |
+7 -3
View File
@@ -1,6 +1,6 @@
# Preview release: v0.37.0-preview.1
# Preview release: v0.37.0-preview.2
Released: April 02, 2026
Released: April 07, 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,10 @@ npm install -g @google/gemini-cli@preview
## What's Changed
- fix(patch): cherry-pick cb7f7d6 to release/v0.37.0-preview.1-pr-24342 to patch
version v0.37.0-preview.1 and create version 0.37.0-preview.2 by
@gemini-cli-robot in
[#24842](https://github.com/google-gemini/gemini-cli/pull/24842)
- fix(patch): cherry-pick 64c928f to release/v0.37.0-preview.0-pr-23257 to patch
version v0.37.0-preview.0 and create version 0.37.0-preview.1 by
@gemini-cli-robot in
@@ -419,4 +423,4 @@ npm install -g @google/gemini-cli@preview
[#23275](https://github.com/google-gemini/gemini-cli/pull/23275)
**Full Changelog**:
https://github.com/google-gemini/gemini-cli/compare/v0.36.0-preview.8...v0.37.0-preview.1
https://github.com/google-gemini/gemini-cli/compare/v0.36.0-preview.8...v0.37.0-preview.2
+1 -1
View File
@@ -75,7 +75,7 @@ they appear in the UI.
| Show User Identity | `ui.showUserIdentity` | Show the signed-in user's identity (e.g. email) in the UI. | `true` |
| Use Alternate Screen Buffer | `ui.useAlternateBuffer` | Use an alternate screen buffer for the UI, preserving shell history. | `false` |
| Render Process | `ui.renderProcess` | Enable Ink render process for the UI. | `true` |
| Terminal Buffer | `ui.terminalBuffer` | Use the new terminal buffer architecture for rendering. | `true` |
| Terminal Buffer | `ui.terminalBuffer` | Use the new terminal buffer architecture for rendering. | `false` |
| Use Background Color | `ui.useBackgroundColor` | Whether to use background colors in the UI. | `true` |
| Incremental Rendering | `ui.incrementalRendering` | Enable incremental rendering for the UI. This option will reduce flickering but may cause rendering artifacts. Only supported when useAlternateBuffer is enabled. | `true` |
| Show Spinner | `ui.showSpinner` | Show the spinner during operations. | `true` |
+40
View File
@@ -117,6 +117,46 @@ npm run test:integration:sandbox:docker
npm run test:integration:sandbox:podman
```
## Memory regression tests
Memory regression tests are designed to detect heap growth and leaks across key
CLI scenarios. They are located in the `memory-tests` directory.
These tests are distinct from standard integration tests because they measure
memory usage and compare it against committed baselines.
### Running memory tests
Memory tests are not run as part of the default `npm run test` or
`npm run test:e2e` commands. They are run nightly in CI but can be run manually:
```bash
npm run test:memory
```
### Updating baselines
If you intentionally change behavior that affects memory usage, you may need to
update the baselines. Set the `UPDATE_MEMORY_BASELINES` environment variable to
`true`:
```bash
UPDATE_MEMORY_BASELINES=true npm run test:memory
```
This will run the tests, take median snapshots, and overwrite
`memory-tests/baselines.json`. You should review the changes and commit the
updated baseline file.
### How it works
The harness (`MemoryTestHarness` in `packages/test-utils`):
- Forces garbage collection multiple times to reduce noise.
- Takes median snapshots to filter spikes.
- Compares against baselines with a 10% tolerance.
- Can analyze sustained leaks across 3 snapshots using `analyzeSnapshots()`.
## Diagnostics
The integration test runner provides several options for diagnostics to help
+7 -1
View File
@@ -346,7 +346,7 @@ their corresponding top-level category object in your `settings.json` file.
- **`ui.terminalBuffer`** (boolean):
- **Description:** Use the new terminal buffer architecture for rendering.
- **Default:** `true`
- **Default:** `false`
- **Requires restart:** Yes
- **`ui.useBackgroundColor`** (boolean):
@@ -1606,6 +1606,12 @@ their corresponding top-level category object in your `settings.json` file.
- **Default:** `false`
- **Requires restart:** Yes
- **`experimental.adk.agentSessionInteractiveEnabled`** (boolean):
- **Description:** Enable the agent session implementation for the interactive
CLI.
- **Default:** `false`
- **Requires restart:** Yes
- **`experimental.enableAgents`** (boolean):
- **Description:** Enable local and remote subagents.
- **Default:** `true`
+9 -8
View File
@@ -86,13 +86,14 @@ available combinations.
#### Text Input
| Command | Action | Keys |
| -------------------------- | ------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- |
| `input.submit` | Submit the current prompt. | `Enter` |
| `input.queueMessage` | Queue the current prompt to be processed after the current task finishes. | `Tab` |
| `input.newline` | Insert a newline without submitting. | `Ctrl+Enter`<br />`Cmd/Win+Enter`<br />`Alt+Enter`<br />`Shift+Enter`<br />`Ctrl+J` |
| `input.openExternalEditor` | Open the current prompt or the plan in an external editor. | `Ctrl+X` |
| `input.paste` | Paste from the clipboard. | `Ctrl+V`<br />`Cmd/Win+V`<br />`Alt+V` |
| Command | Action | Keys |
| ------------------------------------ | ------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- |
| `input.submit` | Submit the current prompt. | `Enter` |
| `input.queueMessage` | Queue the current prompt to be processed after the current task finishes. | `Tab` |
| `input.newline` | Insert a newline without submitting. | `Ctrl+Enter`<br />`Cmd/Win+Enter`<br />`Alt+Enter`<br />`Shift+Enter`<br />`Ctrl+J` |
| `input.openExternalEditor` | Open the current prompt or the plan in an external editor. | `Ctrl+G` |
| `input.deprecatedOpenExternalEditor` | Deprecated command to open external editor. | `Ctrl+X` |
| `input.paste` | Paste from the clipboard. | `Ctrl+V`<br />`Cmd/Win+V`<br />`Alt+V` |
#### App Controls
@@ -100,7 +101,7 @@ available combinations.
| ----------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------ |
| `app.showErrorDetails` | Toggle detailed error information. | `F12` |
| `app.showFullTodos` | Toggle the full TODO list. | `Ctrl+T` |
| `app.showIdeContextDetail` | Show IDE context details. | `Ctrl+G` |
| `app.showIdeContextDetail` | Show IDE context details. | `F4` |
| `app.toggleMarkdown` | Toggle Markdown rendering. | `Alt+M` |
| `app.toggleCopyMode` | Toggle copy mode when in alternate buffer mode. | `F9` |
| `app.toggleMouseMode` | Toggle mouse mode (scrolling and clicking). | `Ctrl+S` |
+3 -3
View File
@@ -290,7 +290,7 @@ When connecting to an OAuth-enabled server:
> OAuth authentication requires that your local machine can:
>
> - Open a web browser for authentication
> - Receive redirects on `http://localhost:7777/oauth/callback`
> - Receive redirects on `http://localhost:<random-port>/oauth/callback` (or a specific port if configured via `redirectUri`)
This feature will not work in:
@@ -323,8 +323,8 @@ Use the `/mcp auth` command to manage OAuth authentication:
if omitted)
- **`tokenUrl`** (string): OAuth token endpoint (auto-discovered if omitted)
- **`scopes`** (string[]): Required OAuth scopes
- **`redirectUri`** (string): Custom redirect URI (defaults to
`http://localhost:7777/oauth/callback`)
- **`redirectUri`** (string): Custom redirect URI (defaults to an OS-assigned
random port, e.g., `http://localhost:<random-port>/oauth/callback`)
- **`tokenParamName`** (string): Query parameter name for tokens in SSE URLs
- **`audiences`** (string[]): Audiences the token is valid for
+64
View File
@@ -0,0 +1,64 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { evalTest, TestRig } from './test-helper.js';
evalTest('USUALLY_PASSES', {
name: 'Reproduction: Agent uses Object.create() for cloning/delegation',
prompt:
'Create a utility function `createScopedConfig(config: Config, additionalDirectories: string[]): Config` in `packages/core/src/config/scoped-config.ts` that returns a new Config instance. This instance should override `getWorkspaceContext()` to include the additional directories, but delegate all other method calls (like `isPathAllowed` or `validatePathAccess`) to the original config. Note that `Config` is a complex class with private state and cannot be easily shallow-copied or reconstructed.',
files: {
'packages/core/src/config/config.ts': `
export class Config {
private _internalState = 'secret';
constructor(private workspaceContext: any) {}
getWorkspaceContext() { return this.workspaceContext; }
isPathAllowed(path: string) {
return this.getWorkspaceContext().isPathWithinWorkspace(path);
}
validatePathAccess(path: string) {
if (!this.isPathAllowed(path)) return 'Denied';
return null;
}
}`,
'packages/core/src/utils/workspaceContext.ts': `
export class WorkspaceContext {
constructor(private root: string, private additional: string[] = []) {}
getDirectories() { return [this.root, ...this.additional]; }
isPathWithinWorkspace(path: string) {
return this.getDirectories().some(d => path.startsWith(d));
}
}`,
'package.json': JSON.stringify({
name: 'test-project',
version: '1.0.0',
type: 'module',
}),
},
assert: async (rig: TestRig) => {
const filePath = 'packages/core/src/config/scoped-config.ts';
const content = rig.readFile(filePath);
if (!content) {
throw new Error(`File ${filePath} was not created.`);
}
// Strip comments to avoid false positives.
const codeWithoutComments = content.replace(/\/\*[\s\S]*?\*\/|\/\/.*/g, '');
// Ensure that the agent did not use Object.create() in the implementation.
// We check for the call pattern specifically using a regex to avoid false positives in comments.
const hasObjectCreate = /\bObject\.create\s*\(/.test(codeWithoutComments);
if (hasObjectCreate) {
throw new Error(
'Evaluation Failed: Agent used Object.create() for cloning. ' +
'This behavior is forbidden by the project lint rules (no-restricted-syntax). ' +
'Implementation found:\n\n' +
content,
);
}
},
});
+43
View File
@@ -215,4 +215,47 @@ export default app;
expect(fs.existsSync(path.join(rig.testDir, 'src/routes.ts'))).toBe(true);
},
});
/**
* Regression test for a bug where update_topic was called multiple times in a
* row. We have seen cases of this occurring in earlier versions of the update_topic
* system instruction, prior to https://github.com/google-gemini/gemini-cli/pull/24640.
* This test demonstrated that there are cases where it can still occur and validates
* the prompt change that improves the behavior.
*/
evalTest('USUALLY_PASSES', {
name: 'update_topic should not be called twice in a row',
prompt: `
We need to build a C compiler.
Before you write any code, you must formally declare your strategy.
First, declare that you will build a Lexer.
Then, immediately realize that is wrong and declare that you will actually build a Parser instead.
Finally, create 'parser.c'.
`,
files: {
'package.json': JSON.stringify({ name: 'test-project' }),
'.gemini/settings.json': JSON.stringify({
experimental: {
topicUpdateNarration: true,
},
}),
},
assert: async (rig) => {
const toolLogs = rig.readToolLogs();
// Check for back-to-back update_topic calls
for (let i = 1; i < toolLogs.length; i++) {
if (
toolLogs[i - 1].toolRequest.name === UPDATE_TOPIC_TOOL_NAME &&
toolLogs[i].toolRequest.name === UPDATE_TOPIC_TOOL_NAME
) {
throw new Error(
`Detected back-to-back ${UPDATE_TOPIC_TOOL_NAME} calls at index ${i - 1} and ${i}`,
);
}
}
},
});
});
@@ -0,0 +1,8 @@
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"I'll launch two browser agents concurrently to check both repositories."},{"functionCall":{"name":"browser_agent","args":{"task":"Navigate to https://example.com and get the page title"}}},{"functionCall":{"name":"browser_agent","args":{"task":"Navigate to https://example.com and get the page title"}}}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":100,"candidatesTokenCount":50,"totalTokenCount":150}}]}
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"navigate_page","args":{"url":"https://example.com"}}}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":100,"candidatesTokenCount":20,"totalTokenCount":120}}]}
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"navigate_page","args":{"url":"https://example.com"}}}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":100,"candidatesTokenCount":20,"totalTokenCount":120}}]}
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"take_snapshot","args":{}}}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":150,"candidatesTokenCount":15,"totalTokenCount":165}}]}
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"take_snapshot","args":{}}}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":150,"candidatesTokenCount":15,"totalTokenCount":165}}]}
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"complete_task","args":{"result":{"success":true,"summary":"Page title is Example Domain."}}}}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":200,"candidatesTokenCount":30,"totalTokenCount":230}}]}
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"complete_task","args":{"result":{"success":true,"summary":"Page title is Example Domain."}}}}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":200,"candidatesTokenCount":30,"totalTokenCount":230}}]}
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"Both browser agents completed successfully. Agent 1 and Agent 2 both navigated to their respective pages and confirmed the page titles."}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":300,"candidatesTokenCount":40,"totalTokenCount":340}}]}
@@ -0,0 +1,8 @@
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"I'll browse to example.com twice to verify the content. Let me first check the page title, then check the links on the page."},{"functionCall":{"name":"browser_agent","args":{"task":"Navigate to https://example.com and tell me the page title using the accessibility tree"}}}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":100,"candidatesTokenCount":50,"totalTokenCount":150}}]}
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"navigate_page","args":{"url":"https://example.com"}}}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":100,"candidatesTokenCount":30,"totalTokenCount":130}}]}
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"take_snapshot","args":{}}}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":150,"candidatesTokenCount":20,"totalTokenCount":170}}]}
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"complete_task","args":{"result":{"success":true,"summary":"Page title is 'Example Domain'."}}}}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":200,"candidatesTokenCount":40,"totalTokenCount":240}}]}
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"The page title is 'Example Domain'. Now let me check the links on the page."},{"functionCall":{"name":"browser_agent","args":{"task":"Take a snapshot of the accessibility tree on the currently open page and tell me about any links"}}}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":200,"candidatesTokenCount":50,"totalTokenCount":250}}]}
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"take_snapshot","args":{}}}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":150,"candidatesTokenCount":20,"totalTokenCount":170}}]}
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"complete_task","args":{"result":{"success":true,"summary":"Found a link 'More information...' pointing to iana.org."}}}}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":200,"candidatesTokenCount":40,"totalTokenCount":240}}]}
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"I browsed example.com twice using persistent browser sessions:\n\n1. **First visit**: Page title is 'Example Domain'\n2. **Second visit**: Found a link 'More information...' pointing to iana.org\n\nThe browser stayed open between both visits, confirming persistent session management works correctly."}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":300,"candidatesTokenCount":60,"totalTokenCount":360}}]}
+89
View File
@@ -229,6 +229,51 @@ describe.skipIf(!chromeAvailable)('browser-agent', () => {
assertModelHasOutput(result);
});
it('should keep browser open across multiple browser_agent invocations', async () => {
rig.setup('browser-persistent-session', {
fakeResponsesPath: join(
__dirname,
'browser-agent.persistent-session.responses',
),
settings: {
agents: {
overrides: {
browser_agent: {
enabled: true,
},
},
browser: {
headless: true,
sessionMode: 'isolated',
},
},
},
});
const result = await rig.run({
args: 'Browse to example.com twice: first get the page title, then check for links.',
});
const toolLogs = rig.readToolLogs();
const browserCalls = toolLogs.filter(
(t) => t.toolRequest.name === 'browser_agent',
);
// Both browser_agent invocations must succeed — if the browser was
// incorrectly closed after the first call (regression #24210),
// the second call would fail.
expect(
browserCalls.length,
'Expected browser_agent to be called twice',
).toBe(2);
expect(
browserCalls.every((c) => c.toolRequest.success),
'Both browser_agent calls should succeed',
).toBe(true);
assertModelHasOutput(result);
});
it('should handle tool confirmation for write_file without crashing', async () => {
rig.setup('tool-confirmation', {
fakeResponsesPath: join(
@@ -262,4 +307,48 @@ describe.skipIf(!chromeAvailable)('browser-agent', () => {
await run.expectText('successfully written', 15000);
});
it('should handle concurrent browser agents with isolated session mode', async () => {
rig.setup('browser-concurrent', {
fakeResponsesPath: join(__dirname, 'browser-agent.concurrent.responses'),
settings: {
agents: {
overrides: {
browser_agent: {
enabled: true,
},
},
browser: {
headless: true,
// Isolated mode supports concurrent browser agents.
// Persistent/existing modes reject concurrent calls to prevent
// Chrome profile lock conflicts.
sessionMode: 'isolated',
},
},
},
});
const result = await rig.run({
args: 'Launch two browser agents concurrently to check example.com',
});
assertModelHasOutput(result);
const toolLogs = rig.readToolLogs();
const browserCalls = toolLogs.filter(
(t) => t.toolRequest.name === 'browser_agent',
);
// Both browser_agent invocations should have been called
expect(browserCalls.length).toBe(2);
// Both should complete successfully (no errors)
for (const call of browserCalls) {
expect(
call.toolRequest.success,
`browser_agent call failed: ${JSON.stringify(call.toolRequest)}`,
).toBe(true);
}
});
});
+2 -19
View File
@@ -5,8 +5,8 @@
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { writeFileSync, readFileSync } from 'node:fs';
import { join, resolve } from 'node:path';
import { writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { TestRig } from './test-helper.js';
// BOM encoders
@@ -116,21 +116,4 @@ describe('BOM end-to-end integraion', () => {
'BOM_OK UTF-32BE',
);
});
it('Can describe a PNG file', async () => {
const imagePath = resolve(
process.cwd(),
'docs/assets/gemini-screenshot.png',
);
const imageContent = readFileSync(imagePath);
const filename = 'gemini-screenshot.png';
writeFileSync(join(rig.testDir!, filename), imageContent);
const prompt = `What is shown in the image ${filename}?`;
const output = await rig.run({ args: prompt });
await rig.waitForToolCall('read_file');
const lower = output.toLowerCase();
// The response is non-deterministic, so we just check for some
// keywords that are very likely to be in the response.
expect(lower.includes('gemini')).toBeTruthy();
});
});
+30
View File
@@ -0,0 +1,30 @@
{
"version": 1,
"updatedAt": "2026-04-08T01:21:58.770Z",
"scenarios": {
"multi-turn-conversation": {
"heapUsedBytes": 120082704,
"heapTotalBytes": 177586176,
"rssBytes": 269172736,
"timestamp": "2026-04-08T01:21:57.127Z"
},
"multi-function-call-repo-search": {
"heapUsedBytes": 104644984,
"heapTotalBytes": 111575040,
"rssBytes": 204079104,
"timestamp": "2026-04-08T01:21:58.770Z"
},
"idle-session-startup": {
"heapUsedBytes": 119813672,
"heapTotalBytes": 177061888,
"rssBytes": 267943936,
"timestamp": "2026-04-08T01:21:53.855Z"
},
"simple-prompt-response": {
"heapUsedBytes": 119722064,
"heapTotalBytes": 177324032,
"rssBytes": 268812288,
"timestamp": "2026-04-08T01:21:55.491Z"
}
}
}
+71
View File
@@ -0,0 +1,71 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { mkdir, readdir, rm } from 'node:fs/promises';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { canUseRipgrep } from '../packages/core/src/tools/ripGrep.js';
const __dirname = dirname(fileURLToPath(import.meta.url));
const rootDir = join(__dirname, '..');
const memoryTestsDir = join(rootDir, '.memory-tests');
let runDir = '';
export async function setup() {
runDir = join(memoryTestsDir, `${Date.now()}`);
await mkdir(runDir, { recursive: true });
// Set the home directory to the test run directory to avoid conflicts
// with the user's local config.
process.env['HOME'] = runDir;
if (process.platform === 'win32') {
process.env['USERPROFILE'] = runDir;
}
process.env['GEMINI_CONFIG_DIR'] = join(runDir, '.gemini');
// Download ripgrep to avoid race conditions
const available = await canUseRipgrep();
if (!available) {
throw new Error('Failed to download ripgrep binary');
}
// Clean up old test runs, keeping the latest few for debugging
try {
const testRuns = await readdir(memoryTestsDir);
if (testRuns.length > 3) {
const oldRuns = testRuns.sort().slice(0, testRuns.length - 3);
await Promise.all(
oldRuns.map((oldRun) =>
rm(join(memoryTestsDir, oldRun), {
recursive: true,
force: true,
}),
),
);
}
} catch (e) {
console.error('Error cleaning up old memory test runs:', e);
}
process.env['INTEGRATION_TEST_FILE_DIR'] = runDir;
process.env['GEMINI_CLI_INTEGRATION_TEST'] = 'true';
process.env['GEMINI_FORCE_FILE_STORAGE'] = 'true';
process.env['TELEMETRY_LOG_FILE'] = join(runDir, 'telemetry.log');
process.env['VERBOSE'] = process.env['VERBOSE'] ?? 'false';
console.log(`\nMemory test output directory: ${runDir}`);
}
export async function teardown() {
// Cleanup unless KEEP_OUTPUT is set
if (process.env['KEEP_OUTPUT'] !== 'true' && runDir) {
try {
await rm(runDir, { recursive: true, force: true });
} catch (e) {
console.warn('Failed to clean up memory test directory:', e);
}
}
}
+185
View File
@@ -0,0 +1,185 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, beforeAll, afterAll, afterEach } from 'vitest';
import { TestRig, MemoryTestHarness } from '@google/gemini-cli-test-utils';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const BASELINES_PATH = join(__dirname, 'baselines.json');
const UPDATE_BASELINES = process.env['UPDATE_MEMORY_BASELINES'] === 'true';
const TOLERANCE_PERCENT = 10;
// Fake API key for tests using fake responses
const TEST_ENV = { GEMINI_API_KEY: 'fake-memory-test-key' };
describe('Memory Usage Tests', () => {
let harness: MemoryTestHarness;
let rig: TestRig;
beforeAll(() => {
harness = new MemoryTestHarness({
baselinesPath: BASELINES_PATH,
defaultTolerancePercent: TOLERANCE_PERCENT,
gcCycles: 3,
gcDelayMs: 100,
sampleCount: 3,
});
});
afterEach(async () => {
await rig.cleanup();
});
afterAll(async () => {
// Generate the summary report after all tests
await harness.generateReport();
});
it('idle-session-startup: memory usage within baseline', async () => {
rig = new TestRig();
rig.setup('memory-idle-startup', {
fakeResponsesPath: join(__dirname, 'memory.idle-startup.responses'),
});
const result = await harness.runScenario(
'idle-session-startup',
async (recordSnapshot) => {
await rig.run({
args: ['hello'],
timeout: 120000,
env: TEST_ENV,
});
await recordSnapshot('after-startup');
},
);
if (UPDATE_BASELINES) {
harness.updateScenarioBaseline(result);
console.log(
`Updated baseline for idle-session-startup: ${(result.finalHeapUsed / (1024 * 1024)).toFixed(1)} MB`,
);
} else {
harness.assertWithinBaseline(result);
}
});
it('simple-prompt-response: memory usage within baseline', async () => {
rig = new TestRig();
rig.setup('memory-simple-prompt', {
fakeResponsesPath: join(__dirname, 'memory.simple-prompt.responses'),
});
const result = await harness.runScenario(
'simple-prompt-response',
async (recordSnapshot) => {
await rig.run({
args: ['What is the capital of France?'],
timeout: 120000,
env: TEST_ENV,
});
await recordSnapshot('after-response');
},
);
if (UPDATE_BASELINES) {
harness.updateScenarioBaseline(result);
console.log(
`Updated baseline for simple-prompt-response: ${(result.finalHeapUsed / (1024 * 1024)).toFixed(1)} MB`,
);
} else {
harness.assertWithinBaseline(result);
}
});
it('multi-turn-conversation: memory remains stable over turns', async () => {
rig = new TestRig();
rig.setup('memory-multi-turn', {
fakeResponsesPath: join(__dirname, 'memory.multi-turn.responses'),
});
const prompts = [
'Hello, what can you help me with?',
'Tell me about JavaScript',
'How is TypeScript different?',
'Can you write a simple TypeScript function?',
'What are some TypeScript best practices?',
];
const result = await harness.runScenario(
'multi-turn-conversation',
async (recordSnapshot) => {
// Run through all turns as a piped sequence
const stdinContent = prompts.join('\n');
await rig.run({
stdin: stdinContent,
timeout: 120000,
env: TEST_ENV,
});
// Take snapshots after the conversation completes
await recordSnapshot('after-all-turns');
},
);
if (UPDATE_BASELINES) {
harness.updateScenarioBaseline(result);
console.log(
`Updated baseline for multi-turn-conversation: ${(result.finalHeapUsed / (1024 * 1024)).toFixed(1)} MB`,
);
} else {
harness.assertWithinBaseline(result);
}
});
it('multi-function-call-repo-search: memory after tool use', async () => {
rig = new TestRig();
rig.setup('memory-multi-func-call', {
fakeResponsesPath: join(
__dirname,
'memory.multi-function-call.responses',
),
});
// Create directories first, then files in the workspace so the tools have targets
rig.mkdir('packages/core/src/telemetry');
rig.createFile(
'packages/core/src/telemetry/memory-monitor.ts',
'export class MemoryMonitor { constructor() {} }',
);
rig.createFile(
'packages/core/src/telemetry/metrics.ts',
'export function recordMemoryUsage() {}',
);
const result = await harness.runScenario(
'multi-function-call-repo-search',
async (recordSnapshot) => {
await rig.run({
args: [
'Search this repository for MemoryMonitor and tell me what it does',
],
timeout: 120000,
env: TEST_ENV,
});
await recordSnapshot('after-tool-calls');
},
);
if (UPDATE_BASELINES) {
harness.updateScenarioBaseline(result);
console.log(
`Updated baseline for multi-function-call-repo-search: ${(result.finalHeapUsed / (1024 * 1024)).toFixed(1)} MB`,
);
} else {
harness.assertWithinBaseline(result);
}
});
});
@@ -0,0 +1,2 @@
{"method":"generateContent","response":{"candidates":[{"content":{"parts":[{"text":"0"}],"role":"model"},"finishReason":"STOP","index":0}]}}
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"Hello! I'm ready to help. What would you like to work on?"}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":5,"candidatesTokenCount":12,"totalTokenCount":17,"promptTokensDetails":[{"modality":"TEXT","tokenCount":5}]}}]}
@@ -0,0 +1,4 @@
{"method":"generateContent","response":{"candidates":[{"content":{"parts":[{"text":"0"}],"role":"model"},"finishReason":"STOP","index":0}]}}
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"I'll search for MemoryMonitor in the repository and analyze what it does."}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":30,"candidatesTokenCount":15,"totalTokenCount":45,"promptTokensDetails":[{"modality":"TEXT","tokenCount":30}]}}]}
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"grep_search","args":{"pattern":"MemoryMonitor","path":".","include_pattern":"*.ts"}}},{"functionCall":{"name":"list_directory","args":{"path":"packages/core/src/telemetry"}}},{"functionCall":{"name":"read_file","args":{"file_path":"packages/core/src/telemetry/memory-monitor.ts"}}}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":30,"candidatesTokenCount":80,"totalTokenCount":110,"promptTokensDetails":[{"modality":"TEXT","tokenCount":30}]}}]}
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"I found the memory monitoring code. Here's a summary:\n\nThe `MemoryMonitor` class in `packages/core/src/telemetry/memory-monitor.ts` provides:\n\n1. **Continuous monitoring** via `start()`/`stop()` with configurable intervals\n2. **V8 heap snapshots** using `v8.getHeapStatistics()` and `process.memoryUsage()`\n3. **High-water mark tracking** to detect significant memory growth\n4. **Rate-limited recording** to avoid metric flood\n5. **Activity detection** — only records when user is active\n\nThe class uses a singleton pattern via `initializeMemoryMonitor()` for global access."}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":500,"candidatesTokenCount":120,"totalTokenCount":620,"promptTokensDetails":[{"modality":"TEXT","tokenCount":500}]}}]}
+10
View File
@@ -0,0 +1,10 @@
{"method":"generateContent","response":{"candidates":[{"content":{"parts":[{"text":"0"}],"role":"model"},"finishReason":"STOP","index":0}]}}
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"Hello! I'm ready to help you with your coding tasks. What would you like to work on today?"}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":5,"candidatesTokenCount":18,"totalTokenCount":23,"promptTokensDetails":[{"modality":"TEXT","tokenCount":5}]}}]}
{"method":"generateContent","response":{"candidates":[{"content":{"parts":[{"text":"0"}],"role":"model"},"finishReason":"STOP","index":0}]}}
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"JavaScript is a high-level, interpreted programming language. It was originally designed for adding interactivity to web pages."}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":25,"candidatesTokenCount":60,"totalTokenCount":85,"promptTokensDetails":[{"modality":"TEXT","tokenCount":25}]}}]}
{"method":"generateContent","response":{"candidates":[{"content":{"parts":[{"text":"0"}],"role":"model"},"finishReason":"STOP","index":0}]}}
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"TypeScript is a typed superset of JavaScript developed by Microsoft. The main differences from JavaScript are static typing and better tooling."}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":45,"candidatesTokenCount":80,"totalTokenCount":125,"promptTokensDetails":[{"modality":"TEXT","tokenCount":45}]}}]}
{"method":"generateContent","response":{"candidates":[{"content":{"parts":[{"text":"0"}],"role":"model"},"finishReason":"STOP","index":0}]}}
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"Here is a simple TypeScript function:\n\nfunction greet(name: string): string { return `Hello, ${name}!`; }"}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":60,"candidatesTokenCount":55,"totalTokenCount":115,"promptTokensDetails":[{"modality":"TEXT","tokenCount":60}]}}]}
{"method":"generateContent","response":{"candidates":[{"content":{"parts":[{"text":"0"}],"role":"model"},"finishReason":"STOP","index":0}]}}
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"Here are 5 key TypeScript best practices: Enable strict mode, prefer interfaces, use union types, leverage type inference, and use readonly."}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":75,"candidatesTokenCount":70,"totalTokenCount":145,"promptTokensDetails":[{"modality":"TEXT","tokenCount":75}]}}]}
@@ -0,0 +1,2 @@
{"method":"generateContent","response":{"candidates":[{"content":{"parts":[{"text":"0"}],"role":"model"},"finishReason":"STOP","index":0}]}}
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"The capital of France is Paris. It has been the capital since the 10th century and is known for iconic landmarks like the Eiffel Tower, the Louvre Museum, and Notre-Dame Cathedral. Paris is also the most populous city in France, with a metropolitan area population of over 12 million people."}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":7,"candidatesTokenCount":55,"totalTokenCount":62,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7}]}}]}
+12
View File
@@ -0,0 +1,12 @@
{
"extends": "../tsconfig.json",
"compilerOptions": {
"noEmit": true,
"allowJs": true
},
"include": ["**/*.ts"],
"references": [
{ "path": "../packages/core" },
{ "path": "../packages/test-utils" }
]
}
+28
View File
@@ -0,0 +1,28 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
testTimeout: 600000, // 10 minutes — memory profiling is slow
globalSetup: './globalSetup.ts',
reporters: ['default'],
include: ['**/*.test.ts'],
retry: 0, // No retries for memory tests — noise is handled by tolerance
fileParallelism: false, // Must run serially to avoid memory interference
pool: 'forks', // Use forks pool for --expose-gc support
poolOptions: {
forks: {
singleFork: true, // Single process for accurate per-test memory readings
execArgv: ['--expose-gc'], // Enable global.gc() for forced GC
},
},
env: {
GEMINI_TEST_TYPE: 'memory',
},
},
});
+38 -3
View File
@@ -446,7 +446,8 @@
"version": "2.11.0",
"resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-2.11.0.tgz",
"integrity": "sha512-sBXGT13cpmPR5BMgHE6UEEfEaShh5Ror6rfN3yEK5si7QVrtZg8LEPQb0VVhiLRUslD2yLnXtnRzG035J/mZXQ==",
"license": "(Apache-2.0 AND BSD-3-Clause)"
"license": "(Apache-2.0 AND BSD-3-Clause)",
"peer": true
},
"node_modules/@bundled-es-modules/cookie": {
"version": "2.0.1",
@@ -1449,6 +1450,7 @@
"resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.13.4.tgz",
"integrity": "sha512-GsFaMXCkMqkKIvwCQjCrwH+GHbPKBjhwo/8ZuUkWHqbI73Kky9I+pQltrlT0+MWpedCoosda53lgjYfyEPgxBg==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@grpc/proto-loader": "^0.7.13",
"@js-sdsl/ordered-map": "^4.4.2"
@@ -2155,6 +2157,7 @@
"integrity": "sha512-t54CUOsFMappY1Jbzb7fetWeO0n6K0k/4+/ZpkS+3Joz8I4VcvY9OiEBFRYISqaI2fq5sCiPtAjRDOzVYG8m+Q==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@octokit/auth-token": "^6.0.0",
"@octokit/graphql": "^9.0.2",
@@ -2335,6 +2338,7 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz",
"integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==",
"license": "Apache-2.0",
"peer": true,
"engines": {
"node": ">=8.0.0"
}
@@ -2384,6 +2388,7 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.5.0.tgz",
"integrity": "sha512-ka4H8OM6+DlUhSAZpONu0cPBtPPTQKxbxVzC4CzVx5+K4JnroJVBtDzLAMx4/3CDTJXRvVFhpFjtl4SaiTNoyQ==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@opentelemetry/semantic-conventions": "^1.29.0"
},
@@ -2758,6 +2763,7 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.5.0.tgz",
"integrity": "sha512-F8W52ApePshpoSrfsSk1H2yJn9aKjCrbpQF1M9Qii0GHzbfVeFUB+rc3X4aggyZD8x9Gu3Slua+s6krmq6Dt8g==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@opentelemetry/core": "2.5.0",
"@opentelemetry/semantic-conventions": "^1.29.0"
@@ -2791,6 +2797,7 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.5.0.tgz",
"integrity": "sha512-BeJLtU+f5Gf905cJX9vXFQorAr6TAfK3SPvTFqP+scfIpDQEJfRaGJWta7sJgP+m4dNtBf9y3yvBKVAZZtJQVA==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@opentelemetry/core": "2.5.0",
"@opentelemetry/resources": "2.5.0"
@@ -2845,6 +2852,7 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.5.0.tgz",
"integrity": "sha512-VzRf8LzotASEyNDUxTdaJ9IRJ1/h692WyArDBInf5puLCjxbICD6XkHgpuudis56EndyS7LYFmtTMny6UABNdQ==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@opentelemetry/core": "2.5.0",
"@opentelemetry/resources": "2.5.0",
@@ -4081,6 +4089,7 @@
"integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.0.2"
}
@@ -4355,6 +4364,7 @@
"integrity": "sha512-6sMvZePQrnZH2/cJkwRpkT7DxoAWh+g6+GFRK6bV3YQo7ogi3SX5rgF6099r5Q53Ma5qeT7LGmOmuIutF4t3lA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.35.0",
"@typescript-eslint/types": "8.35.0",
@@ -5228,6 +5238,7 @@
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -5569,6 +5580,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/asciichart": {
"version": "1.5.25",
"resolved": "https://registry.npmjs.org/asciichart/-/asciichart-1.5.25.tgz",
"integrity": "sha512-PNxzXIPPOtWq8T7bgzBtk9cI2lgS4SJZthUHEiQ1aoIc3lNzGfUvIvo9LiAnq26TACo9t1/4qP6KTGAUbzX9Xg==",
"license": "MIT"
},
"node_modules/assertion-error": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
@@ -7362,7 +7379,8 @@
"version": "0.0.1581282",
"resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1581282.tgz",
"integrity": "sha512-nv7iKtNZQshSW2hKzYNr46nM/Cfh5SEvE2oV0/SEGgc9XupIY5ggf84Cz8eJIkBce7S3bmTAauFD6aysMpnqsQ==",
"license": "BSD-3-Clause"
"license": "BSD-3-Clause",
"peer": true
},
"node_modules/dezalgo": {
"version": "1.0.4",
@@ -7946,6 +7964,7 @@
"integrity": "sha512-GsGizj2Y1rCWDu6XoEekL3RLilp0voSePurjZIkxL3wlm5o5EC9VpgaP7lrCvjnkuLvzFBQWB3vWB3K5KQTveQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -8463,6 +8482,7 @@
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
"license": "MIT",
"peer": true,
"dependencies": {
"accepts": "^2.0.0",
"body-parser": "^2.2.1",
@@ -9775,6 +9795,7 @@
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.7.tgz",
"integrity": "sha512-jq9l1DM0zVIvsm3lv9Nw9nlJnMNPOcAtsbsgiUhWcFzPE99Gvo6yRTlszSLLYacMeQ6quHD6hMfId8crVHvexw==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=16.9.0"
}
@@ -10053,6 +10074,7 @@
"resolved": "https://registry.npmjs.org/@jrichman/ink/-/ink-6.6.7.tgz",
"integrity": "sha512-bDzQLpLzK/dn9Ur/Ku88ZZR9totVcMGrGYAgPHidsAAbe9NKztU1fggj/iu0wRp5g1kBeALb3cfagFGdDxAU1w==",
"license": "MIT",
"peer": true,
"dependencies": {
"ansi-escapes": "^7.0.0",
"ansi-styles": "^6.2.3",
@@ -13826,6 +13848,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -13836,6 +13859,7 @@
"integrity": "sha512-ePrwPfxAnB+7hgnEr8vpKxL9cmnp7F322t8oqcPshbIQQhDKgFDW4tjhF2wjVbdXF9O/nyuy3sQWd9JGpiLPvA==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"shell-quote": "^1.6.1",
"ws": "^7"
@@ -15985,6 +16009,7 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -16207,7 +16232,8 @@
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
"license": "0BSD",
"peer": true
},
"node_modules/tsx": {
"version": "4.20.3",
@@ -16215,6 +16241,7 @@
"integrity": "sha512-qjbnuR9Tr+FJOMBqJCW5ehvIo/buZq7vH7qD7JziU98h6l3qGy0a/yPFjwO+y0/T7GFpNgNAvEcPPVfyT8rrPQ==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "~0.25.0",
"get-tsconfig": "^4.7.5"
@@ -16380,6 +16407,7 @@
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
"devOptional": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -16602,6 +16630,7 @@
"resolved": "https://registry.npmjs.org/vite/-/vite-7.2.2.tgz",
"integrity": "sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.5.0",
@@ -16715,6 +16744,7 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -16727,6 +16757,7 @@
"resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz",
"integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/chai": "^5.2.2",
"@vitest/expect": "3.2.4",
@@ -17374,6 +17405,7 @@
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
@@ -17817,6 +17849,7 @@
"resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.3.tgz",
"integrity": "sha512-Iq8QQQ/7X3Sac15oB6p0FmUg/klxQvXLeileoqrTRGJYLV+/9tubbr9ipz0GKHjmXVsgFPo/+W+2cA8eNcR+XA==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@grpc/proto-loader": "^0.8.0",
"@js-sdsl/ordered-map": "^4.4.2"
@@ -17920,6 +17953,7 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -17979,6 +18013,7 @@
"dependencies": {
"@google/gemini-cli-core": "file:../core",
"@lydell/node-pty": "1.1.0",
"asciichart": "^1.5.25",
"strip-ansi": "^7.1.2",
"vitest": "^3.2.4"
},
+2
View File
@@ -51,6 +51,8 @@
"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:memory": "vitest run --root ./memory-tests",
"test:memory:update-baselines": "cross-env UPDATE_MEMORY_BASELINES=true vitest run --root ./memory-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",
"lint": "cross-env NODE_OPTIONS=\"--max-old-space-size=8192\" eslint . --cache --max-warnings 0",
+1 -1
View File
@@ -1,4 +1,4 @@
#!/usr/bin/env -S node --no-warnings=DEP0040
#!/usr/bin/env node
/**
* @license
+1 -1
View File
@@ -1,4 +1,4 @@
#!/usr/bin/env -S node --no-warnings=DEP0040
#!/usr/bin/env node
/**
* @license
@@ -29,5 +29,8 @@ describe('CommandHandler', () => {
const about = parse('/about');
expect(about.commandToExecute?.name).toBe('about');
const help = parse('/help');
expect(help.commandToExecute?.name).toBe('help');
});
});
+2
View File
@@ -11,6 +11,7 @@ import { ExtensionsCommand } from './commands/extensions.js';
import { InitCommand } from './commands/init.js';
import { RestoreCommand } from './commands/restore.js';
import { AboutCommand } from './commands/about.js';
import { HelpCommand } from './commands/help.js';
export class CommandHandler {
private registry: CommandRegistry;
@@ -26,6 +27,7 @@ export class CommandHandler {
registry.register(new InitCommand());
registry.register(new RestoreCommand());
registry.register(new AboutCommand());
registry.register(new HelpCommand(registry));
return registry;
}
@@ -0,0 +1,53 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import { HelpCommand } from './help.js';
import { CommandRegistry } from './commandRegistry.js';
import type { Command, CommandContext } from './types.js';
describe('HelpCommand', () => {
it('returns formatted help text with sorted commands', async () => {
const registry = new CommandRegistry();
const cmdB: Command = {
name: 'bravo',
description: 'Bravo command',
execute: async () => ({ name: 'bravo', data: '' }),
};
const cmdA: Command = {
name: 'alpha',
description: 'Alpha command',
execute: async () => ({ name: 'alpha', data: '' }),
};
registry.register(cmdB);
registry.register(cmdA);
const helpCommand = new HelpCommand(registry);
const context = {} as CommandContext;
const response = await helpCommand.execute(context, []);
expect(response.name).toBe('help');
const data = response.data as string;
expect(data).toContain('Gemini CLI Help:');
expect(data).toContain('### Basics');
expect(data).toContain('### Commands');
const lines = data.split('\n');
const alphaIndex = lines.findIndex((l) => l.includes('/alpha'));
const bravoIndex = lines.findIndex((l) => l.includes('/bravo'));
expect(alphaIndex).toBeLessThan(bravoIndex);
expect(alphaIndex).not.toBe(-1);
expect(bravoIndex).not.toBe(-1);
});
});
+50
View File
@@ -0,0 +1,50 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type {
Command,
CommandContext,
CommandExecutionResponse,
} from './types.js';
import type { CommandRegistry } from './commandRegistry.js';
export class HelpCommand implements Command {
readonly name = 'help';
readonly description = 'Show available commands';
constructor(private registry: CommandRegistry) {}
async execute(
_context: CommandContext,
_args: string[] = [],
): Promise<CommandExecutionResponse> {
const commands = this.registry
.getAllCommands()
.sort((a, b) => a.name.localeCompare(b.name));
const lines: string[] = [];
lines.push('Gemini CLI Help:');
lines.push('');
lines.push('### Basics');
lines.push(
'- **Add context**: Use `@` to specify files for context (e.g., `@src/myFile.ts`) to target specific files or folders.',
);
lines.push('');
lines.push('### Commands');
for (const cmd of commands) {
if (cmd.description) {
lines.push(`- **/${cmd.name}** - ${cmd.description}`);
}
}
return {
name: this.name,
data: lines.join('\n'),
};
}
}
+26 -27
View File
@@ -4,8 +4,16 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
import { coreEvents, type Config } from '@google/gemini-cli-core';
import {
vi,
describe,
it,
expect,
beforeEach,
afterEach,
type MockInstance,
} from 'vitest';
import { type Config } from '@google/gemini-cli-core';
import { handleList, listCommand } from './list.js';
import { loadSettings, type LoadedSettings } from '../../config/settings.js';
import { loadCliConfig } from '../../config/config.js';
@@ -32,12 +40,16 @@ vi.mock('../utils.js', () => ({
describe('skills list command', () => {
const mockLoadSettings = vi.mocked(loadSettings);
const mockLoadCliConfig = vi.mocked(loadCliConfig);
let stdoutWriteSpy: MockInstance<typeof process.stdout.write>;
beforeEach(async () => {
vi.clearAllMocks();
mockLoadSettings.mockReturnValue({
merged: {},
} as unknown as LoadedSettings);
stdoutWriteSpy = vi
.spyOn(process.stdout, 'write')
.mockImplementation(() => true);
});
afterEach(() => {
@@ -56,10 +68,7 @@ describe('skills list command', () => {
await handleList({});
expect(coreEvents.emitConsoleLog).toHaveBeenCalledWith(
'log',
'No skills discovered.',
);
expect(stdoutWriteSpy).toHaveBeenCalledWith('No skills discovered.\n');
});
it('should list all discovered skills', async () => {
@@ -87,24 +96,19 @@ describe('skills list command', () => {
await handleList({});
expect(coreEvents.emitConsoleLog).toHaveBeenCalledWith(
'log',
chalk.bold('Discovered Agent Skills:'),
expect(stdoutWriteSpy).toHaveBeenCalledWith(
chalk.bold('Discovered Agent Skills:') + '\n\n',
);
expect(coreEvents.emitConsoleLog).toHaveBeenCalledWith(
'log',
expect(stdoutWriteSpy).toHaveBeenCalledWith(
expect.stringContaining('skill1'),
);
expect(coreEvents.emitConsoleLog).toHaveBeenCalledWith(
'log',
expect(stdoutWriteSpy).toHaveBeenCalledWith(
expect.stringContaining(chalk.green('[Enabled]')),
);
expect(coreEvents.emitConsoleLog).toHaveBeenCalledWith(
'log',
expect(stdoutWriteSpy).toHaveBeenCalledWith(
expect.stringContaining('skill2'),
);
expect(coreEvents.emitConsoleLog).toHaveBeenCalledWith(
'log',
expect(stdoutWriteSpy).toHaveBeenCalledWith(
expect.stringContaining(chalk.red('[Disabled]')),
);
});
@@ -135,12 +139,10 @@ describe('skills list command', () => {
// Default
await handleList({ all: false });
expect(coreEvents.emitConsoleLog).toHaveBeenCalledWith(
'log',
expect(stdoutWriteSpy).toHaveBeenCalledWith(
expect.stringContaining('regular'),
);
expect(coreEvents.emitConsoleLog).not.toHaveBeenCalledWith(
'log',
expect(stdoutWriteSpy).not.toHaveBeenCalledWith(
expect.stringContaining('builtin'),
);
@@ -148,16 +150,13 @@ describe('skills list command', () => {
// With all: true
await handleList({ all: true });
expect(coreEvents.emitConsoleLog).toHaveBeenCalledWith(
'log',
expect(stdoutWriteSpy).toHaveBeenCalledWith(
expect.stringContaining('regular'),
);
expect(coreEvents.emitConsoleLog).toHaveBeenCalledWith(
'log',
expect(stdoutWriteSpy).toHaveBeenCalledWith(
expect.stringContaining('builtin'),
);
expect(coreEvents.emitConsoleLog).toHaveBeenCalledWith(
'log',
expect(stdoutWriteSpy).toHaveBeenCalledWith(
expect.stringContaining(chalk.gray(' [Built-in]')),
);
});
+7 -8
View File
@@ -5,7 +5,6 @@
*/
import type { CommandModule } from 'yargs';
import { debugLogger } from '@google/gemini-cli-core';
import { loadSettings } from '../../config/settings.js';
import { loadCliConfig, type CliArgs } from '../../config/config.js';
import { exitCli } from '../utils.js';
@@ -42,12 +41,11 @@ export async function handleList(args: { all?: boolean }) {
});
if (skills.length === 0) {
debugLogger.log('No skills discovered.');
process.stdout.write('No skills discovered.\n');
return;
}
debugLogger.log(chalk.bold('Discovered Agent Skills:'));
debugLogger.log('');
process.stdout.write(chalk.bold('Discovered Agent Skills:') + '\n\n');
for (const skill of skills) {
const status = skill.disabled
@@ -56,10 +54,11 @@ export async function handleList(args: { all?: boolean }) {
const builtinSuffix = skill.isBuiltin ? chalk.gray(' [Built-in]') : '';
debugLogger.log(`${chalk.bold(skill.name)} ${status}${builtinSuffix}`);
debugLogger.log(` Description: ${skill.description}`);
debugLogger.log(` Location: ${skill.location}`);
debugLogger.log('');
process.stdout.write(
`${chalk.bold(skill.name)} ${status}${builtinSuffix}\n`,
);
process.stdout.write(` Description: ${skill.description}\n`);
process.stdout.write(` Location: ${skill.location}\n\n`);
}
}
+2
View File
@@ -938,6 +938,8 @@ export async function loadCliConfig(
: undefined,
blockedEnvironmentVariables:
settings.security?.environmentVariableRedaction?.blocked,
allowedEnvironmentVariables:
settings.security?.environmentVariableRedaction?.allowed,
enableEnvironmentVariableRedaction:
settings.security?.environmentVariableRedaction?.enabled,
userMemory: memoryContent,
@@ -520,8 +520,8 @@ describe('Policy Engine Integration Tests', () => {
const readOnlyToolRule = rules.find(
(r) => r.toolName === 'glob' && !r.subagent,
);
// Priority 70 in default tier → 1.07 (Overriding Plan Mode Deny)
expect(readOnlyToolRule?.priority).toBeCloseTo(1.07, 5);
// Priority 50 in default tier → 1.05 (Overriding Plan Mode Deny)
expect(readOnlyToolRule?.priority).toBeCloseTo(1.05, 5);
// Verify the engine applies these priorities correctly
expect(
@@ -677,8 +677,8 @@ describe('Policy Engine Integration Tests', () => {
expect(server1Rule?.priority).toBe(4.1); // Allowed servers (user tier)
const globRule = rules.find((r) => r.toolName === 'glob' && !r.subagent);
// Priority 70 in default tier → 1.07
expect(globRule?.priority).toBeCloseTo(1.07, 5); // Auto-accept read-only
// Priority 50 in default tier → 1.05
expect(globRule?.priority).toBeCloseTo(1.05, 5); // Auto-accept read-only
// The PolicyEngine will sort these by priority when it's created
const engine = new PolicyEngine(config);
+11 -1
View File
@@ -757,7 +757,7 @@ const SETTINGS_SCHEMA = {
label: 'Terminal Buffer',
category: 'UI',
requiresRestart: true,
default: true,
default: false,
description: 'Use the new terminal buffer architecture for rendering.',
showInDialog: true,
},
@@ -1970,6 +1970,16 @@ const SETTINGS_SCHEMA = {
description: 'Enable non-interactive agent sessions.',
showInDialog: false,
},
agentSessionInteractiveEnabled: {
type: 'boolean',
label: 'Interactive Agent Session Enabled',
category: 'Experimental',
requiresRestart: true,
default: false,
description:
'Enable the agent session implementation for the interactive CLI.',
showInDialog: false,
},
},
},
enableAgents: {
+24 -4
View File
@@ -379,15 +379,30 @@ describe('initializeOutputListenersAndFlush', () => {
describe('getNodeMemoryArgs', () => {
let osTotalMemSpy: MockInstance;
let v8GetHeapStatisticsSpy: MockInstance;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let originalConfig: any;
beforeEach(() => {
osTotalMemSpy = vi.spyOn(os, 'totalmem');
v8GetHeapStatisticsSpy = vi.spyOn(v8, 'getHeapStatistics');
delete process.env['GEMINI_CLI_NO_RELAUNCH'];
originalConfig = process.config;
Object.defineProperty(process, 'config', {
value: {
...originalConfig,
variables: { ...originalConfig?.variables, v8_enable_sandbox: 1 },
},
configurable: true,
});
});
afterEach(() => {
vi.restoreAllMocks();
Object.defineProperty(process, 'config', {
value: originalConfig,
configurable: true,
});
});
it('should return empty array if GEMINI_CLI_NO_RELAUNCH is set', () => {
@@ -400,8 +415,10 @@ describe('getNodeMemoryArgs', () => {
v8GetHeapStatisticsSpy.mockReturnValue({
heap_size_limit: 8 * 1024 * 1024 * 1024, // 8GB
});
// Target is 50% of 16GB = 8GB. Current is 8GB. No relaunch needed.
expect(getNodeMemoryArgs(false)).toEqual([]);
// Target is 50% of 16GB = 8GB. Current is 8GB. Relaunch needed for EPT size only.
expect(getNodeMemoryArgs(false)).toEqual([
'--max-external-pointer-table-size=268435456',
]);
});
it('should return memory args if current heap limit is insufficient', () => {
@@ -409,8 +426,11 @@ describe('getNodeMemoryArgs', () => {
v8GetHeapStatisticsSpy.mockReturnValue({
heap_size_limit: 4 * 1024 * 1024 * 1024, // 4GB
});
// Target is 50% of 16GB = 8GB. Current is 4GB. Relaunch needed.
expect(getNodeMemoryArgs(false)).toEqual(['--max-old-space-size=8192']);
// Target is 50% of 16GB = 8GB. Current is 4GB. Relaunch needed for both.
expect(getNodeMemoryArgs(false)).toEqual([
'--max-external-pointer-table-size=268435456',
'--max-old-space-size=8192',
]);
});
it('should log debug info when isDebugMode is true', () => {
+23 -2
View File
@@ -111,6 +111,8 @@ export function validateDnsResolutionOrder(
return defaultValue;
}
const DEFAULT_EPT_SIZE = (256 * 1024 * 1024).toString();
export function getNodeMemoryArgs(isDebugMode: boolean): string[] {
const totalMemoryMB = os.totalmem() / (1024 * 1024);
const heapStats = v8.getHeapStatistics();
@@ -130,16 +132,35 @@ export function getNodeMemoryArgs(isDebugMode: boolean): string[] {
return [];
}
const args: string[] = [];
// Automatically expand the V8 External Pointer Table to 256MB to prevent
// out-of-memory crashes during high native-handle concurrency.
// Note: Only supported in specific Node.js versions compiled with V8 Sandbox enabled.
const eptFlag = `--max-external-pointer-table-size=${DEFAULT_EPT_SIZE}`;
const isV8SandboxEnabled =
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion
(process.config?.variables as any)?.v8_enable_sandbox === 1;
if (
isV8SandboxEnabled &&
!process.execArgv.some((arg) =>
arg.startsWith('--max-external-pointer-table-size'),
)
) {
args.push(eptFlag);
}
if (targetMaxOldSpaceSizeInMB > currentMaxOldSpaceSizeMb) {
if (isDebugMode) {
debugLogger.debug(
`Need to relaunch with more memory: ${targetMaxOldSpaceSizeInMB.toFixed(2)} MB`,
);
}
return [`--max-old-space-size=${targetMaxOldSpaceSizeInMB}`];
args.push(`--max-old-space-size=${targetMaxOldSpaceSizeInMB}`);
}
return [];
return args;
}
export function setupUnhandledRejectionHandler() {
+2 -1
View File
@@ -156,8 +156,9 @@ export async function startInteractiveUI(
useAlternateBuffer || config.getUseTerminalBuffer(),
patchConsole: false,
alternateBuffer: useAlternateBuffer,
renderProcess: config.getUseRenderProcess(),
terminalBuffer: config.getUseTerminalBuffer(),
renderProcess:
config.getUseRenderProcess() && config.getUseTerminalBuffer(),
incrementalRendering:
settings.merged.ui.incrementalRendering !== false &&
useAlternateBuffer &&
@@ -71,6 +71,7 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
Scheduler: class {
schedule = mockSchedulerSchedule;
cancelAll = vi.fn();
dispose = vi.fn();
},
isTelemetrySdkInitialized: vi.fn().mockReturnValue(true),
ChatRecordingService: MockChatRecordingService,
+3 -1
View File
@@ -187,6 +187,7 @@ export async function runNonInteractive(
};
let errorToHandle: unknown | undefined;
let scheduler: Scheduler | undefined;
try {
consolePatcher.patch();
@@ -215,7 +216,7 @@ export async function runNonInteractive(
});
const geminiClient = config.getGeminiClient();
const scheduler = new Scheduler({
scheduler = new Scheduler({
context: config,
messageBus: config.getMessageBus(),
getPreferredEditor: () => undefined,
@@ -528,6 +529,7 @@ export async function runNonInteractive(
// Cleanup stdin cancellation before other cleanup
cleanupStdinCancellation();
scheduler?.dispose();
consolePatcher.cleanup();
coreEvents.off(CoreEvent.UserFeedback, handleUserFeedback);
}
@@ -71,6 +71,7 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
Scheduler: class {
schedule = mockSchedulerSchedule;
cancelAll = vi.fn();
dispose = vi.fn();
},
isTelemetrySdkInitialized: vi.fn().mockReturnValue(true),
ChatRecordingService: MockChatRecordingService,
@@ -37,6 +37,7 @@ import {
LegacyAgentSession,
ToolErrorType,
geminiPartsToContentParts,
debugLogger,
} from '@google/gemini-cli-core';
import type { Part } from '@google/genai';
@@ -183,6 +184,7 @@ export async function runNonInteractive({
};
let errorToHandle: unknown | undefined;
let scheduler: Scheduler | undefined;
let abortSession = () => {};
try {
consolePatcher.patch();
@@ -214,7 +216,7 @@ export async function runNonInteractive({
});
const geminiClient = config.getGeminiClient();
const scheduler = new Scheduler({
scheduler = new Scheduler({
context: config,
messageBus: config.getMessageBus(),
getPreferredEditor: () => undefined,
@@ -599,6 +601,7 @@ export async function runNonInteractive({
// Explicitly ignore these non-interactive events
break;
default:
debugLogger.error('Unknown agent event type:', event);
event satisfies never;
break;
}
@@ -610,6 +613,7 @@ export async function runNonInteractive({
cleanupStdinCancellation();
abortController.signal.removeEventListener('abort', abortSession);
scheduler?.dispose();
consolePatcher.cleanup();
coreEvents.off(CoreEvent.UserFeedback, handleUserFeedback);
}
@@ -136,6 +136,7 @@ export const createMockConfig = (overrides: Partial<Config> = {}): Config =>
getRetryFetchErrors: vi.fn().mockReturnValue(true),
getEnableShellOutputEfficiency: vi.fn().mockReturnValue(true),
getShellToolInactivityTimeout: vi.fn().mockReturnValue(300000),
getRequestTimeoutMs: vi.fn().mockReturnValue(undefined),
getShellExecutionConfig: vi.fn().mockReturnValue({
sandboxManager: new NoopSandboxManager(),
sanitizationConfig: {
+77 -56
View File
@@ -504,6 +504,8 @@ const baseMockUiState = {
history: [],
renderMarkdown: true,
streamingState: StreamingState.Idle,
isConfigInitialized: true,
isAuthenticating: false,
terminalWidth: 100,
terminalHeight: 40,
currentModel: 'gemini-pro',
@@ -598,6 +600,9 @@ const mockUIActions: UIActions = {
clearAccountSuspension: vi.fn(),
};
import { type TextBuffer } from '../ui/components/shared/text-buffer.js';
import { InputContext, type InputState } from '../ui/contexts/InputContext.js';
let capturedOverflowState: OverflowState | undefined;
let capturedOverflowActions: OverflowActions | undefined;
const ContextCapture: React.FC<{ children: React.ReactNode }> = ({
@@ -614,6 +619,7 @@ export const renderWithProviders = async (
shellFocus = true,
settings = mockSettings,
uiState: providedUiState,
inputState: providedInputState,
width,
mouseEventsEnabled = false,
config,
@@ -625,6 +631,7 @@ export const renderWithProviders = async (
shellFocus?: boolean;
settings?: LoadedSettings;
uiState?: Partial<UIState>;
inputState?: Partial<InputState>;
width?: number;
mouseEventsEnabled?: boolean;
config?: Config;
@@ -659,6 +666,18 @@ export const renderWithProviders = async (
},
) as UIState;
const inputState = {
buffer: { text: '' } as unknown as TextBuffer,
userMessages: [],
shellModeActive: false,
showEscapePrompt: false,
copyModeEnabled: false,
inputWidth: 80,
suggestionsWidth: 40,
...(providedUiState as unknown as Partial<InputState>),
...providedInputState,
};
if (persistentState?.get) {
persistentStateMock.get.mockImplementation(persistentState.get);
}
@@ -708,63 +727,65 @@ export const renderWithProviders = async (
<AppContext.Provider value={appState}>
<ConfigContext.Provider value={config}>
<SettingsContext.Provider value={settings}>
<UIStateContext.Provider value={finalUiState}>
<VimModeProvider>
<ShellFocusContext.Provider value={shellFocus}>
<SessionStatsProvider>
<StreamingContext.Provider
value={finalUiState.streamingState}
>
<UIActionsContext.Provider value={finalUIActions}>
<OverflowProvider>
<ToolActionsProvider
config={config}
toolCalls={allToolCalls}
isExpanded={
toolActions?.isExpanded ??
vi.fn().mockReturnValue(false)
}
toggleExpansion={
toolActions?.toggleExpansion ?? vi.fn()
}
toggleAllExpansion={
toolActions?.toggleAllExpansion ?? vi.fn()
}
>
<AskUserActionsProvider
request={null}
onSubmit={vi.fn()}
onCancel={vi.fn()}
<InputContext.Provider value={inputState}>
<UIStateContext.Provider value={finalUiState}>
<VimModeProvider>
<ShellFocusContext.Provider value={shellFocus}>
<SessionStatsProvider>
<StreamingContext.Provider
value={finalUiState.streamingState}
>
<UIActionsContext.Provider value={finalUIActions}>
<OverflowProvider>
<ToolActionsProvider
config={config}
toolCalls={allToolCalls}
isExpanded={
toolActions?.isExpanded ??
vi.fn().mockReturnValue(false)
}
toggleExpansion={
toolActions?.toggleExpansion ?? vi.fn()
}
toggleAllExpansion={
toolActions?.toggleAllExpansion ?? vi.fn()
}
>
<KeypressProvider>
<MouseProvider
mouseEventsEnabled={mouseEventsEnabled}
>
<TerminalProvider>
<ScrollProvider>
<ContextCapture>
<Box
width={terminalWidth}
flexShrink={0}
flexGrow={0}
flexDirection="column"
>
{comp}
</Box>
</ContextCapture>
</ScrollProvider>
</TerminalProvider>
</MouseProvider>
</KeypressProvider>
</AskUserActionsProvider>
</ToolActionsProvider>
</OverflowProvider>
</UIActionsContext.Provider>
</StreamingContext.Provider>
</SessionStatsProvider>
</ShellFocusContext.Provider>
</VimModeProvider>
</UIStateContext.Provider>
<AskUserActionsProvider
request={null}
onSubmit={vi.fn()}
onCancel={vi.fn()}
>
<KeypressProvider>
<MouseProvider
mouseEventsEnabled={mouseEventsEnabled}
>
<TerminalProvider>
<ScrollProvider>
<ContextCapture>
<Box
width={terminalWidth}
flexShrink={0}
flexGrow={0}
flexDirection="column"
>
{comp}
</Box>
</ContextCapture>
</ScrollProvider>
</TerminalProvider>
</MouseProvider>
</KeypressProvider>
</AskUserActionsProvider>
</ToolActionsProvider>
</OverflowProvider>
</UIActionsContext.Provider>
</StreamingContext.Provider>
</SessionStatsProvider>
</ShellFocusContext.Provider>
</VimModeProvider>
</UIStateContext.Provider>
</InputContext.Provider>
</SettingsContext.Provider>
</ConfigContext.Provider>
</AppContext.Provider>
+9 -5
View File
@@ -122,13 +122,17 @@ vi.mock('ink', async (importOriginal) => {
};
});
import { InputContext, type InputState } from './contexts/InputContext.js';
// Helper component will read the context values provided by AppContainer
// so we can assert against them in our tests.
let capturedUIState: UIState;
let capturedInputState: InputState;
let capturedUIActions: UIActions;
let capturedOverflowActions: OverflowActions;
function TestContextConsumer() {
capturedUIState = useContext(UIStateContext)!;
capturedInputState = useContext(InputContext)!;
capturedUIActions = useContext(UIActionsContext)!;
capturedOverflowActions = useOverflowActions()!;
return null;
@@ -3036,7 +3040,7 @@ describe('AppContainer State Management', () => {
});
const { unmount } = await act(async () => renderAppContainer());
expect(capturedUIState.userMessages).toContain('previous message');
expect(capturedInputState.userMessages).toContain('previous message');
const { onCancelSubmit } = extractUseGeminiStreamArgs(
mockedUseGeminiStream.mock.lastCall!,
@@ -3064,8 +3068,8 @@ describe('AppContainer State Management', () => {
const { rerender, unmount } = await act(async () => renderAppContainer());
// Verify userMessages is populated from inputHistory
expect(capturedUIState.userMessages).toContain('first prompt');
expect(capturedUIState.userMessages).toContain('second prompt');
expect(capturedInputState.userMessages).toContain('first prompt');
expect(capturedInputState.userMessages).toContain('second prompt');
// Clear the conversation history (simulating /clear command)
const mockClearItems = vi.fn();
@@ -3084,8 +3088,8 @@ describe('AppContainer State Management', () => {
// Verify that userMessages still contains the input history
// (it should not be affected by clearing conversation history)
expect(capturedUIState.userMessages).toContain('first prompt');
expect(capturedUIState.userMessages).toContain('second prompt');
expect(capturedInputState.userMessages).toContain('first prompt');
expect(capturedInputState.userMessages).toContain('second prompt');
unmount();
});
+67 -40
View File
@@ -36,9 +36,11 @@ import {
type ConfirmationRequest,
type PermissionConfirmationRequest,
type QuotaStats,
MessageType,
StreamingState,
type HistoryItemInfo,
} from './types.js';
import { checkPermissions } from './hooks/atCommandProcessor.js';
import { MessageType, StreamingState } from './types.js';
import { ToolActionsProvider } from './contexts/ToolActionsContext.js';
import { MouseProvider } from './contexts/MouseContext.js';
import { ScrollProvider } from './contexts/ScrollProvider.js';
@@ -51,6 +53,7 @@ import {
type UserTierId,
type GeminiUserTier,
type UserFeedbackPayload,
type HookSystemMessagePayload,
type AgentDefinition,
type ApprovalMode,
IdeClient,
@@ -194,6 +197,8 @@ import {
} from './hooks/useVisibilityToggle.js';
import { useKeyMatchers } from './hooks/useKeyMatchers.js';
import { InputContext } from './contexts/InputContext.js';
/**
* The fraction of the terminal width to allocate to the shell.
* This provides horizontal padding.
@@ -2109,7 +2114,19 @@ Logging in with Google... Restarting Gemini CLI to continue.
}
};
const handleHookSystemMessage = (payload: HookSystemMessagePayload) => {
historyManager.addItem(
{
type: MessageType.INFO,
text: payload.message,
source: payload.hookName,
} as HistoryItemInfo,
Date.now(),
);
};
coreEvents.on(CoreEvent.UserFeedback, handleUserFeedback);
coreEvents.on(CoreEvent.HookSystemMessage, handleHookSystemMessage);
// Flush any messages that happened during startup before this component
// mounted.
@@ -2117,6 +2134,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
return () => {
coreEvents.off(CoreEvent.UserFeedback, handleUserFeedback);
coreEvents.off(CoreEvent.HookSystemMessage, handleHookSystemMessage);
};
}, [historyManager]);
@@ -2328,6 +2346,27 @@ Logging in with Google... Restarting Gemini CLI to continue.
};
}, [config, refreshStatic]);
const inputState = useMemo(
() => ({
buffer,
userMessages: inputHistory,
shellModeActive,
showEscapePrompt,
copyModeEnabled,
inputWidth,
suggestionsWidth,
}),
[
buffer,
inputHistory,
shellModeActive,
showEscapePrompt,
copyModeEnabled,
inputWidth,
suggestionsWidth,
],
);
const uiState: UIState = useMemo(
() => ({
history: historyManager.history,
@@ -2371,11 +2410,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
initError,
pendingGeminiHistoryItems,
thought,
shellModeActive,
userMessages: inputHistory,
buffer,
inputWidth,
suggestionsWidth,
isInputActive,
isResuming,
shouldShowIdePrompt,
@@ -2391,7 +2425,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
renderMarkdown,
ctrlCPressedOnce: ctrlCPressCount >= 1,
ctrlDPressedOnce: ctrlDPressCount >= 1,
showEscapePrompt,
shortcutsHelpVisible,
cleanUiDetailsVisible,
isFocused,
@@ -2443,7 +2476,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
embeddedShellFocused,
showDebugProfiler,
customDialog,
copyModeEnabled,
transientMessage,
bannerData,
bannerVisible,
@@ -2498,11 +2530,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
initError,
pendingGeminiHistoryItems,
thought,
shellModeActive,
inputHistory,
buffer,
inputWidth,
suggestionsWidth,
isInputActive,
isResuming,
shouldShowIdePrompt,
@@ -2518,7 +2545,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
renderMarkdown,
ctrlCPressCount,
ctrlDPressCount,
showEscapePrompt,
shortcutsHelpVisible,
cleanUiDetailsVisible,
isFocused,
@@ -2570,7 +2596,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
customDialog,
apiKeyDefaultValue,
authState,
copyModeEnabled,
transientMessage,
bannerData,
bannerVisible,
@@ -2757,32 +2782,34 @@ Logging in with Google... Restarting Gemini CLI to continue.
return (
<UIStateContext.Provider value={uiState}>
<UIActionsContext.Provider value={uiActions}>
<ConfigContext.Provider value={config}>
<AppContext.Provider
value={{
version: props.version,
startupWarnings: props.startupWarnings || [],
}}
>
<ToolActionsProvider
config={config}
toolCalls={allToolCalls}
isExpanded={isExpanded}
toggleExpansion={toggleExpansion}
toggleAllExpansion={toggleAllExpansion}
<InputContext.Provider value={inputState}>
<UIActionsContext.Provider value={uiActions}>
<ConfigContext.Provider value={config}>
<AppContext.Provider
value={{
version: props.version,
startupWarnings: props.startupWarnings || [],
}}
>
<ShellFocusContext.Provider value={isFocused}>
<MouseProvider mouseEventsEnabled={mouseMode}>
<ScrollProvider>
<App key={`app-${forceRerenderKey}`} />
</ScrollProvider>
</MouseProvider>
</ShellFocusContext.Provider>
</ToolActionsProvider>
</AppContext.Provider>
</ConfigContext.Provider>
</UIActionsContext.Provider>
<ToolActionsProvider
config={config}
toolCalls={allToolCalls}
isExpanded={isExpanded}
toggleExpansion={toggleExpansion}
toggleAllExpansion={toggleAllExpansion}
>
<ShellFocusContext.Provider value={isFocused}>
<MouseProvider mouseEventsEnabled={mouseMode}>
<ScrollProvider>
<App key={`app-${forceRerenderKey}`} />
</ScrollProvider>
</MouseProvider>
</ShellFocusContext.Provider>
</ToolActionsProvider>
</AppContext.Provider>
</ConfigContext.Provider>
</UIActionsContext.Provider>
</InputContext.Provider>
</UIStateContext.Provider>
);
};
@@ -55,6 +55,12 @@ Footer
Gemini CLI v1.2.3
Tips for getting started:
1. Create GEMINI.md files to customize your interactions
2. /help for more information
3. Ask coding questions, edit code or run commands
4. Be specific for the best results
Composer
"
`;
@@ -10,15 +10,20 @@ import {
} 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 { describe, it, expect, vi, beforeEach } from 'vitest';
import { makeFakeConfig } from '@google/gemini-cli-core';
import crypto from 'node:crypto';
import { _clearSessionBannersForTest } from '../hooks/useBanner.js';
vi.mock('../utils/terminalSetup.js', () => ({
getTerminalProgram: () => null,
}));
describe('<AppHeader />', () => {
beforeEach(() => {
_clearSessionBannersForTest();
});
it('should render the banner with default text', async () => {
const uiState = {
history: [],
+9 -2
View File
@@ -59,13 +59,20 @@ const NARROW_TERMINAL_BREAKPOINT = 60;
export const AppHeader = ({ version, showDetails = true }: AppHeaderProps) => {
const settings = useSettings();
const config = useConfig();
const { terminalWidth, bannerData, bannerVisible, updateInfo } = useUIState();
const {
terminalWidth,
bannerData,
bannerVisible,
updateInfo,
isConfigInitialized,
isAuthenticating,
} = useUIState();
const { bannerText } = useBanner(bannerData);
const { showTips } = useTips();
const authType = config.getContentGeneratorConfig()?.authType;
const loggedOut = !authType;
const loggedOut = isConfigInitialized && !isAuthenticating && !authType;
const showHeader = !(
settings.merged.ui.hideBanner || config.getScreenReader()
@@ -245,20 +245,37 @@ const createMockConfig = (overrides = {}): Config =>
...overrides,
}) as unknown as Config;
import { InputContext, type InputState } from '../contexts/InputContext.js';
const renderComposer = async (
uiState: UIState,
settings = createMockSettings({ ui: {} }),
config = createMockConfig(),
uiActions = createMockUIActions(),
inputStateOverrides: Partial<InputState> = {},
) => {
const inputState = {
buffer: { text: '' } as unknown as TextBuffer,
userMessages: [],
shellModeActive: false,
showEscapePrompt: false,
copyModeEnabled: false,
inputWidth: 80,
suggestionsWidth: 40,
...(uiState as unknown as Partial<InputState>),
...inputStateOverrides,
};
const result = await render(
<ConfigContext.Provider value={config as unknown as Config}>
<SettingsContext.Provider value={settings as unknown as LoadedSettings}>
<UIStateContext.Provider value={uiState}>
<UIActionsContext.Provider value={uiActions}>
<Composer isFocused={true} />
</UIActionsContext.Provider>
</UIStateContext.Provider>
<InputContext.Provider value={inputState}>
<UIStateContext.Provider value={uiState}>
<UIActionsContext.Provider value={uiActions}>
<Composer isFocused={true} />
</UIActionsContext.Provider>
</UIStateContext.Provider>
</InputContext.Provider>
</SettingsContext.Provider>
</ConfigContext.Provider>,
);
@@ -541,7 +558,6 @@ describe('Composer', () => {
const uiState = createMockUIState({
ctrlCPressedOnce: false,
ctrlDPressedOnce: false,
showEscapePrompt: false,
});
const { lastFrame } = await renderComposer(uiState);
@@ -631,7 +647,6 @@ describe('Composer', () => {
async (mode) => {
const uiState = createMockUIState({
showApprovalModeIndicator: mode,
shellModeActive: false,
});
const { lastFrame } = await renderComposer(uiState);
@@ -641,11 +656,15 @@ describe('Composer', () => {
);
it('shows ShellModeIndicator when shell mode is active', async () => {
const uiState = createMockUIState({
shellModeActive: true,
});
const uiState = createMockUIState();
const { lastFrame } = await renderComposer(uiState);
const { lastFrame } = await renderComposer(
uiState,
undefined,
undefined,
undefined,
{ shellModeActive: true },
);
expect(lastFrame()).toMatch(/ShellModeIndic[\s\S]*tor/);
});
@@ -724,11 +743,16 @@ describe('Composer', () => {
it('shows Esc rewind prompt in minimal mode without showing full UI', async () => {
const uiState = createMockUIState({
cleanUiDetailsVisible: false,
showEscapePrompt: true,
history: [{ id: 1, type: 'user', text: 'msg' }],
});
const { lastFrame } = await renderComposer(uiState);
const { lastFrame } = await renderComposer(
uiState,
undefined,
undefined,
undefined,
{ showEscapePrompt: true },
);
const output = lastFrame();
expect(output).toContain('Press Esc again to rewind.');
expect(output).not.toContain('ContextSummaryDisplay');
@@ -828,11 +852,16 @@ describe('Composer', () => {
describe('Shortcuts Hint', () => {
it('restores shortcuts hint after 200ms debounce when buffer is empty', async () => {
const uiState = createMockUIState({
buffer: { text: '' } as unknown as TextBuffer,
cleanUiDetailsVisible: false,
});
const { lastFrame } = await renderComposer(uiState);
const { lastFrame } = await renderComposer(
uiState,
undefined,
undefined,
undefined,
{ buffer: { text: '' } as unknown as TextBuffer },
);
await act(async () => {
await vi.advanceTimersByTimeAsync(250);
@@ -845,11 +874,16 @@ describe('Composer', () => {
it('hides shortcuts hint when text is typed in buffer', async () => {
const uiState = createMockUIState({
buffer: { text: 'hello' } as unknown as TextBuffer,
cleanUiDetailsVisible: false,
});
const { lastFrame } = await renderComposer(uiState);
const { lastFrame } = await renderComposer(
uiState,
undefined,
undefined,
undefined,
{ buffer: { text: 'hello' } as unknown as TextBuffer },
);
expect(lastFrame()).not.toContain('press tab twice for more');
expect(lastFrame()).not.toContain('? for shortcuts');
+5 -9
View File
@@ -9,6 +9,7 @@ import { useState, useEffect } from 'react';
import { useConfig } from '../contexts/ConfigContext.js';
import { useSettings } from '../contexts/SettingsContext.js';
import { useUIState } from '../contexts/UIStateContext.js';
import { useInputState } from '../contexts/InputContext.js';
import { useUIActions } from '../contexts/UIActionsContext.js';
import { useVimMode } from '../contexts/VimModeContext.js';
import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js';
@@ -30,6 +31,7 @@ import { appEvents, AppEvent } from '../../utils/events.js';
export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
const uiState = useUIState();
const inputState = useInputState();
const uiActions = useUIActions();
const settings = useSettings();
const config = useConfig();
@@ -81,12 +83,12 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
return null;
}
const hasToast = shouldShowToast(uiState);
const showToast = shouldShowToast(uiState, inputState);
const hideUiDetailsForSuggestions =
suggestionsVisible && suggestionsPosition === 'above';
// Mini Mode VIP Flags (Pure Content Triggers)
const showMinimalToast = hasToast;
const showMinimalToast = showToast;
return (
<Box
@@ -141,17 +143,12 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
{uiState.isInputActive && (
<InputPrompt
buffer={uiState.buffer}
inputWidth={uiState.inputWidth}
suggestionsWidth={uiState.suggestionsWidth}
onSubmit={uiActions.handleFinalSubmit}
userMessages={uiState.userMessages}
setBannerVisible={uiActions.setBannerVisible}
onClearScreen={uiActions.handleClearScreen}
config={config}
slashCommands={uiState.slashCommands || []}
commandContext={uiState.commandContext}
shellModeActive={uiState.shellModeActive}
setShellModeActive={uiActions.setShellModeActive}
approvalMode={uiState.showApprovalModeIndicator}
onEscapePromptChange={uiActions.onEscapePromptChange}
@@ -165,7 +162,7 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
? vimMode === 'INSERT'
? " Press 'Esc' for NORMAL mode."
: " Press 'i' for INSERT mode."
: uiState.shellModeActive
: inputState.shellModeActive
? ' Type your shell command'
: ' Type your message or @path/to/file'
}
@@ -173,7 +170,6 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
streamingState={uiState.streamingState}
suggestionsPosition={suggestionsPosition}
onSuggestionsVisibilityChange={setSuggestionsVisible}
copyModeEnabled={uiState.copyModeEnabled}
/>
)}
@@ -8,6 +8,8 @@ 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 { Command } from '../key/keyMatchers.js';
import { formatCommand } from '../key/keybindingUtils.js';
interface ContextSummaryDisplayProps {
geminiMdFileCount: number;
@@ -49,7 +51,7 @@ export const ContextSummaryDisplay: React.FC<ContextSummaryDisplayProps> = ({
}
return `${openFileCount} open file${
openFileCount > 1 ? 's' : ''
} (ctrl+g to view)`;
} (${formatCommand(Command.SHOW_IDE_CONTEXT_DETAIL)} to view)`;
})();
const geminiMdText = (() => {
@@ -4,34 +4,36 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { render } from '../../test-utils/render.js';
import { CopyModeWarning } from './CopyModeWarning.js';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { useUIState, type UIState } from '../contexts/UIStateContext.js';
import { renderWithProviders } from '../../test-utils/render.js';
import { useInputState } from '../contexts/InputContext.js';
vi.mock('../contexts/UIStateContext.js');
vi.mock('../contexts/InputContext.js');
describe('CopyModeWarning', () => {
const mockUseUIState = vi.mocked(useUIState);
beforeEach(() => {
vi.clearAllMocks();
});
it('renders nothing when copy mode is disabled', async () => {
mockUseUIState.mockReturnValue({
vi.mocked(useInputState).mockReturnValue({
copyModeEnabled: false,
} as unknown as UIState);
const { lastFrame, unmount } = await render(<CopyModeWarning />);
} as unknown as ReturnType<typeof useInputState>);
const { lastFrame, unmount } = await renderWithProviders(
<CopyModeWarning />,
);
expect(lastFrame({ allowEmpty: true })).toBe('');
unmount();
});
it('renders warning when copy mode is enabled', async () => {
mockUseUIState.mockReturnValue({
vi.mocked(useInputState).mockReturnValue({
copyModeEnabled: true,
} as unknown as UIState);
const { lastFrame, unmount } = await render(<CopyModeWarning />);
} as unknown as ReturnType<typeof useInputState>);
const { lastFrame, unmount } = await renderWithProviders(
<CopyModeWarning />,
);
expect(lastFrame()).toContain('In Copy Mode');
expect(lastFrame()).toContain('Use Page Up/Down to scroll');
expect(lastFrame()).toContain('Press Ctrl+S or any other key to exit');
@@ -6,11 +6,11 @@
import type React from 'react';
import { Box, Text } from 'ink';
import { useUIState } from '../contexts/UIStateContext.js';
import { useInputState } from '../contexts/InputContext.js';
import { theme } from '../semantic-colors.js';
export const CopyModeWarning: React.FC = () => {
const { copyModeEnabled } = useUIState();
const { copyModeEnabled } = useInputState();
return (
<Box height={1}>
@@ -169,6 +169,11 @@ Implement a comprehensive authentication system with multiple providers.
getUseTerminalBuffer: () => false,
} as unknown as import('@google/gemini-cli-core').Config,
settings: createMockSettings({ ui: { useAlternateBuffer } }),
inputState: {
buffer: { text: '' } as never,
showEscapePrompt: false,
shellModeActive: false,
},
},
);
};
@@ -472,6 +477,11 @@ Implement a comprehensive authentication system with multiple providers.
settings: createMockSettings({
ui: { useAlternateBuffer: useAlternateBuffer ?? true },
}),
inputState: {
buffer: { text: '' } as never,
showEscapePrompt: false,
shellModeActive: false,
},
},
),
);
@@ -577,7 +587,7 @@ Implement a comprehensive authentication system with multiple providers.
expect(onFeedback).not.toHaveBeenCalled();
});
it('automatically submits feedback when Ctrl+X is used to edit the plan', async () => {
it('automatically submits feedback when Ctrl+G is used to edit the plan', async () => {
const { stdin, lastFrame } = await act(async () =>
renderDialog({ useAlternateBuffer }),
);
@@ -590,9 +600,9 @@ Implement a comprehensive authentication system with multiple providers.
expect(lastFrame()).toContain('Add user authentication');
});
// Press Ctrl+X
// Press Ctrl+G
await act(async () => {
writeKey(stdin, '\x18'); // Ctrl+X
writeKey(stdin, '\x07'); // Ctrl+G
});
await waitFor(() => {
@@ -25,6 +25,11 @@ import { useKeypress } from '../hooks/useKeypress.js';
import { Command } from '../key/keyMatchers.js';
import { formatCommand } from '../key/keybindingUtils.js';
import { useKeyMatchers } from '../hooks/useKeyMatchers.js';
import {
appEvents,
AppEvent,
TransientMessageType,
} from '../../utils/events.js';
export interface ExitPlanModeDialogProps {
planPath: string;
@@ -173,6 +178,14 @@ export const ExitPlanModeDialog: React.FC<ExitPlanModeDialogProps> = ({
void handleOpenEditor();
return true;
}
if (keyMatchers[Command.DEPRECATED_OPEN_EXTERNAL_EDITOR](key)) {
const cmdKey = formatCommand(Command.OPEN_EXTERNAL_EDITOR);
appEvents.emit(AppEvent.TransientMessage, {
message: `Use ${cmdKey} to open the external editor.`,
type: TransientMessageType.Hint,
});
return true;
}
return false;
},
{ isActive: true, priority: true },
+3 -4
View File
@@ -26,6 +26,7 @@ import { useUIState } from '../contexts/UIStateContext.js';
import { useConfig } from '../contexts/ConfigContext.js';
import { useSettings } from '../contexts/SettingsContext.js';
import { useVimMode } from '../contexts/VimModeContext.js';
import { useInputState } from '../contexts/InputContext.js';
import {
ALL_ITEMS,
type FooterItemId,
@@ -173,6 +174,7 @@ interface FooterColumn {
export const Footer: React.FC = () => {
const uiState = useUIState();
const { copyModeEnabled } = useInputState();
const config = useConfig();
const settings = useSettings();
const { vimEnabled, vimMode } = useVimMode();
@@ -365,10 +367,7 @@ export const Footer: React.FC = () => {
id,
header,
() => (
<MemoryUsageDisplay
color={itemColor}
isActive={!uiState.copyModeEnabled}
/>
<MemoryUsageDisplay color={itemColor} isActive={!copyModeEnabled} />
),
10,
);
@@ -134,6 +134,7 @@ export const HistoryItemDisplay: React.FC<HistoryItemDisplayProps> = ({
<InfoMessage
text={itemForDisplay.text}
secondaryText={itemForDisplay.secondaryText}
source={itemForDisplay.source}
icon={itemForDisplay.icon}
color={itemForDisplay.color}
marginBottom={itemForDisplay.marginBottom}
File diff suppressed because it is too large Load Diff
+68 -32
View File
@@ -5,7 +5,14 @@
*/
import type React from 'react';
import { useCallback, useEffect, useState, useRef, useMemo } from 'react';
import {
useCallback,
useEffect,
useState,
useRef,
useMemo,
Fragment,
} from 'react';
import clipboardy from 'clipboardy';
import { Box, Text, useStdout, type DOMElement } from 'ink';
import { SuggestionsDisplay, MAX_WIDTH } from './SuggestionsDisplay.js';
@@ -70,6 +77,7 @@ import { getSafeLowColorBackground } from '../themes/color-utils.js';
import { isLowColorDepth } from '../utils/terminalUtils.js';
import { useShellFocusState } from '../contexts/ShellFocusContext.js';
import { useUIState } from '../contexts/UIStateContext.js';
import { useInputState } from '../contexts/InputContext.js';
import {
appEvents,
AppEvent,
@@ -104,18 +112,13 @@ export type ScrollableItem =
| { type: 'ghostLine'; ghostLine: string; index: number };
export interface InputPromptProps {
buffer: TextBuffer;
onSubmit: (value: string) => void;
userMessages: readonly string[];
onClearScreen: () => void;
config: Config;
slashCommands: readonly SlashCommand[];
commandContext: CommandContext;
placeholder?: string;
focus?: boolean;
inputWidth: number;
suggestionsWidth: number;
shellModeActive: boolean;
setShellModeActive: (value: boolean) => void;
approvalMode: ApprovalMode;
onEscapePromptChange?: (showPrompt: boolean) => void;
@@ -128,7 +131,6 @@ export interface InputPromptProps {
onQueueMessage?: (message: string) => void;
suggestionsPosition?: 'above' | 'below';
setBannerVisible: (visible: boolean) => void;
copyModeEnabled?: boolean;
}
// The input content, input container, and input suggestions list may have different widths
@@ -199,18 +201,13 @@ export function tryTogglePasteExpansion(buffer: TextBuffer): boolean {
}
export const InputPrompt: React.FC<InputPromptProps> = ({
buffer,
onSubmit,
userMessages,
onClearScreen,
config,
slashCommands,
commandContext,
placeholder = ' Type your message or @path/to/file',
focus = true,
inputWidth,
suggestionsWidth,
shellModeActive,
setShellModeActive,
approvalMode,
onEscapePromptChange,
@@ -223,8 +220,16 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
onQueueMessage,
suggestionsPosition = 'below',
setBannerVisible,
copyModeEnabled = false,
}) => {
const inputState = useInputState();
const {
buffer,
userMessages,
shellModeActive,
copyModeEnabled,
inputWidth,
suggestionsWidth,
} = inputState;
const isHelpDismissKey = useIsHelpDismissKey();
const keyMatchers = useKeyMatchers();
const { stdout } = useStdout();
@@ -434,7 +439,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
slashCommands,
);
if (commandToExecute?.isSafeConcurrent) {
inputHistory.handleSubmit(trimmedMessage);
handleSubmitAndClear(trimmedMessage);
return;
}
}
@@ -452,6 +457,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
streamingState,
setQueueErrorMessage,
slashCommands,
handleSubmitAndClear,
],
);
@@ -1266,6 +1272,15 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
return true;
}
if (keyMatchers[Command.DEPRECATED_OPEN_EXTERNAL_EDITOR](key)) {
const cmdKey = formatCommand(Command.OPEN_EXTERNAL_EDITOR);
appEvents.emit(AppEvent.TransientMessage, {
message: `Use ${cmdKey} to open the external editor.`,
type: TransientMessageType.Hint,
});
return true;
}
// Ctrl+V for clipboard paste
if (keyMatchers[Command.PASTE_CLIPBOARD](key)) {
// eslint-disable-next-line @typescript-eslint/no-floating-promises
@@ -1821,24 +1836,45 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
height={Math.min(buffer.viewportHeight, scrollableData.length)}
width="100%"
>
<ScrollableList
ref={listRef}
hasFocus={focus}
data={scrollableData}
renderItem={renderItem}
estimatedItemHeight={() => 1}
keyExtractor={(item) =>
item.type === 'visualLine'
? `line-${item.absoluteVisualIdx}`
: `ghost-${item.index}`
}
width="100%"
backgroundColor={listBackgroundColor}
containerHeight={Math.min(
buffer.viewportHeight,
scrollableData.length,
)}
/>
{isAlternateBuffer ? (
<ScrollableList
ref={listRef}
hasFocus={focus}
data={scrollableData}
renderItem={renderItem}
estimatedItemHeight={() => 1}
fixedItemHeight={true}
keyExtractor={(item) =>
item.type === 'visualLine'
? `line-${item.absoluteVisualIdx}`
: `ghost-${item.index}`
}
width={inputWidth}
backgroundColor={listBackgroundColor}
containerHeight={Math.min(
buffer.viewportHeight,
scrollableData.length,
)}
/>
) : (
scrollableData
.slice(
buffer.visualScrollRow,
buffer.visualScrollRow + buffer.viewportHeight,
)
.map((item, index) => {
const actualIndex = buffer.visualScrollRow + index;
const key =
item.type === 'visualLine'
? `line-${item.absoluteVisualIdx}`
: `ghost-${item.index}`;
return (
<Fragment key={key}>
{renderItem({ item, index: actualIndex })}
</Fragment>
);
})
)}
</Box>
)}
</Box>
@@ -336,6 +336,10 @@ export const MainContent = () => {
isAlternateBuffer,
]);
if (!uiState.isConfigInitialized) {
return null;
}
if (isAlternateBufferOrTerminalBuffer) {
return scrollableList;
}
@@ -9,7 +9,7 @@ import { describe, it, expect, vi } from 'vitest';
import { StatsDisplay } from './StatsDisplay.js';
import * as SessionContext from '../contexts/SessionContext.js';
import { type SessionMetrics } from '../contexts/SessionContext.js';
import { ToolCallDecision } from '@google/gemini-cli-core';
import { ToolCallDecision, LlmRole } from '@google/gemini-cli-core';
// Mock the context to provide controlled data for testing
vi.mock('../contexts/SessionContext.js', async (importOriginal) => {
@@ -131,6 +131,66 @@ describe('<StatsDisplay />', () => {
expect(output).toMatchSnapshot();
});
it('renders role breakdown correctly under models', async () => {
const metrics = createTestMetrics({
models: {
'gemini-2.5-flash': {
api: { totalRequests: 10, totalErrors: 0, totalLatencyMs: 10000 },
tokens: {
input: 1000,
prompt: 1200,
candidates: 2000,
total: 3200,
cached: 200,
thoughts: 0,
tool: 0,
},
roles: {
[LlmRole.MAIN]: {
totalRequests: 7,
totalErrors: 0,
totalLatencyMs: 7000,
tokens: {
input: 800,
prompt: 900,
candidates: 1500,
total: 2400,
cached: 100,
thoughts: 0,
tool: 0,
},
},
[LlmRole.UTILITY_TOOL]: {
totalRequests: 3,
totalErrors: 0,
totalLatencyMs: 3000,
tokens: {
input: 200,
prompt: 300,
candidates: 500,
total: 800,
cached: 100,
thoughts: 0,
tool: 0,
},
},
},
},
},
});
const { lastFrame } = await renderWithMockedStats(metrics);
const output = lastFrame();
expect(output).toContain('gemini-2.5-flash');
expect(output).toContain('10'); // Total requests
expect(output).toContain('↳ main');
expect(output).toContain('7'); // main requests
expect(output).toContain('↳ utility_tool');
expect(output).toContain('3'); // tool requests
expect(output).toMatchSnapshot();
});
it('renders all sections when all data is present', async () => {
const metrics = createTestMetrics({
models: {
+75 -12
View File
@@ -12,6 +12,7 @@ import { formatDuration } from '../utils/formatters.js';
import {
useSessionStats,
type ModelMetrics,
type RoleMetrics,
} from '../contexts/SessionContext.js';
import {
getStatusColor,
@@ -23,6 +24,7 @@ import {
import { computeSessionStats } from '../utils/computeStats.js';
import { useSettings } from '../contexts/SettingsContext.js';
import type { QuotaStats } from '../types.js';
import { LlmRole } from '@google/gemini-cli-core';
// A more flexible and powerful StatRow component
interface StatRowProps {
@@ -77,6 +79,16 @@ interface ModelUsageTableProps {
models: Record<string, ModelMetrics>;
}
interface ModelRow {
name: string;
displayName: string;
requests: number | string;
cachedTokens: string;
inputTokens: string;
outputTokens: string;
isSubRow: boolean;
}
const ModelUsageTable: React.FC<ModelUsageTableProps> = ({ models }) => {
const nameWidth = 28;
const requestsWidth = 8;
@@ -84,6 +96,46 @@ const ModelUsageTable: React.FC<ModelUsageTableProps> = ({ models }) => {
const cacheReadsWidth = 14;
const outputTokensWidth = 14;
const rows: ModelRow[] = [];
Object.entries(models).forEach(([name, metrics]) => {
rows.push({
name,
displayName: name,
requests: metrics.api.totalRequests,
cachedTokens: metrics.tokens.cached.toLocaleString(),
inputTokens: metrics.tokens.prompt.toLocaleString(),
outputTokens: metrics.tokens.candidates.toLocaleString(),
isSubRow: false,
});
if (metrics.roles) {
const roleEntries = Object.entries(metrics.roles).filter(
(entry): entry is [string, RoleMetrics] =>
entry[1] !== undefined && entry[1].totalRequests > 0,
);
roleEntries.sort(([a], [b]) => {
if (a === b) return 0;
if (a === LlmRole.MAIN) return -1;
if (b === LlmRole.MAIN) return 1;
return a.localeCompare(b);
});
roleEntries.forEach(([role, roleMetrics]) => {
rows.push({
name: `${name}-${role}`,
displayName: `${role}`,
requests: roleMetrics.totalRequests,
cachedTokens: roleMetrics.tokens.cached.toLocaleString(),
inputTokens: roleMetrics.tokens.prompt.toLocaleString(),
outputTokens: roleMetrics.tokens.candidates.toLocaleString(),
isSubRow: true,
});
});
}
});
return (
<Box flexDirection="column" marginTop={1}>
<Text bold color={theme.text.primary}>
@@ -131,31 +183,42 @@ const ModelUsageTable: React.FC<ModelUsageTableProps> = ({ models }) => {
</Box>
{/* Rows */}
{Object.entries(models).map(([name, modelMetrics]) => (
<Box key={name}>
{rows.map((row) => (
<Box key={row.name}>
<Box width={nameWidth}>
<Text color={theme.text.primary} wrap="truncate-end">
{name}
<Text
color={row.isSubRow ? theme.text.secondary : theme.text.primary}
wrap="truncate-end"
>
{row.displayName}
</Text>
</Box>
<Box width={requestsWidth} justifyContent="flex-end">
<Text color={theme.text.primary}>
{modelMetrics.api.totalRequests}
<Text
color={row.isSubRow ? theme.text.secondary : theme.text.primary}
>
{row.requests}
</Text>
</Box>
<Box width={inputTokensWidth} justifyContent="flex-end">
<Text color={theme.text.primary}>
{modelMetrics.tokens.prompt.toLocaleString()}
<Text
color={row.isSubRow ? theme.text.secondary : theme.text.primary}
>
{row.inputTokens}
</Text>
</Box>
<Box width={cacheReadsWidth} justifyContent="flex-end">
<Text color={theme.text.primary}>
{modelMetrics.tokens.cached.toLocaleString()}
<Text
color={row.isSubRow ? theme.text.secondary : theme.text.primary}
>
{row.cachedTokens}
</Text>
</Box>
<Box width={outputTokensWidth} justifyContent="flex-end">
<Text color={theme.text.primary}>
{modelMetrics.tokens.candidates.toLocaleString()}
<Text
color={row.isSubRow ? theme.text.secondary : theme.text.primary}
>
{row.outputTokens}
</Text>
</Box>
</Box>
@@ -9,7 +9,7 @@ import { StatusRow } from './StatusRow.js';
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
import { useComposerStatus } from '../hooks/useComposerStatus.js';
import { type UIState } from '../contexts/UIStateContext.js';
import { type TextBuffer } from '../components/shared/text-buffer.js';
import { type SessionStatsState } from '../contexts/SessionContext.js';
import { type ThoughtSummary } from '../types.js';
import { ApprovalMode } from '@google/gemini-cli-core';
@@ -29,13 +29,11 @@ describe('<StatusRow />', () => {
elapsedTime: 0,
currentWittyPhrase: undefined,
activeHooks: [],
buffer: { text: '' } as unknown as TextBuffer,
sessionStats: { lastPromptTokenCount: 0 } as unknown as SessionStatsState,
shortcutsHelpVisible: false,
contextFileNames: [],
showApprovalModeIndicator: ApprovalMode.DEFAULT,
allowPlanMode: false,
shellModeActive: false,
renderMarkdown: true,
currentModel: 'gemini-3',
};
+13 -9
View File
@@ -153,6 +153,8 @@ export const StatusNode: React.FC<{
);
};
import { useInputState } from '../contexts/InputContext.js';
export const StatusRow: React.FC<StatusRowProps> = ({
showUiDetails,
isNarrow,
@@ -162,6 +164,7 @@ export const StatusRow: React.FC<StatusRowProps> = ({
hasPendingActionRequired,
}) => {
const uiState = useUIState();
const inputState = useInputState();
const settings = useSettings();
const {
isInteractiveShellWaiting,
@@ -225,7 +228,7 @@ export const StatusRow: React.FC<StatusRowProps> = ({
settings.merged.ui.showShortcutsHint &&
!hideUiDetailsForSuggestions &&
!hasPendingActionRequired &&
uiState.buffer.text.length === 0
inputState.buffer.text.length === 0
) {
return showUiDetails ? '? for shortcuts' : 'press tab twice for more';
}
@@ -328,7 +331,7 @@ export const StatusRow: React.FC<StatusRowProps> = ({
) : isInteractiveShellWaiting ? (
<Box width="100%" marginLeft={LAYOUT.INDICATOR_LEFT_MARGIN}>
<Text color={theme.status.warning}>
! Shell awaiting input (Tab to focus)
{INTERACTIVE_SHELL_WAITING_PHRASE}
</Text>
</Box>
) : (
@@ -391,13 +394,14 @@ export const StatusRow: React.FC<StatusRowProps> = ({
>
{showUiDetails ? (
<>
{!hideUiDetailsForSuggestions && !uiState.shellModeActive && (
<ApprovalModeIndicator
approvalMode={uiState.showApprovalModeIndicator}
allowPlanMode={uiState.allowPlanMode}
/>
)}
{uiState.shellModeActive && (
{!hideUiDetailsForSuggestions &&
!inputState.shellModeActive && (
<ApprovalModeIndicator
approvalMode={uiState.showApprovalModeIndicator}
allowPlanMode={uiState.allowPlanMode}
/>
)}
{inputState.shellModeActive && (
<Box marginLeft={LAYOUT.INDICATOR_LEFT_MARGIN}>
<ShellModeIndicator />
</Box>
@@ -9,16 +9,24 @@ import { renderWithProviders } from '../../test-utils/render.js';
import { ToastDisplay, shouldShowToast } from './ToastDisplay.js';
import { TransientMessageType } from '../../utils/events.js';
import { type UIState } from '../contexts/UIStateContext.js';
import { type InputState } from '../contexts/InputContext.js';
import { type TextBuffer } from './shared/text-buffer.js';
import { type HistoryItem } from '../types.js';
const renderToastDisplay = async (uiState: Partial<UIState> = {}) =>
const renderToastDisplay = async (
uiState: Partial<UIState> = {},
inputState: Partial<InputState> = {},
) =>
renderWithProviders(<ToastDisplay />, {
uiState: {
buffer: { text: '' } as TextBuffer,
history: [] as HistoryItem[],
...uiState,
},
inputState: {
buffer: { text: '' } as TextBuffer,
showEscapePrompt: false,
...inputState,
},
});
describe('ToastDisplay', () => {
@@ -27,86 +35,121 @@ describe('ToastDisplay', () => {
});
describe('shouldShowToast', () => {
const baseState: Partial<UIState> = {
const baseUIState: Partial<UIState> = {
ctrlCPressedOnce: false,
transientMessage: null,
ctrlDPressedOnce: false,
showEscapePrompt: false,
buffer: { text: '' } as TextBuffer,
history: [] as HistoryItem[],
queueErrorMessage: null,
showIsExpandableHint: false,
};
const baseInputState: Partial<InputState> = {
showEscapePrompt: false,
buffer: { text: '' } as TextBuffer,
};
it('returns false for default state', () => {
expect(shouldShowToast(baseState as UIState)).toBe(false);
expect(
shouldShowToast(baseUIState as UIState, baseInputState as InputState),
).toBe(false);
});
it('returns true when showIsExpandableHint is true', () => {
expect(
shouldShowToast({
...baseState,
showIsExpandableHint: true,
} as UIState),
shouldShowToast(
{
...baseUIState,
showIsExpandableHint: true,
} as UIState,
baseInputState as InputState,
),
).toBe(true);
});
it('returns true when ctrlCPressedOnce is true', () => {
expect(
shouldShowToast({ ...baseState, ctrlCPressedOnce: true } as UIState),
shouldShowToast(
{ ...baseUIState, ctrlCPressedOnce: true } as UIState,
baseInputState as InputState,
),
).toBe(true);
});
it('returns true when transientMessage is present', () => {
expect(
shouldShowToast({
...baseState,
transientMessage: { text: 'test', type: TransientMessageType.Hint },
} as UIState),
shouldShowToast(
{
...baseUIState,
transientMessage: { text: 'test', type: TransientMessageType.Hint },
} as UIState,
baseInputState as InputState,
),
).toBe(true);
});
it('returns true when ctrlDPressedOnce is true', () => {
expect(
shouldShowToast({ ...baseState, ctrlDPressedOnce: true } as UIState),
shouldShowToast(
{ ...baseUIState, ctrlDPressedOnce: true } as UIState,
baseInputState as InputState,
),
).toBe(true);
});
it('returns true when showEscapePrompt is true and buffer is NOT empty', () => {
expect(
shouldShowToast({
...baseState,
showEscapePrompt: true,
buffer: { text: 'some text' } as TextBuffer,
} as UIState),
shouldShowToast(
{
...baseUIState,
} as UIState,
{
...baseInputState,
showEscapePrompt: true,
buffer: { text: 'some text' } as TextBuffer,
} as InputState,
),
).toBe(true);
});
it('returns true when showEscapePrompt is true and history is NOT empty', () => {
expect(
shouldShowToast({
...baseState,
showEscapePrompt: true,
history: [{ id: '1' } as unknown as HistoryItem],
} as UIState),
shouldShowToast(
{
...baseUIState,
history: [{ id: '1' } as unknown as HistoryItem],
} as UIState,
{
...baseInputState,
showEscapePrompt: true,
} as InputState,
),
).toBe(true);
});
it('returns false when showEscapePrompt is true but buffer and history are empty', () => {
expect(
shouldShowToast({
...baseState,
showEscapePrompt: true,
} as UIState),
shouldShowToast(
{
...baseUIState,
} as UIState,
{
...baseInputState,
showEscapePrompt: true,
} as InputState,
),
).toBe(false);
});
it('returns true when queueErrorMessage is present', () => {
expect(
shouldShowToast({
...baseState,
queueErrorMessage: 'error',
} as UIState),
shouldShowToast(
{
...baseUIState,
queueErrorMessage: 'error',
} as UIState,
baseInputState as InputState,
),
).toBe(true);
});
});
@@ -151,18 +194,25 @@ describe('ToastDisplay', () => {
});
it('renders Escape prompt when buffer is empty', async () => {
const { lastFrame } = await renderToastDisplay({
showEscapePrompt: true,
history: [{ id: 1, type: 'user', text: 'test' }] as HistoryItem[],
});
const { lastFrame } = await renderToastDisplay(
{
history: [{ id: 1, type: 'user', text: 'test' }] as HistoryItem[],
},
{
showEscapePrompt: true,
},
);
expect(lastFrame()).toMatchSnapshot();
});
it('renders Escape prompt when buffer is NOT empty', async () => {
const { lastFrame } = await renderToastDisplay({
showEscapePrompt: true,
buffer: { text: 'some text' } as TextBuffer,
});
const { lastFrame } = await renderToastDisplay(
{},
{
showEscapePrompt: true,
buffer: { text: 'some text' } as TextBuffer,
},
);
expect(lastFrame()).toMatchSnapshot();
});
@@ -8,15 +8,19 @@ import type React from 'react';
import { Text } from 'ink';
import { theme } from '../semantic-colors.js';
import { useUIState, type UIState } from '../contexts/UIStateContext.js';
import { useInputState, type InputState } from '../contexts/InputContext.js';
import { TransientMessageType } from '../../utils/events.js';
export function shouldShowToast(uiState: UIState): boolean {
export function shouldShowToast(
uiState: UIState,
inputState: InputState,
): boolean {
return (
uiState.ctrlCPressedOnce ||
Boolean(uiState.transientMessage) ||
uiState.ctrlDPressedOnce ||
(uiState.showEscapePrompt &&
(uiState.buffer.text.length > 0 || uiState.history.length > 0)) ||
(inputState.showEscapePrompt &&
(inputState.buffer.text.length > 0 || uiState.history.length > 0)) ||
Boolean(uiState.queueErrorMessage) ||
uiState.showIsExpandableHint
);
@@ -24,6 +28,7 @@ export function shouldShowToast(uiState: UIState): boolean {
export const ToastDisplay: React.FC = () => {
const uiState = useUIState();
const inputState = useInputState();
if (uiState.ctrlCPressedOnce) {
return (
@@ -46,8 +51,8 @@ export const ToastDisplay: React.FC = () => {
);
}
if (uiState.showEscapePrompt) {
const isPromptEmpty = uiState.buffer.text.length === 0;
if (inputState.showEscapePrompt) {
const isPromptEmpty = inputState.buffer.text.length === 0;
const hasHistory = uiState.history.length > 0;
if (isPromptEmpty && !hasHistory) {
@@ -1,16 +1,16 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`<ContextSummaryDisplay /> > should not render empty parts 1`] = `
" 1 open file (ctrl+g to view)
" 1 open file (F4 to view)
"
`;
exports[`<ContextSummaryDisplay /> > should render on a single line on a wide screen 1`] = `
" 1 open file (ctrl+g to view) · 1 GEMINI.md file · 1 MCP server · 1 skill
" 1 open file (F4 to view) · 1 GEMINI.md file · 1 MCP server · 1 skill
"
`;
exports[`<ContextSummaryDisplay /> > should render on multiple lines on a narrow screen 1`] = `
" 1 open file (ctrl+g to view) · 1 GEMINI.md file · 1 MCP server · 1 skill
" 1 open file (F4 to view) · 1 GEMINI.md file · 1 MCP server · 1 skill
"
`;
@@ -23,7 +23,7 @@ Files to Modify
Approves plan but requires confirmation for each tool
3. Type your feedback...
Enter to select · ↑/↓ to navigate · Ctrl+X to edit plan · Esc to cancel
Enter to select · ↑/↓ to navigate · Ctrl+G to edit plan · Esc to cancel
"
`;
@@ -50,7 +50,7 @@ Files to Modify
Approves plan but requires confirmation for each tool
3. Type your feedback...
Enter to select · ↑/↓ to navigate · Ctrl+X to edit plan · Esc to cancel
Enter to select · ↑/↓ to navigate · Ctrl+G to edit plan · Esc to cancel
"
`;
@@ -82,7 +82,7 @@ Implementation Steps
Approves plan but requires confirmation for each tool
3. Type your feedback...
Enter to select · ↑/↓ to navigate · Ctrl+X to edit plan · Esc to cancel
Enter to select · ↑/↓ to navigate · Ctrl+G to edit plan · Esc to cancel
"
`;
@@ -109,7 +109,7 @@ Files to Modify
Approves plan but requires confirmation for each tool
3. Type your feedback...
Enter to select · ↑/↓ to navigate · Ctrl+X to edit plan · Esc to cancel
Enter to select · ↑/↓ to navigate · Ctrl+G to edit plan · Esc to cancel
"
`;
@@ -136,7 +136,7 @@ Files to Modify
Approves plan but requires confirmation for each tool
3. Type your feedback...
Enter to select · ↑/↓ to navigate · Ctrl+X to edit plan · Esc to cancel
Enter to select · ↑/↓ to navigate · Ctrl+G to edit plan · Esc to cancel
"
`;
@@ -163,7 +163,7 @@ Files to Modify
Approves plan but requires confirmation for each tool
3. Type your feedback...
Enter to select · ↑/↓ to navigate · Ctrl+X to edit plan · Esc to cancel
Enter to select · ↑/↓ to navigate · Ctrl+G to edit plan · Esc to cancel
"
`;
@@ -216,7 +216,7 @@ Testing Strategy
Approves plan but requires confirmation for each tool
3. Type your feedback...
Enter to select · ↑/↓ to navigate · Ctrl+X to edit plan · Esc to cancel
Enter to select · ↑/↓ to navigate · Ctrl+G to edit plan · Esc to cancel
"
`;
@@ -243,6 +243,6 @@ Files to Modify
Approves plan but requires confirmation for each tool
3. Type your feedback...
Enter to select · ↑/↓ to navigate · Ctrl+X to edit plan · Esc to cancel
Enter to select · ↑/↓ to navigate · Ctrl+G to edit plan · Esc to cancel
"
`;
@@ -112,48 +112,7 @@ exports[`<HistoryItemDisplay /> > gemini items (alternateBuffer=false) > should
exports[`<HistoryItemDisplay /> > gemini items (alternateBuffer=false) > should render a truncated gemini item 1`] = `
"✦ Example code block:
1 Line 1
2 Line 2
3 Line 3
4 Line 4
5 Line 5
6 Line 6
7 Line 7
8 Line 8
9 Line 9
10 Line 10
11 Line 11
12 Line 12
13 Line 13
14 Line 14
15 Line 15
16 Line 16
17 Line 17
18 Line 18
19 Line 19
20 Line 20
21 Line 21
22 Line 22
23 Line 23
24 Line 24
25 Line 25
26 Line 26
27 Line 27
28 Line 28
29 Line 29
30 Line 30
31 Line 31
32 Line 32
33 Line 33
34 Line 34
35 Line 35
36 Line 36
37 Line 37
38 Line 38
39 Line 39
40 Line 40
41 Line 41
42 Line 42
... 42 hidden (Ctrl+O) ...
43 Line 43
44 Line 44
45 Line 45
@@ -167,48 +126,7 @@ exports[`<HistoryItemDisplay /> > gemini items (alternateBuffer=false) > should
exports[`<HistoryItemDisplay /> > gemini items (alternateBuffer=false) > should render a truncated gemini_content item 1`] = `
" Example code block:
1 Line 1
2 Line 2
3 Line 3
4 Line 4
5 Line 5
6 Line 6
7 Line 7
8 Line 8
9 Line 9
10 Line 10
11 Line 11
12 Line 12
13 Line 13
14 Line 14
15 Line 15
16 Line 16
17 Line 17
18 Line 18
19 Line 19
20 Line 20
21 Line 21
22 Line 22
23 Line 23
24 Line 24
25 Line 25
26 Line 26
27 Line 27
28 Line 28
29 Line 29
30 Line 30
31 Line 31
32 Line 32
33 Line 33
34 Line 34
35 Line 35
36 Line 36
37 Line 37
38 Line 38
39 Line 39
40 Line 40
41 Line 41
42 Line 42
... 42 hidden (Ctrl+O) ...
43 Line 43
44 Line 44
45 Line 45
@@ -93,7 +93,7 @@ exports[`InputPrompt > Highlighting and Cursor Display > single-line scenarios >
exports[`InputPrompt > History Navigation and Completion Suppression > should not render suggestions during history navigation 1`] = `
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
> second message
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
"
`;
@@ -120,30 +120,30 @@ exports[`InputPrompt > command search (Ctrl+R when not in shell) > expands and c
exports[`InputPrompt > command search (Ctrl+R when not in shell) > renders match window and expanded view (snapshots) > command-search-render-collapsed-match 1`] = `
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
(r:) commit
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
git commit -m "feat: add search" in src/app
"
`;
exports[`InputPrompt > command search (Ctrl+R when not in shell) > renders match window and expanded view (snapshots) > command-search-render-expanded-match 1`] = `
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
(r:) commit
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
git commit -m "feat: add search" in src/app
"
`;
exports[`InputPrompt > image path transformation snapshots > should snapshot collapsed image path 1`] = `
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
> [Image ...reenshot2x.png]
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
"
`;
exports[`InputPrompt > image path transformation snapshots > should snapshot expanded image path when cursor is on it 1`] = `
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
> @/path/to/screenshots/screenshot2x.png
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
"
`;
@@ -12,7 +12,7 @@ exports[`ShortcutsHelp > renders correctly in 'narrow' mode on 'linux' 1`] = `
Ctrl+V paste images
Alt+M raw markdown mode
Ctrl+R reverse-search history
Ctrl+X open external editor
Ctrl+G open external editor
"
`;
@@ -28,7 +28,7 @@ exports[`ShortcutsHelp > renders correctly in 'narrow' mode on 'mac' 1`] = `
Ctrl+V paste images
Option+M raw markdown mode
Ctrl+R reverse-search history
Ctrl+X open external editor
Ctrl+G open external editor
"
`;
@@ -37,7 +37,7 @@ exports[`ShortcutsHelp > renders correctly in 'wide' mode on 'linux' 1`] = `
Shortcuts See /help for more
! shell mode Shift+Tab cycle mode Ctrl+V paste images
@ select file or folder Ctrl+Y YOLO mode Alt+M raw markdown mode
Double Esc clear & rewind Ctrl+R reverse-search history Ctrl+X open external editor
Double Esc clear & rewind Ctrl+R reverse-search history Ctrl+G open external editor
Tab focus UI
"
`;
@@ -47,7 +47,7 @@ exports[`ShortcutsHelp > renders correctly in 'wide' mode on 'mac' 1`] = `
Shortcuts See /help for more
! shell mode Shift+Tab cycle mode Ctrl+V paste images
@ select file or folder Ctrl+Y YOLO mode Option+M raw markdown mode
Double Esc clear & rewind Ctrl+R reverse-search history Ctrl+X open external editor
Double Esc clear & rewind Ctrl+R reverse-search history Ctrl+G open external editor
Tab focus UI
"
`;
@@ -263,3 +263,32 @@ exports[`<StatsDisplay /> > renders only the Performance section in its zero sta
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
"
`;
exports[`<StatsDisplay /> > renders role breakdown correctly under models 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮
│ │
│ Session Stats │
│ │
│ Interaction Summary │
│ Session ID: test-session-id │
│ Tool Calls: 0 ( ✓ 0 x 0 ) │
│ Success Rate: 0.0% │
│ │
│ Performance │
│ Wall Time: 1s │
│ Agent Active: 10.0s │
│ » API Time: 10.0s (100.0%) │
│ » Tool Time: 0s (0.0%) │
│ │
│ │
│ Model Usage │
│ Use /model to view model quota information │
│ │
│ Model Reqs Input Tokens Cache Reads Output Tokens │
│ ────────────────────────────────────────────────────────────────────────────────────────────── │
│ gemini-2.5-flash 10 1,200 200 2,000 │
│ ↳ main 7 900 100 1,500 │
│ ↳ utility_tool 3 300 100 500 │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
"
`;
@@ -191,7 +191,7 @@ exports[`ToolConfirmationQueue > renders ExitPlanMode tool confirmation with Suc
│ Approves plan but requires confirmation for each tool │
│ 3. Type your feedback... │
│ │
│ Enter to select · ↑/↓ to navigate · Ctrl+X to edit plan · Esc to cancel │
│ Enter to select · ↑/↓ to navigate · Ctrl+G to edit plan · Esc to cancel │
╰──────────────────────────────────────────────────────────────────────────────╯
"
`;
@@ -12,6 +12,7 @@ import { RenderInline } from '../../utils/InlineMarkdownRenderer.js';
interface InfoMessageProps {
text: string;
secondaryText?: string;
source?: string;
icon?: string;
color?: string;
marginBottom?: number;
@@ -20,6 +21,7 @@ interface InfoMessageProps {
export const InfoMessage: React.FC<InfoMessageProps> = ({
text,
secondaryText,
source,
icon,
color,
marginBottom,
@@ -40,6 +42,9 @@ export const InfoMessage: React.FC<InfoMessageProps> = ({
{index === text.split('\n').length - 1 && secondaryText && (
<Text color={theme.text.secondary}> {secondaryText}</Text>
)}
{index === text.split('\n').length - 1 && source && (
<Text color={theme.text.secondary}> [{source}]</Text>
)}
</Text>
))}
</Box>
@@ -293,8 +293,8 @@ describe('<ShellToolMessage />', () => {
await waitUntilReady();
const frame = lastFrame();
// Since it's Executing, it might still constrain to ACTIVE_SHELL_MAX_LINES (10)
// Actually let's just assert on the behaviour that happens right now (which is 10 lines)
expect(frame.match(/Line \d+/g)?.length).toBe(10);
// Actually let's just assert on the behaviour that happens right now (which is 100 lines because we removed the terminalBuffer check)
expect(frame.match(/Line \d+/g)?.length).toBe(100);
unmount();
});
@@ -258,15 +258,42 @@ describe('<ToolGroupMessage />', () => {
unmount();
});
it('renders update_topic tool call prioritizing summary over strategic_intent', async () => {
it('renders update_topic tool call using TopicMessage', async () => {
const toolCalls = [
createToolCall({
callId: 'topic-tool-priority',
callId: 'topic-tool',
name: UPDATE_TOPIC_TOOL_NAME,
args: {
[TOPIC_PARAM_TITLE]: 'Testing Topic',
[TOPIC_PARAM_STRATEGIC_INTENT]: 'This is the description',
},
}),
];
const item = createItem(toolCalls);
const { lastFrame, unmount } = await renderWithProviders(
<ToolGroupMessage {...baseProps} item={item} toolCalls={toolCalls} />,
{
config: baseMockConfig,
settings: fullVerbositySettings,
},
);
const output = lastFrame();
expect(output).toContain('Testing Topic: ');
expect(output).toContain('This is the description');
expect(output).toMatchSnapshot('update_topic_tool');
unmount();
});
it('renders update_topic tool call with summary instead of strategic_intent', async () => {
const toolCalls = [
createToolCall({
callId: 'topic-tool-summary',
name: UPDATE_TOPIC_TOOL_NAME,
args: {
[TOPIC_PARAM_TITLE]: 'Testing Topic',
[TOPIC_PARAM_SUMMARY]: 'This is the summary',
[TOPIC_PARAM_STRATEGIC_INTENT]: 'This should be ignored',
},
}),
];
@@ -283,34 +310,6 @@ describe('<ToolGroupMessage />', () => {
const output = lastFrame();
expect(output).toContain('Testing Topic: ');
expect(output).toContain('This is the summary');
expect(output).not.toContain('This should be ignored');
unmount();
});
it('renders update_topic tool call falling back to strategic_intent', async () => {
const toolCalls = [
createToolCall({
callId: 'topic-tool-fallback',
name: UPDATE_TOPIC_TOOL_NAME,
args: {
[TOPIC_PARAM_TITLE]: 'Testing Topic',
[TOPIC_PARAM_STRATEGIC_INTENT]: 'Fallback intent',
},
}),
];
const item = createItem(toolCalls);
const { lastFrame, unmount } = await renderWithProviders(
<ToolGroupMessage {...baseProps} item={item} toolCalls={toolCalls} />,
{
config: baseMockConfig,
settings: fullVerbositySettings,
},
);
const output = lastFrame();
expect(output).toContain('Testing Topic: ');
expect(output).toContain('Fallback intent');
unmount();
});
@@ -321,7 +320,7 @@ describe('<ToolGroupMessage />', () => {
name: UPDATE_TOPIC_TOOL_NAME,
args: {
[TOPIC_PARAM_TITLE]: 'Testing Topic',
[TOPIC_PARAM_SUMMARY]: 'This is the summary',
[TOPIC_PARAM_STRATEGIC_INTENT]: 'This is the description',
},
}),
createToolCall({
@@ -444,8 +444,8 @@ describe('<ToolMessage />', () => {
constrainHeight: true,
},
width: 80,
config: makeFakeConfig({ useAlternateBuffer: false }),
settings: createMockSettings({ ui: { useAlternateBuffer: false } }),
config: makeFakeConfig({ useAlternateBuffer: true }),
settings: createMockSettings({ ui: { useAlternateBuffer: true } }),
},
);
const output = lastFrame();
@@ -5,6 +5,7 @@
*/
import { renderWithProviders } from '../../../test-utils/render.js';
import { waitFor } from '../../../test-utils/async.js';
import { createMockSettings } from '../../../test-utils/settings.js';
import { ToolResultDisplay } from './ToolResultDisplay.js';
import { describe, it, expect, vi } from 'vitest';
@@ -351,9 +352,10 @@ describe('ToolResultDisplay', () => {
expect(output).not.toContain('Line 1');
expect(output).not.toContain('Line 2');
expect(output).toContain('Line 3');
expect(output).not.toContain('Line 3');
expect(output).toContain('Line 4');
expect(output).toContain('Line 5');
expect(output).toContain('hidden');
expect(output).toMatchSnapshot();
unmount();
});
@@ -391,4 +393,86 @@ describe('ToolResultDisplay', () => {
await expect(renderResult).toMatchSvgSnapshot();
unmount();
});
it('stays scrolled to the bottom when lines are incrementally added', async () => {
const createAnsiLine = (text: string) => [
{
text,
fg: '',
bg: '',
bold: false,
italic: false,
underline: false,
dim: false,
inverse: false,
isUninitialized: false,
},
];
let currentLines: AnsiOutput = [];
// Start with 3 lines, max lines 5. It should fit without scrolling.
for (let i = 1; i <= 3; i++) {
currentLines.push(createAnsiLine(`Line ${i}`));
}
const renderResult = await renderWithProviders(
<ToolResultDisplay
resultDisplay={currentLines}
terminalWidth={80}
maxLines={5}
availableTerminalHeight={5}
overflowDirection="top"
/>,
{
config: makeFakeConfig({ useAlternateBuffer: false }),
settings: createMockSettings({ ui: { useAlternateBuffer: false } }),
uiState: { constrainHeight: true, terminalHeight: 10 },
},
);
const { waitUntilReady, rerender, lastFrame, unmount } = renderResult;
await waitUntilReady();
// Verify initial render has the first 3 lines
expect(lastFrame()).toContain('Line 1');
expect(lastFrame()).toContain('Line 3');
// Incrementally add lines up to 8. Max lines is 5.
// So by the end, it should only show lines 4-8.
for (let i = 4; i <= 8; i++) {
currentLines = [...currentLines, createAnsiLine(`Line ${i}`)];
rerender(
<ToolResultDisplay
resultDisplay={currentLines}
terminalWidth={80}
maxLines={5}
availableTerminalHeight={5}
overflowDirection="top"
/>,
);
// Wait for the new line to be rendered
await waitFor(() => {
expect(lastFrame()).toContain(`Line ${i}`);
});
}
await waitUntilReady();
const output = lastFrame();
// The component should have automatically scrolled to the bottom.
// Lines 1, 2, 3, 4 should be scrolled out of view.
expect(output).not.toContain('Line 1');
expect(output).not.toContain('Line 2');
expect(output).not.toContain('Line 3');
expect(output).not.toContain('Line 4');
// Lines 5, 6, 7, 8 should be visible along with the truncation indicator.
expect(output).toContain('hidden');
expect(output).toContain('Line 5');
expect(output).toContain('Line 8');
expect(output).toMatchSnapshot();
unmount();
});
});
@@ -10,6 +10,7 @@ import { DiffRenderer } from './DiffRenderer.js';
import { MarkdownDisplay } from '../../utils/MarkdownDisplay.js';
import { AnsiOutputText, AnsiLineText } from '../AnsiOutput.js';
import { SlicingMaxSizedBox } from '../shared/SlicingMaxSizedBox.js';
import { MaxSizedBox } from '../shared/MaxSizedBox.js';
import { theme } from '../../semantic-colors.js';
import {
type AnsiOutput,
@@ -51,7 +52,7 @@ export const ToolResultDisplay: React.FC<ToolResultDisplayProps> = ({
hasFocus = false,
overflowDirection = 'top',
}) => {
const { renderMarkdown } = useUIState();
const { renderMarkdown, constrainHeight } = useUIState();
const isAlternateBuffer = useAlternateBuffer();
const availableHeight = calculateToolContentMaxLines({
@@ -209,30 +210,73 @@ export const ToolResultDisplay: React.FC<ToolResultDisplayProps> = ({
if (Array.isArray(resultDisplay)) {
const limit = maxLines ?? availableHeight ?? ACTIVE_SHELL_MAX_LINES;
const listHeight = Math.min(
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
(resultDisplay as AnsiOutput).length,
limit,
);
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const data = resultDisplay as AnsiOutput;
const initialScrollIndex =
overflowDirection === 'bottom' ? 0 : SCROLL_TO_ITEM_END;
// Calculate list height: if not constrained, use full data length.
// If constrained (e.g. alternate buffer), limit to available height
// to ensure virtualization works and fits within the viewport.
const listHeight = !constrainHeight
? data.length
: Math.min(data.length, limit);
return (
<Box width={childWidth} flexDirection="column" maxHeight={listHeight}>
<ScrollableList
width={childWidth}
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
data={resultDisplay as AnsiOutput}
renderItem={renderVirtualizedAnsiLine}
estimatedItemHeight={() => 1}
keyExtractor={keyExtractor}
initialScrollIndex={initialScrollIndex}
hasFocus={hasFocus}
fixedItemHeight={true}
/>
</Box>
);
if (isAlternateBuffer) {
const initialScrollIndex =
overflowDirection === 'bottom' ? 0 : SCROLL_TO_ITEM_END;
return (
<Box width={childWidth} flexDirection="column" maxHeight={listHeight}>
<ScrollableList
width={childWidth}
containerHeight={listHeight}
data={data}
renderItem={renderVirtualizedAnsiLine}
estimatedItemHeight={() => 1}
fixedItemHeight={true}
keyExtractor={keyExtractor}
initialScrollIndex={initialScrollIndex}
hasFocus={hasFocus}
/>
</Box>
);
} else {
let displayData = data;
let hiddenLines = 0;
if (constrainHeight && data.length > listHeight) {
hiddenLines = data.length - listHeight;
if (overflowDirection === 'top') {
displayData = data.slice(hiddenLines);
} else {
displayData = data.slice(0, listHeight);
}
}
return (
<Box width={childWidth} flexDirection="column">
<MaxSizedBox
maxHeight={constrainHeight ? listHeight : undefined}
maxWidth={childWidth}
overflowDirection={overflowDirection}
additionalHiddenLinesCount={hiddenLines}
>
{displayData.map((item, index) => {
const actualIndex =
(overflowDirection === 'top' ? hiddenLines : 0) + index;
return (
<Box
key={keyExtractor(item, actualIndex)}
height={1}
overflow="hidden"
>
<AnsiLineText line={item} />
</Box>
);
})}
</MaxSizedBox>
</Box>
);
}
}
// ASB Mode Handling (Interactive/Fullscreen)
@@ -29,11 +29,12 @@ describe('ToolResultDisplay Overflow', () => {
await waitUntilReady();
const output = lastFrame();
expect(output).not.toContain('Line 1');
expect(output).not.toContain('Line 2');
expect(output).toContain('Line 3');
expect(output).toContain('Line 4');
expect(output).toContain('Line 5');
expect(output).toContain('Line 1');
expect(output).toContain('Line 2');
expect(output).not.toContain('Line 3');
expect(output).not.toContain('Line 4');
expect(output).not.toContain('Line 5');
expect(output).toContain('hidden');
unmount();
});
@@ -57,9 +58,10 @@ describe('ToolResultDisplay Overflow', () => {
expect(output).not.toContain('Line 1');
expect(output).not.toContain('Line 2');
expect(output).toContain('Line 3');
expect(output).not.toContain('Line 3');
expect(output).toContain('Line 4');
expect(output).toContain('Line 5');
expect(output).toContain('hidden');
unmount();
});
@@ -95,11 +97,10 @@ describe('ToolResultDisplay Overflow', () => {
expect(output).toContain('Line 1');
expect(output).toContain('Line 2');
expect(output).toContain('Line 3');
expect(output).not.toContain('Line 3');
expect(output).not.toContain('Line 4');
expect(output).not.toContain('Line 5');
// ScrollableList uses a scroll thumb rather than writing "hidden"
expect(output).toContain('█');
expect(output).toContain('hidden');
unmount();
});
});
@@ -0,0 +1,114 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi } from 'vitest';
import { TopicMessage } from './TopicMessage.js';
import { renderWithProviders } from '../../../test-utils/render.js';
import {
TOPIC_PARAM_TITLE,
TOPIC_PARAM_SUMMARY,
TOPIC_PARAM_STRATEGIC_INTENT,
CoreToolCallStatus,
UPDATE_TOPIC_TOOL_NAME,
} from '@google/gemini-cli-core';
describe('<TopicMessage />', () => {
const baseArgs = {
[TOPIC_PARAM_TITLE]: 'Test Topic',
[TOPIC_PARAM_STRATEGIC_INTENT]: 'This is the strategic intent.',
[TOPIC_PARAM_SUMMARY]:
'This is the detailed summary that should be expandable.',
};
const renderTopic = async (
args: Record<string, unknown>,
height?: number,
toolActions?: {
isExpanded?: (callId: string) => boolean;
toggleExpansion?: (callId: string) => void;
},
) =>
renderWithProviders(
<TopicMessage
args={args}
terminalWidth={80}
availableTerminalHeight={height}
callId="test-topic"
name={UPDATE_TOPIC_TOOL_NAME}
description="Updating topic"
status={CoreToolCallStatus.Success}
confirmationDetails={undefined}
resultDisplay={undefined}
/>,
{ toolActions, mouseEventsEnabled: true },
);
it('renders title and intent by default (collapsed)', async () => {
const { lastFrame } = await renderTopic(baseArgs, 40);
const frame = lastFrame();
expect(frame).toContain('Test Topic:');
expect(frame).toContain('This is the strategic intent.');
expect(frame).not.toContain('This is the detailed summary');
expect(frame).not.toContain('(ctrl+o to expand)');
});
it('renders summary when globally expanded (Ctrl+O)', async () => {
const { lastFrame } = await renderTopic(baseArgs, undefined);
const frame = lastFrame();
expect(frame).toContain('Test Topic:');
expect(frame).toContain('This is the strategic intent.');
expect(frame).toContain('This is the detailed summary');
expect(frame).not.toContain('(ctrl+o to collapse)');
});
it('renders summary when selectively expanded via context', async () => {
const isExpanded = vi.fn((id) => id === 'test-topic');
const { lastFrame } = await renderTopic(baseArgs, 40, { isExpanded });
const frame = lastFrame();
expect(frame).toContain('Test Topic:');
expect(frame).toContain('This is the detailed summary');
expect(frame).not.toContain('(ctrl+o to collapse)');
});
it('calls toggleExpansion when clicked', async () => {
const toggleExpansion = vi.fn();
const { simulateClick } = await renderTopic(baseArgs, 40, {
toggleExpansion,
});
// In renderWithProviders, the component is wrapped in a Box with terminalWidth.
// The TopicMessage has marginLeft={2}.
// So col 5 should definitely hit the text content.
// row 1 is the first line of the TopicMessage.
await simulateClick(5, 1);
expect(toggleExpansion).toHaveBeenCalledWith('test-topic');
});
it('falls back to summary if strategic_intent is missing', async () => {
const args = {
[TOPIC_PARAM_TITLE]: 'Test Topic',
[TOPIC_PARAM_SUMMARY]: 'Only summary is present.',
};
const { lastFrame } = await renderTopic(args, 40);
const frame = lastFrame();
expect(frame).toContain('Test Topic:');
expect(frame).toContain('Only summary is present.');
expect(frame).not.toContain('(ctrl+o to expand)');
});
it('renders only strategic_intent if summary is missing', async () => {
const args = {
[TOPIC_PARAM_TITLE]: 'Test Topic',
[TOPIC_PARAM_STRATEGIC_INTENT]: 'Only intent is present.',
};
const { lastFrame } = await renderTopic(args, 40);
const frame = lastFrame();
expect(frame).toContain('Test Topic:');
expect(frame).toContain('Only intent is present.');
expect(frame).not.toContain('(ctrl+o to expand)');
});
});
@@ -5,7 +5,8 @@
*/
import type React from 'react';
import { Box, Text } from 'ink';
import { useEffect, useId, useRef, useCallback } from 'react';
import { Box, Text, type DOMElement } from 'ink';
import {
UPDATE_TOPIC_TOOL_NAME,
UPDATE_TOPIC_DISPLAY_NAME,
@@ -15,32 +16,103 @@ import {
} from '@google/gemini-cli-core';
import type { IndividualToolCallDisplay } from '../../types.js';
import { theme } from '../../semantic-colors.js';
import { useOverflowActions } from '../../contexts/OverflowContext.js';
import { useToolActions } from '../../contexts/ToolActionsContext.js';
import { useMouseClick } from '../../hooks/useMouseClick.js';
interface TopicMessageProps extends IndividualToolCallDisplay {
terminalWidth: number;
availableTerminalHeight?: number;
isExpandable?: boolean;
}
export const isTopicTool = (name: string): boolean =>
name === UPDATE_TOPIC_TOOL_NAME || name === UPDATE_TOPIC_DISPLAY_NAME;
export const TopicMessage: React.FC<TopicMessageProps> = ({ args }) => {
export const TopicMessage: React.FC<TopicMessageProps> = ({
callId,
args,
availableTerminalHeight,
isExpandable = true,
}) => {
const { isExpanded: isExpandedInContext, toggleExpansion } = useToolActions();
// Expansion is active if either:
// 1. The individual callId is expanded in the ToolActionsContext
// 2. The entire turn is expanded (Ctrl+O) which sets availableTerminalHeight to undefined
const isExpanded =
(isExpandedInContext ? isExpandedInContext(callId) : false) ||
availableTerminalHeight === undefined;
const overflowActions = useOverflowActions();
const uniqueId = useId();
const overflowId = `topic-${uniqueId}`;
const containerRef = useRef<DOMElement>(null);
const rawTitle = args?.[TOPIC_PARAM_TITLE];
const title = typeof rawTitle === 'string' ? rawTitle : undefined;
const rawDescription =
args?.[TOPIC_PARAM_SUMMARY] || args?.[TOPIC_PARAM_STRATEGIC_INTENT];
const description =
typeof rawDescription === 'string' ? rawDescription : undefined;
const rawStrategicIntent = args?.[TOPIC_PARAM_STRATEGIC_INTENT];
const strategicIntent =
typeof rawStrategicIntent === 'string' ? rawStrategicIntent : undefined;
const rawSummary = args?.[TOPIC_PARAM_SUMMARY];
const summary = typeof rawSummary === 'string' ? rawSummary : undefined;
// Top line intent: prefer strategic_intent, fallback to summary
const intent = strategicIntent || summary;
// Extra summary: only if both exist and are different (or just summary if we want to show it below)
const hasExtraSummary = !!(
strategicIntent &&
summary &&
strategicIntent !== summary
);
const handleToggle = useCallback(() => {
if (toggleExpansion && hasExtraSummary) {
toggleExpansion(callId);
}
}, [toggleExpansion, hasExtraSummary, callId]);
useMouseClick(containerRef, handleToggle, {
isActive: isExpandable && hasExtraSummary,
});
useEffect(() => {
// Only register if there is more content (summary) and it's currently hidden
const hasHiddenContent = isExpandable && hasExtraSummary && !isExpanded;
if (hasHiddenContent && overflowActions) {
overflowActions.addOverflowingId(overflowId);
} else if (overflowActions) {
overflowActions.removeOverflowingId(overflowId);
}
return () => {
overflowActions?.removeOverflowingId(overflowId);
};
}, [isExpandable, hasExtraSummary, isExpanded, overflowActions, overflowId]);
return (
<Box flexDirection="row" marginLeft={2} flexWrap="wrap">
<Text color={theme.text.primary} bold wrap="truncate-end">
{title || 'Topic'}
{description && <Text>: </Text>}
</Text>
{description && (
<Text color={theme.text.secondary} wrap="wrap">
{description}
<Box ref={containerRef} flexDirection="column" marginLeft={2}>
<Box flexDirection="row" flexWrap="wrap">
<Text color={theme.text.primary} bold wrap="truncate-end">
{title || 'Topic'}
{intent && <Text>: </Text>}
</Text>
{intent && (
<Text color={theme.text.secondary} wrap="wrap">
{intent}
</Text>
)}
</Box>
{isExpanded && hasExtraSummary && summary && (
<Box marginTop={1} marginLeft={0}>
<Text color={theme.text.secondary} wrap="wrap">
{summary}
</Text>
</Box>
)}
</Box>
);
@@ -1,18 +1,33 @@
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="37" viewBox="0 0 920 37">
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="88" viewBox="0 0 920 88">
<style>
text { font-family: Consolas, "Courier New", monospace; font-size: 14px; dominant-baseline: text-before-edge; white-space: pre; }
</style>
<rect width="920" height="37" fill="#000000" />
<rect width="920" height="88" fill="#000000" />
<g transform="translate(10, 10)">
<text x="18" y="2" fill="#d7ffd7" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="45" y="2" fill="#ffffff" textLength="45" lengthAdjust="spacingAndGlyphs" font-weight="bold">edit </text>
<text x="99" y="2" fill="#afafaf" textLength="63" lengthAdjust="spacingAndGlyphs">test.ts</text>
<text x="171" y="2" fill="#d7afff" textLength="18" lengthAdjust="spacingAndGlyphs"></text>
<text x="189" y="2" fill="#d7afff" textLength="72" lengthAdjust="spacingAndGlyphs" text-decoration="underline">Accepted</text>
<text x="171" y="2" fill="#d7afff" textLength="90" lengthAdjust="spacingAndGlyphs">Accepted</text>
<text x="270" y="2" fill="#afafaf" textLength="9" lengthAdjust="spacingAndGlyphs">(</text>
<text x="279" y="2" fill="#d7ffd7" textLength="18" lengthAdjust="spacingAndGlyphs">+1</text>
<text x="297" y="2" fill="#afafaf" textLength="18" lengthAdjust="spacingAndGlyphs">, </text>
<text x="315" y="2" fill="#ff87af" textLength="18" lengthAdjust="spacingAndGlyphs">-1</text>
<text x="333" y="2" fill="#afafaf" textLength="9" lengthAdjust="spacingAndGlyphs">)</text>
<rect x="54" y="34" width="9" height="17" fill="#5f0000" />
<text x="54" y="36" fill="#afafaf" textLength="9" lengthAdjust="spacingAndGlyphs">1</text>
<rect x="63" y="34" width="9" height="17" fill="#5f0000" />
<rect x="72" y="34" width="9" height="17" fill="#5f0000" />
<text x="72" y="36" fill="#ff87af" textLength="9" lengthAdjust="spacingAndGlyphs">-</text>
<rect x="81" y="34" width="9" height="17" fill="#5f0000" />
<rect x="90" y="34" width="27" height="17" fill="#5f0000" />
<text x="90" y="36" fill="#e5e5e5" textLength="27" lengthAdjust="spacingAndGlyphs">old</text>
<rect x="54" y="51" width="9" height="17" fill="#005f00" />
<text x="54" y="53" fill="#afafaf" textLength="9" lengthAdjust="spacingAndGlyphs">1</text>
<rect x="63" y="51" width="9" height="17" fill="#005f00" />
<rect x="72" y="51" width="9" height="17" fill="#005f00" />
<text x="72" y="53" fill="#d7ffd7" textLength="9" lengthAdjust="spacingAndGlyphs">+</text>
<rect x="81" y="51" width="9" height="17" fill="#005f00" />
<rect x="90" y="51" width="27" height="17" fill="#005f00" />
<text x="90" y="53" fill="#0000ee" textLength="27" lengthAdjust="spacingAndGlyphs">new</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

@@ -7,12 +7,21 @@ exports[`DenseToolMessage > Toggleable Diff View (Alternate Buffer) > hides diff
exports[`DenseToolMessage > Toggleable Diff View (Alternate Buffer) > shows diff content by default when NOT in alternate buffer mode 1`] = `
" ✓ test-tool test.ts → Accepted
1 - old line
1 + new line
"
`;
exports[`DenseToolMessage > Visual Regression > matches SVG snapshot for a Rejected tool call 1`] = `" - read_file Reading important.txt"`;
exports[`DenseToolMessage > Visual Regression > matches SVG snapshot for an Accepted file edit with diff stats 1`] = `" ✓ edit test.ts → Accepted (+1, -1)"`;
exports[`DenseToolMessage > Visual Regression > matches SVG snapshot for an Accepted file edit with diff stats 1`] = `
" ✓ edit test.ts → Accepted (+1, -1)
1 - old
1 + new
"
`;
exports[`DenseToolMessage > does not render result arrow if resultDisplay is missing 1`] = `
" o test-tool Test description
@@ -26,11 +35,17 @@ exports[`DenseToolMessage > flattens newlines in string results 1`] = `
exports[`DenseToolMessage > renders correctly for Edit tool using confirmationDetails 1`] = `
" ? Edit styles.scss → Confirming
1 - body { color: blue; }
1 + body { color: red; }
"
`;
exports[`DenseToolMessage > renders correctly for Errored Edit tool 1`] = `
" x Edit styles.scss → Failed (+1, -1)
1 - old line
1 + new line
"
`;
@@ -45,21 +60,33 @@ exports[`DenseToolMessage > renders correctly for ReadManyFiles results 1`] = `
exports[`DenseToolMessage > renders correctly for Rejected Edit tool 1`] = `
" - Edit styles.scss → Rejected (+1, -1)
1 - old line
1 + new line
"
`;
exports[`DenseToolMessage > renders correctly for Rejected Edit tool with confirmationDetails and diffStat 1`] = `
" - Edit styles.scss → Rejected (+1, -1)
1 - body { color: blue; }
1 + body { color: red; }
"
`;
exports[`DenseToolMessage > renders correctly for Rejected WriteFile tool 1`] = `
" - WriteFile config.json → Rejected
1 - old content
1 + new content
"
`;
exports[`DenseToolMessage > renders correctly for WriteFile tool 1`] = `
" ✓ WriteFile config.json → Accepted (+1, -1)
1 - old content
1 + new content
"
`;
@@ -75,6 +102,9 @@ exports[`DenseToolMessage > renders correctly for error status with string messa
exports[`DenseToolMessage > renders correctly for file diff results with stats 1`] = `
" ✓ test-tool test.ts → Accepted (+15, -6)
1 - old line
1 + diff content
"
`;
@@ -78,7 +78,7 @@ exports[`<ToolGroupMessage /> > Golden Snapshots > renders header when scrolled
exports[`<ToolGroupMessage /> > Golden Snapshots > renders mixed tool calls including update_topic 1`] = `
"
Testing Topic: This is the summary
Testing Topic: This is the description
╭──────────────────────────────────────────────────────────────────────────╮
│ ✓ read_file Read a file │
@@ -142,6 +142,12 @@ exports[`<ToolGroupMessage /> > Golden Snapshots > renders two tool groups where
"
`;
exports[`<ToolGroupMessage /> > Golden Snapshots > renders update_topic tool call using TopicMessage > update_topic_tool 1`] = `
"
Testing Topic: This is the description
"
`;
exports[`<ToolGroupMessage /> > Golden Snapshots > renders with limited terminal height 1`] = `
"╭──────────────────────────────────────────────────────────────────────────╮
│ ✓ tool-with-result Tool with output │
@@ -4,7 +4,7 @@
</style>
<rect width="920" height="445" fill="#000000" />
<g transform="translate(10, 10)">
<text x="0" y="2" fill="#ffffff" textLength="900" lengthAdjust="spacingAndGlyphs">Line 26 </text>
<text x="0" y="2" fill="#afafaf" textLength="234" lengthAdjust="spacingAndGlyphs">... 26 hidden (Ctrl+O) ...</text>
<text x="0" y="19" fill="#ffffff" textLength="900" lengthAdjust="spacingAndGlyphs">Line 27 </text>
<text x="0" y="36" fill="#ffffff" textLength="900" lengthAdjust="spacingAndGlyphs">Line 28 </text>
<text x="0" y="53" fill="#ffffff" textLength="900" lengthAdjust="spacingAndGlyphs">Line 29 </text>
@@ -16,31 +16,18 @@
<text x="0" y="155" fill="#ffffff" textLength="900" lengthAdjust="spacingAndGlyphs">Line 35 </text>
<text x="0" y="172" fill="#ffffff" textLength="900" lengthAdjust="spacingAndGlyphs">Line 36 </text>
<text x="0" y="189" fill="#ffffff" textLength="900" lengthAdjust="spacingAndGlyphs">Line 37 </text>
<text x="0" y="206" fill="#ffffff" textLength="675" lengthAdjust="spacingAndGlyphs">Line 38 </text>
<text x="675" y="206" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="223" fill="#ffffff" textLength="675" lengthAdjust="spacingAndGlyphs">Line 39 </text>
<text x="675" y="223" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="240" fill="#ffffff" textLength="675" lengthAdjust="spacingAndGlyphs">Line 40 </text>
<text x="675" y="240" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="257" fill="#ffffff" textLength="675" lengthAdjust="spacingAndGlyphs">Line 41 </text>
<text x="675" y="257" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="274" fill="#ffffff" textLength="675" lengthAdjust="spacingAndGlyphs">Line 42 </text>
<text x="675" y="274" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="291" fill="#ffffff" textLength="675" lengthAdjust="spacingAndGlyphs">Line 43 </text>
<text x="675" y="291" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="308" fill="#ffffff" textLength="675" lengthAdjust="spacingAndGlyphs">Line 44 </text>
<text x="675" y="308" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="325" fill="#ffffff" textLength="675" lengthAdjust="spacingAndGlyphs">Line 45 </text>
<text x="675" y="325" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="342" fill="#ffffff" textLength="675" lengthAdjust="spacingAndGlyphs">Line 46 </text>
<text x="675" y="342" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="359" fill="#ffffff" textLength="675" lengthAdjust="spacingAndGlyphs">Line 47 </text>
<text x="675" y="359" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="376" fill="#ffffff" textLength="675" lengthAdjust="spacingAndGlyphs">Line 48 </text>
<text x="675" y="376" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="393" fill="#ffffff" textLength="675" lengthAdjust="spacingAndGlyphs">Line 49 </text>
<text x="675" y="393" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="410" fill="#ffffff" textLength="675" lengthAdjust="spacingAndGlyphs">Line 50 </text>
<text x="675" y="410" fill="#333333" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="206" fill="#ffffff" textLength="900" lengthAdjust="spacingAndGlyphs">Line 38 </text>
<text x="0" y="223" fill="#ffffff" textLength="900" lengthAdjust="spacingAndGlyphs">Line 39 </text>
<text x="0" y="240" fill="#ffffff" textLength="900" lengthAdjust="spacingAndGlyphs">Line 40 </text>
<text x="0" y="257" fill="#ffffff" textLength="900" lengthAdjust="spacingAndGlyphs">Line 41 </text>
<text x="0" y="274" fill="#ffffff" textLength="900" lengthAdjust="spacingAndGlyphs">Line 42 </text>
<text x="0" y="291" fill="#ffffff" textLength="900" lengthAdjust="spacingAndGlyphs">Line 43 </text>
<text x="0" y="308" fill="#ffffff" textLength="900" lengthAdjust="spacingAndGlyphs">Line 44 </text>
<text x="0" y="325" fill="#ffffff" textLength="900" lengthAdjust="spacingAndGlyphs">Line 45 </text>
<text x="0" y="342" fill="#ffffff" textLength="900" lengthAdjust="spacingAndGlyphs">Line 46 </text>
<text x="0" y="359" fill="#ffffff" textLength="900" lengthAdjust="spacingAndGlyphs">Line 47 </text>
<text x="0" y="376" fill="#ffffff" textLength="900" lengthAdjust="spacingAndGlyphs">Line 48 </text>
<text x="0" y="393" fill="#ffffff" textLength="900" lengthAdjust="spacingAndGlyphs">Line 49 </text>
<text x="0" y="410" fill="#ffffff" textLength="900" lengthAdjust="spacingAndGlyphs">Line 50 </text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 6.1 KiB

After

Width:  |  Height:  |  Size: 5.0 KiB

@@ -33,15 +33,24 @@ exports[`ToolResultDisplay > renders string result as plain text when renderOutp
"
`;
exports[`ToolResultDisplay > stays scrolled to the bottom when lines are incrementally added 1`] = `
"... 4 hidden (Ctrl+O) ...
Line 5
Line 6
Line 7
Line 8
"
`;
exports[`ToolResultDisplay > truncates ANSI output when maxLines is provided 1`] = `
"Line 3
Line 4
Line 5
"... 3 hidden (Ctrl+O) ...
Line 4
Line 5
"
`;
exports[`ToolResultDisplay > truncates ANSI output when maxLines is provided, even if availableTerminalHeight is undefined 1`] = `
"Line 26
"... 26 hidden (Ctrl+O) ...
Line 27
Line 28
Line 29
@@ -53,34 +62,36 @@ Line 34
Line 35
Line 36
Line 37
Line 38
Line 39
Line 40
Line 41
Line 42
Line 43
Line 44
Line 45
Line 46
Line 47
Line 48
Line 49
Line 50"
Line 38
Line 39
Line 40
Line 41
Line 42
Line 43
Line 44
Line 45
Line 46
Line 47
Line 48
Line 49
Line 50"
`;
exports[`ToolResultDisplay > truncates very long string results 1`] = `
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa… █
"... 250 hidden (Ctrl+O) ...
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
aaaaaaaaaaaaaaa
"
`;
@@ -115,7 +115,7 @@ export const MaxSizedBox: React.FC<MaxSizedBoxProps> = ({
[id, removeOverflowingId],
);
if (effectiveMaxHeight === undefined) {
if (effectiveMaxHeight === undefined && totalHiddenLines === 0) {
return (
<Box flexDirection="column" width={maxWidth}>
{children}
@@ -85,32 +85,43 @@ const VirtualizedListItem = memo(
width,
containerWidth,
itemKey,
itemRef,
index,
onSetRef,
}: {
content: React.ReactElement;
shouldBeStatic: boolean;
width: number | string | undefined;
containerWidth: number;
itemKey: string;
itemRef: (el: DOMElement | null) => void;
}) => (
<Box width="100%" flexDirection="column" flexShrink={0} ref={itemRef}>
{shouldBeStatic ? (
<StaticRender
width={typeof width === 'number' ? width : containerWidth}
key={
itemKey +
'-static-' +
(typeof width === 'number' ? width : containerWidth)
}
>
{content}
</StaticRender>
) : (
content
)}
</Box>
),
index: number;
onSetRef: (index: number, el: DOMElement | null) => void;
}) => {
const itemRef = useCallback(
(el: DOMElement | null) => {
onSetRef(index, el);
},
[index, onSetRef],
);
return (
<Box width="100%" flexDirection="column" flexShrink={0} ref={itemRef}>
{shouldBeStatic ? (
<StaticRender
width={typeof width === 'number' ? width : containerWidth}
key={
itemKey +
'-static-' +
(typeof width === 'number' ? width : containerWidth)
}
>
{content}
</StaticRender>
) : (
content
)}
</Box>
);
},
);
VirtualizedListItem.displayName = 'VirtualizedListItem';
@@ -195,6 +206,10 @@ function VirtualizedList<T>(
const containerObserverRef = useRef<ResizeObserver | null>(null);
const nodeToKeyRef = useRef(new WeakMap<DOMElement, string>());
const onSetRef = useCallback((index: number, el: DOMElement | null) => {
itemRefs.current[index] = el;
}, []);
const containerRefCallback = useCallback((node: DOMElement | null) => {
containerObserverRef.current?.disconnect();
containerRef.current = node;
@@ -517,7 +532,6 @@ function VirtualizedList<T>(
observedNodes.current = currentNodes;
});
const renderedItems = [];
const renderRangeStart =
renderStatic || overflowToBackbuffer ? 0 : startIndex;
const renderRangeEnd = renderStatic ? data.length - 1 : endIndex;
@@ -533,7 +547,12 @@ function VirtualizedList<T>(
process.env['NODE_ENV'] === 'test' ||
(width !== undefined && typeof width === 'number');
if (isReady) {
const renderedItems = useMemo(() => {
if (!isReady) {
return [];
}
const items = [];
for (let i = renderRangeStart; i <= renderRangeEnd; i++) {
const item = data[i];
if (item) {
@@ -545,7 +564,7 @@ function VirtualizedList<T>(
const content = renderItem({ item, index: i });
const key = keyExtractor(item, i);
renderedItems.push(
items.push(
<VirtualizedListItem
key={key}
itemKey={key}
@@ -553,16 +572,28 @@ function VirtualizedList<T>(
shouldBeStatic={shouldBeStatic}
width={width}
containerWidth={containerWidth}
itemRef={(el: DOMElement | null) => {
if (i >= renderRangeStart && i <= renderRangeEnd) {
itemRefs.current[i] = el;
}
}}
index={i}
onSetRef={onSetRef}
/>,
);
}
}
}
return items;
}, [
isReady,
renderRangeStart,
renderRangeEnd,
data,
startIndex,
endIndex,
renderStatic,
isStaticItem,
renderItem,
keyExtractor,
width,
containerWidth,
onSetRef,
]);
const { getScrollTop, setPendingScrollTop } = useBatchedScroll(scrollTop);
+2 -2
View File
@@ -111,10 +111,10 @@ export const INFORMATIVE_TIPS = [
'Paste from your clipboard with Ctrl+V',
'Undo text edits in the input with Alt+Z or Cmd+Z',
'Redo undone text edits with Shift+Alt+Z or Shift+Cmd+Z',
'Open the current prompt in an external editor with Ctrl+X',
'Open the current prompt in an external editor with Ctrl+G',
'In menus, move up/down with k/j or the arrow keys',
'In menus, select an item by typing its number',
"If you're using an IDE, see the context with Ctrl+G",
"If you're using an IDE, see the context with F4",
'Toggle background shells with Ctrl+B or /shells',
'Toggle the background shell process list with Ctrl+L',
// Keyboard shortcut tips end here
@@ -0,0 +1,28 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { createContext, useContext } from 'react';
import type { TextBuffer } from '../components/shared/text-buffer.js';
export interface InputState {
buffer: TextBuffer;
userMessages: string[];
shellModeActive: boolean;
showEscapePrompt: boolean;
copyModeEnabled: boolean | undefined;
inputWidth: number;
suggestionsWidth: number;
}
export const InputContext = createContext<InputState | null>(null);
export const useInputState = () => {
const context = useContext(InputContext);
if (!context) {
throw new Error('useInputState must be used within an InputProvider');
}
return context;
};
@@ -17,6 +17,7 @@ import {
import type {
SessionMetrics,
ModelMetrics,
RoleMetrics,
ToolCallStats,
} from '@google/gemini-cli-core';
import { uiTelemetryService, sessionId } from '@google/gemini-cli-core';
@@ -139,7 +140,7 @@ function areMetricsEqual(a: SessionMetrics, b: SessionMetrics): boolean {
return true;
}
export type { SessionMetrics, ModelMetrics };
export type { SessionMetrics, ModelMetrics, RoleMetrics };
export interface SessionStatsState {
sessionId: string;
@@ -17,7 +17,7 @@ import type {
PermissionConfirmationRequest,
} from '../types.js';
import type { CommandContext, SlashCommand } from '../commands/types.js';
import type { TextBuffer } from '../components/shared/text-buffer.js';
import type {
IdeContext,
ApprovalMode,
@@ -143,11 +143,6 @@ export interface UIState {
initError: string | null;
pendingGeminiHistoryItems: HistoryItemWithoutId[];
thought: ThoughtSummary | null;
shellModeActive: boolean;
userMessages: string[];
buffer: TextBuffer;
inputWidth: number;
suggestionsWidth: number;
isInputActive: boolean;
isResuming: boolean;
shouldShowIdePrompt: boolean;
@@ -162,7 +157,6 @@ export interface UIState {
renderMarkdown: boolean;
ctrlCPressedOnce: boolean;
ctrlDPressedOnce: boolean;
showEscapePrompt: boolean;
shortcutsHelpVisible: boolean;
cleanUiDetailsVisible: boolean;
elapsedTime: number;
@@ -207,7 +201,6 @@ export interface UIState {
embeddedShellFocused: boolean;
showDebugProfiler: boolean;
showFullTodos: boolean;
copyModeEnabled: boolean;
bannerData: {
defaultText: string;
warningText: string;

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