Compare commits

..

31 Commits

Author SHA1 Message Date
jacob314 fcff356a8b test: add svg test for full-width input line in InputPrompt 2026-04-08 16:51:28 -07:00
jacob314 0213621541 Update ink version to 6.6.9 2026-04-08 16:31:18 -07:00
JAYADITYA 8b01d78512 chore: ignore conductor directory (#22128)
Co-authored-by: Coco Sheng <cocosheng@google.com>
2026-04-08 20:56:02 +00:00
gemini-cli-robot 56c2397e78 Changelog for v0.38.0-preview.0 (#24938)
Co-authored-by: gemini-cli-robot <224641728+gemini-cli-robot@users.noreply.github.com>
Co-authored-by: g-samroberts <samroberts@google.com>
2026-04-08 20:45:59 +00:00
Jacob Richman d1a062bcc4 chore: update ink version to 6.6.8 (#24934) 2026-04-08 20:02:03 +00:00
Emily Hedlund a39461718c fix(core): ensure robust sandbox cleanup in all process execution paths (#24763)
Co-authored-by: Spencer <spencertang@google.com>
2026-04-08 19:03:36 +00:00
gemini-cli-robot 3df99d8bcb chore(release): bump version to 0.39.0-nightly.20260408.e77b22e63 (#24939) 2026-04-08 18:10:28 +00:00
Sandy Tao a837b39f8d feat(memory): add /memory inbox command for reviewing extracted skills (#24544) 2026-04-08 18:08:49 +00: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
143 changed files with 5476 additions and 2379 deletions
-1
View File
@@ -2,7 +2,6 @@
"experimental": {
"extensionReloading": true,
"modelSteering": true,
"memoryManager": false,
"topicUpdateNarration": true
},
"general": {
@@ -33,16 +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.
- 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
+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'
+3
View File
@@ -64,3 +64,6 @@ gemini-debug.log
evals/logs/
temp_agents/
# conductor extension and planning directories
conductor/
+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`)
+248 -402
View File
@@ -1,6 +1,6 @@
# Preview release: v0.37.0-preview.1
# Preview release: v0.38.0-preview.0
Released: April 02, 2026
Released: April 08, 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).
@@ -13,410 +13,256 @@ npm install -g @google/gemini-cli@preview
## Highlights
- **Plan Mode Enhancements**: Plan now includes support for untrusted folders,
prioritized pre-approval discussions, and a resolve for sandbox-related
deadlocks during file creation.
- **Browser Agent Evolved**: Significant updates to the browser agent, including
persistent session management, dynamic discovery of read-only tools,
sandbox-aware initialization, and automated reclamation of stale snapshots to
optimize context window usage.
- **Advanced Sandbox Security**: Implementation of dynamic sandbox expansion for
both Linux and Windows, alongside secret visibility lockdown for environment
files and OS-specific forbidden path support.
- **Unified Core Architecture**: Centralized context management and a new
`ModelConfigService` for unified model discovery, complemented by the
introduction of `AgentHistoryProvider` and tool-based topic grouping
(Chapters).
- **UI/UX & Performance Improvements**: New Tokyo Night theme, "tab to queue"
message support, and compact tool output formatting, plus optimized build
scripts and improved layout stability for TUI components.
- **Context Management:** Introduced a Context Compression Service to optimize
context window usage and landed a background memory service for skill
extraction.
- **Enhanced Security:** Implemented context-aware persistent policy approvals
for smarter tool permissions and enabled `web_fetch` in plan mode with user
confirmation.
- **Workflow Monitoring:** Added background process monitoring and inspection
tools for better visibility into long-running tasks.
- **UI/UX Refinements:** Enhanced the tool confirmation UI, selection layout,
and added support for selective topic expansion and click-to-expand.
- **Core Stability:** Improved sandbox reliability on Linux and Windows,
resolved shebang compatibility issues, and fixed various crashes in the CLI
and core services.
## What's Changed
- 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
[#24561](https://github.com/google-gemini/gemini-cli/pull/24561)
- feat(evals): centralize test agents into test-utils for reuse by @Samee24 in
[#23616](https://github.com/google-gemini/gemini-cli/pull/23616)
- revert: chore(config): disable agents by default by @abhipatel12 in
[#23672](https://github.com/google-gemini/gemini-cli/pull/23672)
- fix(plan): update telemetry attribute keys and add timestamp by @Adib234 in
[#23685](https://github.com/google-gemini/gemini-cli/pull/23685)
- fix(core): prevent premature MCP discovery completion by @jackwotherspoon in
[#23637](https://github.com/google-gemini/gemini-cli/pull/23637)
- feat(browser): add maxActionsPerTask for browser agent setting by
@cynthialong0-0 in
[#23216](https://github.com/google-gemini/gemini-cli/pull/23216)
- fix(core): improve agent loader error formatting for empty paths by
@adamfweidman in
[#23690](https://github.com/google-gemini/gemini-cli/pull/23690)
- fix(cli): only show updating spinner when auto-update is in progress by
@scidomino in [#23709](https://github.com/google-gemini/gemini-cli/pull/23709)
- Refine onboarding metrics to log the duration explicitly and use the tier
name. by @yunaseoul in
[#23678](https://github.com/google-gemini/gemini-cli/pull/23678)
- chore(tools): add toJSON to tools and invocations to reduce logging verbosity
by @alisa-alisa in
[#22899](https://github.com/google-gemini/gemini-cli/pull/22899)
- fix(cli): stabilize copy mode to prevent flickering and cursor resets by
@mattKorwel in
[#22584](https://github.com/google-gemini/gemini-cli/pull/22584)
- fix(test): move flaky ctrl-c-exit test to non-blocking suite by @mattKorwel in
[#23732](https://github.com/google-gemini/gemini-cli/pull/23732)
- feat(skills): add ci skill for automated failure replication by @mattKorwel in
[#23720](https://github.com/google-gemini/gemini-cli/pull/23720)
- feat(sandbox): implement forbiddenPaths for OS-specific sandbox managers by
@ehedlund in [#23282](https://github.com/google-gemini/gemini-cli/pull/23282)
- fix(core): conditionally expose additional_permissions in shell tool by
@galz10 in [#23729](https://github.com/google-gemini/gemini-cli/pull/23729)
- refactor(core): standardize OS-specific sandbox tests and extract linux helper
methods by @ehedlund in
[#23715](https://github.com/google-gemini/gemini-cli/pull/23715)
- format recently added script by @scidomino in
[#23739](https://github.com/google-gemini/gemini-cli/pull/23739)
- fix(ui): prevent over-eager slash subcommand completion by @keithguerin in
[#20136](https://github.com/google-gemini/gemini-cli/pull/20136)
- Fix dynamic model routing for gemini 3.1 pro to customtools model by
@kevinjwang1 in
[#23641](https://github.com/google-gemini/gemini-cli/pull/23641)
- feat(core): support inline agentCardJson for remote agents by @adamfweidman in
[#23743](https://github.com/google-gemini/gemini-cli/pull/23743)
- fix(cli): skip console log/info in headless mode by @cynthialong0-0 in
[#22739](https://github.com/google-gemini/gemini-cli/pull/22739)
- test(core): install bubblewrap on Linux CI for sandbox integration tests by
@ehedlund in [#23583](https://github.com/google-gemini/gemini-cli/pull/23583)
- docs(reference): split tools table into category sections by @sheikhlimon in
[#21516](https://github.com/google-gemini/gemini-cli/pull/21516)
- fix(browser): detect embedded URLs in query params to prevent allowedDomains
bypass by @tony-shi in
[#23225](https://github.com/google-gemini/gemini-cli/pull/23225)
- fix(browser): add proxy bypass constraint to domain restriction system prompt
by @tony-shi in
[#23229](https://github.com/google-gemini/gemini-cli/pull/23229)
- fix(policy): relax write_file argsPattern in plan mode to allow paths without
session ID by @Adib234 in
[#23695](https://github.com/google-gemini/gemini-cli/pull/23695)
- docs: fix grammar in CONTRIBUTING and numbering in sandbox docs by
@splint-disk-8i in
[#23448](https://github.com/google-gemini/gemini-cli/pull/23448)
- fix(acp): allow attachments by adding a permission prompt by @sripasg in
[#23680](https://github.com/google-gemini/gemini-cli/pull/23680)
- fix(core): thread AbortSignal to chat compression requests (#20405) by
@SH20RAJ in [#20778](https://github.com/google-gemini/gemini-cli/pull/20778)
- feat(core): implement Windows sandbox dynamic expansion Phase 1 and 2.1 by
@scidomino in [#23691](https://github.com/google-gemini/gemini-cli/pull/23691)
- Add note about root privileges in sandbox docs by @diodesign in
[#23314](https://github.com/google-gemini/gemini-cli/pull/23314)
- docs(core): document agent_card_json string literal options for remote agents
by @adamfweidman in
[#23797](https://github.com/google-gemini/gemini-cli/pull/23797)
- fix(cli): resolve TTY hang on headless environments by unconditionally
resuming process.stdin before React Ink launch by @cocosheng-g in
[#23673](https://github.com/google-gemini/gemini-cli/pull/23673)
- fix(ui): cleanup estimated string length hacks in composer by @keithguerin in
[#23694](https://github.com/google-gemini/gemini-cli/pull/23694)
- feat(browser): dynamically discover read-only tools by @cynthialong0-0 in
[#23805](https://github.com/google-gemini/gemini-cli/pull/23805)
- docs: clarify policy requirement for `general.plan.directory` in settings
schema by @jerop in
[#23784](https://github.com/google-gemini/gemini-cli/pull/23784)
- Revert "perf(cli): optimize --version startup time (#23671)" by @scidomino in
[#23812](https://github.com/google-gemini/gemini-cli/pull/23812)
- don't silence errors from wombat by @scidomino in
[#23822](https://github.com/google-gemini/gemini-cli/pull/23822)
- fix(ui): prevent escape key from cancelling requests in shell mode by
@PrasannaPal21 in
[#21245](https://github.com/google-gemini/gemini-cli/pull/21245)
- Changelog for v0.36.0-preview.0 by @gemini-cli-robot in
[#23702](https://github.com/google-gemini/gemini-cli/pull/23702)
- feat(core,ui): Add experiment-gated support for gemini flash 3.1 lite by
@chrstnb in [#23794](https://github.com/google-gemini/gemini-cli/pull/23794)
- Changelog for v0.36.0-preview.3 by @gemini-cli-robot in
[#23827](https://github.com/google-gemini/gemini-cli/pull/23827)
- new linting check: github-actions-pinning by @alisa-alisa in
[#23808](https://github.com/google-gemini/gemini-cli/pull/23808)
- fix(cli): show helpful guidance when no skills are available by @Niralisj in
[#23785](https://github.com/google-gemini/gemini-cli/pull/23785)
- fix: Chat logs and errors handle tail tool calls correctly by @googlestrobe in
[#22460](https://github.com/google-gemini/gemini-cli/pull/22460)
- Don't try removing a tag from a non-existent release. by @scidomino in
[#23830](https://github.com/google-gemini/gemini-cli/pull/23830)
- fix(cli): allow ask question dialog to take full window height by @jacob314 in
[#23693](https://github.com/google-gemini/gemini-cli/pull/23693)
- fix(core): strip leading underscores from error types in telemetry by
@yunaseoul in [#23824](https://github.com/google-gemini/gemini-cli/pull/23824)
- Changelog for v0.35.0 by @gemini-cli-robot in
[#23819](https://github.com/google-gemini/gemini-cli/pull/23819)
- feat(evals): add reliability harvester and 500/503 retry support by
@alisa-alisa in
[#23626](https://github.com/google-gemini/gemini-cli/pull/23626)
- feat(sandbox): dynamic Linux sandbox expansion and worktree support by @galz10
in [#23692](https://github.com/google-gemini/gemini-cli/pull/23692)
- Merge examples of use into quickstart documentation by @diodesign in
[#23319](https://github.com/google-gemini/gemini-cli/pull/23319)
- fix(cli): prioritize primary name matches in slash command search by @sehoon38
in [#23850](https://github.com/google-gemini/gemini-cli/pull/23850)
- Changelog for v0.35.1 by @gemini-cli-robot in
[#23840](https://github.com/google-gemini/gemini-cli/pull/23840)
- fix(browser): keep input blocker active across navigations by @kunal-10-cloud
in [#22562](https://github.com/google-gemini/gemini-cli/pull/22562)
- feat(core): new skill to look for duplicated code while reviewing PRs by
@devr0306 in [#23704](https://github.com/google-gemini/gemini-cli/pull/23704)
- fix(core): replace hardcoded non-interactive ASK_USER denial with explicit
policy rules by @ruomengz in
[#23668](https://github.com/google-gemini/gemini-cli/pull/23668)
- fix(plan): after exiting plan mode switches model to a flash model by @Adib234
in [#23885](https://github.com/google-gemini/gemini-cli/pull/23885)
- feat(gcp): add development worker infrastructure by @mattKorwel in
[#23814](https://github.com/google-gemini/gemini-cli/pull/23814)
- fix(a2a-server): A2A server should execute ask policies in interactive mode by
@kschaab in [#23831](https://github.com/google-gemini/gemini-cli/pull/23831)
- feat(core): define TrajectoryProvider interface by @sehoon38 in
[#23050](https://github.com/google-gemini/gemini-cli/pull/23050)
- Docs: Update quotas and pricing by @jkcinouye in
[#23835](https://github.com/google-gemini/gemini-cli/pull/23835)
- fix(core): allow disabling environment variable redaction by @galz10 in
[#23927](https://github.com/google-gemini/gemini-cli/pull/23927)
- feat(cli): enable notifications cross-platform via terminal bell fallback by
@genneth in [#21618](https://github.com/google-gemini/gemini-cli/pull/21618)
- feat(sandbox): implement secret visibility lockdown for env files by
@DavidAPierce in
[#23712](https://github.com/google-gemini/gemini-cli/pull/23712)
- fix(core): remove shell outputChunks buffer caching to prevent memory bloat
and sanitize prompt input by @spencer426 in
[#23751](https://github.com/google-gemini/gemini-cli/pull/23751)
- feat(core): implement persistent browser session management by @kunal-10-cloud
in [#21306](https://github.com/google-gemini/gemini-cli/pull/21306)
- refactor(core): delegate sandbox denial parsing to SandboxManager by
@scidomino in [#23928](https://github.com/google-gemini/gemini-cli/pull/23928)
- dep(update) Update Ink version to 6.5.0 by @jacob314 in
[#23843](https://github.com/google-gemini/gemini-cli/pull/23843)
- Docs: Update 'docs-writer' skill for relative links by @jkcinouye in
[#21463](https://github.com/google-gemini/gemini-cli/pull/21463)
- Changelog for v0.36.0-preview.4 by @gemini-cli-robot in
[#23935](https://github.com/google-gemini/gemini-cli/pull/23935)
- fix(acp): Update allow approval policy flow for ACP clients to fix config
persistence and compatible with TUI by @sripasg in
[#23818](https://github.com/google-gemini/gemini-cli/pull/23818)
- Changelog for v0.35.2 by @gemini-cli-robot in
[#23960](https://github.com/google-gemini/gemini-cli/pull/23960)
- ACP integration documents by @g-samroberts in
[#22254](https://github.com/google-gemini/gemini-cli/pull/22254)
- fix(core): explicitly set error names to avoid bundling renaming issues by
@yunaseoul in [#23913](https://github.com/google-gemini/gemini-cli/pull/23913)
- feat(core): subagent isolation and cleanup hardening by @abhipatel12 in
[#23903](https://github.com/google-gemini/gemini-cli/pull/23903)
- disable extension-reload test by @scidomino in
[#24018](https://github.com/google-gemini/gemini-cli/pull/24018)
- feat(core): add forbiddenPaths to GlobalSandboxOptions and refactor
createSandboxManager by @ehedlund in
[#23936](https://github.com/google-gemini/gemini-cli/pull/23936)
- refactor(core): improve ignore resolution and fix directory-matching bug by
@ehedlund in [#23816](https://github.com/google-gemini/gemini-cli/pull/23816)
- revert(core): support custom base URL via env vars by @spencer426 in
[#23976](https://github.com/google-gemini/gemini-cli/pull/23976)
- Increase memory limited for eslint. by @jacob314 in
[#24022](https://github.com/google-gemini/gemini-cli/pull/24022)
- fix(acp): prevent crash on empty response in ACP mode by @sripasg in
[#23952](https://github.com/google-gemini/gemini-cli/pull/23952)
- feat(core): Land `AgentHistoryProvider`. by @joshualitt in
[#23978](https://github.com/google-gemini/gemini-cli/pull/23978)
- fix(core): switch to subshells for shell tool wrapping to fix heredocs and
edge cases by @abhipatel12 in
[#24024](https://github.com/google-gemini/gemini-cli/pull/24024)
- Debug command. by @jacob314 in
[#23851](https://github.com/google-gemini/gemini-cli/pull/23851)
- Changelog for v0.36.0-preview.5 by @gemini-cli-robot in
[#24046](https://github.com/google-gemini/gemini-cli/pull/24046)
- Fix test flakes by globally mocking ink-spinner by @jacob314 in
[#24044](https://github.com/google-gemini/gemini-cli/pull/24044)
- Enable network access in sandbox configuration by @galz10 in
[#24055](https://github.com/google-gemini/gemini-cli/pull/24055)
- feat(context): add configurable memoryBoundaryMarkers setting by @SandyTao520
in [#24020](https://github.com/google-gemini/gemini-cli/pull/24020)
- feat(core): implement windows sandbox expansion and denial detection by
@scidomino in [#24027](https://github.com/google-gemini/gemini-cli/pull/24027)
- fix(core): resolve ACP Operation Aborted Errors in grep_search by @ivanporty
in [#23821](https://github.com/google-gemini/gemini-cli/pull/23821)
- fix(hooks): prevent SessionEnd from firing twice in non-interactive mode by
@krishdef7 in [#22139](https://github.com/google-gemini/gemini-cli/pull/22139)
- Re-word intro to Gemini 3 page. by @g-samroberts in
[#24069](https://github.com/google-gemini/gemini-cli/pull/24069)
- fix(cli): resolve layout contention and flashing loop in StatusRow by
@keithguerin in
[#24065](https://github.com/google-gemini/gemini-cli/pull/24065)
- fix(sandbox): implement Windows Mandatory Integrity Control for GeminiSandbox
by @galz10 in [#24057](https://github.com/google-gemini/gemini-cli/pull/24057)
- feat(core): implement tool-based topic grouping (Chapters) by @Abhijit-2592 in
[#23150](https://github.com/google-gemini/gemini-cli/pull/23150)
- feat(cli): support 'tab to queue' for messages while generating by @gundermanc
in [#24052](https://github.com/google-gemini/gemini-cli/pull/24052)
- feat(core): agnostic background task UI with CompletionBehavior by
@adamfweidman in
[#22740](https://github.com/google-gemini/gemini-cli/pull/22740)
- UX for topic narration tool by @gundermanc in
[#24079](https://github.com/google-gemini/gemini-cli/pull/24079)
- fix: shellcheck warnings in scripts by @scidomino in
[#24035](https://github.com/google-gemini/gemini-cli/pull/24035)
- test(evals): add comprehensive subagent delegation evaluations by @abhipatel12
in [#24132](https://github.com/google-gemini/gemini-cli/pull/24132)
- fix(a2a-server): prioritize ADC before evaluating headless constraints for
auth initialization by @spencer426 in
[#23614](https://github.com/google-gemini/gemini-cli/pull/23614)
- Text can be added after /plan command by @rambleraptor in
[#22833](https://github.com/google-gemini/gemini-cli/pull/22833)
- fix(cli): resolve missing F12 logs via global console store by @scidomino in
[#24235](https://github.com/google-gemini/gemini-cli/pull/24235)
- fix broken tests by @scidomino in
[#24279](https://github.com/google-gemini/gemini-cli/pull/24279)
- fix(evals): add update_topic behavioral eval by @gundermanc in
[#24223](https://github.com/google-gemini/gemini-cli/pull/24223)
- feat(core): Unified Context Management and Tool Distillation. by @joshualitt
in [#24157](https://github.com/google-gemini/gemini-cli/pull/24157)
- Default enable narration for the team. by @gundermanc in
[#24224](https://github.com/google-gemini/gemini-cli/pull/24224)
- fix(core): ensure default agents provide tools and use model-specific schemas
by @abhipatel12 in
[#24268](https://github.com/google-gemini/gemini-cli/pull/24268)
- feat(cli): show Flash Lite Preview model regardless of user tier by @sehoon38
in [#23904](https://github.com/google-gemini/gemini-cli/pull/23904)
- feat(cli): implement compact tool output by @jwhelangoog in
[#20974](https://github.com/google-gemini/gemini-cli/pull/20974)
- Add security settings for tool sandboxing by @galz10 in
[#23923](https://github.com/google-gemini/gemini-cli/pull/23923)
- chore(test-utils): switch integration tests to use PREVIEW_GEMINI_MODEL by
@sehoon38 in [#24276](https://github.com/google-gemini/gemini-cli/pull/24276)
- feat(core): enable topic update narration for legacy models by @Abhijit-2592
in [#24241](https://github.com/google-gemini/gemini-cli/pull/24241)
- feat(core): add project-level memory scope to save_memory tool by @SandyTao520
in [#24161](https://github.com/google-gemini/gemini-cli/pull/24161)
- test(integration): fix plan mode write denial test false positive by @sehoon38
in [#24299](https://github.com/google-gemini/gemini-cli/pull/24299)
- feat(plan): support `Plan` mode in untrusted folders by @Adib234 in
[#17586](https://github.com/google-gemini/gemini-cli/pull/17586)
- fix(core): enable mid-stream retries for all models and re-enable compression
test by @sehoon38 in
[#24302](https://github.com/google-gemini/gemini-cli/pull/24302)
- Changelog for v0.36.0-preview.6 by @gemini-cli-robot in
[#24082](https://github.com/google-gemini/gemini-cli/pull/24082)
- Changelog for v0.35.3 by @gemini-cli-robot in
[#24083](https://github.com/google-gemini/gemini-cli/pull/24083)
- feat(cli): add auth info to footer by @sehoon38 in
[#24042](https://github.com/google-gemini/gemini-cli/pull/24042)
- fix(browser): reset action counter for each agent session and let it ignore
internal actions by @cynthialong0-0 in
[#24228](https://github.com/google-gemini/gemini-cli/pull/24228)
- feat(plan): promote planning feature to stable by @ruomengz in
[#24282](https://github.com/google-gemini/gemini-cli/pull/24282)
- fix(browser): terminate subagent immediately on domain restriction violations
by @gsquared94 in
[#24313](https://github.com/google-gemini/gemini-cli/pull/24313)
- feat(cli): add UI to update extensions by @ruomengz in
[#23682](https://github.com/google-gemini/gemini-cli/pull/23682)
- Fix(browser): terminate immediately for "browser is already running" error by
@cynthialong0-0 in
[#24233](https://github.com/google-gemini/gemini-cli/pull/24233)
- docs: Add 'plan' option to approval mode in CLI reference by @YifanRuan in
[#24134](https://github.com/google-gemini/gemini-cli/pull/24134)
- fix(core): batch macOS seatbelt rules into a profile file to prevent ARG_MAX
errors by @ehedlund in
[#24255](https://github.com/google-gemini/gemini-cli/pull/24255)
- fix(core): fix race condition between browser agent and main closing process
by @cynthialong0-0 in
[#24340](https://github.com/google-gemini/gemini-cli/pull/24340)
- perf(build): optimize build scripts for parallel execution and remove
redundant checks by @sehoon38 in
[#24307](https://github.com/google-gemini/gemini-cli/pull/24307)
- ci: install bubblewrap on Linux for release workflows by @ehedlund in
[#24347](https://github.com/google-gemini/gemini-cli/pull/24347)
- chore(release): allow bundling for all builds, including stable by @sehoon38
in [#24305](https://github.com/google-gemini/gemini-cli/pull/24305)
- Revert "Add security settings for tool sandboxing" by @jerop in
[#24357](https://github.com/google-gemini/gemini-cli/pull/24357)
- docs: update subagents docs to not be experimental by @abhipatel12 in
[#24343](https://github.com/google-gemini/gemini-cli/pull/24343)
- fix(core): implement **read and **write commands in sandbox managers by
@galz10 in [#24283](https://github.com/google-gemini/gemini-cli/pull/24283)
- don't try to remove tags in dry run by @scidomino in
[#24356](https://github.com/google-gemini/gemini-cli/pull/24356)
- fix(config): disable JIT context loading by default by @SandyTao520 in
[#24364](https://github.com/google-gemini/gemini-cli/pull/24364)
- test(sandbox): add integration test for dynamic permission expansion by
@galz10 in [#24359](https://github.com/google-gemini/gemini-cli/pull/24359)
- docs(policy): remove unsupported mcpName wildcard edge case by @abhipatel12 in
[#24133](https://github.com/google-gemini/gemini-cli/pull/24133)
- docs: fix broken GEMINI.md link in CONTRIBUTING.md by @Panchal-Tirth in
[#24182](https://github.com/google-gemini/gemini-cli/pull/24182)
- feat(core): infrastructure for event-driven subagent history by @abhipatel12
in [#23914](https://github.com/google-gemini/gemini-cli/pull/23914)
- fix(core): resolve Plan Mode deadlock during plan file creation due to sandbox
restrictions by @DavidAPierce in
[#24047](https://github.com/google-gemini/gemini-cli/pull/24047)
- fix(core): fix browser agent UX issues and improve E2E test reliability by
@gsquared94 in
[#24312](https://github.com/google-gemini/gemini-cli/pull/24312)
- fix(ui): wrap topic and intent fields in TopicMessage by @jwhelangoog in
[#24386](https://github.com/google-gemini/gemini-cli/pull/24386)
- refactor(core): Centralize context management logic into src/context by
@joshualitt in
[#24380](https://github.com/google-gemini/gemini-cli/pull/24380)
- fix(core): pin AuthType.GATEWAY to use Gemini 3.1 Pro/Flash Lite by default by
@sripasg in [#24375](https://github.com/google-gemini/gemini-cli/pull/24375)
- feat(ui): add Tokyo Night theme by @danrneal in
[#24054](https://github.com/google-gemini/gemini-cli/pull/24054)
- fix(cli): refactor test config loading and mock debugLogger in test-setup by
@mattKorwel in
[#24389](https://github.com/google-gemini/gemini-cli/pull/24389)
- Set memoryManager to false in settings.json by @mattKorwel in
[#24393](https://github.com/google-gemini/gemini-cli/pull/24393)
- ink 6.6.3 by @jacob314 in
[#24372](https://github.com/google-gemini/gemini-cli/pull/24372)
- fix(core): resolve subagent chat recording gaps and directory inheritance by
- fix(cli): refresh slash command list after /skills reload by @NTaylorMullen in
[#24454](https://github.com/google-gemini/gemini-cli/pull/24454)
- Update README.md for links. by @g-samroberts in
[#22759](https://github.com/google-gemini/gemini-cli/pull/22759)
- fix(core): ensure complete_task tool calls are recorded in chat history by
@abhipatel12 in
[#24368](https://github.com/google-gemini/gemini-cli/pull/24368)
- fix(cli): cap shell output at 10 MB to prevent RangeError crash by @ProthamD
in [#24168](https://github.com/google-gemini/gemini-cli/pull/24168)
- feat(plan): conditionally add enter/exit plan mode tools based on current mode
by @ruomengz in
[#24378](https://github.com/google-gemini/gemini-cli/pull/24378)
- feat(core): prioritize discussion before formal plan approval by @jerop in
[#24423](https://github.com/google-gemini/gemini-cli/pull/24423)
- fix(ui): add accelerated scrolling on alternate buffer mode by @devr0306 in
[#23940](https://github.com/google-gemini/gemini-cli/pull/23940)
- feat(core): populate sandbox forbidden paths with project ignore file contents
by @ehedlund in
[#24038](https://github.com/google-gemini/gemini-cli/pull/24038)
- fix(core): ensure blue border overlay and input blocker to act correctly
depending on browser agent activities by @cynthialong0-0 in
[#24385](https://github.com/google-gemini/gemini-cli/pull/24385)
- fix(ui): removed additional vertical padding for tables by @devr0306 in
[#24381](https://github.com/google-gemini/gemini-cli/pull/24381)
- fix(build): upload full bundle directory archive to GitHub releases by
@sehoon38 in [#24403](https://github.com/google-gemini/gemini-cli/pull/24403)
- fix(build): wire bundle:browser-mcp into bundle pipeline by @gsquared94 in
[#24424](https://github.com/google-gemini/gemini-cli/pull/24424)
- feat(browser): add sandbox-aware browser agent initialization by @gsquared94
in [#24419](https://github.com/google-gemini/gemini-cli/pull/24419)
- feat(core): enhance tracker task schemas for detailed titles and descriptions
by @anj-s in [#23902](https://github.com/google-gemini/gemini-cli/pull/23902)
- refactor(core): Unified context management settings schema by @joshualitt in
[#24391](https://github.com/google-gemini/gemini-cli/pull/24391)
- feat(core): update browser agent prompt to check open pages first when
bringing up by @cynthialong0-0 in
[#24431](https://github.com/google-gemini/gemini-cli/pull/24431)
- fix(acp) refactor(core,cli): centralize model discovery logic in
ModelConfigService by @sripasg in
[#24392](https://github.com/google-gemini/gemini-cli/pull/24392)
- Changelog for v0.36.0-preview.7 by @gemini-cli-robot in
[#24346](https://github.com/google-gemini/gemini-cli/pull/24346)
- fix: update task tracker storage location in system prompt by @anj-s in
[#24034](https://github.com/google-gemini/gemini-cli/pull/24034)
- feat(browser): supersede stale snapshots to reclaim context-window tokens by
[#24437](https://github.com/google-gemini/gemini-cli/pull/24437)
- feat(policy): explicitly allow web_fetch in plan mode with ask_user by
@Adib234 in [#24456](https://github.com/google-gemini/gemini-cli/pull/24456)
- fix(core): refactor linux sandbox to fix ARG_MAX crashes by @ehedlund in
[#24286](https://github.com/google-gemini/gemini-cli/pull/24286)
- feat(config): add experimental.adk.agentSessionNoninteractiveEnabled setting
by @adamfweidman in
[#24439](https://github.com/google-gemini/gemini-cli/pull/24439)
- Changelog for v0.36.0-preview.8 by @gemini-cli-robot in
[#24453](https://github.com/google-gemini/gemini-cli/pull/24453)
- feat(cli): change default loadingPhrases to 'off' to hide tips by @keithguerin
in [#24342](https://github.com/google-gemini/gemini-cli/pull/24342)
- fix(cli): ensure agent stops when all declinable tools are cancelled by
@NTaylorMullen in
[#24479](https://github.com/google-gemini/gemini-cli/pull/24479)
- fix(core): enhance sandbox usability and fix build error by @galz10 in
[#24460](https://github.com/google-gemini/gemini-cli/pull/24460)
- Terminal Serializer Optimization by @jacob314 in
[#24485](https://github.com/google-gemini/gemini-cli/pull/24485)
- Auto configure memory. by @jacob314 in
[#24474](https://github.com/google-gemini/gemini-cli/pull/24474)
- Unused error variables in catch block are not allowed by @alisa-alisa in
[#24487](https://github.com/google-gemini/gemini-cli/pull/24487)
- feat(core): add background memory service for skill extraction by @SandyTao520
in [#24274](https://github.com/google-gemini/gemini-cli/pull/24274)
- feat: implement high-signal PR regression check for evaluations by
@alisa-alisa in
[#23937](https://github.com/google-gemini/gemini-cli/pull/23937)
- Fix shell output display by @jacob314 in
[#24490](https://github.com/google-gemini/gemini-cli/pull/24490)
- fix(ui): resolve unwanted vertical spacing around various tool output
treatments by @jwhelangoog in
[#24449](https://github.com/google-gemini/gemini-cli/pull/24449)
- revert(cli): bring back input box and footer visibility in copy mode by
@sehoon38 in [#24504](https://github.com/google-gemini/gemini-cli/pull/24504)
- fix(cli): prevent crash in AnsiOutputText when handling non-array data by
@sehoon38 in [#24498](https://github.com/google-gemini/gemini-cli/pull/24498)
- feat(cli): support default values for environment variables by @ruomengz in
[#24469](https://github.com/google-gemini/gemini-cli/pull/24469)
- Implement background process monitoring and inspection tools by @cocosheng-g
in [#23799](https://github.com/google-gemini/gemini-cli/pull/23799)
- docs(browser-agent): update stale browser agent documentation by @gsquared94
in [#24463](https://github.com/google-gemini/gemini-cli/pull/24463)
- fix: enable browser_agent in integration tests and add localhost fixture tests
by @gsquared94 in
[#24523](https://github.com/google-gemini/gemini-cli/pull/24523)
- fix(browser): handle computer-use model detection for analyze_screenshot by
@gsquared94 in
[#24440](https://github.com/google-gemini/gemini-cli/pull/24440)
- docs(core): add subagent tool isolation draft doc by @akh64bit in
[#23275](https://github.com/google-gemini/gemini-cli/pull/23275)
[#24502](https://github.com/google-gemini/gemini-cli/pull/24502)
- feat(core): Land ContextCompressionService by @joshualitt in
[#24483](https://github.com/google-gemini/gemini-cli/pull/24483)
- feat(core): scope subagent workspace directories via AsyncLocalStorage by
@SandyTao520 in
[#24445](https://github.com/google-gemini/gemini-cli/pull/24445)
- Update ink version to 6.6.7 by @jacob314 in
[#24514](https://github.com/google-gemini/gemini-cli/pull/24514)
- fix(acp): handle all InvalidStreamError types gracefully in prompt by @sripasg
in [#24540](https://github.com/google-gemini/gemini-cli/pull/24540)
- Fix crash when vim editor is not found in PATH on Windows by
@Nagajyothi-tammisetti in
[#22423](https://github.com/google-gemini/gemini-cli/pull/22423)
- fix(core): move project memory dir under tmp directory by @SandyTao520 in
[#24542](https://github.com/google-gemini/gemini-cli/pull/24542)
- Enable 'Other' option for yesno question type by @ruomengz in
[#24545](https://github.com/google-gemini/gemini-cli/pull/24545)
- fix(cli): clear stale retry/loading state after cancellation (#21096) by
@Aaxhirrr in [#21960](https://github.com/google-gemini/gemini-cli/pull/21960)
- Changelog for v0.37.0-preview.0 by @gemini-cli-robot in
[#24464](https://github.com/google-gemini/gemini-cli/pull/24464)
- feat(core): implement context-aware persistent policy approvals by @jerop in
[#23257](https://github.com/google-gemini/gemini-cli/pull/23257)
- docs: move agent disabling instructions and update remote agent status by
@jackwotherspoon in
[#24559](https://github.com/google-gemini/gemini-cli/pull/24559)
- feat(cli): migrate nonInteractiveCli to LegacyAgentSession by @adamfweidman in
[#22987](https://github.com/google-gemini/gemini-cli/pull/22987)
- fix(core): unsafe type assertions in Core File System #19712 by
@aniketsaurav18 in
[#19739](https://github.com/google-gemini/gemini-cli/pull/19739)
- fix(ui): hide model quota in /stats and refactor quota display by @danzaharia1
in [#24206](https://github.com/google-gemini/gemini-cli/pull/24206)
- Changelog for v0.36.0 by @gemini-cli-robot in
[#24558](https://github.com/google-gemini/gemini-cli/pull/24558)
- Changelog for v0.37.0-preview.1 by @gemini-cli-robot in
[#24568](https://github.com/google-gemini/gemini-cli/pull/24568)
- docs: add missing .md extensions to internal doc links by @ishaan-arora-1 in
[#24145](https://github.com/google-gemini/gemini-cli/pull/24145)
- fix(ui): fixed table styling by @devr0306 in
[#24565](https://github.com/google-gemini/gemini-cli/pull/24565)
- fix(core): pass includeDirectories to sandbox configuration by @galz10 in
[#24573](https://github.com/google-gemini/gemini-cli/pull/24573)
- feat(ui): enable "TerminalBuffer" mode to solve flicker by @jacob314 in
[#24512](https://github.com/google-gemini/gemini-cli/pull/24512)
- docs: clarify release coordination by @scidomino in
[#24575](https://github.com/google-gemini/gemini-cli/pull/24575)
- fix(core): remove broken PowerShell translation and fix native \_\_write in
Windows sandbox by @scidomino in
[#24571](https://github.com/google-gemini/gemini-cli/pull/24571)
- Add instructions for how to start react in prod and force react to prod mode
by @jacob314 in
[#24590](https://github.com/google-gemini/gemini-cli/pull/24590)
- feat(cli): minimalist sandbox status labels by @galz10 in
[#24582](https://github.com/google-gemini/gemini-cli/pull/24582)
- Feat/browser agent metrics by @kunal-10-cloud in
[#24210](https://github.com/google-gemini/gemini-cli/pull/24210)
- test: fix Windows CI execution and resolve exposed platform failures by
@ehedlund in [#24476](https://github.com/google-gemini/gemini-cli/pull/24476)
- feat(core,cli): prioritize summary for topics (#24608) by @Abhijit-2592 in
[#24609](https://github.com/google-gemini/gemini-cli/pull/24609)
- show color by @jacob314 in
[#24613](https://github.com/google-gemini/gemini-cli/pull/24613)
- feat(cli): enable compact tool output by default (#24509) by @jwhelangoog in
[#24510](https://github.com/google-gemini/gemini-cli/pull/24510)
- fix(core): inject skill system instructions into subagent prompts if activated
by @abhipatel12 in
[#24620](https://github.com/google-gemini/gemini-cli/pull/24620)
- fix(core): improve windows sandbox reliability and fix integration tests by
@ehedlund in [#24480](https://github.com/google-gemini/gemini-cli/pull/24480)
- fix(core): ensure sandbox approvals are correctly persisted and matched for
proactive expansions by @galz10 in
[#24577](https://github.com/google-gemini/gemini-cli/pull/24577)
- feat(cli) Scrollbar for input prompt by @jacob314 in
[#21992](https://github.com/google-gemini/gemini-cli/pull/21992)
- Do not run pr-eval workflow when no steering changes detected by @alisa-alisa
in [#24621](https://github.com/google-gemini/gemini-cli/pull/24621)
- Fix restoration of topic headers. by @gundermanc in
[#24650](https://github.com/google-gemini/gemini-cli/pull/24650)
- feat(core): discourage update topic tool for simple tasks by @Samee24 in
[#24640](https://github.com/google-gemini/gemini-cli/pull/24640)
- fix(core): ensure global temp directory is always in sandbox allowed paths by
@galz10 in [#24638](https://github.com/google-gemini/gemini-cli/pull/24638)
- fix(core): detect uninitialized lines by @jacob314 in
[#24646](https://github.com/google-gemini/gemini-cli/pull/24646)
- docs: update sandboxing documentation and toolSandboxing settings by @galz10
in [#24655](https://github.com/google-gemini/gemini-cli/pull/24655)
- feat(cli): enhance tool confirmation UI and selection layout by @galz10 in
[#24376](https://github.com/google-gemini/gemini-cli/pull/24376)
- feat(acp): add support for `/about` command by @sripasg in
[#24649](https://github.com/google-gemini/gemini-cli/pull/24649)
- feat(cli): add role specific metrics to /stats by @cynthialong0-0 in
[#24659](https://github.com/google-gemini/gemini-cli/pull/24659)
- split context by @jacob314 in
[#24623](https://github.com/google-gemini/gemini-cli/pull/24623)
- fix(cli): remove -S from shebang to fix Windows and BSD execution by
@scidomino in [#24756](https://github.com/google-gemini/gemini-cli/pull/24756)
- Fix issue where topic headers can be posted back to back by @gundermanc in
[#24759](https://github.com/google-gemini/gemini-cli/pull/24759)
- fix(core): handle partial llm_request in BeforeModel hook override by
@krishdef7 in [#22326](https://github.com/google-gemini/gemini-cli/pull/22326)
- fix(ui): improve narration suppression and reduce flicker by @gundermanc in
[#24635](https://github.com/google-gemini/gemini-cli/pull/24635)
- fix(ui): fixed auth race condition causing logo to flicker by @devr0306 in
[#24652](https://github.com/google-gemini/gemini-cli/pull/24652)
- fix(browser): remove premature browser cleanup after subagent invocation by
@gsquared94 in
[#24753](https://github.com/google-gemini/gemini-cli/pull/24753)
- Revert "feat(core,cli): prioritize summary for topics (#24608)" by
@Abhijit-2592 in
[#24777](https://github.com/google-gemini/gemini-cli/pull/24777)
- relax tool sandboxing overrides for plan mode to match defaults. by
@DavidAPierce in
[#24762](https://github.com/google-gemini/gemini-cli/pull/24762)
- fix(cli): respect global environment variable allowlist by @scidomino in
[#24767](https://github.com/google-gemini/gemini-cli/pull/24767)
- fix(cli): ensure skills list outputs to stdout in non-interactive environments
by @spencer426 in
[#24566](https://github.com/google-gemini/gemini-cli/pull/24566)
- Add an eval for and fix unsafe cloning behavior. by @gundermanc in
[#24457](https://github.com/google-gemini/gemini-cli/pull/24457)
- fix(policy): allow complete_task in plan mode by @abhipatel12 in
[#24771](https://github.com/google-gemini/gemini-cli/pull/24771)
- feat(telemetry): add browser agent clearcut metrics by @gsquared94 in
[#24688](https://github.com/google-gemini/gemini-cli/pull/24688)
- feat(cli): support selective topic expansion and click-to-expand by
@Abhijit-2592 in
[#24793](https://github.com/google-gemini/gemini-cli/pull/24793)
- temporarily disable sandbox integration test on windows by @ehedlund in
[#24786](https://github.com/google-gemini/gemini-cli/pull/24786)
- Remove flakey test by @scidomino in
[#24837](https://github.com/google-gemini/gemini-cli/pull/24837)
- Alisa/approve button by @alisa-alisa in
[#24645](https://github.com/google-gemini/gemini-cli/pull/24645)
- feat(hooks): display hook system messages in UI by @mbleigh in
[#24616](https://github.com/google-gemini/gemini-cli/pull/24616)
- fix(core): propagate BeforeModel hook model override end-to-end by @krishdef7
in [#24784](https://github.com/google-gemini/gemini-cli/pull/24784)
- chore: fix formatting for behavioral eval skill reference file by @abhipatel12
in [#24846](https://github.com/google-gemini/gemini-cli/pull/24846)
- fix: use directory junctions on Windows for skill linking by @enjoykumawat in
[#24823](https://github.com/google-gemini/gemini-cli/pull/24823)
- fix(cli): prevent multiple banner increments on remount by @sehoon38 in
[#24843](https://github.com/google-gemini/gemini-cli/pull/24843)
- feat(acp): add /help command by @sripasg in
[#24839](https://github.com/google-gemini/gemini-cli/pull/24839)
- fix(core): remove tmux alternate buffer warning by @jackwotherspoon in
[#24852](https://github.com/google-gemini/gemini-cli/pull/24852)
- Improve sandbox error matching and caching by @DavidAPierce in
[#24550](https://github.com/google-gemini/gemini-cli/pull/24550)
- feat(core): add agent protocol UI types and experimental flag by @mbleigh in
[#24275](https://github.com/google-gemini/gemini-cli/pull/24275)
- feat(core): use experiment flags for default fetch timeouts by @yunaseoul in
[#24261](https://github.com/google-gemini/gemini-cli/pull/24261)
- Revert "fix(ui): improve narration suppression and reduce flicker (#2… by
@gundermanc in
[#24857](https://github.com/google-gemini/gemini-cli/pull/24857)
- refactor(cli): remove duplication in interactive shell awaiting input hint by
@JayadityaGit in
[#24801](https://github.com/google-gemini/gemini-cli/pull/24801)
- refactor(core): make LegacyAgentSession dependencies optional by @mbleigh in
[#24287](https://github.com/google-gemini/gemini-cli/pull/24287)
- Changelog for v0.37.0-preview.2 by @gemini-cli-robot in
[#24848](https://github.com/google-gemini/gemini-cli/pull/24848)
- fix(cli): always show shell command description or actual command by @jacob314
in [#24774](https://github.com/google-gemini/gemini-cli/pull/24774)
- Added flag for ept size and increased default size by @devr0306 in
[#24859](https://github.com/google-gemini/gemini-cli/pull/24859)
- fix(core): dispose Scheduler to prevent McpProgress listener leak by
@Anjaligarhwal in
[#24870](https://github.com/google-gemini/gemini-cli/pull/24870)
- fix(cli): switch default back to terminalBuffer=false and fix regressions
introduced for that mode by @jacob314 in
[#24873](https://github.com/google-gemini/gemini-cli/pull/24873)
- feat(cli): switch to ctrl+g from ctrl-x by @jacob314 in
[#24861](https://github.com/google-gemini/gemini-cli/pull/24861)
- fix: isolate concurrent browser agent instances by @gsquared94 in
[#24794](https://github.com/google-gemini/gemini-cli/pull/24794)
- docs: update MCP server OAuth redirect port documentation by @adamfweidman in
[#24844](https://github.com/google-gemini/gemini-cli/pull/24844)
**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.37.0-preview.2...v0.38.0-preview.0
+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
@@ -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}}]}
+44
View File
@@ -307,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);
}
});
});
+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',
},
},
});
+21 -14
View File
@@ -1,17 +1,17 @@
{
"name": "@google/gemini-cli",
"version": "0.36.0-nightly.20260317.2f90b4653",
"version": "0.39.0-nightly.20260408.e77b22e63",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@google/gemini-cli",
"version": "0.36.0-nightly.20260317.2f90b4653",
"version": "0.39.0-nightly.20260408.e77b22e63",
"workspaces": [
"packages/*"
],
"dependencies": {
"ink": "npm:@jrichman/ink@6.6.7",
"ink": "npm:@jrichman/ink@6.6.9",
"latest-version": "^9.0.0",
"node-fetch-native": "^1.6.7",
"proper-lockfile": "^4.1.2",
@@ -5569,6 +5569,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",
@@ -10049,9 +10055,9 @@
},
"node_modules/ink": {
"name": "@jrichman/ink",
"version": "6.6.7",
"resolved": "https://registry.npmjs.org/@jrichman/ink/-/ink-6.6.7.tgz",
"integrity": "sha512-bDzQLpLzK/dn9Ur/Ku88ZZR9totVcMGrGYAgPHidsAAbe9NKztU1fggj/iu0wRp5g1kBeALb3cfagFGdDxAU1w==",
"version": "6.6.9",
"resolved": "https://registry.npmjs.org/@jrichman/ink/-/ink-6.6.9.tgz",
"integrity": "sha512-RL9sSiLQZECnjbmBwjIHOp8yVGdWF7C/uifg7ISv/e+F3nLNsfl7FdUFQs8iZARFMJAYxMFpxW6OW+HSt9drwQ==",
"license": "MIT",
"dependencies": {
"ansi-escapes": "^7.0.0",
@@ -17389,7 +17395,7 @@
},
"packages/a2a-server": {
"name": "@google/gemini-cli-a2a-server",
"version": "0.36.0-nightly.20260317.2f90b4653",
"version": "0.39.0-nightly.20260408.e77b22e63",
"dependencies": {
"@a2a-js/sdk": "0.3.11",
"@google-cloud/storage": "^7.16.0",
@@ -17504,7 +17510,7 @@
},
"packages/cli": {
"name": "@google/gemini-cli",
"version": "0.36.0-nightly.20260317.2f90b4653",
"version": "0.39.0-nightly.20260408.e77b22e63",
"license": "Apache-2.0",
"dependencies": {
"@agentclientprotocol/sdk": "^0.16.1",
@@ -17526,7 +17532,7 @@
"fzf": "^0.5.2",
"glob": "^12.0.0",
"highlight.js": "^11.11.1",
"ink": "npm:@jrichman/ink@6.6.7",
"ink": "npm:@jrichman/ink@6.6.9",
"ink-gradient": "^3.0.0",
"ink-spinner": "^5.0.0",
"latest-version": "^9.0.0",
@@ -17676,7 +17682,7 @@
},
"packages/core": {
"name": "@google/gemini-cli-core",
"version": "0.36.0-nightly.20260317.2f90b4653",
"version": "0.39.0-nightly.20260408.e77b22e63",
"license": "Apache-2.0",
"dependencies": {
"@a2a-js/sdk": "0.3.11",
@@ -17942,7 +17948,7 @@
},
"packages/devtools": {
"name": "@google/gemini-cli-devtools",
"version": "0.36.0-nightly.20260317.2f90b4653",
"version": "0.39.0-nightly.20260408.e77b22e63",
"license": "Apache-2.0",
"dependencies": {
"ws": "^8.16.0"
@@ -17957,7 +17963,7 @@
},
"packages/sdk": {
"name": "@google/gemini-cli-sdk",
"version": "0.36.0-nightly.20260317.2f90b4653",
"version": "0.39.0-nightly.20260408.e77b22e63",
"license": "Apache-2.0",
"dependencies": {
"@google/gemini-cli-core": "file:../core",
@@ -17974,11 +17980,12 @@
},
"packages/test-utils": {
"name": "@google/gemini-cli-test-utils",
"version": "0.36.0-nightly.20260317.2f90b4653",
"version": "0.39.0-nightly.20260408.e77b22e63",
"license": "Apache-2.0",
"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"
},
@@ -17991,7 +17998,7 @@
},
"packages/vscode-ide-companion": {
"name": "gemini-cli-vscode-ide-companion",
"version": "0.36.0-nightly.20260317.2f90b4653",
"version": "0.39.0-nightly.20260408.e77b22e63",
"license": "LICENSE",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.23.0",
+6 -4
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli",
"version": "0.36.0-nightly.20260317.2f90b4653",
"version": "0.39.0-nightly.20260408.e77b22e63",
"engines": {
"node": ">=20.0.0"
},
@@ -14,7 +14,7 @@
"url": "git+https://github.com/google-gemini/gemini-cli.git"
},
"config": {
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.36.0-nightly.20260317.2f90b4653"
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.39.0-nightly.20260408.e77b22e63"
},
"scripts": {
"start": "cross-env NODE_ENV=development node scripts/start.js",
@@ -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",
@@ -69,7 +71,7 @@
"pre-commit": "node scripts/pre-commit.js"
},
"overrides": {
"ink": "npm:@jrichman/ink@6.6.7",
"ink": "npm:@jrichman/ink@6.6.9",
"wrap-ansi": "9.0.2",
"cliui": {
"wrap-ansi": "7.0.0"
@@ -137,7 +139,7 @@
"yargs": "^17.7.2"
},
"dependencies": {
"ink": "npm:@jrichman/ink@6.6.7",
"ink": "npm:@jrichman/ink@6.6.9",
"latest-version": "^9.0.0",
"node-fetch-native": "^1.6.7",
"proper-lockfile": "^4.1.2",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli-a2a-server",
"version": "0.36.0-nightly.20260317.2f90b4653",
"version": "0.39.0-nightly.20260408.e77b22e63",
"description": "Gemini CLI A2A Server",
"repository": {
"type": "git",
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli",
"version": "0.36.0-nightly.20260317.2f90b4653",
"version": "0.39.0-nightly.20260408.e77b22e63",
"description": "Gemini CLI",
"license": "Apache-2.0",
"repository": {
@@ -27,7 +27,7 @@
"dist"
],
"config": {
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.36.0-nightly.20260317.2f90b4653"
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.39.0-nightly.20260408.e77b22e63"
},
"dependencies": {
"@agentclientprotocol/sdk": "^0.16.1",
@@ -49,7 +49,7 @@
"fzf": "^0.5.2",
"glob": "^12.0.0",
"highlight.js": "^11.11.1",
"ink": "npm:@jrichman/ink@6.6.7",
"ink": "npm:@jrichman/ink@6.6.9",
"ink-gradient": "^3.0.0",
"ink-spinner": "^5.0.0",
"latest-version": "^9.0.0",
@@ -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'),
};
}
}
+38
View File
@@ -6,6 +6,7 @@
import {
addMemory,
listInboxSkills,
listMemoryFiles,
refreshMemory,
showMemory,
@@ -30,6 +31,7 @@ export class MemoryCommand implements Command {
new RefreshMemoryCommand(),
new ListMemoryCommand(),
new AddMemoryCommand(),
new InboxMemoryCommand(),
];
readonly requiresWorkspace = true;
@@ -122,3 +124,39 @@ export class AddMemoryCommand implements Command {
}
}
}
export class InboxMemoryCommand implements Command {
readonly name = 'memory inbox';
readonly description =
'Lists skills extracted from past sessions that are pending review.';
async execute(
context: CommandContext,
_: string[],
): Promise<CommandExecutionResponse> {
if (!context.agentContext.config.isMemoryManagerEnabled()) {
return {
name: this.name,
data: 'The memory inbox requires the experimental memory manager. Enable it with: experimental.memoryManager = true in settings.',
};
}
const skills = await listInboxSkills(context.agentContext.config);
if (skills.length === 0) {
return { name: this.name, data: 'No extracted skills in inbox.' };
}
const lines = skills.map((s) => {
const date = s.extractedAt
? ` (extracted: ${new Date(s.extractedAt).toLocaleDateString()})`
: '';
return `- **${s.name}**: ${s.description}${date}`;
});
return {
name: this.name,
data: `Skill inbox (${skills.length}):\n${lines.join('\n')}`,
};
}
}
@@ -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: {
@@ -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
"
`;
@@ -457,4 +457,78 @@ describe('memoryCommand', () => {
);
});
});
describe('/memory inbox', () => {
let inboxCommand: SlashCommand;
beforeEach(() => {
inboxCommand = memoryCommand.subCommands!.find(
(cmd) => cmd.name === 'inbox',
)!;
expect(inboxCommand).toBeDefined();
});
it('should return custom_dialog when config is available and flag is enabled', () => {
if (!inboxCommand.action) throw new Error('Command has no action');
const mockConfig = {
reloadSkills: vi.fn(),
isMemoryManagerEnabled: vi.fn().mockReturnValue(true),
};
const context = createMockCommandContext({
services: {
agentContext: { config: mockConfig },
},
ui: {
removeComponent: vi.fn(),
reloadCommands: vi.fn(),
},
});
const result = inboxCommand.action(context, '');
expect(result).toHaveProperty('type', 'custom_dialog');
expect(result).toHaveProperty('component');
});
it('should return info message when memory manager is disabled', () => {
if (!inboxCommand.action) throw new Error('Command has no action');
const mockConfig = {
isMemoryManagerEnabled: vi.fn().mockReturnValue(false),
};
const context = createMockCommandContext({
services: {
agentContext: { config: mockConfig },
},
});
const result = inboxCommand.action(context, '');
expect(result).toEqual({
type: 'message',
messageType: 'info',
content:
'The memory inbox requires the experimental memory manager. Enable it with: experimental.memoryManager = true in settings.',
});
});
it('should return error when config is not loaded', () => {
if (!inboxCommand.action) throw new Error('Command has no action');
const context = createMockCommandContext({
services: {
agentContext: null,
},
});
const result = inboxCommand.action(context, '');
expect(result).toEqual({
type: 'message',
messageType: 'error',
content: 'Config not loaded.',
});
});
});
});
@@ -4,6 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import React from 'react';
import {
addMemory,
listMemoryFiles,
@@ -13,9 +14,11 @@ import {
import { MessageType } from '../types.js';
import {
CommandKind,
type OpenCustomDialogActionReturn,
type SlashCommand,
type SlashCommandActionReturn,
} from './types.js';
import { SkillInboxDialog } from '../components/SkillInboxDialog.js';
export const memoryCommand: SlashCommand = {
name: 'memory',
@@ -124,5 +127,45 @@ export const memoryCommand: SlashCommand = {
);
},
},
{
name: 'inbox',
description:
'Review skills extracted from past sessions and move them to global or project skills',
kind: CommandKind.BUILT_IN,
autoExecute: true,
action: (
context,
): OpenCustomDialogActionReturn | SlashCommandActionReturn | void => {
const config = context.services.agentContext?.config;
if (!config) {
return {
type: 'message',
messageType: 'error',
content: 'Config not loaded.',
};
}
if (!config.isMemoryManagerEnabled()) {
return {
type: 'message',
messageType: 'info',
content:
'The memory inbox requires the experimental memory manager. Enable it with: experimental.memoryManager = true in settings.',
};
}
return {
type: 'custom_dialog',
component: React.createElement(SkillInboxDialog, {
config,
onClose: () => context.ui.removeComponent(),
onReloadSkills: async () => {
await config.reloadSkills();
context.ui.reloadCommands();
},
}),
};
},
},
],
};
@@ -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: [],
@@ -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 = (() => {
@@ -587,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 }),
);
@@ -600,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 },
@@ -69,6 +69,7 @@ import {
AppEvent,
TransientMessageType,
} from '../../utils/events.js';
import '../../test-utils/customMatchers.js';
vi.mock('../hooks/useShellHistory.js');
vi.mock('../hooks/useCommandCompletion.js');
@@ -254,7 +255,7 @@ describe('InputPrompt', () => {
setText: vi.fn(
(newText: string, cursorPosition?: 'start' | 'end' | number) => {
mockBuffer.text = newText;
mockBuffer.lines = [newText];
mockBuffer.lines = newText.split('\n');
let col = 0;
if (typeof cursorPosition === 'number') {
col = cursorPosition;
@@ -264,11 +265,18 @@ describe('InputPrompt', () => {
col = newText.length;
}
mockBuffer.cursor = [0, col];
mockBuffer.allVisualLines = [newText];
mockBuffer.viewportVisualLines = [newText];
mockBuffer.allVisualLines = [newText];
mockBuffer.visualToLogicalMap = [[0, 0]];
mockBuffer.allVisualLines = newText.split('\n');
mockBuffer.viewportVisualLines = newText.split('\n');
mockBuffer.visualToLogicalMap = newText
.split('\n')
.map((_, i) => [i, 0] as [number, number]);
mockBuffer.visualCursor = [0, col];
mockBuffer.visualScrollRow = 0;
mockBuffer.viewportHeight = 10;
mockBuffer.visualToTransformedMap = newText
.split('\n')
.map((_, i) => i);
mockBuffer.transformationsByLine = newText.split('\n').map(() => []);
},
),
replaceRangeByOffset: vi.fn(),
@@ -276,6 +284,7 @@ describe('InputPrompt', () => {
allVisualLines: [''],
visualCursor: [0, 0],
visualScrollRow: 0,
viewportHeight: 10,
handleInput: vi.fn((key: Key) => {
if (defaultKeyMatchers[Command.CLEAR_INPUT](key)) {
if (mockBuffer.text.length > 0) {
@@ -409,6 +418,7 @@ describe('InputPrompt', () => {
getTargetDir: () => path.join('test', 'project', 'src'),
getVimMode: () => false,
getUseBackgroundColor: () => true,
getUseTerminalBuffer: () => false,
getTerminalBackground: () => undefined,
getWorkspaceContext: () => ({
getDirectories: () => ['/test/project/src'],
@@ -2308,6 +2318,11 @@ describe('InputPrompt', () => {
text: '',
visualCursor: [0, 0],
},
{
name: 'on a full-width line with no whitespace',
text: 'x'.repeat(94),
visualCursor: [0, 94],
},
{
name: 'on a space between words',
text: 'hello world',
@@ -3779,11 +3794,7 @@ describe('InputPrompt', () => {
);
it('should unfocus embedded shell on click', async () => {
props.buffer.text = 'hello';
props.buffer.lines = ['hello'];
props.buffer.allVisualLines = ['hello'];
props.buffer.viewportVisualLines = ['hello'];
props.buffer.visualToLogicalMap = [[0, 0]];
props.buffer.setText('hello');
props.isEmbeddedShellFocused = true;
const { stdin, stdout, unmount } = await renderWithProviders(
@@ -4291,11 +4302,7 @@ describe('InputPrompt', () => {
describe('IME Cursor Support', () => {
it('should report correct cursor position for simple ASCII text', async () => {
const text = 'hello';
mockBuffer.text = text;
mockBuffer.lines = [text];
mockBuffer.allVisualLines = [text];
mockBuffer.viewportVisualLines = [text];
mockBuffer.visualToLogicalMap = [[0, 0]];
mockBuffer.setText(text);
mockBuffer.visualCursor = [0, 3]; // Cursor after 'hel'
mockBuffer.visualScrollRow = 0;
@@ -4322,11 +4329,7 @@ describe('InputPrompt', () => {
it('should report correct cursor position for text with double-width characters', async () => {
const text = '👍hello';
mockBuffer.text = text;
mockBuffer.lines = [text];
mockBuffer.allVisualLines = [text];
mockBuffer.viewportVisualLines = [text];
mockBuffer.visualToLogicalMap = [[0, 0]];
mockBuffer.setText(text);
mockBuffer.visualCursor = [0, 2]; // Cursor after '👍h' (Note: '👍' is one code point but width 2)
mockBuffer.visualScrollRow = 0;
@@ -4352,11 +4355,7 @@ describe('InputPrompt', () => {
it('should report correct cursor position for a line full of "😀" emojis', async () => {
const text = '😀😀😀';
mockBuffer.text = text;
mockBuffer.lines = [text];
mockBuffer.allVisualLines = [text];
mockBuffer.viewportVisualLines = [text];
mockBuffer.visualToLogicalMap = [[0, 0]];
mockBuffer.setText(text);
mockBuffer.visualCursor = [0, 2]; // Cursor after 2 emojis (each 1 code point, width 2)
mockBuffer.visualScrollRow = 0;
@@ -4501,12 +4500,12 @@ describe('InputPrompt', () => {
mockBuffer.lines = [logicalLine];
mockBuffer.allVisualLines = [visualLine];
mockBuffer.viewportVisualLines = [visualLine];
mockBuffer.allVisualLines = [visualLine];
mockBuffer.visualToLogicalMap = [[0, 0]];
mockBuffer.visualToTransformedMap = [0];
mockBuffer.transformationsByLine = [transformations];
mockBuffer.cursor = [0, cursorCol];
mockBuffer.visualCursor = [0, 0];
mockBuffer.visualCursor = [0, cursorCol];
mockBuffer.visualScrollRow = 0;
};
it('should snapshot collapsed image path', async () => {
@@ -5071,8 +5070,8 @@ describe('InputPrompt', () => {
input: '\x12',
},
{
name: 'Ctrl+X hotkey is pressed',
input: '\x18',
name: 'Ctrl+G hotkey is pressed',
input: '\x07',
},
{
name: 'F12 hotkey is pressed',
+56 -19
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';
@@ -1265,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
@@ -1820,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 + 1 /* space for scrollbar */}
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>
@@ -6,11 +6,7 @@
import { renderWithProviders } from '../../test-utils/render.js';
import { createMockSettings } from '../../test-utils/settings.js';
import {
makeFakeConfig,
CoreToolCallStatus,
UPDATE_TOPIC_TOOL_NAME,
} from '@google/gemini-cli-core';
import { makeFakeConfig, CoreToolCallStatus } from '@google/gemini-cli-core';
import { waitFor } from '../../test-utils/async.js';
import { MainContent } from './MainContent.js';
import { getToolGroupBorderAppearance } from '../utils/borderStyles.js';
@@ -732,158 +728,6 @@ describe('MainContent', () => {
unmount();
});
describe('Narration Suppression', () => {
const settingsWithNarration = createMockSettings({
merged: {
ui: { inlineThinkingMode: 'expanded' },
experimental: { topicUpdateNarration: true },
},
});
it('suppresses thinking ALWAYS when narration is enabled', async () => {
mockUseSettings.mockReturnValue(settingsWithNarration);
const uiState = {
...defaultMockUiState,
history: [
{ id: 1, type: 'user' as const, text: 'Hello' },
{
id: 2,
type: 'thinking' as const,
thought: {
subject: 'Thinking...',
description: 'Thinking about hello',
},
},
{ id: 3, type: 'gemini' as const, text: 'I am helping.' },
],
};
const { lastFrame, unmount } = await renderWithProviders(
<MainContent />,
{
uiState: uiState as Partial<UIState>,
settings: settingsWithNarration,
},
);
const output = lastFrame();
expect(output).not.toContain('Thinking...');
expect(output).toContain('I am helping.');
unmount();
});
it('suppresses text in intermediate turns (contains non-topic tools)', async () => {
mockUseSettings.mockReturnValue(settingsWithNarration);
const uiState = {
...defaultMockUiState,
history: [
{ id: 100, type: 'user' as const, text: 'Search' },
{
id: 101,
type: 'gemini' as const,
text: 'I will now search the files.',
},
{
id: 102,
type: 'tool_group' as const,
tools: [
{
callId: '1',
name: 'ls',
args: { path: '.' },
status: CoreToolCallStatus.Success,
},
],
},
],
};
const { lastFrame, unmount } = await renderWithProviders(
<MainContent />,
{
uiState: uiState as Partial<UIState>,
settings: settingsWithNarration,
},
);
const output = lastFrame();
expect(output).not.toContain('I will now search the files.');
unmount();
});
it('suppresses text that precedes a topic tool in the same turn', async () => {
mockUseSettings.mockReturnValue(settingsWithNarration);
const uiState = {
...defaultMockUiState,
history: [
{ id: 200, type: 'user' as const, text: 'Hello' },
{ id: 201, type: 'gemini' as const, text: 'I will now help you.' },
{
id: 202,
type: 'tool_group' as const,
tools: [
{
callId: '1',
name: UPDATE_TOPIC_TOOL_NAME,
args: { title: 'Helping', summary: 'Helping the user' },
status: CoreToolCallStatus.Success,
},
],
},
],
};
const { lastFrame, unmount } = await renderWithProviders(
<MainContent />,
{
uiState: uiState as Partial<UIState>,
settings: settingsWithNarration,
},
);
const output = lastFrame();
expect(output).not.toContain('I will now help you.');
expect(output).toContain('Helping');
expect(output).toContain('Helping the user');
unmount();
});
it('shows text in the final turn if it comes AFTER the topic tool', async () => {
mockUseSettings.mockReturnValue(settingsWithNarration);
const uiState = {
...defaultMockUiState,
history: [
{ id: 300, type: 'user' as const, text: 'Hello' },
{
id: 301,
type: 'tool_group' as const,
tools: [
{
callId: '1',
name: UPDATE_TOPIC_TOOL_NAME,
args: { title: 'Final Answer', summary: 'I have finished' },
status: CoreToolCallStatus.Success,
},
],
},
{ id: 302, type: 'gemini' as const, text: 'Here is your answer.' },
],
};
const { lastFrame, unmount } = await renderWithProviders(
<MainContent />,
{
uiState: uiState as Partial<UIState>,
settings: settingsWithNarration,
},
);
const output = lastFrame();
expect(output).toContain('Here is your answer.');
unmount();
});
});
it('renders multiple thinking messages sequentially correctly', async () => {
mockUseSettings.mockReturnValue({
merged: {
+8 -35
View File
@@ -91,47 +91,20 @@ export const MainContent = () => {
const flags = new Array<boolean>(combinedHistory.length).fill(false);
if (topicUpdateNarrationEnabled) {
let turnIsIntermediate = false;
let hasTopicToolInTurn = false;
let toolGroupInTurn = false;
for (let i = combinedHistory.length - 1; i >= 0; i--) {
const item = combinedHistory[i];
if (item.type === 'user' || item.type === 'user_shell') {
turnIsIntermediate = false;
hasTopicToolInTurn = false;
toolGroupInTurn = false;
} else if (item.type === 'tool_group') {
const hasTopic = item.tools.some((t) => isTopicTool(t.name));
const hasNonTopic = item.tools.some((t) => !isTopicTool(t.name));
if (hasTopic) {
hasTopicToolInTurn = true;
}
if (hasNonTopic) {
turnIsIntermediate = true;
}
toolGroupInTurn = item.tools.some((t) => isTopicTool(t.name));
} else if (
item.type === 'thinking' ||
item.type === 'gemini' ||
item.type === 'gemini_content'
(item.type === 'thinking' ||
item.type === 'gemini' ||
item.type === 'gemini_content') &&
toolGroupInTurn
) {
// Rule 1: Always suppress thinking when narration is enabled to avoid
// "flashing" as the model starts its response, and because the Topic
// UI provides the necessary high-level intent.
if (item.type === 'thinking') {
flags[i] = true;
continue;
}
// Rule 2: Suppress text in intermediate turns (turns containing non-topic
// tools) to hide mechanical narration.
if (turnIsIntermediate) {
flags[i] = true;
}
// Rule 3: Suppress text that precedes a topic tool in the same turn,
// as the topic tool "replaces" it.
if (hasTopicToolInTurn) {
flags[i] = true;
}
flags[i] = true;
}
}
}
@@ -0,0 +1,187 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { act } from 'react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { Config, InboxSkill } from '@google/gemini-cli-core';
import {
dismissInboxSkill,
listInboxSkills,
moveInboxSkill,
} from '@google/gemini-cli-core';
import { waitFor } from '../../test-utils/async.js';
import { renderWithProviders } from '../../test-utils/render.js';
import { SkillInboxDialog } from './SkillInboxDialog.js';
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const original =
await importOriginal<typeof import('@google/gemini-cli-core')>();
return {
...original,
dismissInboxSkill: vi.fn(),
listInboxSkills: vi.fn(),
moveInboxSkill: vi.fn(),
getErrorMessage: vi.fn((error: unknown) =>
error instanceof Error ? error.message : String(error),
),
};
});
const mockListInboxSkills = vi.mocked(listInboxSkills);
const mockMoveInboxSkill = vi.mocked(moveInboxSkill);
const mockDismissInboxSkill = vi.mocked(dismissInboxSkill);
const inboxSkill: InboxSkill = {
dirName: 'inbox-skill',
name: 'Inbox Skill',
description: 'A test skill',
extractedAt: '2025-01-15T10:00:00Z',
};
describe('SkillInboxDialog', () => {
beforeEach(() => {
vi.clearAllMocks();
mockListInboxSkills.mockResolvedValue([inboxSkill]);
mockMoveInboxSkill.mockResolvedValue({
success: true,
message: 'Moved "inbox-skill" to ~/.gemini/skills.',
});
mockDismissInboxSkill.mockResolvedValue({
success: true,
message: 'Dismissed "inbox-skill" from inbox.',
});
});
it('disables the project destination when the workspace is untrusted', async () => {
const config = {
isTrustedFolder: vi.fn().mockReturnValue(false),
} as unknown as Config;
const onReloadSkills = vi.fn().mockResolvedValue(undefined);
const { lastFrame, stdin, unmount, waitUntilReady } = await act(async () =>
renderWithProviders(
<SkillInboxDialog
config={config}
onClose={vi.fn()}
onReloadSkills={onReloadSkills}
/>,
),
);
await waitFor(() => {
expect(lastFrame()).toContain('Inbox Skill');
});
await act(async () => {
stdin.write('\r');
await waitUntilReady();
});
await waitFor(() => {
const frame = lastFrame();
expect(frame).toContain('Project');
expect(frame).toContain('unavailable until this workspace is trusted');
});
await act(async () => {
stdin.write('\x1b[B');
await waitUntilReady();
});
await act(async () => {
stdin.write('\r');
await waitUntilReady();
});
await waitFor(() => {
expect(mockDismissInboxSkill).toHaveBeenCalledWith(config, 'inbox-skill');
});
expect(mockMoveInboxSkill).not.toHaveBeenCalled();
expect(onReloadSkills).not.toHaveBeenCalled();
unmount();
});
it('shows inline feedback when moving a skill throws', async () => {
mockMoveInboxSkill.mockRejectedValue(new Error('permission denied'));
const config = {
isTrustedFolder: vi.fn().mockReturnValue(true),
} as unknown as Config;
const { lastFrame, stdin, unmount, waitUntilReady } = await act(async () =>
renderWithProviders(
<SkillInboxDialog
config={config}
onClose={vi.fn()}
onReloadSkills={vi.fn().mockResolvedValue(undefined)}
/>,
),
);
await waitFor(() => {
expect(lastFrame()).toContain('Inbox Skill');
});
await act(async () => {
stdin.write('\r');
await waitUntilReady();
});
await act(async () => {
stdin.write('\r');
await waitUntilReady();
});
await waitFor(() => {
const frame = lastFrame();
expect(frame).toContain('Move "Inbox Skill"');
expect(frame).toContain('Failed to install skill: permission denied');
});
unmount();
});
it('shows inline feedback when reloading skills fails after a move', async () => {
const config = {
isTrustedFolder: vi.fn().mockReturnValue(true),
} as unknown as Config;
const onReloadSkills = vi
.fn()
.mockRejectedValue(new Error('reload hook failed'));
const { lastFrame, stdin, unmount, waitUntilReady } = await act(async () =>
renderWithProviders(
<SkillInboxDialog
config={config}
onClose={vi.fn()}
onReloadSkills={onReloadSkills}
/>,
),
);
await waitFor(() => {
expect(lastFrame()).toContain('Inbox Skill');
});
await act(async () => {
stdin.write('\r');
await waitUntilReady();
});
await act(async () => {
stdin.write('\r');
await waitUntilReady();
});
await waitFor(() => {
expect(lastFrame()).toContain(
'Moved "inbox-skill" to ~/.gemini/skills. Failed to reload skills: reload hook failed',
);
});
expect(onReloadSkills).toHaveBeenCalledTimes(1);
unmount();
});
});
@@ -0,0 +1,378 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { useState, useMemo, useCallback, useEffect } from 'react';
import { Box, Text } from 'ink';
import { theme } from '../semantic-colors.js';
import { useKeypress } from '../hooks/useKeypress.js';
import { Command } from '../key/keyMatchers.js';
import { useKeyMatchers } from '../hooks/useKeyMatchers.js';
import { BaseSelectionList } from './shared/BaseSelectionList.js';
import type { SelectionListItem } from '../hooks/useSelectionList.js';
import { DialogFooter } from './shared/DialogFooter.js';
import {
type Config,
type InboxSkill,
type InboxSkillDestination,
getErrorMessage,
listInboxSkills,
moveInboxSkill,
dismissInboxSkill,
} from '@google/gemini-cli-core';
type Phase = 'list' | 'action';
interface DestinationChoice {
destination: InboxSkillDestination | 'dismiss';
label: string;
description: string;
}
const DESTINATION_CHOICES: DestinationChoice[] = [
{
destination: 'global',
label: 'Global',
description: '~/.gemini/skills — available in all projects',
},
{
destination: 'project',
label: 'Project',
description: '.gemini/skills — available in this workspace',
},
{
destination: 'dismiss',
label: 'Dismiss',
description: 'Delete from inbox',
},
];
function formatDate(isoString: string): string {
try {
const date = new Date(isoString);
return date.toLocaleDateString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric',
});
} catch {
return isoString;
}
}
interface SkillInboxDialogProps {
config: Config;
onClose: () => void;
onReloadSkills: () => Promise<void>;
}
export const SkillInboxDialog: React.FC<SkillInboxDialogProps> = ({
config,
onClose,
onReloadSkills,
}) => {
const keyMatchers = useKeyMatchers();
const isTrustedFolder = config.isTrustedFolder();
const [phase, setPhase] = useState<Phase>('list');
const [skills, setSkills] = useState<InboxSkill[]>([]);
const [loading, setLoading] = useState(true);
const [selectedSkill, setSelectedSkill] = useState<InboxSkill | null>(null);
const [feedback, setFeedback] = useState<{
text: string;
isError: boolean;
} | null>(null);
// Load inbox skills on mount
useEffect(() => {
let cancelled = false;
void (async () => {
try {
const result = await listInboxSkills(config);
if (!cancelled) {
setSkills(result);
setLoading(false);
}
} catch {
if (!cancelled) {
setSkills([]);
setLoading(false);
}
}
})();
return () => {
cancelled = true;
};
}, [config]);
const skillItems: Array<SelectionListItem<InboxSkill>> = useMemo(
() =>
skills.map((skill) => ({
key: skill.dirName,
value: skill,
})),
[skills],
);
const destinationItems: Array<SelectionListItem<DestinationChoice>> = useMemo(
() =>
DESTINATION_CHOICES.map((choice) => {
if (choice.destination === 'project' && !isTrustedFolder) {
return {
key: choice.destination,
value: {
...choice,
description:
'.gemini/skills — unavailable until this workspace is trusted',
},
disabled: true,
};
}
return {
key: choice.destination,
value: choice,
};
}),
[isTrustedFolder],
);
const handleSelectSkill = useCallback((skill: InboxSkill) => {
setSelectedSkill(skill);
setFeedback(null);
setPhase('action');
}, []);
const handleSelectDestination = useCallback(
(choice: DestinationChoice) => {
if (!selectedSkill) return;
if (choice.destination === 'project' && !config.isTrustedFolder()) {
setFeedback({
text: 'Project skills are unavailable until this workspace is trusted.',
isError: true,
});
return;
}
setFeedback(null);
void (async () => {
try {
let result: { success: boolean; message: string };
if (choice.destination === 'dismiss') {
result = await dismissInboxSkill(config, selectedSkill.dirName);
} else {
result = await moveInboxSkill(
config,
selectedSkill.dirName,
choice.destination,
);
}
setFeedback({ text: result.message, isError: !result.success });
if (!result.success) {
return;
}
// Remove the skill from the local list.
setSkills((prev) =>
prev.filter((skill) => skill.dirName !== selectedSkill.dirName),
);
setSelectedSkill(null);
setPhase('list');
if (choice.destination === 'dismiss') {
return;
}
try {
await onReloadSkills();
} catch (error) {
setFeedback({
text: `${result.message} Failed to reload skills: ${getErrorMessage(error)}`,
isError: true,
});
}
} catch (error) {
const operation =
choice.destination === 'dismiss'
? 'dismiss skill'
: 'install skill';
setFeedback({
text: `Failed to ${operation}: ${getErrorMessage(error)}`,
isError: true,
});
}
})();
},
[config, selectedSkill, onReloadSkills],
);
useKeypress(
(key) => {
if (keyMatchers[Command.ESCAPE](key)) {
if (phase === 'action') {
setPhase('list');
setSelectedSkill(null);
setFeedback(null);
} else {
onClose();
}
return true;
}
return false;
},
{ isActive: true, priority: true },
);
if (loading) {
return (
<Box
flexDirection="column"
borderStyle="round"
borderColor={theme.border.default}
paddingX={2}
paddingY={1}
>
<Text>Loading inbox</Text>
</Box>
);
}
if (skills.length === 0 && !feedback) {
return (
<Box
flexDirection="column"
borderStyle="round"
borderColor={theme.border.default}
paddingX={2}
paddingY={1}
>
<Text bold>Skill Inbox</Text>
<Box marginTop={1}>
<Text color={theme.text.secondary}>
No extracted skills in inbox.
</Text>
</Box>
<DialogFooter primaryAction="Esc to close" cancelAction="" />
</Box>
);
}
return (
<Box
flexDirection="column"
borderStyle="round"
borderColor={theme.border.default}
paddingX={2}
paddingY={1}
width="100%"
>
{phase === 'list' ? (
<>
<Text bold>
Skill Inbox ({skills.length} skill{skills.length !== 1 ? 's' : ''})
</Text>
<Text color={theme.text.secondary}>
Skills extracted from past sessions. Select one to move or dismiss.
</Text>
<Box flexDirection="column" marginTop={1}>
<BaseSelectionList<InboxSkill>
items={skillItems}
onSelect={handleSelectSkill}
isFocused={true}
showNumbers={true}
showScrollArrows={true}
maxItemsToShow={8}
renderItem={(item, { titleColor }) => (
<Box flexDirection="column" minHeight={2}>
<Text color={titleColor} bold>
{item.value.name}
</Text>
<Box flexDirection="row">
<Text color={theme.text.secondary} wrap="wrap">
{item.value.description}
</Text>
{item.value.extractedAt && (
<Text color={theme.text.secondary}>
{' · '}
{formatDate(item.value.extractedAt)}
</Text>
)}
</Box>
</Box>
)}
/>
</Box>
{feedback && (
<Box marginTop={1}>
<Text
color={
feedback.isError ? theme.status.error : theme.status.success
}
>
{feedback.isError ? '✗ ' : '✓ '}
{feedback.text}
</Text>
</Box>
)}
<DialogFooter
primaryAction="Enter to select"
cancelAction="Esc to close"
/>
</>
) : (
<>
<Text bold>Move &quot;{selectedSkill?.name}&quot;</Text>
<Text color={theme.text.secondary}>
Choose where to install this skill.
</Text>
<Box flexDirection="column" marginTop={1}>
<BaseSelectionList<DestinationChoice>
items={destinationItems}
onSelect={handleSelectDestination}
isFocused={true}
showNumbers={true}
renderItem={(item, { titleColor }) => (
<Box flexDirection="column" minHeight={2}>
<Text color={titleColor} bold>
{item.value.label}
</Text>
<Text color={theme.text.secondary}>
{item.value.description}
</Text>
</Box>
)}
/>
</Box>
{feedback && (
<Box marginTop={1}>
<Text
color={
feedback.isError ? theme.status.error : theme.status.success
}
>
{feedback.isError ? '✗ ' : '✓ '}
{feedback.text}
</Text>
</Box>
)}
<DialogFooter
primaryAction="Enter to confirm"
cancelAction="Esc to go back"
/>
</>
)}
</Box>
);
};
+1 -1
View File
@@ -331,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>
) : (
@@ -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
@@ -0,0 +1,15 @@
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="71" viewBox="0 0 920 71">
<style>
text { font-family: Consolas, "Courier New", monospace; font-size: 14px; dominant-baseline: text-before-edge; white-space: pre; }
</style>
<rect width="920" height="71" fill="#000000" />
<g transform="translate(10, 10)">
<text x="0" y="2" fill="#00cd00" textLength="900" lengthAdjust="spacingAndGlyphs">────────────────────────────────────────────────────────────────────────────────────────────────────</text>
<text x="0" y="19" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="18" y="19" fill="#d7afff" textLength="18" lengthAdjust="spacingAndGlyphs">&gt; </text>
<text x="36" y="19" fill="#ffffff" textLength="846" lengthAdjust="spacingAndGlyphs">xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx</text>
<rect x="882" y="17" width="9" height="17" fill="#ffffff" />
<text x="891" y="19" fill="#00cd00" textLength="9" lengthAdjust="spacingAndGlyphs"></text>
<text x="0" y="36" fill="#00cd00" textLength="900" lengthAdjust="spacingAndGlyphs">────────────────────────────────────────────────────────────────────────────────────────────────────</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

@@ -72,6 +72,12 @@ exports[`InputPrompt > Highlighting and Cursor Display > single-line scenarios >
────────────────────────────────────────────────────────────────────────────────────────────────────"
`;
exports[`InputPrompt > Highlighting and Cursor Display > single-line scenarios > should display cursor correctly 'on a full-width line with no whitespa…' 1`] = `
"────────────────────────────────────────────────────────────────────────────────────────────────────
│ > xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx │
────────────────────────────────────────────────────────────────────────────────────────────────────"
`;
exports[`InputPrompt > Highlighting and Cursor Display > single-line scenarios > should display cursor correctly 'on a highlighted token' 1`] = `
"────────────────────────────────────────────────────────────────────────────────────────────────────
│ > run @path/to/file │
@@ -93,7 +99,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 +126,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
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
"
`;
@@ -168,13 +174,6 @@ exports[`InputPrompt > mouse interaction > should toggle paste expansion on doub
"
`;
exports[`InputPrompt > mouse interaction > should toggle paste expansion on double-click 4`] = `
"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
> [Pasted Text: 10 lines]
▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
"
`;
exports[`InputPrompt > multiline rendering > should correctly render multiline input including blank lines 1`] = `
"────────────────────────────────────────────────────────────────────────────────────────────────────
│ > hello │
@@ -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
"
`;
@@ -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 │
╰──────────────────────────────────────────────────────────────────────────────╯
"
`;
@@ -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();
});
@@ -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();
});
});
@@ -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
"
`;
@@ -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}
+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
+10 -4
View File
@@ -13,7 +13,7 @@ import {
type MockedFunction,
} from 'vitest';
import { renderHook } from '../../test-utils/render.js';
import { useBanner } from './useBanner.js';
import { useBanner, _clearSessionBannersForTest } from './useBanner.js';
import { persistentState } from '../../utils/persistentState.js';
import crypto from 'node:crypto';
@@ -56,6 +56,7 @@ describe('useBanner', () => {
beforeEach(() => {
vi.resetAllMocks();
_clearSessionBannersForTest();
// Default persistentState behavior: return empty object (no counts)
mockedPersistentStateGet.mockReturnValue({});
@@ -101,13 +102,18 @@ describe('useBanner', () => {
);
});
it('should NOT increment count if warning text is shown instead', async () => {
it('should increment count if warning text is shown instead', async () => {
const data = { defaultText: 'Standard', warningText: 'Warning' };
await renderHook(() => useBanner(data));
// Since warning text takes precedence, default banner logic (and increment) is skipped
expect(mockedPersistentStateSet).not.toHaveBeenCalled();
// Warning text now also gets counted
expect(mockedPersistentStateSet).toHaveBeenCalledWith(
'defaultBannerShownCount',
{
[crypto.createHash('sha256').update(data.warningText).digest('hex')]: 1,
},
);
});
it('should handle newline replacements', async () => {
+20 -11
View File
@@ -4,12 +4,21 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { useState, useEffect, useRef } from 'react';
import { useState, useEffect } from 'react';
import { persistentState } from '../../utils/persistentState.js';
import crypto from 'node:crypto';
const DEFAULT_MAX_BANNER_SHOWN_COUNT = 5;
// Track banners incremented during this session to prevent multiple increments
// on React unmounts/remounts
const sessionIncrementedBanners = new Set<string>();
// For testing purposes
export function _clearSessionBannersForTest() {
sessionIncrementedBanners.clear();
}
interface BannerData {
defaultText: string;
warningText: string;
@@ -22,25 +31,25 @@ export function useBanner(bannerData: BannerData) {
() => persistentState.get('defaultBannerShownCount') || {},
);
const activeText = warningText ? warningText : defaultText;
const hashedText = crypto
.createHash('sha256')
.update(defaultText)
.update(activeText)
.digest('hex');
const currentBannerCount = bannerCounts[hashedText] || 0;
const showDefaultBanner =
warningText === '' && currentBannerCount < DEFAULT_MAX_BANNER_SHOWN_COUNT;
const showBanner =
activeText !== '' && currentBannerCount < DEFAULT_MAX_BANNER_SHOWN_COUNT;
const rawBannerText = showDefaultBanner ? defaultText : warningText;
const rawBannerText = showBanner ? activeText : '';
const bannerText = rawBannerText.replace(/\\n/g, '\n');
const lastIncrementedKey = useRef<string | null>(null);
useEffect(() => {
if (showDefaultBanner && defaultText) {
if (lastIncrementedKey.current !== defaultText) {
lastIncrementedKey.current = defaultText;
if (showBanner && activeText) {
if (!sessionIncrementedBanners.has(activeText)) {
sessionIncrementedBanners.add(activeText);
const allCounts = persistentState.get('defaultBannerShownCount') || {};
const current = allCounts[hashedText] || 0;
@@ -51,7 +60,7 @@ export function useBanner(bannerData: BannerData) {
});
}
}
}, [showDefaultBanner, defaultText, hashedText]);
}, [showBanner, activeText, hashedText]);
return {
bannerText,
+7 -2
View File
@@ -77,6 +77,7 @@ export enum Command {
QUEUE_MESSAGE = 'input.queueMessage',
NEWLINE = 'input.newline',
OPEN_EXTERNAL_EDITOR = 'input.openExternalEditor',
DEPRECATED_OPEN_EXTERNAL_EDITOR = 'input.deprecatedOpenExternalEditor',
PASTE_CLIPBOARD = 'input.paste',
// App Controls
@@ -375,7 +376,8 @@ export const defaultKeyBindingConfig: KeyBindingConfig = new Map([
new KeyBinding('ctrl+j'),
],
],
[Command.OPEN_EXTERNAL_EDITOR, [new KeyBinding('ctrl+x')]],
[Command.OPEN_EXTERNAL_EDITOR, [new KeyBinding('ctrl+g')]],
[Command.DEPRECATED_OPEN_EXTERNAL_EDITOR, [new KeyBinding('ctrl+x')]],
[
Command.PASTE_CLIPBOARD,
[
@@ -388,7 +390,7 @@ export const defaultKeyBindingConfig: KeyBindingConfig = new Map([
// App Controls
[Command.SHOW_ERROR_DETAILS, [new KeyBinding('f12')]],
[Command.SHOW_FULL_TODOS, [new KeyBinding('ctrl+t')]],
[Command.SHOW_IDE_CONTEXT_DETAIL, [new KeyBinding('ctrl+g')]],
[Command.SHOW_IDE_CONTEXT_DETAIL, [new KeyBinding('f4')]],
[Command.TOGGLE_MARKDOWN, [new KeyBinding('alt+m')]],
[Command.TOGGLE_COPY_MODE, [new KeyBinding('f9')]],
[Command.TOGGLE_MOUSE_MODE, [new KeyBinding('ctrl+s')]],
@@ -510,6 +512,7 @@ export const commandCategories: readonly CommandCategory[] = [
Command.QUEUE_MESSAGE,
Command.NEWLINE,
Command.OPEN_EXTERNAL_EDITOR,
Command.DEPRECATED_OPEN_EXTERNAL_EDITOR,
Command.PASTE_CLIPBOARD,
],
},
@@ -626,6 +629,8 @@ export const commandDescriptions: Readonly<Record<Command, string>> = {
[Command.NEWLINE]: 'Insert a newline without submitting.',
[Command.OPEN_EXTERNAL_EDITOR]:
'Open the current prompt or the plan in an external editor.',
[Command.DEPRECATED_OPEN_EXTERNAL_EDITOR]:
'Deprecated command to open external editor.',
[Command.PASTE_CLIPBOARD]: 'Paste from the clipboard.',
// App Controls
+7 -2
View File
@@ -311,6 +311,11 @@ describe('keyMatchers', () => {
// External tools
{
command: Command.OPEN_EXTERNAL_EDITOR,
positive: [createKey('g', { ctrl: true })],
negative: [createKey('g'), createKey('c', { ctrl: true })],
},
{
command: Command.DEPRECATED_OPEN_EXTERNAL_EDITOR,
positive: [createKey('x', { ctrl: true })],
negative: [createKey('x'), createKey('c', { ctrl: true })],
},
@@ -336,8 +341,8 @@ describe('keyMatchers', () => {
},
{
command: Command.SHOW_IDE_CONTEXT_DETAIL,
positive: [createKey('g', { ctrl: true })],
negative: [createKey('g'), createKey('t', { ctrl: true })],
positive: [createKey('f4')],
negative: [createKey('f5'), createKey('t', { ctrl: true })],
},
{
command: Command.TOGGLE_MARKDOWN,
+65 -81
View File
@@ -26,66 +26,49 @@ describe('skillUtils', () => {
vi.unstubAllEnvs();
});
const itif = (condition: boolean) => (condition ? it : it.skip);
describe('linkSkill', () => {
// TODO: issue 19388 - Enable linkSkill tests on Windows
itif(process.platform !== 'win32')(
'should successfully link from a local directory',
async () => {
// Create a mock skill directory
const mockSkillSourceDir = path.join(tempDir, 'mock-skill-source');
const skillSubDir = path.join(mockSkillSourceDir, 'test-skill');
await fs.mkdir(skillSubDir, { recursive: true });
await fs.writeFile(
path.join(skillSubDir, 'SKILL.md'),
'---\nname: test-skill\ndescription: test\n---\nbody',
);
it('should successfully link from a local directory', async () => {
// Create a mock skill directory
const mockSkillSourceDir = path.join(tempDir, 'mock-skill-source');
const skillSubDir = path.join(mockSkillSourceDir, 'test-skill');
await fs.mkdir(skillSubDir, { recursive: true });
await fs.writeFile(
path.join(skillSubDir, 'SKILL.md'),
'---\nname: test-skill\ndescription: test\n---\nbody',
);
const skills = await linkSkill(
mockSkillSourceDir,
'workspace',
() => {},
);
expect(skills.length).toBe(1);
expect(skills[0].name).toBe('test-skill');
const skills = await linkSkill(mockSkillSourceDir, 'workspace', () => {});
expect(skills.length).toBe(1);
expect(skills[0].name).toBe('test-skill');
const linkedPath = path.join(tempDir, '.gemini/skills', 'test-skill');
const stats = await fs.lstat(linkedPath);
expect(stats.isSymbolicLink()).toBe(true);
const linkedPath = path.join(tempDir, '.gemini/skills', 'test-skill');
const stats = await fs.lstat(linkedPath);
expect(stats.isSymbolicLink()).toBe(true);
const linkTarget = await fs.readlink(linkedPath);
expect(path.resolve(linkTarget)).toBe(path.resolve(skillSubDir));
},
);
const linkTarget = await fs.readlink(linkedPath);
expect(path.resolve(linkTarget)).toBe(path.resolve(skillSubDir));
});
itif(process.platform !== 'win32')(
'should overwrite existing skill at destination',
async () => {
const mockSkillSourceDir = path.join(tempDir, 'mock-skill-source');
const skillSubDir = path.join(mockSkillSourceDir, 'test-skill');
await fs.mkdir(skillSubDir, { recursive: true });
await fs.writeFile(
path.join(skillSubDir, 'SKILL.md'),
'---\nname: test-skill\ndescription: test\n---\nbody',
);
it('should overwrite existing skill at destination', async () => {
const mockSkillSourceDir = path.join(tempDir, 'mock-skill-source');
const skillSubDir = path.join(mockSkillSourceDir, 'test-skill');
await fs.mkdir(skillSubDir, { recursive: true });
await fs.writeFile(
path.join(skillSubDir, 'SKILL.md'),
'---\nname: test-skill\ndescription: test\n---\nbody',
);
const targetDir = path.join(tempDir, '.gemini/skills');
await fs.mkdir(targetDir, { recursive: true });
const existingPath = path.join(targetDir, 'test-skill');
await fs.mkdir(existingPath);
const targetDir = path.join(tempDir, '.gemini/skills');
await fs.mkdir(targetDir, { recursive: true });
const existingPath = path.join(targetDir, 'test-skill');
await fs.mkdir(existingPath);
const skills = await linkSkill(
mockSkillSourceDir,
'workspace',
() => {},
);
expect(skills.length).toBe(1);
const skills = await linkSkill(mockSkillSourceDir, 'workspace', () => {});
expect(skills.length).toBe(1);
const stats = await fs.lstat(existingPath);
expect(stats.isSymbolicLink()).toBe(true);
},
);
const stats = await fs.lstat(existingPath);
expect(stats.isSymbolicLink()).toBe(true);
});
it('should abort linking if consent is rejected', async () => {
const mockSkillSourceDir = path.join(tempDir, 'mock-skill-source');
@@ -237,39 +220,40 @@ describe('skillUtils', () => {
expect(result).toBeNull();
});
itif(process.platform !== 'win32')(
'should successfully uninstall a skill even if its name was updated after linking',
async () => {
// 1. Create source skill
const sourceDir = path.join(tempDir, 'source-skill');
await fs.mkdir(sourceDir, { recursive: true });
const skillMdPath = path.join(sourceDir, 'SKILL.md');
await fs.writeFile(
skillMdPath,
'---\nname: original-name\ndescription: test\n---\nbody',
);
it('should successfully uninstall a skill even if its name was updated after linking', async () => {
// 1. Create source skill
const sourceDir = path.join(tempDir, 'source-skill');
await fs.mkdir(sourceDir, { recursive: true });
const skillMdPath = path.join(sourceDir, 'SKILL.md');
await fs.writeFile(
skillMdPath,
'---\nname: original-name\ndescription: test\n---\nbody',
);
// 2. Link it
const skillsDir = path.join(tempDir, '.gemini/skills');
await fs.mkdir(skillsDir, { recursive: true });
const destPath = path.join(skillsDir, 'original-name');
await fs.symlink(sourceDir, destPath, 'dir');
// 2. Link it
const skillsDir = path.join(tempDir, '.gemini/skills');
await fs.mkdir(skillsDir, { recursive: true });
const destPath = path.join(skillsDir, 'original-name');
await fs.symlink(
sourceDir,
destPath,
process.platform === 'win32' ? 'junction' : 'dir',
);
// 3. Update name in source
await fs.writeFile(
skillMdPath,
'---\nname: updated-name\ndescription: test\n---\nbody',
);
// 3. Update name in source
await fs.writeFile(
skillMdPath,
'---\nname: updated-name\ndescription: test\n---\nbody',
);
// 4. Uninstall by NEW name (this is the bug fix)
const result = await uninstallSkill('updated-name', 'user');
expect(result).not.toBeNull();
expect(result?.location).toBe(destPath);
// 4. Uninstall by NEW name (this is the bug fix)
const result = await uninstallSkill('updated-name', 'user');
expect(result).not.toBeNull();
expect(result?.location).toBe(destPath);
const exists = await fs.lstat(destPath).catch(() => null);
expect(exists).toBeNull();
},
);
const exists = await fs.lstat(destPath).catch(() => null);
expect(exists).toBeNull();
});
it('should successfully uninstall a skill by directory name if metadata is missing (fallback)', async () => {
const skillsDir = path.join(tempDir, '.gemini/skills');
+7 -1
View File
@@ -248,7 +248,13 @@ export async function linkSkill(
await fs.rm(destPath, { recursive: true, force: true });
}
await fs.symlink(skillSourceDir, destPath, 'dir');
// Use 'junction' on Windows to avoid EPERM errors — junctions don't
// require elevated privileges or Developer Mode (fixes #24816)
await fs.symlink(
skillSourceDir,
destPath,
process.platform === 'win32' ? 'junction' : 'dir',
);
linkedSkills.push({ name: skillName, location: destPath });
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli-core",
"version": "0.36.0-nightly.20260317.2f90b4653",
"version": "0.39.0-nightly.20260408.e77b22e63",
"description": "Gemini CLI Core",
"license": "Apache-2.0",
"repository": {
@@ -432,6 +432,7 @@ function isStructuredError(error: unknown): error is StructuredError {
return (
typeof error === 'object' &&
error !== null &&
'status' in error &&
'message' in error &&
typeof error.message === 'string'
);
@@ -17,6 +17,9 @@ import type {
ToolCallRequestInfo,
} from '../scheduler/types.js';
import { CoreToolCallStatus } from '../scheduler/types.js';
import type { GeminiClient } from '../core/client.js';
import type { Scheduler } from '../scheduler/scheduler.js';
import type { Config } from '../config/config.js';
// ---------------------------------------------------------------------------
// Mock helpers
@@ -24,7 +27,7 @@ import { CoreToolCallStatus } from '../scheduler/types.js';
function createMockDeps(
overrides?: Partial<LegacyAgentSessionDeps>,
): LegacyAgentSessionDeps {
): Required<LegacyAgentSessionDeps> {
const mockClient = {
sendMessageStream: vi.fn(),
getChat: vi.fn().mockReturnValue({
@@ -40,18 +43,22 @@ function createMockDeps(
const mockConfig = {
getMaxSessionTurns: vi.fn().mockReturnValue(-1),
getModel: vi.fn().mockReturnValue('gemini-2.5-pro'),
getGeminiClient: vi.fn().mockReturnValue(mockClient),
getMessageBus: vi.fn().mockImplementation(() => ({
subscribe: vi.fn(),
unsubscribe: vi.fn(),
})),
};
return {
client: mockClient as unknown as LegacyAgentSessionDeps['client'],
scheduler: mockScheduler as unknown as LegacyAgentSessionDeps['scheduler'],
config: mockConfig as unknown as LegacyAgentSessionDeps['config'],
client: mockClient as unknown as GeminiClient,
scheduler: mockScheduler as unknown as Scheduler,
config: mockConfig as unknown as Config,
promptId: 'test-prompt',
streamId: 'test-stream',
getPreferredEditor: vi.fn().mockReturnValue(undefined),
...overrides,
};
} as Required<LegacyAgentSessionDeps>;
}
async function* makeStream(
@@ -129,7 +136,7 @@ async function collectEvents(
// ---------------------------------------------------------------------------
describe('LegacyAgentSession', () => {
let deps: LegacyAgentSessionDeps;
let deps: Required<LegacyAgentSessionDeps>;
beforeEach(() => {
deps = createMockDeps();
@@ -14,10 +14,11 @@ import type { Part } from '@google/genai';
import type { GeminiClient } from '../core/client.js';
import type { Config } from '../config/config.js';
import type { ToolCallRequestInfo } from '../scheduler/types.js';
import type { Scheduler } from '../scheduler/scheduler.js';
import { Scheduler } from '../scheduler/scheduler.js';
import { recordToolCallInteractions } from '../code_assist/telemetry.js';
import { ToolErrorType, isFatalToolError } from '../tools/tool-error.js';
import { debugLogger } from '../utils/debugLogger.js';
import type { EditorType } from '../utils/editor.js';
import {
buildToolResponseData,
contentPartsToGeminiParts,
@@ -45,14 +46,17 @@ function isAbortLikeError(err: unknown): boolean {
}
export interface LegacyAgentSessionDeps {
client: GeminiClient;
scheduler: Scheduler;
config: Config;
promptId: string;
client?: GeminiClient;
scheduler?: Scheduler;
promptId?: string;
streamId?: string;
getPreferredEditor?: () => EditorType | undefined;
}
class LegacyAgentProtocol implements AgentProtocol {
const schedulerMap = new WeakMap<Config, Scheduler>();
export class LegacyAgentProtocol implements AgentProtocol {
private _events: AgentEvent[] = [];
private _subscribers = new Set<(event: AgentEvent) => void>();
private _translationState: TranslationState;
@@ -69,10 +73,26 @@ class LegacyAgentProtocol implements AgentProtocol {
constructor(deps: LegacyAgentSessionDeps) {
this._translationState = createTranslationState(deps.streamId);
this._nextStreamIdOverride = deps.streamId;
this._client = deps.client;
this._scheduler = deps.scheduler;
this._config = deps.config;
this._promptId = deps.promptId;
this._client = deps.client ?? deps.config.getGeminiClient();
this._promptId = deps.promptId ?? deps.config.promptId ?? '';
if (deps.scheduler) {
this._scheduler = deps.scheduler;
} else {
let scheduler = schedulerMap.get(deps.config);
if (!scheduler) {
const sessionId = deps.config.getSessionId();
const schedulerId = `legacy-agent-scheduler-${sessionId}`;
scheduler = new Scheduler({
context: deps.config,
schedulerId,
getPreferredEditor: deps.getPreferredEditor ?? (() => undefined),
});
schedulerMap.set(deps.config, scheduler);
}
this._scheduler = scheduler;
}
}
get events(): readonly AgentEvent[] {
+31
View File
@@ -4,6 +4,8 @@
* SPDX-License-Identifier: Apache-2.0
*/
import type { Kind } from '../tools/tools.js';
export type WithMeta = { _meta?: Record<string, unknown> };
export type Unsubscribe = () => void;
@@ -180,6 +182,16 @@ export interface ToolRequest {
name: string;
/** The arguments for the tool. */
args: Record<string, unknown>;
/** UI specific metadata */
_meta?: {
legacyState?: {
displayName?: string;
isOutputMarkdown?: boolean;
description?: string;
kind?: Kind;
};
[key: string]: unknown;
};
}
/**
@@ -192,6 +204,18 @@ export interface ToolUpdate {
displayContent?: ContentPart[];
content?: ContentPart[];
data?: Record<string, unknown>;
/** UI specific metadata */
_meta?: {
legacyState?: {
status?: string;
progressMessage?: string;
progress?: number;
progressTotal?: number;
pid?: number;
description?: string;
};
[key: string]: unknown;
};
}
export interface ToolResponse {
@@ -205,6 +229,13 @@ export interface ToolResponse {
data?: Record<string, unknown>;
/** When true, the tool call encountered an error that will be sent to the model. */
isError?: boolean;
/** UI specific metadata */
_meta?: {
legacyState?: {
outputFile?: string;
};
[key: string]: unknown;
};
}
export type ElicitationRequest = {
@@ -15,6 +15,7 @@ import type { MessageBus } from '../confirmation-bus/message-bus.js';
vi.mock('../scheduler/scheduler.js', () => ({
Scheduler: vi.fn().mockImplementation(() => ({
schedule: vi.fn().mockResolvedValue([{ status: 'success' }]),
dispose: vi.fn(),
})),
}));
@@ -125,6 +126,57 @@ describe('agent-scheduler', () => {
expect(schedulerConfig.toolRegistry).not.toBe(mainRegistry);
});
it('should dispose the scheduler after schedule completes', async () => {
const mockConfig = {
getPromptRegistry: vi.fn(),
getResourceRegistry: vi.fn(),
messageBus: mockMessageBus,
toolRegistry: mockToolRegistry,
} as unknown as Mocked<Config>;
const options = {
schedulerId: 'subagent-1',
toolRegistry: mockToolRegistry as unknown as ToolRegistry,
signal: new AbortController().signal,
};
await scheduleAgentTools(mockConfig as unknown as Config, [], options);
const schedulerInstance = vi.mocked(Scheduler).mock.results[0].value;
expect(schedulerInstance.dispose).toHaveBeenCalledOnce();
});
it('should dispose the scheduler even when schedule throws', async () => {
const scheduleError = new Error('schedule failed');
vi.mocked(Scheduler).mockImplementationOnce(
() =>
({
schedule: vi.fn().mockRejectedValue(scheduleError),
dispose: vi.fn(),
}) as unknown as Scheduler,
);
const mockConfig = {
getPromptRegistry: vi.fn(),
getResourceRegistry: vi.fn(),
messageBus: mockMessageBus,
toolRegistry: mockToolRegistry,
} as unknown as Mocked<Config>;
const options = {
schedulerId: 'subagent-1',
toolRegistry: mockToolRegistry as unknown as ToolRegistry,
signal: new AbortController().signal,
};
await expect(
scheduleAgentTools(mockConfig as unknown as Config, [], options),
).rejects.toThrow('schedule failed');
const schedulerInstance = vi.mocked(Scheduler).mock.results[0].value;
expect(schedulerInstance.dispose).toHaveBeenCalledOnce();
});
it('should create an AgentLoopContext that has a defined .config property', async () => {
const mockConfig = {
getPromptRegistry: vi.fn(),
+5 -1
View File
@@ -85,5 +85,9 @@ export async function scheduleAgentTools(
onWaitingForConfirmation,
});
return scheduler.schedule(requests, signal);
try {
return await scheduler.schedule(requests, signal);
} finally {
scheduler.dispose();
}
}
@@ -38,6 +38,8 @@ const mockBrowserManager = {
]),
callTool: vi.fn().mockResolvedValue({ content: [] }),
close: vi.fn().mockResolvedValue(undefined),
acquire: vi.fn(),
release: vi.fn(),
};
// Mock dependencies
@@ -81,207 +81,218 @@ export async function createBrowserAgentDefinition(
// Get or create browser manager singleton for this session mode/profile
const browserManager = BrowserManager.getInstance(config);
await browserManager.ensureConnection();
browserManager.acquire();
debugLogger.log('Browser connected with isolated MCP client.');
try {
await browserManager.ensureConnection();
// Determine if input blocker should be active (non-headless + enabled)
const shouldDisableInput = config.shouldDisableBrowserUserInput();
// Inject automation overlay and input blocker if not in headless mode
const browserConfig = config.getBrowserAgentConfig();
if (!browserConfig?.customConfig?.headless) {
debugLogger.log('Injecting automation overlay...');
await injectAutomationOverlay(browserManager);
if (shouldDisableInput) {
debugLogger.log('Injecting input blocker...');
await injectInputBlocker(browserManager);
}
}
debugLogger.log('Browser connected with isolated MCP client.');
// Create declarative tools from dynamically discovered MCP tools
// These tools dispatch to browserManager's isolated client
const mcpTools = await createMcpDeclarativeTools(
browserManager,
messageBus,
shouldDisableInput,
browserConfig.customConfig.blockFileUploads,
);
const availableToolNames = mcpTools.map((t) => t.name);
// Register high-priority policy rules for sensitive actions which is not
// able to be overwrite by YOLO mode.
const policyEngine = config.getPolicyEngine();
if (policyEngine) {
const existingRules = policyEngine.getRules();
const restrictedTools = ['fill', 'fill_form'];
// ASK_USER for upload_file and evaluate_script when sensitive action
// need confirmation.
if (browserConfig.customConfig.confirmSensitiveActions) {
restrictedTools.push('upload_file', 'evaluate_script');
}
for (const toolName of restrictedTools) {
const rule = generateAskUserRules(toolName);
if (!existingRules.some((r) => isRuleEqual(r, rule))) {
policyEngine.addRule(rule);
// Determine if input blocker should be active (non-headless + enabled)
const shouldDisableInput = config.shouldDisableBrowserUserInput();
// Inject automation overlay and input blocker if not in headless mode
const browserConfig = config.getBrowserAgentConfig();
if (!browserConfig?.customConfig?.headless) {
debugLogger.log('Injecting automation overlay...');
await injectAutomationOverlay(browserManager);
if (shouldDisableInput) {
debugLogger.log('Injecting input blocker...');
await injectInputBlocker(browserManager);
}
}
// Reduce noise for read-only tools in default mode
const readOnlyTools = (await browserManager.getDiscoveredTools())
.filter((t) => !!t.annotations?.readOnlyHint)
.map((t) => t.name);
const allowlistedReadonlyTools = ['take_snapshot', 'take_screenshot'];
// Create declarative tools from dynamically discovered MCP tools
// These tools dispatch to browserManager's isolated client
const mcpTools = await createMcpDeclarativeTools(
browserManager,
messageBus,
shouldDisableInput,
browserConfig.customConfig.blockFileUploads,
);
const availableToolNames = mcpTools.map((t) => t.name);
for (const toolName of [...readOnlyTools, ...allowlistedReadonlyTools]) {
if (availableToolNames.includes(toolName)) {
const rule = generateAllowRules(toolName);
// Register high-priority policy rules for sensitive actions which is not
// able to be overwrite by YOLO mode.
const policyEngine = config.getPolicyEngine();
if (policyEngine) {
const existingRules = policyEngine.getRules();
const restrictedTools = ['fill', 'fill_form'];
// ASK_USER for upload_file and evaluate_script when sensitive action
// need confirmation.
if (browserConfig.customConfig.confirmSensitiveActions) {
restrictedTools.push('upload_file', 'evaluate_script');
}
for (const toolName of restrictedTools) {
const rule = generateAskUserRules(toolName);
if (!existingRules.some((r) => isRuleEqual(r, rule))) {
policyEngine.addRule(rule);
}
}
}
}
function generateAskUserRules(toolName: string): PolicyRule {
return {
toolName: `${MCP_TOOL_PREFIX}${BROWSER_AGENT_NAME}_${toolName}`,
decision: PolicyDecision.ASK_USER,
priority: 999,
source: 'BrowserAgent (Sensitive Actions)',
mcpName: BROWSER_AGENT_NAME,
// Reduce noise for read-only tools in default mode
const readOnlyTools = (await browserManager.getDiscoveredTools())
.filter((t) => !!t.annotations?.readOnlyHint)
.map((t) => t.name);
const allowlistedReadonlyTools = ['take_snapshot', 'take_screenshot'];
for (const toolName of [...readOnlyTools, ...allowlistedReadonlyTools]) {
if (availableToolNames.includes(toolName)) {
const rule = generateAllowRules(toolName);
if (!existingRules.some((r) => isRuleEqual(r, rule))) {
policyEngine.addRule(rule);
}
}
}
}
function generateAskUserRules(toolName: string): PolicyRule {
return {
toolName: `${MCP_TOOL_PREFIX}${BROWSER_AGENT_NAME}_${toolName}`,
decision: PolicyDecision.ASK_USER,
priority: 999,
source: 'BrowserAgent (Sensitive Actions)',
mcpName: BROWSER_AGENT_NAME,
};
}
function generateAllowRules(toolName: string): PolicyRule {
return {
toolName: `${MCP_TOOL_PREFIX}${BROWSER_AGENT_NAME}_${toolName}`,
decision: PolicyDecision.ALLOW,
priority: PRIORITY_SUBAGENT_TOOL,
source: 'BrowserAgent (Read-Only)',
mcpName: BROWSER_AGENT_NAME,
};
}
// Check if policy rule the same in all the attributes that we care about
function isRuleEqual(rule1: PolicyRule, rule2: PolicyRule) {
return (
rule1.toolName === rule2.toolName &&
rule1.decision === rule2.decision &&
rule1.priority === rule2.priority &&
rule1.mcpName === rule2.mcpName
);
}
// Validate required semantic tools are available
const requiredSemanticTools = [
'click',
'fill',
'navigate_page',
'take_snapshot',
];
const missingSemanticTools = requiredSemanticTools.filter(
(t) => !availableToolNames.includes(t),
);
const rawSessionMode = browserConfig?.customConfig?.sessionMode;
const sessionMode =
rawSessionMode === 'isolated' || rawSessionMode === 'existing'
? rawSessionMode
: 'persistent';
recordBrowserAgentToolDiscovery(
config,
mcpTools.length,
missingSemanticTools,
sessionMode,
);
if (missingSemanticTools.length > 0) {
debugLogger.warn(
`Semantic tools missing (${missingSemanticTools.join(', ')}). ` +
'Some browser interactions may not work correctly.',
);
}
// Only click_at is strictly required — text input can use press_key or fill.
const requiredVisualTools = ['click_at'];
const missingVisualTools = requiredVisualTools.filter(
(t) => !availableToolNames.includes(t),
);
// Check whether vision can be enabled; returns structured type with code and message.
function getVisionDisabledReason(): VisionDisabledReason {
const browserConfig = config.getBrowserAgentConfig();
if (!browserConfig.customConfig.visualModel) {
return {
code: 'no_visual_model',
message: 'No visualModel configured.',
};
}
if (missingVisualTools.length > 0) {
return {
code: 'missing_visual_tools',
message:
`Visual tools missing (${missingVisualTools.join(', ')}). ` +
`The installed chrome-devtools-mcp version may be too old.`,
};
}
const authType = config.getContentGeneratorConfig()?.authType;
const blockedAuthTypes = new Set([
AuthType.LOGIN_WITH_GOOGLE,
AuthType.LEGACY_CLOUD_SHELL,
AuthType.COMPUTE_ADC,
]);
if (authType && blockedAuthTypes.has(authType)) {
return {
code: 'blocked_auth_type',
message: 'Visual agent model not available for current auth type.',
};
}
return undefined;
}
const allTools: AnyDeclarativeTool[] = [...mcpTools];
const visionDisabledReason = getVisionDisabledReason();
logBrowserAgentVisionStatus(config, {
enabled: !visionDisabledReason,
disabled_reason: visionDisabledReason?.code,
});
if (visionDisabledReason) {
debugLogger.log(`Vision disabled: ${visionDisabledReason.message}`);
} else {
allTools.push(
createAnalyzeScreenshotTool(browserManager, config, messageBus),
);
}
debugLogger.log(
`Created ${allTools.length} tools for browser agent: ` +
allTools.map((t) => t.name).join(', '),
);
// Create configured definition with tools
// BrowserAgentDefinition is a factory function - call it with config
const baseDefinition = BrowserAgentDefinition(
config,
!visionDisabledReason,
);
const definition: LocalAgentDefinition<typeof BrowserTaskResultSchema> = {
...baseDefinition,
toolConfig: {
tools: allTools,
},
};
}
function generateAllowRules(toolName: string): PolicyRule {
return {
toolName: `${MCP_TOOL_PREFIX}${BROWSER_AGENT_NAME}_${toolName}`,
decision: PolicyDecision.ALLOW,
priority: PRIORITY_SUBAGENT_TOOL,
source: 'BrowserAgent (Read-Only)',
mcpName: BROWSER_AGENT_NAME,
definition,
browserManager,
visionEnabled: !visionDisabledReason,
sessionMode,
};
} catch (error) {
// Release the browser manager if setup fails, so concurrent tasks can try again.
browserManager.release();
throw error;
}
// Check if policy rule the same in all the attributes that we care about
function isRuleEqual(rule1: PolicyRule, rule2: PolicyRule) {
return (
rule1.toolName === rule2.toolName &&
rule1.decision === rule2.decision &&
rule1.priority === rule2.priority &&
rule1.mcpName === rule2.mcpName
);
}
// Validate required semantic tools are available
const requiredSemanticTools = [
'click',
'fill',
'navigate_page',
'take_snapshot',
];
const missingSemanticTools = requiredSemanticTools.filter(
(t) => !availableToolNames.includes(t),
);
const rawSessionMode = browserConfig?.customConfig?.sessionMode;
const sessionMode =
rawSessionMode === 'isolated' || rawSessionMode === 'existing'
? rawSessionMode
: 'persistent';
recordBrowserAgentToolDiscovery(
config,
mcpTools.length,
missingSemanticTools,
sessionMode,
);
if (missingSemanticTools.length > 0) {
debugLogger.warn(
`Semantic tools missing (${missingSemanticTools.join(', ')}). ` +
'Some browser interactions may not work correctly.',
);
}
// Only click_at is strictly required — text input can use press_key or fill.
const requiredVisualTools = ['click_at'];
const missingVisualTools = requiredVisualTools.filter(
(t) => !availableToolNames.includes(t),
);
// Check whether vision can be enabled; returns structured type with code and message.
function getVisionDisabledReason(): VisionDisabledReason {
const browserConfig = config.getBrowserAgentConfig();
if (!browserConfig.customConfig.visualModel) {
return {
code: 'no_visual_model',
message: 'No visualModel configured.',
};
}
if (missingVisualTools.length > 0) {
return {
code: 'missing_visual_tools',
message:
`Visual tools missing (${missingVisualTools.join(', ')}). ` +
`The installed chrome-devtools-mcp version may be too old.`,
};
}
const authType = config.getContentGeneratorConfig()?.authType;
const blockedAuthTypes = new Set([
AuthType.LOGIN_WITH_GOOGLE,
AuthType.LEGACY_CLOUD_SHELL,
AuthType.COMPUTE_ADC,
]);
if (authType && blockedAuthTypes.has(authType)) {
return {
code: 'blocked_auth_type',
message: 'Visual agent model not available for current auth type.',
};
}
return undefined;
}
const allTools: AnyDeclarativeTool[] = [...mcpTools];
const visionDisabledReason = getVisionDisabledReason();
logBrowserAgentVisionStatus(config, {
enabled: !visionDisabledReason,
disabled_reason: visionDisabledReason?.code,
});
if (visionDisabledReason) {
debugLogger.log(`Vision disabled: ${visionDisabledReason.message}`);
} else {
allTools.push(
createAnalyzeScreenshotTool(browserManager, config, messageBus),
);
}
debugLogger.log(
`Created ${allTools.length} tools for browser agent: ` +
allTools.map((t) => t.name).join(', '),
);
// Create configured definition with tools
// BrowserAgentDefinition is a factory function - call it with config
const baseDefinition = BrowserAgentDefinition(config, !visionDisabledReason);
const definition: LocalAgentDefinition<typeof BrowserTaskResultSchema> = {
...baseDefinition,
toolConfig: {
tools: allTools,
},
};
return {
definition,
browserManager,
visionEnabled: !visionDisabledReason,
sessionMode,
};
}
/**
@@ -192,7 +192,10 @@ describe('BrowserAgentInvocation', () => {
promptConfig: { query: '', systemPrompt: '' },
toolConfig: { tools: ['analyze_screenshot', 'click'] },
},
browserManager: {} as never,
browserManager: {
release: vi.fn(),
callTool: vi.fn().mockResolvedValue({ content: [] }),
} as never,
visionEnabled: true,
sessionMode: 'persistent',
});
@@ -766,6 +769,7 @@ describe('BrowserAgentInvocation', () => {
}
return { isError: false };
}),
release: vi.fn(),
};
vi.mocked(createBrowserAgentDefinition).mockResolvedValue({
@@ -440,6 +440,8 @@ ${output.result}`;
}
} catch {
// Ignore errors for removing the overlays.
} finally {
browserManager.release();
}
}
}
@@ -873,6 +873,122 @@ describe('BrowserManager', () => {
expect(instance1).not.toBe(instance2);
});
it('should throw when acquired instance is requested in persistent mode', () => {
// mockConfig defaults to persistent mode
const instance1 = BrowserManager.getInstance(mockConfig);
instance1.acquire();
expect(() => BrowserManager.getInstance(mockConfig)).toThrow(
/Cannot launch a concurrent browser agent in "persistent" session mode/,
);
});
it('should throw when acquired instance is requested in existing mode', () => {
const existingConfig = makeFakeConfig({
agents: {
overrides: { browser_agent: { enabled: true } },
browser: { sessionMode: 'existing' },
},
});
const instance1 = BrowserManager.getInstance(existingConfig);
instance1.acquire();
expect(() => BrowserManager.getInstance(existingConfig)).toThrow(
/Cannot launch a concurrent browser agent in "existing" session mode/,
);
});
it('should return a different instance when the primary is acquired in isolated mode', () => {
const isolatedConfig = makeFakeConfig({
agents: {
overrides: { browser_agent: { enabled: true } },
browser: { sessionMode: 'isolated' },
},
});
const instance1 = BrowserManager.getInstance(isolatedConfig);
instance1.acquire();
const instance2 = BrowserManager.getInstance(isolatedConfig);
expect(instance2).not.toBe(instance1);
expect(instance1.isAcquired()).toBe(true);
expect(instance2.isAcquired()).toBe(false);
});
it('should reuse the primary when it has been released', () => {
const instance1 = BrowserManager.getInstance(mockConfig);
instance1.acquire();
instance1.release();
const instance2 = BrowserManager.getInstance(mockConfig);
expect(instance2).toBe(instance1);
expect(instance1.isAcquired()).toBe(false);
});
it('should reuse a released parallel instance in isolated mode', () => {
const isolatedConfig = makeFakeConfig({
agents: {
overrides: { browser_agent: { enabled: true } },
browser: { sessionMode: 'isolated' },
},
});
const instance1 = BrowserManager.getInstance(isolatedConfig);
instance1.acquire();
const instance2 = BrowserManager.getInstance(isolatedConfig);
instance2.acquire();
instance2.release();
// Primary is still acquired, parallel is released — should reuse parallel
const instance3 = BrowserManager.getInstance(isolatedConfig);
expect(instance3).toBe(instance2);
});
it('should create multiple parallel instances in isolated mode', () => {
const isolatedConfig = makeFakeConfig({
agents: {
overrides: { browser_agent: { enabled: true } },
browser: { sessionMode: 'isolated' },
},
});
const instance1 = BrowserManager.getInstance(isolatedConfig);
instance1.acquire();
const instance2 = BrowserManager.getInstance(isolatedConfig);
instance2.acquire();
const instance3 = BrowserManager.getInstance(isolatedConfig);
expect(instance1).not.toBe(instance2);
expect(instance2).not.toBe(instance3);
expect(instance1).not.toBe(instance3);
});
it('should throw when MAX_PARALLEL_INSTANCES is reached in isolated mode', () => {
const isolatedConfig = makeFakeConfig({
agents: {
overrides: { browser_agent: { enabled: true } },
browser: { sessionMode: 'isolated' },
},
});
// Acquire MAX_PARALLEL_INSTANCES instances
for (let i = 0; i < BrowserManager.MAX_PARALLEL_INSTANCES; i++) {
const instance = BrowserManager.getInstance(isolatedConfig);
instance.acquire();
}
// Next call should throw
expect(() => BrowserManager.getInstance(isolatedConfig)).toThrow(
/Maximum number of parallel browser instances/,
);
});
});
describe('resetAll', () => {
@@ -114,6 +114,12 @@ export class BrowserManager {
// --- Static singleton management ---
private static instances = new Map<string, BrowserManager>();
/**
* Maximum number of parallel browser instances allowed in isolated mode.
* Prevents unbounded resource consumption from concurrent browser_agent calls.
*/
static readonly MAX_PARALLEL_INSTANCES = 5;
/**
* Returns the cache key for a given config.
* Uses `sessionMode:profilePath` so different profiles get separate instances.
@@ -128,14 +134,64 @@ export class BrowserManager {
/**
* Returns an existing BrowserManager for the current config's session mode
* and profile, or creates a new one.
*
* Concurrency rules:
* - **persistent / existing mode**: Only one instance is allowed at a time.
* If the instance is already in-use, an error is thrown instructing the
* caller to run browser tasks sequentially.
* - **isolated mode**: Parallel instances are allowed up to
* MAX_PARALLEL_INSTANCES. Each isolated instance gets its own temp profile.
*/
static getInstance(config: Config): BrowserManager {
const key = BrowserManager.getInstanceKey(config);
const sessionMode =
config.getBrowserAgentConfig().customConfig.sessionMode ?? 'persistent';
let instance = BrowserManager.instances.get(key);
if (!instance) {
instance = new BrowserManager(config);
BrowserManager.instances.set(key, instance);
debugLogger.log(`Created new BrowserManager singleton (key: ${key})`);
} else if (instance.inUse) {
// Persistent and existing modes share a browser profile directory.
// Chrome prevents multiple instances from using the same profile, so
// concurrent usage would cause "profile locked" errors.
if (sessionMode === 'persistent' || sessionMode === 'existing') {
throw new Error(
`Cannot launch a concurrent browser agent in "${sessionMode}" session mode. ` +
`The browser instance is already in use by another task. ` +
`Please run browser tasks sequentially, or switch to "isolated" session mode for concurrent browser usage.`,
);
}
// Isolated mode: allow parallel instances up to the limit.
let inUseCount = 1; // primary is already in-use
let suffix = 1;
let parallelKey = `${key}:${suffix}`;
let parallel = BrowserManager.instances.get(parallelKey);
while (parallel?.inUse) {
inUseCount++;
if (inUseCount >= BrowserManager.MAX_PARALLEL_INSTANCES) {
throw new Error(
`Maximum number of parallel browser instances (${BrowserManager.MAX_PARALLEL_INSTANCES}) reached. ` +
`Please wait for an existing browser task to complete before starting a new one.`,
);
}
suffix++;
parallelKey = `${key}:${suffix}`;
parallel = BrowserManager.instances.get(parallelKey);
}
if (!parallel) {
parallel = new BrowserManager(config);
BrowserManager.instances.set(parallelKey, parallel);
debugLogger.log(
`Created parallel BrowserManager (key: ${parallelKey})`,
);
} else {
debugLogger.log(
`Reusing released parallel BrowserManager (key: ${parallelKey})`,
);
}
instance = parallel;
} else {
debugLogger.log(
`Reusing existing BrowserManager singleton (key: ${key})`,
@@ -180,6 +236,36 @@ export class BrowserManager {
private isClosing = false;
private connectionPromise: Promise<void> | undefined;
/**
* Whether this instance is currently acquired by an active invocation.
* Used by getInstance() to avoid handing the same browser to concurrent
* browser_agent calls.
*/
private inUse = false;
/**
* Marks this instance as in-use. Call this when starting a browser agent
* invocation so concurrent calls get a separate instance.
*/
acquire(): void {
this.inUse = true;
}
/**
* Marks this instance as available for reuse. Call this in the finally
* block of a browser agent invocation.
*/
release(): void {
this.inUse = false;
}
/**
* Returns whether this instance is currently acquired by an active invocation.
*/
isAcquired(): boolean {
return this.inUse;
}
/** State for action rate limiting */
private actionCounter = 0;
private readonly maxActionsPerTask: number;
+2 -2
View File
@@ -1075,7 +1075,7 @@ describe('AgentRegistry', () => {
expect.objectContaining({
toolName: 'PolicyTestAgent',
decision: PolicyDecision.ALLOW,
priority: 1.05,
priority: 1.03,
}),
);
});
@@ -1102,7 +1102,7 @@ describe('AgentRegistry', () => {
expect.objectContaining({
toolName: 'RemotePolicyAgent',
decision: PolicyDecision.ASK_USER,
priority: 1.05,
priority: 1.03,
}),
);
});
@@ -19,6 +19,7 @@ export const ExperimentFlags = {
GEMINI_3_1_PRO_LAUNCHED: 45760185,
PRO_MODEL_NO_ACCESS: 45768879,
GEMINI_3_1_FLASH_LITE_LAUNCHED: 45771641,
DEFAULT_REQUEST_TIMEOUT: 45773134,
} as const;
export type ExperimentFlagName =
+326
View File
@@ -4,11 +4,18 @@
* SPDX-License-Identifier: Apache-2.0
*/
import * as fs from 'node:fs/promises';
import * as os from 'node:os';
import * as path from 'node:path';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import type { Config } from '../config/config.js';
import { Storage } from '../config/storage.js';
import {
addMemory,
dismissInboxSkill,
listInboxSkills,
listMemoryFiles,
moveInboxSkill,
refreshMemory,
showMemory,
} from './memory.js';
@@ -18,6 +25,12 @@ vi.mock('../utils/memoryDiscovery.js', () => ({
refreshServerHierarchicalMemory: vi.fn(),
}));
vi.mock('../config/storage.js', () => ({
Storage: {
getUserSkillsDir: vi.fn(),
},
}));
const mockRefresh = vi.mocked(memoryDiscovery.refreshServerHierarchicalMemory);
describe('memory commands', () => {
@@ -202,4 +215,317 @@ describe('memory commands', () => {
}
});
});
describe('listInboxSkills', () => {
let tmpDir: string;
let skillsDir: string;
let memoryTempDir: string;
let inboxConfig: Config;
async function writeSkillMd(
dirName: string,
name: string,
description: string,
): Promise<void> {
const dir = path.join(skillsDir, dirName);
await fs.mkdir(dir, { recursive: true });
await fs.writeFile(
path.join(dir, 'SKILL.md'),
`---\nname: ${name}\ndescription: ${description}\n---\nBody content here\n`,
);
}
beforeEach(async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'inbox-test-'));
skillsDir = path.join(tmpDir, 'skills-memory');
memoryTempDir = path.join(tmpDir, 'memory-temp');
await fs.mkdir(skillsDir, { recursive: true });
await fs.mkdir(memoryTempDir, { recursive: true });
inboxConfig = {
storage: {
getProjectSkillsMemoryDir: () => skillsDir,
getProjectMemoryTempDir: () => memoryTempDir,
getProjectSkillsDir: () => path.join(tmpDir, 'project-skills'),
},
} as unknown as Config;
});
afterEach(async () => {
await fs.rm(tmpDir, { recursive: true, force: true });
});
it('should return inbox skills with name, description, and extractedAt', async () => {
await writeSkillMd('my-skill', 'my-skill', 'A test skill');
await writeSkillMd('other-skill', 'other-skill', 'Another skill');
const stateContent = JSON.stringify({
runs: [
{
runAt: '2025-01-15T10:00:00Z',
sessionIds: ['sess-1'],
skillsCreated: ['my-skill'],
},
{
runAt: '2025-01-16T12:00:00Z',
sessionIds: ['sess-2'],
skillsCreated: ['other-skill'],
},
],
});
await fs.writeFile(
path.join(memoryTempDir, '.extraction-state.json'),
stateContent,
);
const skills = await listInboxSkills(inboxConfig);
expect(skills).toHaveLength(2);
const mySkill = skills.find((s) => s.dirName === 'my-skill');
expect(mySkill).toBeDefined();
expect(mySkill!.name).toBe('my-skill');
expect(mySkill!.description).toBe('A test skill');
expect(mySkill!.extractedAt).toBe('2025-01-15T10:00:00Z');
const otherSkill = skills.find((s) => s.dirName === 'other-skill');
expect(otherSkill).toBeDefined();
expect(otherSkill!.name).toBe('other-skill');
expect(otherSkill!.description).toBe('Another skill');
expect(otherSkill!.extractedAt).toBe('2025-01-16T12:00:00Z');
});
it('should return an empty array when the inbox is empty', async () => {
const skills = await listInboxSkills(inboxConfig);
expect(skills).toEqual([]);
});
it('should return an empty array when the inbox directory does not exist', async () => {
const missingConfig = {
storage: {
getProjectSkillsMemoryDir: () => path.join(tmpDir, 'nonexistent-dir'),
getProjectMemoryTempDir: () => memoryTempDir,
},
} as unknown as Config;
const skills = await listInboxSkills(missingConfig);
expect(skills).toEqual([]);
});
});
describe('moveInboxSkill', () => {
let tmpDir: string;
let skillsDir: string;
let globalSkillsDir: string;
let projectSkillsDir: string;
let moveConfig: Config;
async function writeSkillMd(
dirName: string,
name: string,
description: string,
): Promise<void> {
const dir = path.join(skillsDir, dirName);
await fs.mkdir(dir, { recursive: true });
await fs.writeFile(
path.join(dir, 'SKILL.md'),
`---\nname: ${name}\ndescription: ${description}\n---\nBody content here\n`,
);
}
beforeEach(async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'move-test-'));
skillsDir = path.join(tmpDir, 'skills-memory');
globalSkillsDir = path.join(tmpDir, 'global-skills');
projectSkillsDir = path.join(tmpDir, 'project-skills');
await fs.mkdir(skillsDir, { recursive: true });
moveConfig = {
storage: {
getProjectSkillsMemoryDir: () => skillsDir,
getProjectSkillsDir: () => projectSkillsDir,
},
} as unknown as Config;
vi.mocked(Storage.getUserSkillsDir).mockReturnValue(globalSkillsDir);
});
afterEach(async () => {
await fs.rm(tmpDir, { recursive: true, force: true });
});
it('should move a skill to global skills directory', async () => {
await writeSkillMd('my-skill', 'my-skill', 'A test skill');
const result = await moveInboxSkill(moveConfig, 'my-skill', 'global');
expect(result.success).toBe(true);
expect(result.message).toBe('Moved "my-skill" to ~/.gemini/skills.');
// Verify the skill was copied to global
const targetSkill = await fs.readFile(
path.join(globalSkillsDir, 'my-skill', 'SKILL.md'),
'utf-8',
);
expect(targetSkill).toContain('name: my-skill');
// Verify the skill was removed from inbox
await expect(
fs.access(path.join(skillsDir, 'my-skill')),
).rejects.toThrow();
});
it('should move a skill to project skills directory', async () => {
await writeSkillMd('my-skill', 'my-skill', 'A test skill');
const result = await moveInboxSkill(moveConfig, 'my-skill', 'project');
expect(result.success).toBe(true);
expect(result.message).toBe('Moved "my-skill" to .gemini/skills.');
// Verify the skill was copied to project
const targetSkill = await fs.readFile(
path.join(projectSkillsDir, 'my-skill', 'SKILL.md'),
'utf-8',
);
expect(targetSkill).toContain('name: my-skill');
// Verify the skill was removed from inbox
await expect(
fs.access(path.join(skillsDir, 'my-skill')),
).rejects.toThrow();
});
it('should return an error when the source skill does not exist', async () => {
const result = await moveInboxSkill(moveConfig, 'nonexistent', 'global');
expect(result.success).toBe(false);
expect(result.message).toBe('Skill "nonexistent" not found in inbox.');
});
it('should reject invalid skill directory names', async () => {
const result = await moveInboxSkill(moveConfig, '../escape', 'global');
expect(result.success).toBe(false);
expect(result.message).toBe('Invalid skill name.');
});
it('should return an error when the target already exists', async () => {
await writeSkillMd('my-skill', 'my-skill', 'A test skill');
// Pre-create the target
const targetDir = path.join(globalSkillsDir, 'my-skill');
await fs.mkdir(targetDir, { recursive: true });
await fs.writeFile(path.join(targetDir, 'SKILL.md'), 'existing content');
const result = await moveInboxSkill(moveConfig, 'my-skill', 'global');
expect(result.success).toBe(false);
expect(result.message).toBe(
'A skill named "my-skill" already exists in global skills.',
);
});
it('should detect conflicts based on the normalized skill name', async () => {
await writeSkillMd(
'inbox-skill',
'gke:prs-troubleshooter',
'A test skill',
);
await fs.mkdir(
path.join(globalSkillsDir, 'existing-gke-prs-troubleshooter'),
{ recursive: true },
);
await fs.writeFile(
path.join(
globalSkillsDir,
'existing-gke-prs-troubleshooter',
'SKILL.md',
),
[
'---',
'name: gke-prs-troubleshooter',
'description: Existing skill',
'---',
'Existing body content',
'',
].join('\n'),
);
const result = await moveInboxSkill(moveConfig, 'inbox-skill', 'global');
expect(result.success).toBe(false);
expect(result.message).toBe(
'A skill named "gke-prs-troubleshooter" already exists in global skills.',
);
await expect(
fs.access(path.join(skillsDir, 'inbox-skill', 'SKILL.md')),
).resolves.toBeUndefined();
await expect(
fs.access(path.join(globalSkillsDir, 'inbox-skill')),
).rejects.toThrow();
});
});
describe('dismissInboxSkill', () => {
let tmpDir: string;
let skillsDir: string;
let dismissConfig: Config;
async function writeSkillMd(
dirName: string,
name: string,
description: string,
): Promise<void> {
const dir = path.join(skillsDir, dirName);
await fs.mkdir(dir, { recursive: true });
await fs.writeFile(
path.join(dir, 'SKILL.md'),
`---\nname: ${name}\ndescription: ${description}\n---\nBody content here\n`,
);
}
beforeEach(async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'dismiss-test-'));
skillsDir = path.join(tmpDir, 'skills-memory');
await fs.mkdir(skillsDir, { recursive: true });
dismissConfig = {
storage: {
getProjectSkillsMemoryDir: () => skillsDir,
},
} as unknown as Config;
});
afterEach(async () => {
await fs.rm(tmpDir, { recursive: true, force: true });
});
it('should remove a skill from the inbox', async () => {
await writeSkillMd('my-skill', 'my-skill', 'A test skill');
const result = await dismissInboxSkill(dismissConfig, 'my-skill');
expect(result.success).toBe(true);
expect(result.message).toBe('Dismissed "my-skill" from inbox.');
// Verify the skill directory was removed
await expect(
fs.access(path.join(skillsDir, 'my-skill')),
).rejects.toThrow();
});
it('should return an error when the skill does not exist', async () => {
const result = await dismissInboxSkill(dismissConfig, 'nonexistent');
expect(result.success).toBe(false);
expect(result.message).toBe('Skill "nonexistent" not found in inbox.');
});
it('should reject invalid skill directory names', async () => {
const result = await dismissInboxSkill(dismissConfig, 'nested\\skill');
expect(result.success).toBe(false);
expect(result.message).toBe('Invalid skill name.');
});
});
});
+188
View File
@@ -4,8 +4,13 @@
* SPDX-License-Identifier: Apache-2.0
*/
import * as fs from 'node:fs/promises';
import * as path from 'node:path';
import type { Config } from '../config/config.js';
import { Storage } from '../config/storage.js';
import { flattenMemory } from '../config/memory.js';
import { loadSkillFromFile, loadSkillsFromDir } from '../skills/skillLoader.js';
import { readExtractionState } from '../services/memoryService.js';
import { refreshServerHierarchicalMemory } from '../utils/memoryDiscovery.js';
import type { MessageActionReturn, ToolActionReturn } from './types.js';
@@ -95,3 +100,186 @@ export function listMemoryFiles(config: Config): MessageActionReturn {
content,
};
}
/**
* Represents a skill found in the extraction inbox.
*/
export interface InboxSkill {
/** Directory name in the inbox. */
dirName: string;
/** Skill name from SKILL.md frontmatter. */
name: string;
/** Skill description from SKILL.md frontmatter. */
description: string;
/** When the skill was extracted (ISO string), if known. */
extractedAt?: string;
}
/**
* Scans the skill extraction inbox and returns structured data
* for each extracted skill.
*/
export async function listInboxSkills(config: Config): Promise<InboxSkill[]> {
const skillsDir = config.storage.getProjectSkillsMemoryDir();
let entries: Array<import('node:fs').Dirent>;
try {
entries = await fs.readdir(skillsDir, { withFileTypes: true });
} catch {
return [];
}
const dirs = entries.filter((e) => e.isDirectory());
if (dirs.length === 0) {
return [];
}
// Load extraction state to get dates
const memoryDir = config.storage.getProjectMemoryTempDir();
const statePath = path.join(memoryDir, '.extraction-state.json');
const state = await readExtractionState(statePath);
// Build a map: skillDirName → extractedAt
const skillDateMap = new Map<string, string>();
for (const run of state.runs) {
for (const skillName of run.skillsCreated) {
skillDateMap.set(skillName, run.runAt);
}
}
const skills: InboxSkill[] = [];
for (const dir of dirs) {
const skillPath = path.join(skillsDir, dir.name, 'SKILL.md');
const skillDef = await loadSkillFromFile(skillPath);
if (!skillDef) continue;
skills.push({
dirName: dir.name,
name: skillDef.name,
description: skillDef.description,
extractedAt: skillDateMap.get(dir.name),
});
}
return skills;
}
export type InboxSkillDestination = 'global' | 'project';
function isValidInboxSkillDirName(dirName: string): boolean {
return (
dirName.length > 0 &&
dirName !== '.' &&
dirName !== '..' &&
!dirName.includes('/') &&
!dirName.includes('\\')
);
}
async function getSkillNameForConflictCheck(
skillDir: string,
fallbackName: string,
): Promise<string> {
const skill = await loadSkillFromFile(path.join(skillDir, 'SKILL.md'));
return skill?.name ?? fallbackName;
}
/**
* Copies an inbox skill to the target skills directory.
*/
export async function moveInboxSkill(
config: Config,
dirName: string,
destination: InboxSkillDestination,
): Promise<{ success: boolean; message: string }> {
if (!isValidInboxSkillDirName(dirName)) {
return {
success: false,
message: 'Invalid skill name.',
};
}
const skillsDir = config.storage.getProjectSkillsMemoryDir();
const sourcePath = path.join(skillsDir, dirName);
try {
await fs.access(sourcePath);
} catch {
return {
success: false,
message: `Skill "${dirName}" not found in inbox.`,
};
}
const targetBase =
destination === 'global'
? Storage.getUserSkillsDir()
: config.storage.getProjectSkillsDir();
const targetPath = path.join(targetBase, dirName);
const skillName = await getSkillNameForConflictCheck(sourcePath, dirName);
try {
await fs.access(targetPath);
return {
success: false,
message: `A skill named "${skillName}" already exists in ${destination} skills.`,
};
} catch {
// Target doesn't exist — good
}
const existingTargetSkills = await loadSkillsFromDir(targetBase);
if (existingTargetSkills.some((skill) => skill.name === skillName)) {
return {
success: false,
message: `A skill named "${skillName}" already exists in ${destination} skills.`,
};
}
await fs.mkdir(targetBase, { recursive: true });
await fs.cp(sourcePath, targetPath, { recursive: true });
// Remove from inbox after successful copy
await fs.rm(sourcePath, { recursive: true, force: true });
const label =
destination === 'global' ? '~/.gemini/skills' : '.gemini/skills';
return {
success: true,
message: `Moved "${dirName}" to ${label}.`,
};
}
/**
* Removes a skill from the extraction inbox.
*/
export async function dismissInboxSkill(
config: Config,
dirName: string,
): Promise<{ success: boolean; message: string }> {
if (!isValidInboxSkillDirName(dirName)) {
return {
success: false,
message: 'Invalid skill name.',
};
}
const skillsDir = config.storage.getProjectSkillsMemoryDir();
const sourcePath = path.join(skillsDir, dirName);
try {
await fs.access(sourcePath);
} catch {
return {
success: false,
message: `Skill "${dirName}" not found in inbox.`,
};
}
await fs.rm(sourcePath, { recursive: true, force: true });
return {
success: true,
message: `Dismissed "${dirName}" from inbox.`,
};
}
+62
View File
@@ -644,6 +644,58 @@ describe('Server Config (config.ts)', () => {
},
);
});
describe('getRequestTimeoutMs', () => {
it('should return undefined if the flag is not set', () => {
const config = new Config(baseParams);
expect(config.getRequestTimeoutMs()).toBeUndefined();
});
it('should return timeout in milliseconds if flag is set', () => {
const config = new Config({
...baseParams,
experiments: {
flags: {
[ExperimentFlags.DEFAULT_REQUEST_TIMEOUT]: {
intValue: '30',
},
},
experimentIds: [],
},
} as unknown as ConfigParameters);
expect(config.getRequestTimeoutMs()).toBe(30000);
});
it('should return undefined if intValue is not a valid integer', () => {
const config = new Config({
...baseParams,
experiments: {
flags: {
[ExperimentFlags.DEFAULT_REQUEST_TIMEOUT]: {
intValue: 'abc',
},
},
experimentIds: [],
},
} as unknown as ConfigParameters);
expect(config.getRequestTimeoutMs()).toBeUndefined();
});
it('should return undefined if intValue is negative', () => {
const config = new Config({
...baseParams,
experiments: {
flags: {
[ExperimentFlags.DEFAULT_REQUEST_TIMEOUT]: {
intValue: '-10',
},
},
experimentIds: [],
},
} as unknown as ConfigParameters);
expect(config.getRequestTimeoutMs()).toBeUndefined();
});
});
});
describe('refreshAuth', () => {
@@ -2078,8 +2130,18 @@ describe('BaseLlmClient Lifecycle', () => {
usageStatisticsEnabled: false,
};
it('should throw an error if getBaseLlmClient is called before experiments have been fetched', () => {
const config = new Config(baseParams);
// By default on a new Config instance, experiments are undefined
expect(() => config.getBaseLlmClient()).toThrow(
'BaseLlmClient not initialized. Ensure experiments have been fetched and configuration is ready.',
);
});
it('should throw an error if getBaseLlmClient is called before refreshAuth', () => {
const config = new Config(baseParams);
// Explicitly set experiments to avoid triggering the new missing-experiments error
config.setExperiments({ flags: {}, experimentIds: [] });
expect(() => config.getBaseLlmClient()).toThrow(
'BaseLlmClient not initialized. Ensure authentication has occurred and ContentGenerator is ready.',
);
+41 -8
View File
@@ -160,7 +160,7 @@ import {
} from '../code_assist/experiments/experiments.js';
import { AgentRegistry } from '../agents/registry.js';
import { AcknowledgedAgentsService } from '../agents/acknowledgedAgents.js';
import { setGlobalProxy } from '../utils/fetch.js';
import { setGlobalProxy, updateGlobalFetchTimeouts } from '../utils/fetch.js';
import { SubagentTool } from '../agents/subagent-tool.js';
import { ExperimentFlags } from '../code_assist/experiments/flagNames.js';
import { debugLogger } from '../utils/debugLogger.js';
@@ -225,6 +225,7 @@ export interface GemmaModelRouterSettings {
export interface ADKSettings {
agentSessionNoninteractiveEnabled?: boolean;
agentSessionInteractiveEnabled?: boolean;
}
export interface ExtensionSetting {
@@ -894,6 +895,7 @@ export class Config implements McpContext, AgentLoopContext {
private readonly gemmaModelRouter: GemmaModelRouterSettings;
private readonly agentSessionNoninteractiveEnabled: boolean;
private readonly agentSessionInteractiveEnabled: boolean;
private readonly continueOnFailedApiCall: boolean;
private readonly retryFetchErrors: boolean;
@@ -1222,7 +1224,7 @@ export class Config implements McpContext, AgentLoopContext {
this.useRipgrep = params.useRipgrep ?? true;
this.useBackgroundColor = params.useBackgroundColor ?? true;
this.useAlternateBuffer = params.useAlternateBuffer ?? false;
this.useTerminalBuffer = params.useTerminalBuffer ?? true;
this.useTerminalBuffer = params.useTerminalBuffer ?? false;
this.useRenderProcess = params.useRenderProcess ?? true;
this.enableInteractiveShell = params.enableInteractiveShell ?? false;
@@ -1325,6 +1327,8 @@ export class Config implements McpContext, AgentLoopContext {
this.agentSessionNoninteractiveEnabled =
params.adk?.agentSessionNoninteractiveEnabled ?? false;
this.agentSessionInteractiveEnabled =
params.adk?.agentSessionInteractiveEnabled ?? false;
this.retryFetchErrors = params.retryFetchErrors ?? true;
this.maxAttempts = Math.min(
params.maxAttempts ?? DEFAULT_MAX_ATTEMPTS,
@@ -1544,9 +1548,6 @@ export class Config implements McpContext, AgentLoopContext {
// Only assign to instance properties after successful initialization
this.contentGeneratorConfig = newContentGeneratorConfig;
// Initialize BaseLlmClient now that the ContentGenerator is available
this.baseLlmClient = new BaseLlmClient(this.contentGenerator, this);
const codeAssistServer = getCodeAssistServer(this);
const quotaPromise = codeAssistServer?.projectId
? this.refreshUserQuota()
@@ -1562,6 +1563,17 @@ export class Config implements McpContext, AgentLoopContext {
return undefined;
});
// Fetch experiments and update timeouts before continuing initialization
const experiments = await this.experimentsPromise;
const requestTimeoutMs = this.getRequestTimeoutMs();
if (requestTimeoutMs !== undefined) {
updateGlobalFetchTimeouts(requestTimeoutMs);
}
// Initialize BaseLlmClient now that the ContentGenerator and experiments are available
this.baseLlmClient = new BaseLlmClient(this.contentGenerator, this);
await quotaPromise;
const authType = this.contentGeneratorConfig.authType;
@@ -1581,9 +1593,6 @@ export class Config implements McpContext, AgentLoopContext {
this.setModel(DEFAULT_GEMINI_MODEL_AUTO);
}
// Fetch admin controls
const experiments = await this.experimentsPromise;
const adminControlsEnabled =
experiments?.flags[ExperimentFlags.ENABLE_ADMIN_CONTROLS]?.boolValue ??
false;
@@ -1629,6 +1638,11 @@ export class Config implements McpContext, AgentLoopContext {
getBaseLlmClient(): BaseLlmClient {
if (!this.baseLlmClient) {
// Handle cases where initialization might be deferred or authentication failed
if (!this.experiments) {
throw new Error(
'BaseLlmClient not initialized. Ensure experiments have been fetched and configuration is ready.',
);
}
if (this.contentGenerator) {
this.baseLlmClient = new BaseLlmClient(
this.getContentGenerator(),
@@ -3149,6 +3163,21 @@ export class Config implements McpContext, AgentLoopContext {
);
}
/**
* Returns the configured default request timeout in milliseconds.
*/
getRequestTimeoutMs(): number | undefined {
const flag =
this.experiments?.flags?.[ExperimentFlags.DEFAULT_REQUEST_TIMEOUT];
if (flag?.intValue !== undefined) {
const seconds = parseInt(flag.intValue, 10);
if (Number.isInteger(seconds) && seconds >= 0) {
return seconds * 1000; // Convert seconds to milliseconds
}
}
return undefined;
}
/**
* Returns whether Gemini 3.1 Flash Lite has been launched.
*
@@ -3396,6 +3425,10 @@ export class Config implements McpContext, AgentLoopContext {
return this.agentSessionNoninteractiveEnabled;
}
getAgentSessionInteractiveEnabled(): boolean {
return this.agentSessionInteractiveEnabled;
}
/**
* Get override settings for a specific agent.
* Reads from agents.overrides.<agentName>.
@@ -102,6 +102,7 @@ describe('BaseLlmClient', () => {
);
mockConfig = {
getRequestTimeoutMs: vi.fn().mockReturnValue(undefined),
getSessionId: vi.fn().mockReturnValue('test-session-id'),
getContentGeneratorConfig: vi
.fn()
+1
View File
@@ -203,6 +203,7 @@ describe('Gemini Client (client.ts)', () => {
authType: AuthType.USE_GEMINI,
};
mockConfig = {
getRequestTimeoutMs: vi.fn().mockReturnValue(undefined),
getContentGeneratorConfig: vi
.fn()
.mockReturnValue(contentGeneratorConfig),

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