Compare commits

...

25 Commits

Author SHA1 Message Date
Sandy Tao 1a6f730439 feat: add Forever Mode with time-based scheduled work and A2A listener
Add --forever CLI flag that enables autonomous agent operation with
scheduled work, context management, A2A protocol support, and session
optimization.

Core features:
- Time-based WorkScheduler: manages a sorted list of scheduled prompts
  that fire at absolute or relative times, persisted across sessions
- schedule_work tool: add/cancel scheduled prompts with 'at' (local
  time) or 'inMinutes' (relative) params; current time and schedule
  prepended to every model turn
- A2A HTTP listener: JSON-RPC 2.0 server bridges external messages
  into the session (message/send, tasks/get, responses/poll)
- PreCompress hook: hooks can return newHistory to replace built-in
  LLM compression
- Idle hook: fires after configurable inactivity, can auto-submit
  prompts
- Forever mode disables MemoryTool, EnterPlanModeTool, interactive
  shell

UI:
- ScheduledWorkDisplay component shows all pending items above the
  context summary bar
- A2A port shown in StatusDisplay when active

Session optimization for long-running sessions:
- Record lastCompressionIndex on ConversationRecord; on resume, only
  load post-compression messages
- Restore scheduled work items on session resume (past-due fire
  immediately)
- Skip file I/O in updateMessagesFromHistory when no tool results to
  sync
- Prune UI history to last 50 items after each context compression
2026-03-18 11:32:57 -07:00
Tommaso Sciortino a5a461c234 test(cli): simplify createMockSettings calls (#22952) 2026-03-18 18:12:44 +00:00
gemini-cli-robot fac3661980 Changelog for v0.34.0 (#22860)
Co-authored-by: gemini-cli-robot <224641728+gemini-cli-robot@users.noreply.github.com>
2026-03-18 17:23:05 +00:00
Tommaso Sciortino d7dfcf7f99 refactor(cli): simplify keypress and mouse providers and update tests (#22853) 2026-03-18 16:38:56 +00:00
Abhi 81a97e78f1 refactor(cli): group subagent trajectory deletion and use native filesystem testing (#22890) 2026-03-18 14:42:15 +00:00
jhhornn 1311e8c480 fix: updates Docker image reference for GitHub MCP server (#22938) 2026-03-18 14:32:57 +00:00
Abhi 4ecb4bb24b refactor(ui): extract SessionBrowser search and navigation components (#22377) 2026-03-18 04:44:01 +00:00
Abhi be7c7bb83d fix(cli): resolve subagent grouping and UI state persistence (#22252) 2026-03-18 03:11:20 +00:00
AK 7bfe6ac418 feat(core): subagent local execution and tool isolation (#22718) 2026-03-18 02:34:44 +00:00
adithya32 bd34a42ec3 fix(cli): lazily load settings in onModelChange to prevent stale closure data loss (#20403)
Co-authored-by: Spencer <spencertang@google.com>
2026-03-18 00:40:38 +00:00
N. Taylor Mullen e2658ccda8 refactor(cli): remove extra newlines in ShellToolMessage.tsx (#22868)
Co-authored-by: Spencer <spencertang@google.com>
2026-03-17 23:48:16 +00:00
anj-s b8719bcd47 feat: add 'blocked' status to tasks and todos (#22735) 2026-03-17 23:24:26 +00:00
Sakshi semalti e1eefffcf1 fix(cli): automatically add all VSCode workspace folders to Gemini context (#21380)
Co-authored-by: Spencer <spencertang@google.com>
2026-03-17 23:05:49 +00:00
Alisa 7ae39fd622 feat(a2a): add agent acknowledgment command and enhance registry discovery (#22389) 2026-03-17 22:47:05 +00:00
N. Taylor Mullen fb9264bf80 chore: add posttest build hooks and fix missing dependencies (#22865) 2026-03-17 22:23:00 +00:00
anj-s d4397dbfc5 feat(tracker): integrate task tracker protocol into core system prompt (#22442) 2026-03-17 22:19:36 +00:00
Spencer 5fb0d1f01d fix(core): add actionable warnings for terminal fallbacks (#14426) (#22211) 2026-03-17 21:57:37 +00:00
Abhi 95bca2c3b3 fix(cli): expand tilde in policy paths from settings.json (#22772) 2026-03-17 21:48:24 +00:00
matt korwel e0be1b2afd fix(cli): use active sessionId in useLogger and improve resume robustness (#22606) 2026-03-17 21:42:40 +00:00
gemini-cli-robot 5d4e4c2814 chore(release): bump version to 0.36.0-nightly.20260317.2f90b4653 (#22858) 2026-03-17 21:18:21 +00:00
kevinjwang1 27a50191e3 Add support for dynamic model Resolution to ModelConfigService (#22578) 2026-03-17 21:15:50 +00:00
Sandy Tao 77ca3c0e13 fix(devtools): use theme-aware text colors for console warnings and errors (#22181) 2026-03-17 21:00:40 +00:00
AK 2504105a1c feat(core): multi-registry architecture and tool filtering for subagents (#22712) 2026-03-17 20:54:07 +00:00
gemini-cli-robot a361a84708 Changelog for v0.34.0-preview.4 (#22752)
Co-authored-by: gemini-cli-robot <224641728+gemini-cli-robot@users.noreply.github.com>
Co-authored-by: Sam Roberts <158088236+g-samroberts@users.noreply.github.com>
2026-03-17 20:46:38 +00:00
gemini-cli-robot ff196fbe6f Changelog for v0.33.2 (#22730)
Co-authored-by: gemini-cli-robot <224641728+gemini-cli-robot@users.noreply.github.com>
Co-authored-by: Sam Roberts <158088236+g-samroberts@users.noreply.github.com>
2026-03-17 20:33:30 +00:00
185 changed files with 8745 additions and 3611 deletions
+11
View File
@@ -18,6 +18,17 @@ on GitHub.
| [Preview](preview.md) | Experimental features ready for early feedback. |
| [Stable](latest.md) | Stable, recommended for general use. |
## Announcements: v0.34.0 - 2026-03-17
- **Plan Mode Enabled by Default:** Plan Mode is now enabled by default to help
you break down complex tasks and execute them systematically
([#21713](https://github.com/google-gemini/gemini-cli/pull/21713) by @jerop).
- **Sandboxing Enhancements:** We've added native gVisor (runsc) and
experimental LXC container sandboxing support for safer execution environments
([#21062](https://github.com/google-gemini/gemini-cli/pull/21062) by
@Zheyuan-Lin, [#20735](https://github.com/google-gemini/gemini-cli/pull/20735)
by @h30s).
## Announcements: v0.33.0 - 2026-03-11
- **Agent Architecture Enhancements:** Introduced HTTP authentication for A2A
+460 -210
View File
@@ -1,6 +1,6 @@
# Latest stable release: v0.33.1
# Latest stable release: v0.34.0
Released: March 12, 2026
Released: March 17, 2026
For most users, our latest stable release is the recommended release. Install
the latest stable version with:
@@ -11,224 +11,474 @@ npm install -g @google/gemini-cli
## Highlights
- **Agent Architecture Enhancements:** Introduced HTTP authentication support
for A2A remote agents, authenticated A2A agent card discovery, and directly
indicated auth-required states.
- **Plan Mode Updates:** Expanded Plan Mode capabilities with built-in research
subagents, annotation support for feedback during iteration, and a new `copy`
subcommand.
- **CLI UX Improvements:** Redesigned the header to be compact with an ASCII
icon, inverted the context window display to show usage, and allowed sub-agent
confirmation requests in the UI while preventing background flicker.
- **ACP & MCP Integrations:** Implemented slash command handling in ACP for
`/memory`, `/init`, `/extensions`, and `/restore`, added an MCPOAuthProvider,
and introduced a `set models` interface for ACP.
- **Admin & Core Stability:** Enabled a 30-day default retention for chat
history, added tool name validation in TOML policy files, and improved tool
parameter extraction.
- **Plan Mode Enabled by Default**: The comprehensive planning capability is now
enabled by default, allowing for better structured task management and
execution.
- **Enhanced Sandboxing Capabilities**: Added support for native gVisor (runsc)
sandboxing as well as experimental LXC container sandboxing to provide more
robust and isolated execution environments.
- **Improved Loop Detection & Recovery**: Implemented iterative loop detection
and model feedback mechanisms to prevent the CLI from getting stuck in
repetitive actions.
- **Customizable UI Elements**: You can now configure a custom footer using the
new `/footer` command, and enjoy standardized semantic focus colors for better
history visibility.
- **Extensive Subagent Updates**: Refinements across the tracker visualization
tools, background process logging, and broader fallback support for models in
tool execution scenarios.
## What's Changed
- fix(patch): cherry-pick 8432bce to release/v0.33.0-pr-22069 to patch version
v0.33.0 and create version 0.33.1 by @gemini-cli-robot in
[#22206](https://github.com/google-gemini/gemini-cli/pull/22206)
- Docs: Update model docs to remove Preview Features. by @jkcinouye in
[#20084](https://github.com/google-gemini/gemini-cli/pull/20084)
- docs: fix typo in installation documentation by @AdityaSharma-Git3207 in
[#20153](https://github.com/google-gemini/gemini-cli/pull/20153)
- docs: add Windows PowerShell equivalents for environments and scripting by
@scidomino in [#20333](https://github.com/google-gemini/gemini-cli/pull/20333)
- fix(core): parse raw ASCII buffer strings in Gaxios errors by @sehoon38 in
[#20626](https://github.com/google-gemini/gemini-cli/pull/20626)
- chore(release): bump version to 0.33.0-nightly.20260227.ba149afa0 by @galz10
in [#20637](https://github.com/google-gemini/gemini-cli/pull/20637)
- fix(github): use robot PAT for automated PRs to pass CLA check by @galz10 in
[#20641](https://github.com/google-gemini/gemini-cli/pull/20641)
- chore/release: bump version to 0.33.0-nightly.20260228.1ca5c05d0 by
- feat(cli): add chat resume footer on session quit by @lordshashank in
[#20667](https://github.com/google-gemini/gemini-cli/pull/20667)
- Support bold and other styles in svg snapshots by @jacob314 in
[#20937](https://github.com/google-gemini/gemini-cli/pull/20937)
- fix(core): increase A2A agent timeout to 30 minutes by @adamfweidman in
[#21028](https://github.com/google-gemini/gemini-cli/pull/21028)
- Cleanup old branches. by @jacob314 in
[#19354](https://github.com/google-gemini/gemini-cli/pull/19354)
- chore(release): bump version to 0.34.0-nightly.20260303.34f0c1538 by
@gemini-cli-robot in
[#20644](https://github.com/google-gemini/gemini-cli/pull/20644)
- Changelog for v0.31.0 by @gemini-cli-robot in
[#20634](https://github.com/google-gemini/gemini-cli/pull/20634)
- fix: use full paths for ACP diff payloads by @JagjeevanAK in
[#19539](https://github.com/google-gemini/gemini-cli/pull/19539)
- Changelog for v0.32.0-preview.0 by @gemini-cli-robot in
[#20627](https://github.com/google-gemini/gemini-cli/pull/20627)
- fix: acp/zed race condition between MCP initialisation and prompt by
@kartikangiras in
[#20205](https://github.com/google-gemini/gemini-cli/pull/20205)
- fix(cli): reset themeManager between tests to ensure isolation by
@NTaylorMullen in
[#20598](https://github.com/google-gemini/gemini-cli/pull/20598)
- refactor(core): Extract tool parameter names as constants by @SandyTao520 in
[#20460](https://github.com/google-gemini/gemini-cli/pull/20460)
- fix(cli): resolve autoThemeSwitching when background hasn't changed but theme
mismatches by @sehoon38 in
[#20706](https://github.com/google-gemini/gemini-cli/pull/20706)
- feat(skills): add github-issue-creator skill by @sehoon38 in
[#20709](https://github.com/google-gemini/gemini-cli/pull/20709)
- fix(cli): allow sub-agent confirmation requests in UI while preventing
background flicker by @abhipatel12 in
[#20722](https://github.com/google-gemini/gemini-cli/pull/20722)
- Merge User and Agent Card Descriptions #20849 by @adamfweidman in
[#20850](https://github.com/google-gemini/gemini-cli/pull/20850)
- fix(core): reduce LLM-based loop detection false positives by @SandyTao520 in
[#20701](https://github.com/google-gemini/gemini-cli/pull/20701)
- fix(plan): deflake plan mode integration tests by @Adib234 in
[#20477](https://github.com/google-gemini/gemini-cli/pull/20477)
- Add /unassign support by @scidomino in
[#20864](https://github.com/google-gemini/gemini-cli/pull/20864)
- feat(core): implement HTTP authentication support for A2A remote agents by
@SandyTao520 in
[#20510](https://github.com/google-gemini/gemini-cli/pull/20510)
- feat(core): centralize read_file limits and update gemini-3 description by
[#21034](https://github.com/google-gemini/gemini-cli/pull/21034)
- feat(ui): standardize semantic focus colors and enhance history visibility by
@keithguerin in
[#20745](https://github.com/google-gemini/gemini-cli/pull/20745)
- fix: merge duplicate imports in packages/core (3/4) by @Nixxx19 in
[#20928](https://github.com/google-gemini/gemini-cli/pull/20928)
- Add extra safety checks for proto pollution by @jacob314 in
[#20396](https://github.com/google-gemini/gemini-cli/pull/20396)
- feat(core): Add tracker CRUD tools & visualization by @anj-s in
[#19489](https://github.com/google-gemini/gemini-cli/pull/19489)
- Revert "fix(ui): persist expansion in AskUser dialog when navigating options"
by @jacob314 in
[#21042](https://github.com/google-gemini/gemini-cli/pull/21042)
- Changelog for v0.33.0-preview.0 by @gemini-cli-robot in
[#21030](https://github.com/google-gemini/gemini-cli/pull/21030)
- fix: model persistence for all scenarios by @sripasg in
[#21051](https://github.com/google-gemini/gemini-cli/pull/21051)
- chore/release: bump version to 0.34.0-nightly.20260304.28af4e127 by
@gemini-cli-robot in
[#21054](https://github.com/google-gemini/gemini-cli/pull/21054)
- Consistently guard restarts against concurrent auto updates by @scidomino in
[#21016](https://github.com/google-gemini/gemini-cli/pull/21016)
- Defensive coding to reduce the risk of Maximum update depth errors by
@jacob314 in [#20940](https://github.com/google-gemini/gemini-cli/pull/20940)
- fix(cli): Polish shell autocomplete rendering to be a little more shell native
feeling. by @jacob314 in
[#20931](https://github.com/google-gemini/gemini-cli/pull/20931)
- Docs: Update plan mode docs by @jkcinouye in
[#19682](https://github.com/google-gemini/gemini-cli/pull/19682)
- fix(mcp): Notifications/tools/list_changed support not working by @jacob314 in
[#21050](https://github.com/google-gemini/gemini-cli/pull/21050)
- fix(cli): register extension lifecycle events in DebugProfiler by
@fayerman-source in
[#20101](https://github.com/google-gemini/gemini-cli/pull/20101)
- chore(dev): update vscode settings for typescriptreact by @rohit-4321 in
[#19907](https://github.com/google-gemini/gemini-cli/pull/19907)
- fix(cli): enable multi-arch docker builds for sandbox by @ru-aish in
[#19821](https://github.com/google-gemini/gemini-cli/pull/19821)
- Changelog for v0.32.0 by @gemini-cli-robot in
[#21033](https://github.com/google-gemini/gemini-cli/pull/21033)
- Changelog for v0.33.0-preview.1 by @gemini-cli-robot in
[#21058](https://github.com/google-gemini/gemini-cli/pull/21058)
- feat(core): improve @scripts/copy_files.js autocomplete to prioritize
filenames by @sehoon38 in
[#21064](https://github.com/google-gemini/gemini-cli/pull/21064)
- feat(sandbox): add experimental LXC container sandbox support by @h30s in
[#20735](https://github.com/google-gemini/gemini-cli/pull/20735)
- feat(evals): add overall pass rate row to eval nightly summary table by
@gundermanc in
[#20905](https://github.com/google-gemini/gemini-cli/pull/20905)
- feat(telemetry): include language in telemetry and fix accepted lines
computation by @gundermanc in
[#21126](https://github.com/google-gemini/gemini-cli/pull/21126)
- Changelog for v0.32.1 by @gemini-cli-robot in
[#21055](https://github.com/google-gemini/gemini-cli/pull/21055)
- feat(core): add robustness tests, logging, and metrics for CodeAssistServer
SSE parsing by @yunaseoul in
[#21013](https://github.com/google-gemini/gemini-cli/pull/21013)
- feat: add issue assignee workflow by @kartikangiras in
[#21003](https://github.com/google-gemini/gemini-cli/pull/21003)
- fix: improve error message when OAuth succeeds but project ID is required by
@Nixxx19 in [#21070](https://github.com/google-gemini/gemini-cli/pull/21070)
- feat(loop-reduction): implement iterative loop detection and model feedback by
@aishaneeshah in
[#20619](https://github.com/google-gemini/gemini-cli/pull/20619)
- Do not block CI on evals by @gundermanc in
[#20870](https://github.com/google-gemini/gemini-cli/pull/20870)
- document node limitation for shift+tab by @scidomino in
[#20877](https://github.com/google-gemini/gemini-cli/pull/20877)
- Add install as an option when extension is selected. by @DavidAPierce in
[#20358](https://github.com/google-gemini/gemini-cli/pull/20358)
- Update CODEOWNERS for README.md reviewers by @g-samroberts in
[#20860](https://github.com/google-gemini/gemini-cli/pull/20860)
- feat(core): truncate large MCP tool output by @SandyTao520 in
[#19365](https://github.com/google-gemini/gemini-cli/pull/19365)
- Subagent activity UX. by @gundermanc in
[#17570](https://github.com/google-gemini/gemini-cli/pull/17570)
- style(cli) : Dialog pattern for /hooks Command by @AbdulTawabJuly in
[#17930](https://github.com/google-gemini/gemini-cli/pull/17930)
- feat: redesign header to be compact with ASCII icon by @keithguerin in
[#18713](https://github.com/google-gemini/gemini-cli/pull/18713)
- fix(core): ensure subagents use qualified MCP tool names by @abhipatel12 in
[#20801](https://github.com/google-gemini/gemini-cli/pull/20801)
- feat(core): support authenticated A2A agent card discovery by @SandyTao520 in
[#20622](https://github.com/google-gemini/gemini-cli/pull/20622)
- refactor(cli): fully remove React anti patterns, improve type safety and fix
UX oversights in SettingsDialog.tsx by @psinha40898 in
[#18963](https://github.com/google-gemini/gemini-cli/pull/18963)
- Adding MCPOAuthProvider implementing the MCPSDK OAuthClientProvider by
@Nayana-Parameswarappa in
[#20121](https://github.com/google-gemini/gemini-cli/pull/20121)
- feat(core): add tool name validation in TOML policy files by @allenhutchison
in [#19281](https://github.com/google-gemini/gemini-cli/pull/19281)
- docs: fix broken markdown links in main README.md by @Hamdanbinhashim in
[#20300](https://github.com/google-gemini/gemini-cli/pull/20300)
- refactor(core): replace manual syncPlanModeTools with declarative policy rules
by @jerop in [#20596](https://github.com/google-gemini/gemini-cli/pull/20596)
- fix(core): increase default headers timeout to 5 minutes by @gundermanc in
[#20890](https://github.com/google-gemini/gemini-cli/pull/20890)
- feat(admin): enable 30 day default retention for chat history & remove warning
[#20763](https://github.com/google-gemini/gemini-cli/pull/20763)
- chore(github): require prompt approvers for agent prompt files by @gundermanc
in [#20896](https://github.com/google-gemini/gemini-cli/pull/20896)
- Docs: Create tools reference by @jkcinouye in
[#19470](https://github.com/google-gemini/gemini-cli/pull/19470)
- fix(core, a2a-server): prevent hang during OAuth in non-interactive sessions
by @spencer426 in
[#21045](https://github.com/google-gemini/gemini-cli/pull/21045)
- chore(cli): enable deprecated settings removal by default by @yashodipmore in
[#20682](https://github.com/google-gemini/gemini-cli/pull/20682)
- feat(core): Disable fast ack helper for hints. by @joshualitt in
[#21011](https://github.com/google-gemini/gemini-cli/pull/21011)
- fix(ui): suppress redundant failure note when tool error note is shown by
@NTaylorMullen in
[#21078](https://github.com/google-gemini/gemini-cli/pull/21078)
- docs: document planning workflows with Conductor example by @jerop in
[#21166](https://github.com/google-gemini/gemini-cli/pull/21166)
- feat(release): ship esbuild bundle in npm package by @genneth in
[#19171](https://github.com/google-gemini/gemini-cli/pull/19171)
- fix(extensions): preserve symlinks in extension source path while enforcing
folder trust by @galz10 in
[#20867](https://github.com/google-gemini/gemini-cli/pull/20867)
- fix(cli): defer tool exclusions to policy engine in non-interactive mode by
@EricRahm in [#20639](https://github.com/google-gemini/gemini-cli/pull/20639)
- fix(ui): removed double padding on rendered content by @devr0306 in
[#21029](https://github.com/google-gemini/gemini-cli/pull/21029)
- fix(core): truncate excessively long lines in grep search output by
@gundermanc in
[#21147](https://github.com/google-gemini/gemini-cli/pull/21147)
- feat: add custom footer configuration via `/footer` by @jackwotherspoon in
[#19001](https://github.com/google-gemini/gemini-cli/pull/19001)
- perf(core): fix OOM crash in long-running sessions by @WizardsForgeGames in
[#19608](https://github.com/google-gemini/gemini-cli/pull/19608)
- refactor(cli): categorize built-in themes into dark/ and light/ directories by
@JayadityaGit in
[#18634](https://github.com/google-gemini/gemini-cli/pull/18634)
- fix(core): explicitly allow codebase_investigator and cli_help in read-only
mode by @Adib234 in
[#21157](https://github.com/google-gemini/gemini-cli/pull/21157)
- test: add browser agent integration tests by @kunal-10-cloud in
[#21151](https://github.com/google-gemini/gemini-cli/pull/21151)
- fix(cli): fix enabling kitty codes on Windows Terminal by @scidomino in
[#21136](https://github.com/google-gemini/gemini-cli/pull/21136)
- refactor(core): extract shared OAuth flow primitives from MCPOAuthProvider by
@SandyTao520 in
[#20895](https://github.com/google-gemini/gemini-cli/pull/20895)
- fix(ui): add partial output to cancelled shell UI by @devr0306 in
[#21178](https://github.com/google-gemini/gemini-cli/pull/21178)
- fix(cli): replace hardcoded keybinding strings with dynamic formatters by
@scidomino in [#21159](https://github.com/google-gemini/gemini-cli/pull/21159)
- DOCS: Update quota and pricing page by @g-samroberts in
[#21194](https://github.com/google-gemini/gemini-cli/pull/21194)
- feat(telemetry): implement Clearcut logging for startup statistics by
@yunaseoul in [#21172](https://github.com/google-gemini/gemini-cli/pull/21172)
- feat(triage): add area/documentation to issue triage by @g-samroberts in
[#21222](https://github.com/google-gemini/gemini-cli/pull/21222)
- Fix so shell calls are formatted by @jacob314 in
[#21237](https://github.com/google-gemini/gemini-cli/pull/21237)
- feat(cli): add native gVisor (runsc) sandboxing support by @Zheyuan-Lin in
[#21062](https://github.com/google-gemini/gemini-cli/pull/21062)
- docs: use absolute paths for internal links in plan-mode.md by @jerop in
[#21299](https://github.com/google-gemini/gemini-cli/pull/21299)
- fix(core): prevent unhandled AbortError crash during stream loop detection by
@7hokerz in [#21123](https://github.com/google-gemini/gemini-cli/pull/21123)
- fix:reorder env var redaction checks to scan values first by @kartikangiras in
[#21059](https://github.com/google-gemini/gemini-cli/pull/21059)
- fix(acp): rename --experimental-acp to --acp & remove Zed-specific refrences
by @skeshive in
[#20853](https://github.com/google-gemini/gemini-cli/pull/20853)
- feat(plan): support annotating plans with feedback for iteration by @Adib234
in [#20876](https://github.com/google-gemini/gemini-cli/pull/20876)
- Add some dos and don'ts to behavioral evals README. by @gundermanc in
[#20629](https://github.com/google-gemini/gemini-cli/pull/20629)
- fix(core): skip telemetry logging for AbortError exceptions by @yunaseoul in
[#19477](https://github.com/google-gemini/gemini-cli/pull/19477)
- fix(core): restrict "System: Please continue" invalid stream retry to Gemini 2
models by @SandyTao520 in
[#20897](https://github.com/google-gemini/gemini-cli/pull/20897)
- ci(evals): only run evals in CI if prompts or tools changed by @gundermanc in
[#20898](https://github.com/google-gemini/gemini-cli/pull/20898)
- Build binary by @aswinashok44 in
[#18933](https://github.com/google-gemini/gemini-cli/pull/18933)
- Code review fixes as a pr by @jacob314 in
[#20612](https://github.com/google-gemini/gemini-cli/pull/20612)
- fix(ci): handle empty APP_ID in stale PR closer by @bdmorgan in
[#20919](https://github.com/google-gemini/gemini-cli/pull/20919)
- feat(cli): invert context window display to show usage by @keithguerin in
[#20071](https://github.com/google-gemini/gemini-cli/pull/20071)
- fix(plan): clean up session directories and plans on deletion by @jerop in
[#20914](https://github.com/google-gemini/gemini-cli/pull/20914)
- fix(core): enforce optionality for API response fields in code_assist by
@sehoon38 in [#20714](https://github.com/google-gemini/gemini-cli/pull/20714)
- feat(extensions): add support for plan directory in extension manifest by
@mahimashanware in
[#20354](https://github.com/google-gemini/gemini-cli/pull/20354)
- feat(plan): enable built-in research subagents in plan mode by @Adib234 in
[#20972](https://github.com/google-gemini/gemini-cli/pull/20972)
- feat(agents): directly indicate auth required state by @adamfweidman in
[#20986](https://github.com/google-gemini/gemini-cli/pull/20986)
- fix(cli): wait for background auto-update before relaunching by @scidomino in
[#20904](https://github.com/google-gemini/gemini-cli/pull/20904)
- fix: pre-load @scripts/copy_files.js references from external editor prompts
by @kartikangiras in
[#20963](https://github.com/google-gemini/gemini-cli/pull/20963)
- feat(evals): add behavioral evals for ask_user tool by @Adib234 in
[#20620](https://github.com/google-gemini/gemini-cli/pull/20620)
- refactor common settings logic for skills,agents by @ishaanxgupta in
[#17490](https://github.com/google-gemini/gemini-cli/pull/17490)
- Update docs-writer skill with new resource by @g-samroberts in
[#20917](https://github.com/google-gemini/gemini-cli/pull/20917)
- fix(cli): pin clipboardy to ~5.2.x by @scidomino in
[#21009](https://github.com/google-gemini/gemini-cli/pull/21009)
- feat: Implement slash command handling in ACP for
`/memory`,`/init`,`/extensions` and `/restore` by @sripasg in
[#20528](https://github.com/google-gemini/gemini-cli/pull/20528)
- Docs/add hooks reference by @AadithyaAle in
[#20961](https://github.com/google-gemini/gemini-cli/pull/20961)
- feat(plan): add copy subcommand to plan (#20491) by @ruomengz in
[#20988](https://github.com/google-gemini/gemini-cli/pull/20988)
- fix(core): sanitize and length-check MCP tool qualified names by @abhipatel12
in [#20987](https://github.com/google-gemini/gemini-cli/pull/20987)
- Format the quota/limit style guide. by @g-samroberts in
[#21017](https://github.com/google-gemini/gemini-cli/pull/21017)
- fix(core): send shell output to model on cancel by @devr0306 in
[#20501](https://github.com/google-gemini/gemini-cli/pull/20501)
- remove hardcoded tiername when missing tier by @sehoon38 in
[#21022](https://github.com/google-gemini/gemini-cli/pull/21022)
- feat(acp): add set models interface by @skeshive in
[#20991](https://github.com/google-gemini/gemini-cli/pull/20991)
- fix(patch): cherry-pick 0659ad1 to release/v0.33.0-preview.0-pr-21042 to patch
version v0.33.0-preview.0 and create version 0.33.0-preview.1 by
[#21171](https://github.com/google-gemini/gemini-cli/pull/21171)
- feat(core): fallback to 2.5 models with no access for toolcalls by @sehoon38
in [#21283](https://github.com/google-gemini/gemini-cli/pull/21283)
- test(core): improve testing for API request/response parsing by @sehoon38 in
[#21227](https://github.com/google-gemini/gemini-cli/pull/21227)
- docs(links): update docs-writer skill and fix broken link by @g-samroberts in
[#21314](https://github.com/google-gemini/gemini-cli/pull/21314)
- Fix code colorizer ansi escape bug. by @jacob314 in
[#21321](https://github.com/google-gemini/gemini-cli/pull/21321)
- remove wildcard behavior on keybindings by @scidomino in
[#21315](https://github.com/google-gemini/gemini-cli/pull/21315)
- feat(acp): Add support for AI Gateway auth by @skeshive in
[#21305](https://github.com/google-gemini/gemini-cli/pull/21305)
- fix(theme): improve theme color contrast for macOS Terminal.app by @clocky in
[#21175](https://github.com/google-gemini/gemini-cli/pull/21175)
- feat (core): Implement tracker related SI changes by @anj-s in
[#19964](https://github.com/google-gemini/gemini-cli/pull/19964)
- Changelog for v0.33.0-preview.2 by @gemini-cli-robot in
[#21333](https://github.com/google-gemini/gemini-cli/pull/21333)
- Changelog for v0.33.0-preview.3 by @gemini-cli-robot in
[#21347](https://github.com/google-gemini/gemini-cli/pull/21347)
- docs: format release times as HH:MM UTC by @pavan-sh in
[#20726](https://github.com/google-gemini/gemini-cli/pull/20726)
- fix(cli): implement --all flag for extensions uninstall by @sehoon38 in
[#21319](https://github.com/google-gemini/gemini-cli/pull/21319)
- docs: fix incorrect relative links to command reference by @kanywst in
[#20964](https://github.com/google-gemini/gemini-cli/pull/20964)
- documentiong ensures ripgrep by @Jatin24062005 in
[#21298](https://github.com/google-gemini/gemini-cli/pull/21298)
- fix(core): handle AbortError thrown during processTurn by @MumuTW in
[#21296](https://github.com/google-gemini/gemini-cli/pull/21296)
- docs(cli): clarify ! command output visibility in shell commands tutorial by
@MohammedADev in
[#21041](https://github.com/google-gemini/gemini-cli/pull/21041)
- fix: logic for task tracker strategy and remove tracker tools by @anj-s in
[#21355](https://github.com/google-gemini/gemini-cli/pull/21355)
- fix(partUtils): display media type and size for inline data parts by @Aboudjem
in [#21358](https://github.com/google-gemini/gemini-cli/pull/21358)
- Fix(accessibility): add screen reader support to RewindViewer by @Famous077 in
[#20750](https://github.com/google-gemini/gemini-cli/pull/20750)
- fix(hooks): propagate stopHookActive in AfterAgent retry path (#20426) by
@Aarchi-07 in [#20439](https://github.com/google-gemini/gemini-cli/pull/20439)
- fix(core): deduplicate GEMINI.md files by device/inode on case-insensitive
filesystems (#19904) by @Nixxx19 in
[#19915](https://github.com/google-gemini/gemini-cli/pull/19915)
- feat(core): add concurrency safety guidance for subagent delegation (#17753)
by @abhipatel12 in
[#21278](https://github.com/google-gemini/gemini-cli/pull/21278)
- feat(ui): dynamically generate all keybinding hints by @scidomino in
[#21346](https://github.com/google-gemini/gemini-cli/pull/21346)
- feat(core): implement unified KeychainService and migrate token storage by
@ehedlund in [#21344](https://github.com/google-gemini/gemini-cli/pull/21344)
- fix(cli): gracefully handle --resume when no sessions exist by @SandyTao520 in
[#21429](https://github.com/google-gemini/gemini-cli/pull/21429)
- fix(plan): keep approved plan during chat compression by @ruomengz in
[#21284](https://github.com/google-gemini/gemini-cli/pull/21284)
- feat(core): implement generic CacheService and optimize setupUser by @sehoon38
in [#21374](https://github.com/google-gemini/gemini-cli/pull/21374)
- Update quota and pricing documentation with subscription tiers by @srithreepo
in [#21351](https://github.com/google-gemini/gemini-cli/pull/21351)
- fix(core): append correct OTLP paths for HTTP exporters by
@sebastien-prudhomme in
[#16836](https://github.com/google-gemini/gemini-cli/pull/16836)
- Changelog for v0.33.0-preview.4 by @gemini-cli-robot in
[#21354](https://github.com/google-gemini/gemini-cli/pull/21354)
- feat(cli): implement dot-prefixing for slash command conflicts by @ehedlund in
[#20979](https://github.com/google-gemini/gemini-cli/pull/20979)
- refactor(core): standardize MCP tool naming to mcp\_ FQN format by
@abhipatel12 in
[#21425](https://github.com/google-gemini/gemini-cli/pull/21425)
- feat(cli): hide gemma settings from display and mark as experimental by
@abhipatel12 in
[#21471](https://github.com/google-gemini/gemini-cli/pull/21471)
- feat(skills): refine string-reviewer guidelines and description by @clocky in
[#20368](https://github.com/google-gemini/gemini-cli/pull/20368)
- fix(core): whitelist TERM and COLORTERM in environment sanitization by
@deadsmash07 in
[#20514](https://github.com/google-gemini/gemini-cli/pull/20514)
- fix(billing): fix overage strategy lifecycle and settings integration by
@gsquared94 in
[#21236](https://github.com/google-gemini/gemini-cli/pull/21236)
- fix: expand paste placeholders in TextInput on submit by @Jefftree in
[#19946](https://github.com/google-gemini/gemini-cli/pull/19946)
- fix(core): add in-memory cache to ChatRecordingService to prevent OOM by
@SandyTao520 in
[#21502](https://github.com/google-gemini/gemini-cli/pull/21502)
- feat(cli): overhaul thinking UI by @keithguerin in
[#18725](https://github.com/google-gemini/gemini-cli/pull/18725)
- fix(ui): unify Ctrl+O expansion hint experience across buffer modes by
@jwhelangoog in
[#21474](https://github.com/google-gemini/gemini-cli/pull/21474)
- fix(cli): correct shell height reporting by @jacob314 in
[#21492](https://github.com/google-gemini/gemini-cli/pull/21492)
- Make test suite pass when the GEMINI_SYSTEM_MD env variable or
GEMINI_WRITE_SYSTEM_MD variable happens to be set locally/ by @jacob314 in
[#21480](https://github.com/google-gemini/gemini-cli/pull/21480)
- Disallow underspecified types by @gundermanc in
[#21485](https://github.com/google-gemini/gemini-cli/pull/21485)
- refactor(cli): standardize on 'reload' verb for all components by @keithguerin
in [#20654](https://github.com/google-gemini/gemini-cli/pull/20654)
- feat(cli): Invert quota language to 'percent used' by @keithguerin in
[#20100](https://github.com/google-gemini/gemini-cli/pull/20100)
- Docs: Add documentation for notifications (experimental)(macOS) by @jkcinouye
in [#21163](https://github.com/google-gemini/gemini-cli/pull/21163)
- Code review comments as a pr by @jacob314 in
[#21209](https://github.com/google-gemini/gemini-cli/pull/21209)
- feat(cli): unify /chat and /resume command UX by @LyalinDotCom in
[#20256](https://github.com/google-gemini/gemini-cli/pull/20256)
- docs: fix typo 'allowslisted' -> 'allowlisted' in mcp-server.md by
@Gyanranjan-Priyam in
[#21665](https://github.com/google-gemini/gemini-cli/pull/21665)
- fix(core): display actual graph output in tracker_visualize tool by @anj-s in
[#21455](https://github.com/google-gemini/gemini-cli/pull/21455)
- fix(core): sanitize SSE-corrupted JSON and domain strings in error
classification by @gsquared94 in
[#21702](https://github.com/google-gemini/gemini-cli/pull/21702)
- Docs: Make documentation links relative by @diodesign in
[#21490](https://github.com/google-gemini/gemini-cli/pull/21490)
- feat(cli): expose /tools desc as explicit subcommand for discoverability by
@aworki in [#21241](https://github.com/google-gemini/gemini-cli/pull/21241)
- feat(cli): add /compact alias for /compress command by @jackwotherspoon in
[#21711](https://github.com/google-gemini/gemini-cli/pull/21711)
- feat(plan): enable Plan Mode by default by @jerop in
[#21713](https://github.com/google-gemini/gemini-cli/pull/21713)
- feat(core): Introduce `AgentLoopContext`. by @joshualitt in
[#21198](https://github.com/google-gemini/gemini-cli/pull/21198)
- fix(core): resolve symlinks for non-existent paths during validation by
@Adib234 in [#21487](https://github.com/google-gemini/gemini-cli/pull/21487)
- docs: document tool exclusion from memory via deny policy by @Abhijit-2592 in
[#21428](https://github.com/google-gemini/gemini-cli/pull/21428)
- perf(core): cache loadApiKey to reduce redundant keychain access by @sehoon38
in [#21520](https://github.com/google-gemini/gemini-cli/pull/21520)
- feat(cli): implement /upgrade command by @sehoon38 in
[#21511](https://github.com/google-gemini/gemini-cli/pull/21511)
- Feat/browser agent progress emission by @kunal-10-cloud in
[#21218](https://github.com/google-gemini/gemini-cli/pull/21218)
- fix(settings): display objects as JSON instead of [object Object] by
@Zheyuan-Lin in
[#21458](https://github.com/google-gemini/gemini-cli/pull/21458)
- Unmarshall update by @DavidAPierce in
[#21721](https://github.com/google-gemini/gemini-cli/pull/21721)
- Update mcp's list function to check for disablement. by @DavidAPierce in
[#21148](https://github.com/google-gemini/gemini-cli/pull/21148)
- robustness(core): static checks to validate history is immutable by @jacob314
in [#21228](https://github.com/google-gemini/gemini-cli/pull/21228)
- refactor(cli): better react patterns for BaseSettingsDialog by @psinha40898 in
[#21206](https://github.com/google-gemini/gemini-cli/pull/21206)
- feat(security): implement robust IP validation and safeFetch foundation by
@alisa-alisa in
[#21401](https://github.com/google-gemini/gemini-cli/pull/21401)
- feat(core): improve subagent result display by @joshualitt in
[#20378](https://github.com/google-gemini/gemini-cli/pull/20378)
- docs: fix broken markdown syntax and anchor links in /tools by @campox747 in
[#20902](https://github.com/google-gemini/gemini-cli/pull/20902)
- feat(policy): support subagent-specific policies in TOML by @akh64bit in
[#21431](https://github.com/google-gemini/gemini-cli/pull/21431)
- Add script to speed up reviewing PRs adding a worktree. by @jacob314 in
[#21748](https://github.com/google-gemini/gemini-cli/pull/21748)
- fix(core): prevent infinite recursion in symlink resolution by @Adib234 in
[#21750](https://github.com/google-gemini/gemini-cli/pull/21750)
- fix(docs): fix headless mode docs by @ame2en in
[#21287](https://github.com/google-gemini/gemini-cli/pull/21287)
- feat/redesign header compact by @jacob314 in
[#20922](https://github.com/google-gemini/gemini-cli/pull/20922)
- refactor: migrate to useKeyMatchers hook by @scidomino in
[#21753](https://github.com/google-gemini/gemini-cli/pull/21753)
- perf(cli): cache loadSettings to reduce redundant disk I/O at startup by
@sehoon38 in [#21521](https://github.com/google-gemini/gemini-cli/pull/21521)
- fix(core): resolve Windows line ending and path separation bugs across CLI by
@muhammadusman586 in
[#21068](https://github.com/google-gemini/gemini-cli/pull/21068)
- docs: fix heading formatting in commands.md and phrasing in tools-api.md by
@campox747 in [#20679](https://github.com/google-gemini/gemini-cli/pull/20679)
- refactor(ui): unify keybinding infrastructure and support string
initialization by @scidomino in
[#21776](https://github.com/google-gemini/gemini-cli/pull/21776)
- Add support for updating extension sources and names by @chrstnb in
[#21715](https://github.com/google-gemini/gemini-cli/pull/21715)
- fix(core): handle GUI editor non-zero exit codes gracefully by @reyyanxahmed
in [#20376](https://github.com/google-gemini/gemini-cli/pull/20376)
- fix(core): destroy PTY on kill() and exception to prevent fd leak by @nbardy
in [#21693](https://github.com/google-gemini/gemini-cli/pull/21693)
- fix(docs): update theme screenshots and add missing themes by @ashmod in
[#20689](https://github.com/google-gemini/gemini-cli/pull/20689)
- refactor(cli): rename 'return' key to 'enter' internally by @scidomino in
[#21796](https://github.com/google-gemini/gemini-cli/pull/21796)
- build(release): restrict npm bundling to non-stable tags by @sehoon38 in
[#21821](https://github.com/google-gemini/gemini-cli/pull/21821)
- fix(core): override toolRegistry property for sub-agent schedulers by
@gsquared94 in
[#21766](https://github.com/google-gemini/gemini-cli/pull/21766)
- fix(cli): make footer items equally spaced by @jacob314 in
[#21843](https://github.com/google-gemini/gemini-cli/pull/21843)
- docs: clarify global policy rules application in plan mode by @jerop in
[#21864](https://github.com/google-gemini/gemini-cli/pull/21864)
- fix(core): ensure correct flash model steering in plan mode implementation
phase by @jerop in
[#21871](https://github.com/google-gemini/gemini-cli/pull/21871)
- fix(core): update @a2a-js/sdk to 0.3.11 by @adamfweidman in
[#21875](https://github.com/google-gemini/gemini-cli/pull/21875)
- refactor(core): improve API response error logging when retry by @yunaseoul in
[#21784](https://github.com/google-gemini/gemini-cli/pull/21784)
- fix(ui): handle headless execution in credits and upgrade dialogs by
@gsquared94 in
[#21850](https://github.com/google-gemini/gemini-cli/pull/21850)
- fix(core): treat retryable errors with >5 min delay as terminal quota errors
by @gsquared94 in
[#21881](https://github.com/google-gemini/gemini-cli/pull/21881)
- feat(telemetry): add specific PR, issue, and custom tracking IDs for GitHub
Actions by @cocosheng-g in
[#21129](https://github.com/google-gemini/gemini-cli/pull/21129)
- feat(core): add OAuth2 Authorization Code auth provider for A2A agents by
@SandyTao520 in
[#21496](https://github.com/google-gemini/gemini-cli/pull/21496)
- feat(cli): give visibility to /tools list command in the TUI and follow the
subcommand pattern of other commands by @JayadityaGit in
[#21213](https://github.com/google-gemini/gemini-cli/pull/21213)
- Handle dirty worktrees better and warn about running scripts/review.sh on
untrusted code. by @jacob314 in
[#21791](https://github.com/google-gemini/gemini-cli/pull/21791)
- feat(policy): support auto-add to policy by default and scoped persistence by
@spencer426 in
[#20361](https://github.com/google-gemini/gemini-cli/pull/20361)
- fix(core): handle AbortError when ESC cancels tool execution by @PrasannaPal21
in [#20863](https://github.com/google-gemini/gemini-cli/pull/20863)
- fix(release): Improve Patch Release Workflow Comments: Clearer Approval
Guidance by @jerop in
[#21894](https://github.com/google-gemini/gemini-cli/pull/21894)
- docs: clarify telemetry setup and comprehensive data map by @jerop in
[#21879](https://github.com/google-gemini/gemini-cli/pull/21879)
- feat(core): add per-model token usage to stream-json output by @yongruilin in
[#21839](https://github.com/google-gemini/gemini-cli/pull/21839)
- docs: remove experimental badge from plan mode in sidebar by @jerop in
[#21906](https://github.com/google-gemini/gemini-cli/pull/21906)
- fix(cli): prevent race condition in loop detection retry by @skyvanguard in
[#17916](https://github.com/google-gemini/gemini-cli/pull/17916)
- Add behavioral evals for tracker by @anj-s in
[#20069](https://github.com/google-gemini/gemini-cli/pull/20069)
- fix(auth): update terminology to 'sign in' and 'sign out' by @clocky in
[#20892](https://github.com/google-gemini/gemini-cli/pull/20892)
- docs(mcp): standardize mcp tool fqn documentation by @abhipatel12 in
[#21664](https://github.com/google-gemini/gemini-cli/pull/21664)
- fix(ui): prevent empty tool-group border stubs after filtering by @Aaxhirrr in
[#21852](https://github.com/google-gemini/gemini-cli/pull/21852)
- make command names consistent by @scidomino in
[#21907](https://github.com/google-gemini/gemini-cli/pull/21907)
- refactor: remove agent_card_requires_auth config flag by @adamfweidman in
[#21914](https://github.com/google-gemini/gemini-cli/pull/21914)
- feat(a2a): implement standardized normalization and streaming reassembly by
@alisa-alisa in
[#21402](https://github.com/google-gemini/gemini-cli/pull/21402)
- feat(cli): enable skill activation via slash commands by @NTaylorMullen in
[#21758](https://github.com/google-gemini/gemini-cli/pull/21758)
- docs(cli): mention per-model token usage in stream-json result event by
@yongruilin in
[#21908](https://github.com/google-gemini/gemini-cli/pull/21908)
- fix(plan): prevent plan truncation in approval dialog by supporting
unconstrained heights by @Adib234 in
[#21037](https://github.com/google-gemini/gemini-cli/pull/21037)
- feat(a2a): switch from callback-based to event-driven tool scheduler by
@cocosheng-g in
[#21467](https://github.com/google-gemini/gemini-cli/pull/21467)
- feat(voice): implement speech-friendly response formatter by @ayush31010 in
[#20989](https://github.com/google-gemini/gemini-cli/pull/20989)
- feat: add pulsating blue border automation overlay to browser agent by
@kunal-10-cloud in
[#21173](https://github.com/google-gemini/gemini-cli/pull/21173)
- Add extensionRegistryURI setting to change where the registry is read from by
@kevinjwang1 in
[#20463](https://github.com/google-gemini/gemini-cli/pull/20463)
- fix: patch gaxios v7 Array.toString() stream corruption by @gsquared94 in
[#21884](https://github.com/google-gemini/gemini-cli/pull/21884)
- fix: prevent hangs in non-interactive mode and improve agent guidance by
@cocosheng-g in
[#20893](https://github.com/google-gemini/gemini-cli/pull/20893)
- Add ExtensionDetails dialog and support install by @chrstnb in
[#20845](https://github.com/google-gemini/gemini-cli/pull/20845)
- chore/release: bump version to 0.34.0-nightly.20260310.4653b126f by
@gemini-cli-robot in
[#21047](https://github.com/google-gemini/gemini-cli/pull/21047)
- fix(patch): cherry-pick 173376b to release/v0.33.0-preview.1-pr-21157 to patch
version v0.33.0-preview.1 and create version 0.33.0-preview.2 by
@gemini-cli-robot in
[#21300](https://github.com/google-gemini/gemini-cli/pull/21300)
- fix(patch): cherry-pick 0135b03 to release/v0.33.0-preview.2-pr-21171
[#21816](https://github.com/google-gemini/gemini-cli/pull/21816)
- Changelog for v0.33.0-preview.13 by @gemini-cli-robot in
[#21927](https://github.com/google-gemini/gemini-cli/pull/21927)
- fix(cli): stabilize prompt layout to prevent jumping when typing by
@NTaylorMullen in
[#21081](https://github.com/google-gemini/gemini-cli/pull/21081)
- fix: preserve prompt text when cancelling streaming by @Nixxx19 in
[#21103](https://github.com/google-gemini/gemini-cli/pull/21103)
- fix: robust UX for remote agent errors by @Shyam-Raghuwanshi in
[#20307](https://github.com/google-gemini/gemini-cli/pull/20307)
- feat: implement background process logging and cleanup by @galz10 in
[#21189](https://github.com/google-gemini/gemini-cli/pull/21189)
- Changelog for v0.33.0-preview.14 by @gemini-cli-robot in
[#21938](https://github.com/google-gemini/gemini-cli/pull/21938)
- fix(patch): cherry-pick 45faf4d to release/v0.34.0-preview.0-pr-22148
[CONFLICTS] by @gemini-cli-robot in
[#21336](https://github.com/google-gemini/gemini-cli/pull/21336)
- fix(patch): cherry-pick 7ec477d to release/v0.33.0-preview.3-pr-21305 to patch
version v0.33.0-preview.3 and create version 0.33.0-preview.4 by
[#22174](https://github.com/google-gemini/gemini-cli/pull/22174)
- fix(patch): cherry-pick 8432bce to release/v0.34.0-preview.1-pr-22069 to patch
version v0.34.0-preview.1 and create version 0.34.0-preview.2 by
@gemini-cli-robot in
[#21349](https://github.com/google-gemini/gemini-cli/pull/21349)
- fix(patch): cherry-pick 931e668 to release/v0.33.0-preview.4-pr-21425
[CONFLICTS] by @gemini-cli-robot in
[#21478](https://github.com/google-gemini/gemini-cli/pull/21478)
- fix(patch): cherry-pick 7837194 to release/v0.33.0-preview.5-pr-21487 to patch
version v0.33.0-preview.5 and create version 0.33.0-preview.6 by
[#22205](https://github.com/google-gemini/gemini-cli/pull/22205)
- fix(patch): cherry-pick 24adacd to release/v0.34.0-preview.2-pr-22332 to patch
version v0.34.0-preview.2 and create version 0.34.0-preview.3 by
@gemini-cli-robot in
[#21720](https://github.com/google-gemini/gemini-cli/pull/21720)
- fix(patch): cherry-pick 4f4431e to release/v0.33.0-preview.7-pr-21750 to patch
version v0.33.0-preview.7 and create version 0.33.0-preview.8 by
[#22391](https://github.com/google-gemini/gemini-cli/pull/22391)
- fix(patch): cherry-pick 48130eb to release/v0.34.0-preview.3-pr-22665 to patch
version v0.34.0-preview.3 and create version 0.34.0-preview.4 by
@gemini-cli-robot in
[#21782](https://github.com/google-gemini/gemini-cli/pull/21782)
- fix(patch): cherry-pick 9a74271 to release/v0.33.0-preview.8-pr-21236
[CONFLICTS] by @gemini-cli-robot in
[#21788](https://github.com/google-gemini/gemini-cli/pull/21788)
- fix(patch): cherry-pick 936f624 to release/v0.33.0-preview.9-pr-21702 to patch
version v0.33.0-preview.9 and create version 0.33.0-preview.10 by
@gemini-cli-robot in
[#21800](https://github.com/google-gemini/gemini-cli/pull/21800)
- fix(patch): cherry-pick 35ee2a8 to release/v0.33.0-preview.10-pr-21713 by
@gemini-cli-robot in
[#21859](https://github.com/google-gemini/gemini-cli/pull/21859)
- fix(patch): cherry-pick 5dd2dab to release/v0.33.0-preview.11-pr-21871 by
@gemini-cli-robot in
[#21876](https://github.com/google-gemini/gemini-cli/pull/21876)
- fix(patch): cherry-pick e5615f4 to release/v0.33.0-preview.12-pr-21037 to
patch version v0.33.0-preview.12 and create version 0.33.0-preview.13 by
@gemini-cli-robot in
[#21922](https://github.com/google-gemini/gemini-cli/pull/21922)
- fix(patch): cherry-pick 1b69637 to release/v0.33.0-preview.13-pr-21467
[CONFLICTS] by @gemini-cli-robot in
[#21930](https://github.com/google-gemini/gemini-cli/pull/21930)
- fix(patch): cherry-pick 3ff68a9 to release/v0.33.0-preview.14-pr-21884
[CONFLICTS] by @gemini-cli-robot in
[#21952](https://github.com/google-gemini/gemini-cli/pull/21952)
[#22719](https://github.com/google-gemini/gemini-cli/pull/22719)
**Full Changelog**:
https://github.com/google-gemini/gemini-cli/compare/v0.32.1...v0.33.1
https://github.com/google-gemini/gemini-cli/compare/v0.33.2...v0.34.0
+7 -3
View File
@@ -1,6 +1,6 @@
# Preview release: v0.34.0-preview.3
# Preview release: v0.34.0-preview.4
Released: March 13, 2026
Released: March 16, 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).
@@ -28,6 +28,10 @@ npm install -g @google/gemini-cli@preview
## What's Changed
- fix(patch): cherry-pick 48130eb to release/v0.34.0-preview.3-pr-22665 to patch
version v0.34.0-preview.3 and create version 0.34.0-preview.4 by
@gemini-cli-robot in
[#22719](https://github.com/google-gemini/gemini-cli/pull/22719)
- fix(patch): cherry-pick 24adacd to release/v0.34.0-preview.2-pr-22332 to patch
version v0.34.0-preview.2 and create version 0.34.0-preview.3 by
@gemini-cli-robot in
@@ -476,4 +480,4 @@ npm install -g @google/gemini-cli@preview
[#21938](https://github.com/google-gemini/gemini-cli/pull/21938)
**Full Changelog**:
https://github.com/google-gemini/gemini-cli/compare/v0.33.0-preview.15...v0.34.0-preview.3
https://github.com/google-gemini/gemini-cli/compare/v0.33.0-preview.15...v0.34.0-preview.4
+1 -1
View File
@@ -52,7 +52,7 @@ You tell Gemini about new servers by editing your `settings.json`.
"--rm",
"-e",
"GITHUB_PERSONAL_ACCESS_TOKEN",
"ghcr.io/modelcontextprotocol/servers/github:latest"
"ghcr.io/github/github-mcp-server:latest"
],
"env": {
"GITHUB_PERSONAL_ACCESS_TOKEN": "${GITHUB_PERSONAL_ACCESS_TOKEN}"
+191 -8
View File
@@ -688,7 +688,7 @@ their corresponding top-level category object in your `settings.json` file.
"tier": "pro",
"family": "gemini-3",
"isPreview": true,
"dialogLocation": "manual",
"isVisible": true,
"features": {
"thinking": true,
"multimodalToolUse": true
@@ -698,6 +698,7 @@ their corresponding top-level category object in your `settings.json` file.
"tier": "pro",
"family": "gemini-3",
"isPreview": true,
"isVisible": false,
"features": {
"thinking": true,
"multimodalToolUse": true
@@ -707,7 +708,7 @@ their corresponding top-level category object in your `settings.json` file.
"tier": "pro",
"family": "gemini-3",
"isPreview": true,
"dialogLocation": "manual",
"isVisible": true,
"features": {
"thinking": true,
"multimodalToolUse": true
@@ -717,7 +718,7 @@ their corresponding top-level category object in your `settings.json` file.
"tier": "flash",
"family": "gemini-3",
"isPreview": true,
"dialogLocation": "manual",
"isVisible": true,
"features": {
"thinking": false,
"multimodalToolUse": true
@@ -727,7 +728,7 @@ their corresponding top-level category object in your `settings.json` file.
"tier": "pro",
"family": "gemini-2.5",
"isPreview": false,
"dialogLocation": "manual",
"isVisible": true,
"features": {
"thinking": false,
"multimodalToolUse": false
@@ -737,7 +738,7 @@ their corresponding top-level category object in your `settings.json` file.
"tier": "flash",
"family": "gemini-2.5",
"isPreview": false,
"dialogLocation": "manual",
"isVisible": true,
"features": {
"thinking": false,
"multimodalToolUse": false
@@ -747,7 +748,7 @@ their corresponding top-level category object in your `settings.json` file.
"tier": "flash-lite",
"family": "gemini-2.5",
"isPreview": false,
"dialogLocation": "manual",
"isVisible": true,
"features": {
"thinking": false,
"multimodalToolUse": false
@@ -756,6 +757,7 @@ their corresponding top-level category object in your `settings.json` file.
"auto": {
"tier": "auto",
"isPreview": true,
"isVisible": false,
"features": {
"thinking": true,
"multimodalToolUse": false
@@ -764,6 +766,7 @@ their corresponding top-level category object in your `settings.json` file.
"pro": {
"tier": "pro",
"isPreview": false,
"isVisible": false,
"features": {
"thinking": true,
"multimodalToolUse": false
@@ -772,6 +775,7 @@ their corresponding top-level category object in your `settings.json` file.
"flash": {
"tier": "flash",
"isPreview": false,
"isVisible": false,
"features": {
"thinking": false,
"multimodalToolUse": false
@@ -780,6 +784,7 @@ their corresponding top-level category object in your `settings.json` file.
"flash-lite": {
"tier": "flash-lite",
"isPreview": false,
"isVisible": false,
"features": {
"thinking": false,
"multimodalToolUse": false
@@ -789,7 +794,7 @@ their corresponding top-level category object in your `settings.json` file.
"displayName": "Auto (Gemini 3)",
"tier": "auto",
"isPreview": true,
"dialogLocation": "main",
"isVisible": true,
"dialogDescription": "Let Gemini CLI decide the best model for the task: gemini-3.1-pro, gemini-3-flash",
"features": {
"thinking": true,
@@ -800,7 +805,7 @@ their corresponding top-level category object in your `settings.json` file.
"displayName": "Auto (Gemini 2.5)",
"tier": "auto",
"isPreview": false,
"dialogLocation": "main",
"isVisible": true,
"dialogDescription": "Let Gemini CLI decide the best model for the task: gemini-2.5-pro, gemini-2.5-flash",
"features": {
"thinking": false,
@@ -812,6 +817,184 @@ their corresponding top-level category object in your `settings.json` file.
- **Requires restart:** Yes
- **`modelConfigs.modelIdResolutions`** (object):
- **Description:** Rules for resolving requested model names to concrete model
IDs based on context.
- **Default:**
```json
{
"gemini-3-pro-preview": {
"default": "gemini-3-pro-preview",
"contexts": [
{
"condition": {
"hasAccessToPreview": false
},
"target": "gemini-2.5-pro"
},
{
"condition": {
"useGemini3_1": true,
"useCustomTools": true
},
"target": "gemini-3.1-pro-preview-customtools"
},
{
"condition": {
"useGemini3_1": true
},
"target": "gemini-3.1-pro-preview"
}
]
},
"auto-gemini-3": {
"default": "gemini-3-pro-preview",
"contexts": [
{
"condition": {
"hasAccessToPreview": false
},
"target": "gemini-2.5-pro"
},
{
"condition": {
"useGemini3_1": true,
"useCustomTools": true
},
"target": "gemini-3.1-pro-preview-customtools"
},
{
"condition": {
"useGemini3_1": true
},
"target": "gemini-3.1-pro-preview"
}
]
},
"auto": {
"default": "gemini-3-pro-preview",
"contexts": [
{
"condition": {
"hasAccessToPreview": false
},
"target": "gemini-2.5-pro"
},
{
"condition": {
"useGemini3_1": true,
"useCustomTools": true
},
"target": "gemini-3.1-pro-preview-customtools"
},
{
"condition": {
"useGemini3_1": true
},
"target": "gemini-3.1-pro-preview"
}
]
},
"pro": {
"default": "gemini-3-pro-preview",
"contexts": [
{
"condition": {
"hasAccessToPreview": false
},
"target": "gemini-2.5-pro"
},
{
"condition": {
"useGemini3_1": true,
"useCustomTools": true
},
"target": "gemini-3.1-pro-preview-customtools"
},
{
"condition": {
"useGemini3_1": true
},
"target": "gemini-3.1-pro-preview"
}
]
},
"auto-gemini-2.5": {
"default": "gemini-2.5-pro"
},
"flash": {
"default": "gemini-3-flash-preview",
"contexts": [
{
"condition": {
"hasAccessToPreview": false
},
"target": "gemini-2.5-flash"
}
]
},
"flash-lite": {
"default": "gemini-2.5-flash-lite"
}
}
```
- **Requires restart:** Yes
- **`modelConfigs.classifierIdResolutions`** (object):
- **Description:** Rules for resolving classifier tiers (flash, pro) to
concrete model IDs.
- **Default:**
```json
{
"flash": {
"default": "gemini-3-flash-preview",
"contexts": [
{
"condition": {
"requestedModels": ["auto-gemini-2.5", "gemini-2.5-pro"]
},
"target": "gemini-2.5-flash"
},
{
"condition": {
"requestedModels": ["auto-gemini-3", "gemini-3-pro-preview"]
},
"target": "gemini-3-flash-preview"
}
]
},
"pro": {
"default": "gemini-3-pro-preview",
"contexts": [
{
"condition": {
"requestedModels": ["auto-gemini-2.5", "gemini-2.5-pro"]
},
"target": "gemini-2.5-pro"
},
{
"condition": {
"useGemini3_1": true,
"useCustomTools": true
},
"target": "gemini-3.1-pro-preview-customtools"
},
{
"condition": {
"useGemini3_1": true
},
"target": "gemini-3.1-pro-preview"
}
]
}
}
```
- **Requires restart:** Yes
#### `agents`
- **`agents.overrides`** (object):
+2 -1
View File
@@ -13,7 +13,8 @@ updates to the CLI interface.
- `todos` (array of objects, required): The complete list of tasks. Each object
includes:
- `description` (string): Technical description of the task.
- `status` (enum): `pending`, `in_progress`, `completed`, or `cancelled`.
- `status` (enum): `pending`, `in_progress`, `completed`, `cancelled`, or
`blocked`.
## Technical behavior
+9 -10
View File
@@ -1,12 +1,12 @@
{
"name": "@google/gemini-cli",
"version": "0.35.0-nightly.20260313.bb060d7a9",
"version": "0.36.0-nightly.20260317.2f90b4653",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@google/gemini-cli",
"version": "0.35.0-nightly.20260313.bb060d7a9",
"version": "0.36.0-nightly.20260317.2f90b4653",
"workspaces": [
"packages/*"
],
@@ -16231,7 +16231,6 @@
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"dev": true,
"license": "0BSD"
},
"node_modules/tsx": {
@@ -17414,7 +17413,7 @@
},
"packages/a2a-server": {
"name": "@google/gemini-cli-a2a-server",
"version": "0.35.0-nightly.20260313.bb060d7a9",
"version": "0.36.0-nightly.20260317.2f90b4653",
"dependencies": {
"@a2a-js/sdk": "0.3.11",
"@google-cloud/storage": "^7.16.0",
@@ -17529,7 +17528,7 @@
},
"packages/cli": {
"name": "@google/gemini-cli",
"version": "0.35.0-nightly.20260313.bb060d7a9",
"version": "0.36.0-nightly.20260317.2f90b4653",
"license": "Apache-2.0",
"dependencies": {
"@agentclientprotocol/sdk": "^0.12.0",
@@ -17701,7 +17700,7 @@
},
"packages/core": {
"name": "@google/gemini-cli-core",
"version": "0.35.0-nightly.20260313.bb060d7a9",
"version": "0.36.0-nightly.20260317.2f90b4653",
"license": "Apache-2.0",
"dependencies": {
"@a2a-js/sdk": "0.3.11",
@@ -17967,7 +17966,7 @@
},
"packages/devtools": {
"name": "@google/gemini-cli-devtools",
"version": "0.35.0-nightly.20260313.bb060d7a9",
"version": "0.36.0-nightly.20260317.2f90b4653",
"license": "Apache-2.0",
"dependencies": {
"ws": "^8.16.0"
@@ -17982,7 +17981,7 @@
},
"packages/sdk": {
"name": "@google/gemini-cli-sdk",
"version": "0.35.0-nightly.20260313.bb060d7a9",
"version": "0.36.0-nightly.20260317.2f90b4653",
"license": "Apache-2.0",
"dependencies": {
"@google/gemini-cli-core": "file:../core",
@@ -17999,7 +17998,7 @@
},
"packages/test-utils": {
"name": "@google/gemini-cli-test-utils",
"version": "0.35.0-nightly.20260313.bb060d7a9",
"version": "0.36.0-nightly.20260317.2f90b4653",
"license": "Apache-2.0",
"dependencies": {
"@google/gemini-cli-core": "file:../core",
@@ -18016,7 +18015,7 @@
},
"packages/vscode-ide-companion": {
"name": "gemini-cli-vscode-ide-companion",
"version": "0.35.0-nightly.20260313.bb060d7a9",
"version": "0.36.0-nightly.20260317.2f90b4653",
"license": "LICENSE",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.23.0",
+3 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli",
"version": "0.35.0-nightly.20260313.bb060d7a9",
"version": "0.36.0-nightly.20260317.2f90b4653",
"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.35.0-nightly.20260313.bb060d7a9"
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.36.0-nightly.20260317.2f90b4653"
},
"scripts": {
"start": "cross-env NODE_ENV=development node scripts/start.js",
@@ -43,6 +43,7 @@
"test:ci": "npm run test:ci --workspaces --if-present && npm run test:scripts && npm run test:sea-launch",
"test:scripts": "vitest run --config ./scripts/tests/vitest.config.ts",
"test:sea-launch": "vitest run sea/sea-launch.test.js",
"posttest": "npm run build",
"test:always_passing_evals": "vitest run --config evals/vitest.config.ts",
"test:all_evals": "cross-env RUN_EVALS=1 vitest run --config evals/vitest.config.ts",
"test:e2e": "cross-env VERBOSE=true KEEP_OUTPUT=true npm run test:integration:sandbox:none",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli-a2a-server",
"version": "0.35.0-nightly.20260313.bb060d7a9",
"version": "0.36.0-nightly.20260317.2f90b4653",
"description": "Gemini CLI A2A Server",
"repository": {
"type": "git",
@@ -19,6 +19,8 @@ import {
AuthType,
isHeadlessMode,
FatalAuthenticationError,
PolicyDecision,
PRIORITY_YOLO_ALLOW_ALL,
} from '@google/gemini-cli-core';
// Mock dependencies
@@ -325,6 +327,29 @@ describe('loadConfig', () => {
);
});
it('should pass enableAgents to Config constructor', async () => {
const settings: Settings = {
experimental: {
enableAgents: false,
},
};
await loadConfig(settings, mockExtensionLoader, taskId);
expect(Config).toHaveBeenCalledWith(
expect.objectContaining({
enableAgents: false,
}),
);
});
it('should default enableAgents to true when not provided', async () => {
await loadConfig(mockSettings, mockExtensionLoader, taskId);
expect(Config).toHaveBeenCalledWith(
expect.objectContaining({
enableAgents: true,
}),
);
});
describe('interactivity', () => {
it('should set interactive true when not headless', async () => {
vi.mocked(isHeadlessMode).mockReturnValue(false);
@@ -349,6 +374,41 @@ describe('loadConfig', () => {
});
});
describe('YOLO mode', () => {
it('should enable YOLO mode and add policy rule when GEMINI_YOLO_MODE is true', async () => {
vi.stubEnv('GEMINI_YOLO_MODE', 'true');
await loadConfig(mockSettings, mockExtensionLoader, taskId);
expect(Config).toHaveBeenCalledWith(
expect.objectContaining({
approvalMode: 'yolo',
policyEngineConfig: expect.objectContaining({
rules: expect.arrayContaining([
expect.objectContaining({
decision: PolicyDecision.ALLOW,
priority: PRIORITY_YOLO_ALLOW_ALL,
modes: ['yolo'],
allowRedirection: true,
}),
]),
}),
}),
);
});
it('should use default approval mode and empty rules when GEMINI_YOLO_MODE is not true', async () => {
vi.stubEnv('GEMINI_YOLO_MODE', 'false');
await loadConfig(mockSettings, mockExtensionLoader, taskId);
expect(Config).toHaveBeenCalledWith(
expect.objectContaining({
approvalMode: 'default',
policyEngineConfig: expect.objectContaining({
rules: [],
}),
}),
);
});
});
describe('authentication fallback', () => {
beforeEach(() => {
vi.stubEnv('USE_CCPA', 'true');
+22 -4
View File
@@ -26,6 +26,8 @@ import {
isHeadlessMode,
FatalAuthenticationError,
isCloudShell,
PolicyDecision,
PRIORITY_YOLO_ALLOW_ALL,
type TelemetryTarget,
type ConfigParameters,
type ExtensionLoader,
@@ -60,6 +62,11 @@ export async function loadConfig(
}
}
const approvalMode =
process.env['GEMINI_YOLO_MODE'] === 'true'
? ApprovalMode.YOLO
: ApprovalMode.DEFAULT;
const configParams: ConfigParameters = {
sessionId: taskId,
clientName: 'a2a-server',
@@ -74,10 +81,20 @@ export async function loadConfig(
excludeTools: settings.excludeTools || settings.tools?.exclude || undefined,
allowedTools: settings.allowedTools || settings.tools?.allowed || undefined,
showMemoryUsage: settings.showMemoryUsage || false,
approvalMode:
process.env['GEMINI_YOLO_MODE'] === 'true'
? ApprovalMode.YOLO
: ApprovalMode.DEFAULT,
approvalMode,
policyEngineConfig: {
rules:
approvalMode === ApprovalMode.YOLO
? [
{
decision: PolicyDecision.ALLOW,
priority: PRIORITY_YOLO_ALLOW_ALL,
modes: [ApprovalMode.YOLO],
allowRedirection: true,
},
]
: [],
},
mcpServers: settings.mcpServers,
cwd: workspaceDir,
telemetry: {
@@ -110,6 +127,7 @@ export async function loadConfig(
interactive: !isHeadlessMode(),
enableInteractiveShell: !isHeadlessMode(),
ptyInfo: 'auto',
enableAgents: settings.experimental?.enableAgents ?? true,
};
const fileService = new FileDiscoveryService(workspaceDir, {
@@ -112,6 +112,18 @@ describe('loadSettings', () => {
expect(result.fileFiltering?.respectGitIgnore).toBe(true);
});
it('should load experimental settings correctly', () => {
const settings = {
experimental: {
enableAgents: true,
},
};
fs.writeFileSync(USER_SETTINGS_PATH, JSON.stringify(settings));
const result = loadSettings(mockWorkspaceDir);
expect(result.experimental?.enableAgents).toBe(true);
});
it('should overwrite top-level settings from workspace (shallow merge)', () => {
const userSettings = {
showMemoryUsage: false,
@@ -48,6 +48,9 @@ export interface Settings {
enableRecursiveFileSearch?: boolean;
customIgnoreFilePaths?: string[];
};
experimental?: {
enableAgents?: boolean;
};
}
export interface SettingsError {
+3 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli",
"version": "0.35.0-nightly.20260313.bb060d7a9",
"version": "0.36.0-nightly.20260317.2f90b4653",
"description": "Gemini CLI",
"license": "Apache-2.0",
"repository": {
@@ -20,13 +20,14 @@
"format": "prettier --write .",
"test": "vitest run",
"test:ci": "vitest run",
"posttest": "npm run build",
"typecheck": "tsc --noEmit"
},
"files": [
"dist"
],
"config": {
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.35.0-nightly.20260313.bb060d7a9"
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.36.0-nightly.20260317.2f90b4653"
},
"dependencies": {
"@agentclientprotocol/sdk": "^0.12.0",
+4 -1
View File
@@ -360,7 +360,10 @@ export class GeminiAgent {
config.setFileSystemService(acpFileSystemService);
}
const clientHistory = convertSessionToClientHistory(sessionData.messages);
const clientHistory = convertSessionToClientHistory(
sessionData.messages,
sessionData.lastCompressionIndex,
);
const geminiClient = config.getGeminiClient();
await geminiClient.initialize();
+48 -1
View File
@@ -763,6 +763,48 @@ describe('loadCliConfig', () => {
});
});
it('should add IDE workspace folders from GEMINI_CLI_IDE_WORKSPACE_PATH to include directories', async () => {
vi.stubEnv(
'GEMINI_CLI_IDE_WORKSPACE_PATH',
['/project/folderA', '/project/folderB'].join(path.delimiter),
);
process.argv = ['node', 'script.js'];
const argv = await parseArguments(createTestMergedSettings());
const settings = createTestMergedSettings();
const config = await loadCliConfig(settings, 'test-session', argv);
const dirs = config.getPendingIncludeDirectories();
expect(dirs).toContain('/project/folderA');
expect(dirs).toContain('/project/folderB');
});
it('should skip inaccessible workspace folders from GEMINI_CLI_IDE_WORKSPACE_PATH', async () => {
const resolveToRealPathSpy = vi
.spyOn(ServerConfig, 'resolveToRealPath')
.mockImplementation((p) => {
if (p.toString().includes('restricted')) {
const err = new Error('EACCES: permission denied');
(err as NodeJS.ErrnoException).code = 'EACCES';
throw err;
}
return p.toString();
});
vi.stubEnv(
'GEMINI_CLI_IDE_WORKSPACE_PATH',
['/project/folderA', '/nonexistent/restricted/folder'].join(
path.delimiter,
),
);
process.argv = ['node', 'script.js'];
const argv = await parseArguments(createTestMergedSettings());
const settings = createTestMergedSettings();
const config = await loadCliConfig(settings, 'test-session', argv);
const dirs = config.getPendingIncludeDirectories();
expect(dirs).toContain('/project/folderA');
expect(dirs).not.toContain('/nonexistent/restricted/folder');
resolveToRealPathSpy.mockRestore();
});
it('should use default fileFilter options when unconfigured', async () => {
process.argv = ['node', 'script.js'];
const argv = await parseArguments(createTestMergedSettings());
@@ -798,6 +840,7 @@ describe('loadCliConfig', () => {
describe('Hierarchical Memory Loading (config.ts) - Placeholder Suite', () => {
beforeEach(() => {
vi.resetAllMocks();
vi.stubEnv('GEMINI_CLI_IDE_WORKSPACE_PATH', '');
// Restore ExtensionManager mocks that were reset
ExtensionManager.prototype.getExtensions = vi.fn().mockReturnValue([]);
ExtensionManager.prototype.loadExtensions = vi
@@ -809,6 +852,7 @@ describe('Hierarchical Memory Loading (config.ts) - Placeholder Suite', () => {
});
afterEach(() => {
vi.unstubAllEnvs();
vi.restoreAllMocks();
});
@@ -3347,7 +3391,10 @@ describe('Policy Engine Integration in loadCliConfig', () => {
expect(ServerConfig.createPolicyEngineConfig).toHaveBeenCalledWith(
expect.objectContaining({
policyPaths: ['/path/to/policy1.toml', '/path/to/policy2.toml'],
policyPaths: [
path.normalize('/path/to/policy1.toml'),
path.normalize('/path/to/policy2.toml'),
],
}),
expect.anything(),
);
+40 -7
View File
@@ -97,6 +97,7 @@ export interface CliArgs {
rawOutput: boolean | undefined;
acceptRawOutputRisk: boolean | undefined;
isCommand: boolean | undefined;
forever: boolean | undefined;
}
/**
@@ -244,10 +245,11 @@ export async function parseArguments(
// When --resume passed without a value (`gemini --resume`): value = "" (string)
// When --resume not passed at all: this `coerce` function is not called at all, and
// `yargsInstance.argv.resume` is undefined.
if (value === '') {
const trimmed = value.trim();
if (trimmed === '') {
return RESUME_LATEST;
}
return value;
return trimmed;
},
})
.option('list-sessions', {
@@ -297,6 +299,12 @@ export async function parseArguments(
.option('accept-raw-output-risk', {
type: 'boolean',
description: 'Suppress the security warning when using --raw-output.',
})
.option('forever', {
type: 'boolean',
description:
'Run as a long-running autonomous agent with auto-resume and schedule_work support.',
default: false,
}),
)
// Register MCP subcommands
@@ -429,8 +437,6 @@ export async function loadCliConfig(
const { cwd = process.cwd(), projectHooks } = options;
const debugMode = isDebugMode(argv);
const loadedSettings = loadSettings(cwd);
if (argv.sandbox) {
process.env['GEMINI_SANDBOX'] = 'true';
}
@@ -474,10 +480,32 @@ export async function loadCliConfig(
...settings.context?.fileFiltering,
};
//changes the includeDirectories to be absolute paths based on the cwd, and also include any additional directories specified via CLI args
const includeDirectories = (settings.context?.includeDirectories || [])
.map(resolvePath)
.concat((argv.includeDirectories || []).map(resolvePath));
// When running inside VSCode with multiple workspace folders,
// automatically add the other folders as include directories
// so Gemini has context of all open folders, not just the cwd.
const ideWorkspacePath = process.env['GEMINI_CLI_IDE_WORKSPACE_PATH'];
if (ideWorkspacePath) {
const realCwd = resolveToRealPath(cwd);
const ideFolders = ideWorkspacePath.split(path.delimiter).filter((p) => {
const trimmedPath = p.trim();
if (!trimmedPath) return false;
try {
return resolveToRealPath(trimmedPath) !== realCwd;
} catch (e) {
debugLogger.debug(
`[IDE] Skipping inaccessible workspace folder: ${trimmedPath} (${e instanceof Error ? e.message : String(e)})`,
);
return false;
}
});
includeDirectories.push(...ideFolders);
}
const extensionManager = new ExtensionManager({
settings,
requestConsent: requestConsentNonInteractive,
@@ -650,8 +678,12 @@ export async function loadCliConfig(
...settings.mcp,
allowed: argv.allowedMcpServerNames ?? settings.mcp?.allowed,
},
policyPaths: argv.policy ?? settings.policyPaths,
adminPolicyPaths: argv.adminPolicy ?? settings.adminPolicyPaths,
policyPaths: (argv.policy ?? settings.policyPaths)?.map((p) =>
resolvePath(p),
),
adminPolicyPaths: (argv.adminPolicy ?? settings.adminPolicyPaths)?.map(
(p) => resolvePath(p),
),
};
const { workspacePoliciesDir, policyUpdateConfirmationRequest } =
@@ -859,7 +891,7 @@ export async function loadCliConfig(
hooks: settings.hooks || {},
disabledHooks: settings.hooksConfig?.disabled || [],
projectHooks: projectHooks || {},
onModelChange: (model: string) => saveModelChange(loadedSettings, model),
onModelChange: (model: string) => saveModelChange(loadSettings(cwd), model),
onReload: async () => {
const refreshedSettings = loadSettings(cwd);
return {
@@ -868,6 +900,7 @@ export async function loadCliConfig(
};
},
enableConseca: settings.security?.enableConseca,
isForeverMode: !!argv.forever,
});
}
+69 -1
View File
@@ -1053,6 +1053,34 @@ const SETTINGS_SCHEMA = {
ref: 'ModelDefinition',
},
},
modelIdResolutions: {
type: 'object',
label: 'Model ID Resolutions',
category: 'Model',
requiresRestart: true,
default: DEFAULT_MODEL_CONFIGS.modelIdResolutions,
description:
'Rules for resolving requested model names to concrete model IDs based on context.',
showInDialog: false,
additionalProperties: {
type: 'object',
ref: 'ModelResolution',
},
},
classifierIdResolutions: {
type: 'object',
label: 'Classifier ID Resolutions',
category: 'Model',
requiresRestart: true,
default: DEFAULT_MODEL_CONFIGS.classifierIdResolutions,
description:
'Rules for resolving classifier tiers (flash, pro) to concrete model IDs.',
showInDialog: false,
additionalProperties: {
type: 'object',
ref: 'ModelResolution',
},
},
},
},
@@ -2284,6 +2312,18 @@ const SETTINGS_SCHEMA = {
ref: 'HookDefinitionArray',
mergeStrategy: MergeStrategy.CONCAT,
},
Idle: {
type: 'array',
label: 'Idle Hooks',
category: 'Advanced',
requiresRestart: false,
default: [],
description:
'Hooks that execute after a period of inactivity. Can trigger maintenance tasks like memory consolidation.',
showInDialog: false,
ref: 'HookDefinitionArray',
mergeStrategy: MergeStrategy.CONCAT,
},
},
additionalProperties: {
type: 'array',
@@ -2800,7 +2840,7 @@ export const SETTINGS_SCHEMA_DEFINITIONS: Record<
tier: { enum: ['pro', 'flash', 'flash-lite', 'custom', 'auto'] },
family: { type: 'string' },
isPreview: { type: 'boolean' },
dialogLocation: { enum: ['main', 'manual'] },
isVisible: { type: 'boolean' },
dialogDescription: { type: 'string' },
features: {
type: 'object',
@@ -2811,6 +2851,34 @@ export const SETTINGS_SCHEMA_DEFINITIONS: Record<
},
},
},
ModelResolution: {
type: 'object',
description: 'Model resolution rule.',
properties: {
default: { type: 'string' },
contexts: {
type: 'array',
items: {
type: 'object',
properties: {
condition: {
type: 'object',
properties: {
useGemini3_1: { type: 'boolean' },
useCustomTools: { type: 'boolean' },
hasAccessToPreview: { type: 'boolean' },
requestedModels: {
type: 'array',
items: { type: 'string' },
},
},
},
target: { type: 'string' },
},
},
},
},
},
};
export function getSettingsSchema(): SettingsSchemaType {
+453
View File
@@ -0,0 +1,453 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import http from 'node:http';
import { writeFileSync, mkdirSync, unlinkSync } from 'node:fs';
import { join } from 'node:path';
import os from 'node:os';
import crypto from 'node:crypto';
import { appEvents, AppEvent } from './utils/events.js';
// --- A2A Task management ---
interface A2AResponseMessage {
kind: 'message';
role: 'agent';
parts: Array<{ kind: 'text'; text: string }>;
messageId: string;
}
interface A2ATask {
id: string;
contextId: string;
status: {
state: 'submitted' | 'working' | 'completed' | 'failed';
timestamp: string;
message?: A2AResponseMessage;
};
}
const tasks = new Map<string, A2ATask>();
const TASK_CLEANUP_DELAY_MS = 10 * 60 * 1000; // 10 minutes
const DEFAULT_BLOCKING_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
interface ResponseWaiter {
taskId: string;
resolve: (text: string) => void;
}
const responseWaiters: ResponseWaiter[] = [];
// Queue for unsolicited responses (e.g. forever mode auto-resume output)
const unsolicitedResponses: string[] = [];
/**
* Called by AppContainer when streaming transitions from non-Idle to Idle.
* If there's a pending A2A task, resolves it. Otherwise queues as unsolicited.
*/
export function notifyResponse(responseText: string): void {
if (!responseText) return;
const waiter = responseWaiters.shift();
if (!waiter) {
// No A2A task waiting — queue as unsolicited (forever mode, etc.)
unsolicitedResponses.push(responseText);
return;
}
const task = tasks.get(waiter.taskId);
if (task) {
task.status = {
state: 'completed',
timestamp: new Date().toISOString(),
message: {
kind: 'message',
role: 'agent',
parts: [{ kind: 'text', text: responseText }],
messageId: crypto.randomUUID(),
},
};
scheduleTaskCleanup(task.id);
}
waiter.resolve(responseText);
}
/**
* Drain all unsolicited responses (from forever mode auto-resume, etc.).
*/
export function drainUnsolicitedResponses(): string[] {
return unsolicitedResponses.splice(0, unsolicitedResponses.length);
}
/**
* Returns true if there are any in-flight tasks waiting for a response.
*/
export function hasPendingTasks(): boolean {
return responseWaiters.length > 0;
}
/**
* Called when streaming starts (Idle -> non-Idle) to mark the oldest
* submitted task as "working".
*/
export function markTasksWorking(): void {
const waiter = responseWaiters[0];
if (!waiter) return;
const task = tasks.get(waiter.taskId);
if (task && task.status.state === 'submitted') {
task.status = {
state: 'working',
timestamp: new Date().toISOString(),
};
}
}
function scheduleTaskCleanup(taskId: string): void {
setTimeout(() => {
tasks.delete(taskId);
}, TASK_CLEANUP_DELAY_MS);
}
function createTask(): A2ATask {
const task: A2ATask = {
id: crypto.randomUUID(),
contextId: `session-${process.pid}`,
status: {
state: 'submitted',
timestamp: new Date().toISOString(),
},
};
tasks.set(task.id, task);
return task;
}
function formatTaskResult(task: A2ATask): object {
return {
kind: 'task',
id: task.id,
contextId: task.contextId,
status: task.status,
};
}
// --- JSON-RPC helpers ---
interface JsonRpcRequest {
jsonrpc?: string;
id?: string | number | null;
method?: string;
params?: Record<string, unknown>;
}
function jsonRpcSuccess(id: string | number | null, result: object): object {
return { jsonrpc: '2.0', id, result };
}
function jsonRpcError(
id: string | number | null,
code: number,
message: string,
): object {
return { jsonrpc: '2.0', id, error: { code, message } };
}
// --- HTTP utilities ---
function getSessionsDir(): string {
return join(os.homedir(), '.gemini', 'sessions');
}
function getPortFilePath(): string {
return join(getSessionsDir(), `interactive-${process.pid}.port`);
}
function buildAgentCard(port: number): object {
return {
name: 'Gemini CLI Interactive Session',
url: `http://localhost:${port}/`,
protocolVersion: '0.3.0',
provider: { organization: 'Google', url: 'https://google.com' },
capabilities: { streaming: false, pushNotifications: false },
defaultInputModes: ['text'],
defaultOutputModes: ['text'],
skills: [
{
id: 'interactive_session',
name: 'Interactive Session',
description: 'Send messages to the live interactive Gemini CLI session',
},
],
};
}
interface A2AMessagePart {
kind?: string;
text?: string;
}
function extractTextFromParts(
parts: A2AMessagePart[] | undefined,
): string | null {
if (!Array.isArray(parts)) {
return null;
}
const texts: string[] = [];
for (const part of parts) {
if (part.kind === 'text' && typeof part.text === 'string') {
texts.push(part.text);
}
}
return texts.length > 0 ? texts.join('\n') : null;
}
function sendJson(
res: http.ServerResponse,
statusCode: number,
data: object,
): void {
const body = JSON.stringify(data);
res.writeHead(statusCode, {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(body),
});
res.end(body);
}
function readBody(req: http.IncomingMessage): Promise<string> {
return new Promise((resolve, reject) => {
const chunks: Buffer[] = [];
let size = 0;
const maxSize = 1024 * 1024; // 1MB limit
req.on('data', (chunk: Buffer) => {
size += chunk.length;
if (size > maxSize) {
req.destroy();
reject(new Error('Request body too large'));
return;
}
chunks.push(chunk);
});
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf-8')));
req.on('error', reject);
});
}
// --- JSON-RPC request handlers ---
function handleMessageSend(
rpcId: string | number | null,
params: Record<string, unknown>,
res: http.ServerResponse,
): void {
const messageVal = params['message'];
const message =
messageVal && typeof messageVal === 'object'
? (messageVal as { role?: string; parts?: A2AMessagePart[] })
: undefined;
const text = extractTextFromParts(message?.parts);
if (!text) {
sendJson(
res,
200,
jsonRpcError(
rpcId,
-32602,
'Missing or empty text. Expected: params.message.parts with kind "text".',
),
);
return;
}
const task = createTask();
// Inject message into the session
appEvents.emit(AppEvent.ExternalMessage, text);
// Block until response (standard A2A message/send semantics)
const timer = setTimeout(() => {
const idx = responseWaiters.findIndex((w) => w.taskId === task.id);
if (idx !== -1) {
responseWaiters.splice(idx, 1);
}
task.status = {
state: 'failed',
timestamp: new Date().toISOString(),
};
scheduleTaskCleanup(task.id);
sendJson(res, 200, jsonRpcError(rpcId, -32000, 'Request timed out'));
}, DEFAULT_BLOCKING_TIMEOUT_MS);
responseWaiters.push({
taskId: task.id,
resolve: () => {
clearTimeout(timer);
// Task is already updated in notifyResponse
const updatedTask = tasks.get(task.id);
sendJson(
res,
200,
jsonRpcSuccess(rpcId, formatTaskResult(updatedTask ?? task)),
);
},
});
}
function handleResponsesPoll(
rpcId: string | number | null,
res: http.ServerResponse,
): void {
const responses = drainUnsolicitedResponses();
sendJson(res, 200, jsonRpcSuccess(rpcId, { responses }));
}
function handleTasksGet(
rpcId: string | number | null,
params: Record<string, unknown>,
res: http.ServerResponse,
): void {
const taskId = params['id'];
if (typeof taskId !== 'string') {
sendJson(
res,
200,
jsonRpcError(rpcId, -32602, 'Missing or invalid params.id'),
);
return;
}
const task = tasks.get(taskId);
if (!task) {
sendJson(res, 200, jsonRpcError(rpcId, -32001, 'Task not found'));
return;
}
sendJson(res, 200, jsonRpcSuccess(rpcId, formatTaskResult(task)));
}
// --- Server ---
export interface ExternalListenerResult {
port: number;
cleanup: () => void;
}
/**
* Start an embedded HTTP server that accepts A2A-format JSON-RPC messages
* and bridges them into the interactive session's message queue.
*/
export function startExternalListener(options?: {
port?: number;
}): Promise<ExternalListenerResult> {
const port = options?.port ?? 0;
return new Promise((resolve, reject) => {
const server = http.createServer(
(req: http.IncomingMessage, res: http.ServerResponse) => {
const url = new URL(req.url ?? '/', `http://localhost`);
// GET /.well-known/agent-card.json
if (
req.method === 'GET' &&
url.pathname === '/.well-known/agent-card.json'
) {
const address = server.address();
const actualPort =
typeof address === 'object' && address ? address.port : port;
sendJson(res, 200, buildAgentCard(actualPort));
return;
}
// POST / — JSON-RPC 2.0 routing
if (req.method === 'POST' && url.pathname === '/') {
readBody(req)
.then((rawBody) => {
let parsed: JsonRpcRequest;
try {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
parsed = JSON.parse(rawBody) as JsonRpcRequest;
} catch {
sendJson(
res,
200,
jsonRpcError(null, -32700, 'Parse error: invalid JSON'),
);
return;
}
const rpcId = parsed.id ?? null;
const method = parsed.method;
const params = parsed.params ?? {};
switch (method) {
case 'message/send':
handleMessageSend(rpcId, params, res);
break;
case 'tasks/get':
handleTasksGet(rpcId, params, res);
break;
case 'responses/poll':
handleResponsesPoll(rpcId, res);
break;
default:
sendJson(
res,
200,
jsonRpcError(
rpcId,
-32601,
`Method not found: ${method ?? '(none)'}`,
),
);
}
})
.catch(() => {
sendJson(
res,
200,
jsonRpcError(null, -32603, 'Failed to read request body'),
);
});
return;
}
// 404 for everything else
sendJson(res, 404, { error: 'Not found' });
},
);
server.listen(port, '127.0.0.1', () => {
const address = server.address();
const actualPort =
typeof address === 'object' && address ? address.port : port;
// Write port file
try {
const sessionsDir = getSessionsDir();
mkdirSync(sessionsDir, { recursive: true });
writeFileSync(getPortFilePath(), String(actualPort), 'utf-8');
} catch {
// Non-fatal: port file is a convenience, not a requirement
}
const cleanup = () => {
server.close();
try {
unlinkSync(getPortFilePath());
} catch {
// Ignore: file may already be deleted
}
};
resolve({ port: actualPort, cleanup });
});
server.on('error', (err) => {
reject(err);
});
});
}
+1
View File
@@ -513,6 +513,7 @@ describe('gemini.tsx main function kitty protocol', () => {
rawOutput: undefined,
acceptRawOutputRisk: undefined,
isCommand: undefined,
forever: undefined,
});
await act(async () => {
+1 -1
View File
@@ -647,7 +647,7 @@ export async function main() {
process.exit(ExitCodes.FATAL_INPUT_ERROR);
}
const prompt_id = Math.random().toString(16).slice(2);
const prompt_id = sessionId;
logUserPrompt(
config,
new UserPromptEvent(
+22 -12
View File
@@ -10,6 +10,8 @@ import { basename } from 'node:path';
import { AppContainer } from './ui/AppContainer.js';
import { ConsolePatcher } from './ui/utils/ConsolePatcher.js';
import { registerCleanup, setupTtyCheck } from './utils/cleanup.js';
import { startExternalListener } from './external-listener.js';
import { appEvents, AppEvent } from './utils/events.js';
import {
type StartupWarning,
type Config,
@@ -101,18 +103,8 @@ export async function startInteractiveUI(
return (
<SettingsContext.Provider value={settings}>
<KeyMatchersProvider value={matchers}>
<KeypressProvider
config={config}
debugKeystrokeLogging={
settings.merged.general.debugKeystrokeLogging
}
>
<MouseProvider
mouseEventsEnabled={mouseEventsEnabled}
debugKeystrokeLogging={
settings.merged.general.debugKeystrokeLogging
}
>
<KeypressProvider config={config}>
<MouseProvider mouseEventsEnabled={mouseEventsEnabled}>
<TerminalProvider>
<ScrollProvider>
<OverflowProvider>
@@ -191,6 +183,24 @@ export async function startInteractiveUI(
registerCleanup(() => instance.unmount());
// Auto-start A2A HTTP listener in Forever Mode
if (config.getIsForeverMode()) {
try {
const listener = await startExternalListener({ port: 0 });
registerCleanup(listener.cleanup);
appEvents.emit(AppEvent.A2AListenerStarted, listener.port);
coreEvents.emitFeedback(
'info',
`A2A endpoint listening on port ${listener.port}`,
);
} catch (err) {
coreEvents.emitFeedback(
'warning',
`Failed to start A2A listener: ${err instanceof Error ? err.message : String(err)}`,
);
}
}
registerCleanup(setupTtyCheck());
}
+1
View File
@@ -222,6 +222,7 @@ export async function runNonInteractive({
await geminiClient.resumeChat(
convertSessionToClientHistory(
resumedSessionData.conversation.messages,
resumedSessionData.conversation.lastCompressionIndex,
),
resumedSessionData,
);
+9 -6
View File
@@ -204,6 +204,7 @@ export class AppRig {
enableEventDrivenScheduler: true,
extensionLoader: new MockExtensionManager(),
excludeTools: this.options.configOverrides?.excludeTools,
useAlternateBuffer: false,
...this.options.configOverrides,
};
this.config = makeFakeConfig(configParams);
@@ -275,19 +276,22 @@ export class AppRig {
enabled: false,
hasSeenNudge: true,
},
ui: {
useAlternateBuffer: false,
},
},
});
}
private stubRefreshAuth() {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const gcConfig = this.config as any;
gcConfig.refreshAuth = async (authMethod: AuthType) => {
gcConfig.modelAvailabilityService.reset();
const newContentGeneratorConfig = {
authType: authMethod,
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
proxy: gcConfig.getProxy(),
apiKey: process.env['GEMINI_API_KEY'] || 'test-api-key',
};
@@ -410,7 +414,6 @@ export class AppRig {
config: this.config!,
settings: this.settings!,
width: this.options.terminalWidth ?? 120,
useAlternateBuffer: false,
uiState: {
terminalHeight: this.options.terminalHeight ?? 40,
},
@@ -456,7 +459,7 @@ export class AppRig {
const actualToolName = toolName === '*' ? undefined : toolName;
this.config
.getPolicyEngine()
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
.removeRulesForTool(actualToolName as string, source);
this.breakpointTools.delete(toolName);
}
@@ -729,7 +732,7 @@ export class AppRig {
.getGeminiClient()
?.getChatRecordingService();
if (recordingService) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(recordingService as any).conversationFile = null;
}
}
@@ -749,7 +752,7 @@ export class AppRig {
MockShellExecutionService.reset();
ideContextStore.clear();
// Forcefully clear IdeClient singleton promise
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(IdeClient as any).instancePromise = null;
vi.clearAllMocks();
@@ -37,14 +37,14 @@ export const createMockCommandContext = (
},
services: {
config: null,
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
settings: {
merged: defaultMergedSettings,
setValue: vi.fn(),
forScope: vi.fn().mockReturnValue({ settings: {} }),
} as unknown as LoadedSettings,
git: undefined as GitService | undefined,
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-unsafe-assignment
logger: {
log: vi.fn(),
logMessage: vi.fn(),
@@ -53,7 +53,7 @@ export const createMockCommandContext = (
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any, // Cast because Logger is a class.
},
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-unsafe-assignment
ui: {
addItem: vi.fn(),
clear: vi.fn(),
@@ -72,7 +72,7 @@ export const createMockCommandContext = (
} as any,
session: {
sessionShellAllowlist: new Set<string>(),
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
stats: {
sessionStartTime: new Date(),
lastPromptTokenCount: 0,
@@ -93,14 +93,12 @@ export const createMockCommandContext = (
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const merge = (target: any, source: any): any => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const output = { ...target };
for (const key in source) {
if (Object.prototype.hasOwnProperty.call(source, key)) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const sourceValue = source[key];
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const targetValue = output[key];
if (
@@ -108,11 +106,10 @@ export const createMockCommandContext = (
Object.prototype.toString.call(sourceValue) === '[object Object]' &&
Object.prototype.toString.call(targetValue) === '[object Object]'
) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
output[key] = merge(targetValue, sourceValue);
} else {
// If not, we do a direct assignment. This preserves Date objects and others.
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
output[key] = sourceValue;
}
}
@@ -120,6 +117,5 @@ export const createMockCommandContext = (
return output;
};
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return merge(defaultMocks, overrides);
};
@@ -173,6 +173,14 @@ export const createMockConfig = (overrides: Partial<Config> = {}): Config =>
getHasAccessToPreviewModel: vi.fn().mockReturnValue(false),
validatePathAccess: vi.fn().mockReturnValue(null),
getUseAlternateBuffer: vi.fn().mockReturnValue(false),
getIsForeverMode: vi.fn().mockReturnValue(false),
getWorkScheduler: vi.fn().mockReturnValue({
on: vi.fn(),
off: vi.fn(),
serialize: vi.fn().mockReturnValue([]),
restore: vi.fn(),
dispose: vi.fn(),
}),
...overrides,
}) as unknown as Config;
+17 -56
View File
@@ -18,7 +18,7 @@ import type React from 'react';
import { act, useState } from 'react';
import os from 'node:os';
import path from 'node:path';
import { LoadedSettings } from '../config/settings.js';
import type { LoadedSettings } from '../config/settings.js';
import { KeypressProvider } from '../ui/contexts/KeypressContext.js';
import { SettingsContext } from '../ui/contexts/SettingsContext.js';
import { ShellFocusContext } from '../ui/contexts/ShellFocusContext.js';
@@ -416,11 +416,10 @@ export const render = (
stdout.clear();
act(() => {
instance = inkRenderDirect(tree, {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
stdout: stdout as unknown as NodeJS.WriteStream,
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
stderr: stderr as unknown as NodeJS.WriteStream,
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
stdin: stdin as unknown as NodeJS.ReadStream,
debug: false,
exitOnCtrlC: false,
@@ -499,7 +498,6 @@ const getMockConfigInternal = (): Config => {
return mockConfigInternal;
};
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const configProxy = new Proxy({} as Config, {
get(_target, prop) {
if (prop === 'getTargetDir') {
@@ -526,21 +524,13 @@ const configProxy = new Proxy({} as Config, {
}
const internal = getMockConfigInternal();
if (prop in internal) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
return internal[prop as keyof typeof internal];
}
throw new Error(`mockConfig does not have property ${String(prop)}`);
},
});
export const mockSettings = new LoadedSettings(
{ path: '', settings: {}, originalSettings: {} },
{ path: '', settings: {}, originalSettings: {} },
{ path: '', settings: {}, originalSettings: {} },
{ path: '', settings: {}, originalSettings: {} },
true,
[],
);
export const mockSettings = createMockSettings();
// A minimal mock UIState to satisfy the context provider.
// Tests that need specific UIState values should provide their own.
@@ -657,9 +647,8 @@ export const renderWithProviders = (
uiState: providedUiState,
width,
mouseEventsEnabled = false,
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
config = configProxy as unknown as Config,
useAlternateBuffer = true,
uiActions,
persistentState,
appState = mockAppState,
@@ -670,7 +659,6 @@ export const renderWithProviders = (
width?: number;
mouseEventsEnabled?: boolean;
config?: Config;
useAlternateBuffer?: boolean;
uiActions?: Partial<UIActions>;
persistentState?: {
get?: typeof persistentStateMock.get;
@@ -685,20 +673,17 @@ export const renderWithProviders = (
button?: 0 | 1 | 2,
) => Promise<void>;
} => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const baseState: UIState = new Proxy(
{ ...baseMockUiState, ...providedUiState },
{
get(target, prop) {
if (prop in target) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
return target[prop as keyof typeof target];
}
// For properties not in the base mock or provided state,
// we'll check the original proxy to see if it's a defined but
// unprovided property, and if not, throw.
if (prop in baseMockUiState) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
return baseMockUiState[prop as keyof typeof baseMockUiState];
}
throw new Error(`mockUiState does not have property ${String(prop)}`);
@@ -716,31 +701,8 @@ export const renderWithProviders = (
persistentStateMock.mockClear();
const terminalWidth = width ?? baseState.terminalWidth;
let finalSettings = settings;
if (useAlternateBuffer !== undefined) {
finalSettings = createMockSettings({
...settings.merged,
ui: {
...settings.merged.ui,
useAlternateBuffer,
},
});
}
// Wrap config in a Proxy so useAlternateBuffer hook (which reads from Config) gets the correct value,
// without replacing the entire config object and its other values.
let finalConfig = config;
if (useAlternateBuffer !== undefined) {
finalConfig = new Proxy(config, {
get(target, prop, receiver) {
if (prop === 'getUseAlternateBuffer') {
return () => useAlternateBuffer;
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return Reflect.get(target, prop, receiver);
},
});
}
const finalSettings = settings;
const finalConfig = config;
const mainAreaWidth = terminalWidth;
@@ -768,7 +730,7 @@ export const renderWithProviders = (
capturedOverflowState = undefined;
capturedOverflowActions = undefined;
const renderResult = render(
const wrapWithProviders = (comp: React.ReactElement) => (
<AppContext.Provider value={appState}>
<ConfigContext.Provider value={finalConfig}>
<SettingsContext.Provider value={finalSettings}>
@@ -803,7 +765,7 @@ export const renderWithProviders = (
flexGrow={0}
flexDirection="column"
>
{component}
{comp}
</Box>
</ContextCapture>
</ScrollProvider>
@@ -821,12 +783,16 @@ export const renderWithProviders = (
</UIStateContext.Provider>
</SettingsContext.Provider>
</ConfigContext.Provider>
</AppContext.Provider>,
terminalWidth,
</AppContext.Provider>
);
const renderResult = render(wrapWithProviders(component), terminalWidth);
return {
...renderResult,
rerender: (newComponent: React.ReactElement) => {
renderResult.rerender(wrapWithProviders(newComponent));
},
capturedOverflowState,
capturedOverflowActions,
simulateClick: (col: number, row: number, button?: 0 | 1 | 2) =>
@@ -847,9 +813,8 @@ export function renderHook<Result, Props>(
waitUntilReady: () => Promise<void>;
generateSvg: () => string;
} {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const result = { current: undefined as unknown as Result };
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
let currentProps = options?.initialProps as Props;
function TestComponent({
@@ -884,7 +849,6 @@ export function renderHook<Result, Props>(
function rerender(props?: Props) {
if (arguments.length > 0) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
currentProps = props as Props;
}
act(() => {
@@ -911,7 +875,6 @@ export function renderHookWithProviders<Result, Props>(
width?: number;
mouseEventsEnabled?: boolean;
config?: Config;
useAlternateBuffer?: boolean;
} = {},
): {
result: { current: Result };
@@ -920,7 +883,6 @@ export function renderHookWithProviders<Result, Props>(
waitUntilReady: () => Promise<void>;
generateSvg: () => string;
} {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const result = { current: undefined as unknown as Result };
let setPropsFn: ((props: Props) => void) | undefined;
@@ -942,7 +904,7 @@ export function renderHookWithProviders<Result, Props>(
act(() => {
renderResult = renderWithProviders(
<Wrapper>
{/* eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion */}
{}
<TestComponent initialProps={options.initialProps as Props} />
</Wrapper>,
options,
@@ -952,7 +914,6 @@ export function renderHookWithProviders<Result, Props>(
function rerender(newProps?: Props) {
act(() => {
if (arguments.length > 0 && setPropsFn) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
setPropsFn(newProps as Props);
} else if (forceUpdateFn) {
forceUpdateFn();
+4 -6
View File
@@ -46,23 +46,22 @@ export const createMockSettings = (
workspace,
isTrusted,
errors,
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
merged: mergedOverride,
...settingsOverrides
} = overrides;
const loaded = new LoadedSettings(
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
(system as any) || { path: '', settings: {}, originalSettings: {} },
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
(systemDefaults as any) || { path: '', settings: {}, originalSettings: {} },
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
(user as any) || {
path: '',
settings: settingsOverrides,
originalSettings: settingsOverrides,
},
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
(workspace as any) || { path: '', settings: {}, originalSettings: {} },
isTrusted ?? true,
errors || [],
@@ -76,7 +75,6 @@ export const createMockSettings = (
// Assign any function overrides (e.g., vi.fn() for methods)
for (const key in overrides) {
if (typeof overrides[key] === 'function') {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-unsafe-assignment
(loaded as any)[key] = overrides[key];
}
}
+24 -4
View File
@@ -7,6 +7,7 @@
import { describe, it, expect, vi, type Mock, beforeEach } from 'vitest';
import type React from 'react';
import { renderWithProviders } from '../test-utils/render.js';
import { createMockSettings } from '../test-utils/settings.js';
import { Text, useIsScreenReaderEnabled, type DOMElement } from 'ink';
import { App } from './App.js';
import { type UIState } from './contexts/UIStateContext.js';
@@ -81,6 +82,7 @@ describe('App', () => {
updateItem: vi.fn(),
clearItems: vi.fn(),
loadHistory: vi.fn(),
pruneItems: vi.fn(),
},
history: [],
pendingHistoryItems: [],
@@ -97,7 +99,8 @@ describe('App', () => {
<App />,
{
uiState: mockUIState,
useAlternateBuffer: false,
config: makeFakeConfig({ useAlternateBuffer: false }),
settings: createMockSettings({ ui: { useAlternateBuffer: false } }),
},
);
await waitUntilReady();
@@ -118,7 +121,8 @@ describe('App', () => {
<App />,
{
uiState: quittingUIState,
useAlternateBuffer: false,
config: makeFakeConfig({ useAlternateBuffer: false }),
settings: createMockSettings({ ui: { useAlternateBuffer: false } }),
},
);
await waitUntilReady();
@@ -139,7 +143,8 @@ describe('App', () => {
<App />,
{
uiState: quittingUIState,
useAlternateBuffer: true,
config: makeFakeConfig({ useAlternateBuffer: true }),
settings: createMockSettings({ ui: { useAlternateBuffer: true } }),
},
);
await waitUntilReady();
@@ -159,6 +164,8 @@ describe('App', () => {
<App />,
{
uiState: dialogUIState,
config: makeFakeConfig({ useAlternateBuffer: true }),
settings: createMockSettings({ ui: { useAlternateBuffer: true } }),
},
);
await waitUntilReady();
@@ -185,6 +192,8 @@ describe('App', () => {
<App />,
{
uiState,
config: makeFakeConfig({ useAlternateBuffer: true }),
settings: createMockSettings({ ui: { useAlternateBuffer: true } }),
},
);
await waitUntilReady();
@@ -201,6 +210,8 @@ describe('App', () => {
<App />,
{
uiState: mockUIState,
config: makeFakeConfig({ useAlternateBuffer: true }),
settings: createMockSettings({ ui: { useAlternateBuffer: true } }),
},
);
await waitUntilReady();
@@ -219,6 +230,8 @@ describe('App', () => {
<App />,
{
uiState: mockUIState,
config: makeFakeConfig({ useAlternateBuffer: true }),
settings: createMockSettings({ ui: { useAlternateBuffer: true } }),
},
);
await waitUntilReady();
@@ -265,7 +278,7 @@ describe('App', () => {
],
} as UIState;
const configWithExperiment = makeFakeConfig();
const configWithExperiment = makeFakeConfig({ useAlternateBuffer: true });
vi.spyOn(configWithExperiment, 'isTrustedFolder').mockReturnValue(true);
vi.spyOn(configWithExperiment, 'getIdeMode').mockReturnValue(false);
@@ -274,6 +287,7 @@ describe('App', () => {
{
uiState: stateWithConfirmingTool,
config: configWithExperiment,
settings: createMockSettings({ ui: { useAlternateBuffer: true } }),
},
);
await waitUntilReady();
@@ -293,6 +307,8 @@ describe('App', () => {
<App />,
{
uiState: mockUIState,
config: makeFakeConfig({ useAlternateBuffer: true }),
settings: createMockSettings({ ui: { useAlternateBuffer: true } }),
},
);
await waitUntilReady();
@@ -306,6 +322,8 @@ describe('App', () => {
<App />,
{
uiState: mockUIState,
config: makeFakeConfig({ useAlternateBuffer: true }),
settings: createMockSettings({ ui: { useAlternateBuffer: true } }),
},
);
await waitUntilReady();
@@ -322,6 +340,8 @@ describe('App', () => {
<App />,
{
uiState: dialogUIState,
config: makeFakeConfig({ useAlternateBuffer: true }),
settings: createMockSettings({ ui: { useAlternateBuffer: true } }),
},
);
await waitUntilReady();
+96 -212
View File
@@ -95,7 +95,8 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
};
});
import ansiEscapes from 'ansi-escapes';
import { mergeSettings, type LoadedSettings } from '../config/settings.js';
import { type LoadedSettings } from '../config/settings.js';
import { createMockSettings } from '../test-utils/settings.js';
import type { InitializationResult } from '../core/initializer.js';
import { useQuotaAndFallback } from './hooks/useQuotaAndFallback.js';
import { StreamingState } from './types.js';
@@ -484,23 +485,18 @@ describe('AppContainer State Management', () => {
);
// Mock LoadedSettings
const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true);
mockSettings = {
merged: {
...defaultMergedSettings,
hideBanner: false,
hideFooter: false,
hideTips: false,
showMemoryUsage: false,
theme: 'default',
ui: {
...defaultMergedSettings.ui,
showStatusInTitle: false,
hideWindowTitle: false,
useAlternateBuffer: false,
},
mockSettings = createMockSettings({
hideBanner: false,
hideFooter: false,
hideTips: false,
showMemoryUsage: false,
theme: 'default',
ui: {
showStatusInTitle: false,
hideWindowTitle: false,
useAlternateBuffer: false,
},
} as unknown as LoadedSettings;
});
// Mock InitializationResult
mockInitResult = {
@@ -1008,16 +1004,12 @@ describe('AppContainer State Management', () => {
describe('Settings Integration', () => {
it('handles settings with all display options disabled', async () => {
const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true);
const settingsAllHidden = {
merged: {
...defaultMergedSettings,
hideBanner: true,
hideFooter: true,
hideTips: true,
showMemoryUsage: false,
},
} as unknown as LoadedSettings;
const settingsAllHidden = createMockSettings({
hideBanner: true,
hideFooter: true,
hideTips: true,
showMemoryUsage: false,
});
let unmount: () => void;
await act(async () => {
@@ -1029,16 +1021,9 @@ describe('AppContainer State Management', () => {
});
it('handles settings with memory usage enabled', async () => {
const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true);
const settingsWithMemory = {
merged: {
...defaultMergedSettings,
hideBanner: false,
hideFooter: false,
hideTips: false,
showMemoryUsage: true,
},
} as unknown as LoadedSettings;
const settingsWithMemory = createMockSettings({
showMemoryUsage: true,
});
let unmount: () => void;
await act(async () => {
@@ -1078,9 +1063,7 @@ describe('AppContainer State Management', () => {
});
it('handles undefined settings gracefully', async () => {
const undefinedSettings = {
merged: mergeSettings({}, {}, {}, {}, true),
} as LoadedSettings;
const undefinedSettings = createMockSettings();
let unmount: () => void;
await act(async () => {
@@ -1498,18 +1481,12 @@ describe('AppContainer State Management', () => {
it('should update terminal title with Working… when showStatusInTitle is false', () => {
// Arrange: Set up mock settings with showStatusInTitle disabled
const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true);
const mockSettingsWithShowStatusFalse = {
...mockSettings,
merged: {
...defaultMergedSettings,
ui: {
...defaultMergedSettings.ui,
showStatusInTitle: false,
hideWindowTitle: false,
},
const mockSettingsWithShowStatusFalse = createMockSettings({
ui: {
showStatusInTitle: false,
hideWindowTitle: false,
},
} as unknown as LoadedSettings;
});
// Mock the streaming state as Active
mockedUseGeminiStream.mockReturnValue({
@@ -1537,17 +1514,12 @@ describe('AppContainer State Management', () => {
it('should use legacy terminal title when dynamicWindowTitle is false', () => {
// Arrange: Set up mock settings with dynamicWindowTitle disabled
const mockSettingsWithDynamicTitleFalse = {
...mockSettings,
merged: {
...mockSettings.merged,
ui: {
...mockSettings.merged.ui,
dynamicWindowTitle: false,
hideWindowTitle: false,
},
const mockSettingsWithDynamicTitleFalse = createMockSettings({
ui: {
dynamicWindowTitle: false,
hideWindowTitle: false,
},
} as unknown as LoadedSettings;
});
// Mock the streaming state
mockedUseGeminiStream.mockReturnValue({
@@ -1575,18 +1547,12 @@ describe('AppContainer State Management', () => {
it('should not update terminal title when hideWindowTitle is true', () => {
// Arrange: Set up mock settings with hideWindowTitle enabled
const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true);
const mockSettingsWithHideTitleTrue = {
...mockSettings,
merged: {
...defaultMergedSettings,
ui: {
...defaultMergedSettings.ui,
showStatusInTitle: true,
hideWindowTitle: true,
},
const mockSettingsWithHideTitleTrue = createMockSettings({
ui: {
showStatusInTitle: true,
hideWindowTitle: true,
},
} as unknown as LoadedSettings;
});
// Act: Render the container
const { unmount } = renderAppContainer({
@@ -1604,18 +1570,12 @@ describe('AppContainer State Management', () => {
it('should update terminal title with thought subject when in active state', () => {
// Arrange: Set up mock settings with showStatusInTitle enabled
const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true);
const mockSettingsWithTitleEnabled = {
...mockSettings,
merged: {
...defaultMergedSettings,
ui: {
...defaultMergedSettings.ui,
showStatusInTitle: true,
hideWindowTitle: false,
},
const mockSettingsWithTitleEnabled = createMockSettings({
ui: {
showStatusInTitle: true,
hideWindowTitle: false,
},
} as unknown as LoadedSettings;
});
// Mock the streaming state and thought
const thoughtSubject = 'Processing request';
@@ -1644,18 +1604,12 @@ describe('AppContainer State Management', () => {
it('should update terminal title with default text when in Idle state and no thought subject', () => {
// Arrange: Set up mock settings with showStatusInTitle enabled
const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true);
const mockSettingsWithTitleEnabled = {
...mockSettings,
merged: {
...defaultMergedSettings,
ui: {
...defaultMergedSettings.ui,
showStatusInTitle: true,
hideWindowTitle: false,
},
const mockSettingsWithTitleEnabled = createMockSettings({
ui: {
showStatusInTitle: true,
hideWindowTitle: false,
},
} as unknown as LoadedSettings;
});
// Mock the streaming state as Idle with no thought
mockedUseGeminiStream.mockReturnValue(DEFAULT_GEMINI_STREAM_MOCK);
@@ -1679,18 +1633,12 @@ describe('AppContainer State Management', () => {
it('should update terminal title when in WaitingForConfirmation state with thought subject', async () => {
// Arrange: Set up mock settings with showStatusInTitle enabled
const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true);
const mockSettingsWithTitleEnabled = {
...mockSettings,
merged: {
...defaultMergedSettings,
ui: {
...defaultMergedSettings.ui,
showStatusInTitle: true,
hideWindowTitle: false,
},
const mockSettingsWithTitleEnabled = createMockSettings({
ui: {
showStatusInTitle: true,
hideWindowTitle: false,
},
} as unknown as LoadedSettings;
});
// Mock the streaming state and thought
const thoughtSubject = 'Confirm tool execution';
@@ -1742,17 +1690,12 @@ describe('AppContainer State Management', () => {
vi.setSystemTime(startTime);
// Arrange: Set up mock settings with showStatusInTitle enabled
const mockSettingsWithTitleEnabled = {
...mockSettings,
merged: {
...mockSettings.merged,
ui: {
...mockSettings.merged.ui,
showStatusInTitle: true,
hideWindowTitle: false,
},
const mockSettingsWithTitleEnabled = createMockSettings({
ui: {
showStatusInTitle: true,
hideWindowTitle: false,
},
} as unknown as LoadedSettings;
});
// Mock an active shell pty but not focused
mockedUseGeminiStream.mockReturnValue({
@@ -1801,17 +1744,12 @@ describe('AppContainer State Management', () => {
vi.setSystemTime(startTime);
// Arrange: Set up mock settings with showStatusInTitle enabled
const mockSettingsWithTitleEnabled = {
...mockSettings,
merged: {
...mockSettings.merged,
ui: {
...mockSettings.merged.ui,
showStatusInTitle: true,
hideWindowTitle: false,
},
const mockSettingsWithTitleEnabled = createMockSettings({
ui: {
showStatusInTitle: true,
hideWindowTitle: false,
},
} as unknown as LoadedSettings;
});
// Mock an active shell pty with redirection active
mockedUseGeminiStream.mockReturnValue({
@@ -1871,17 +1809,12 @@ describe('AppContainer State Management', () => {
vi.setSystemTime(startTime);
// Arrange: Set up mock settings with showStatusInTitle enabled
const mockSettingsWithTitleEnabled = {
...mockSettings,
merged: {
...mockSettings.merged,
ui: {
...mockSettings.merged.ui,
showStatusInTitle: true,
hideWindowTitle: false,
},
const mockSettingsWithTitleEnabled = createMockSettings({
ui: {
showStatusInTitle: true,
hideWindowTitle: false,
},
} as unknown as LoadedSettings;
});
// Mock an active shell pty with NO output since operation started (silent)
mockedUseGeminiStream.mockReturnValue({
@@ -1921,17 +1854,12 @@ describe('AppContainer State Management', () => {
vi.setSystemTime(startTime);
// Arrange: Set up mock settings with showStatusInTitle enabled
const mockSettingsWithTitleEnabled = {
...mockSettings,
merged: {
...mockSettings.merged,
ui: {
...mockSettings.merged.ui,
showStatusInTitle: true,
hideWindowTitle: false,
},
const mockSettingsWithTitleEnabled = createMockSettings({
ui: {
showStatusInTitle: true,
hideWindowTitle: false,
},
} as unknown as LoadedSettings;
});
// Mock an active shell pty but not focused
let lastOutputTime = startTime + 1000;
@@ -2005,18 +1933,12 @@ describe('AppContainer State Management', () => {
it('should pad title to exactly 80 characters', () => {
// Arrange: Set up mock settings with showStatusInTitle enabled
const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true);
const mockSettingsWithTitleEnabled = {
...mockSettings,
merged: {
...defaultMergedSettings,
ui: {
...defaultMergedSettings.ui,
showStatusInTitle: true,
hideWindowTitle: false,
},
const mockSettingsWithTitleEnabled = createMockSettings({
ui: {
showStatusInTitle: true,
hideWindowTitle: false,
},
} as unknown as LoadedSettings;
});
// Mock the streaming state and thought with a short subject
const shortTitle = 'Short';
@@ -2046,18 +1968,12 @@ describe('AppContainer State Management', () => {
it('should use correct ANSI escape code format', () => {
// Arrange: Set up mock settings with showStatusInTitle enabled
const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true);
const mockSettingsWithTitleEnabled = {
...mockSettings,
merged: {
...defaultMergedSettings,
ui: {
...defaultMergedSettings.ui,
showStatusInTitle: true,
hideWindowTitle: false,
},
const mockSettingsWithTitleEnabled = createMockSettings({
ui: {
showStatusInTitle: true,
hideWindowTitle: false,
},
} as unknown as LoadedSettings;
});
// Mock the streaming state and thought
const title = 'Test Title';
@@ -2085,17 +2001,12 @@ describe('AppContainer State Management', () => {
it('should use CLI_TITLE environment variable when set', () => {
// Arrange: Set up mock settings with showStatusInTitle disabled (so it shows suffix)
const mockSettingsWithTitleDisabled = {
...mockSettings,
merged: {
...mockSettings.merged,
ui: {
...mockSettings.merged.ui,
showStatusInTitle: false,
hideWindowTitle: false,
},
const mockSettingsWithTitleDisabled = createMockSettings({
ui: {
showStatusInTitle: false,
hideWindowTitle: false,
},
} as unknown as LoadedSettings;
});
// Mock CLI_TITLE environment variable
vi.stubEnv('CLI_TITLE', 'Custom Gemini Title');
@@ -2664,17 +2575,9 @@ describe('AppContainer State Management', () => {
);
// Update settings for this test run
const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true);
const testSettings = {
...mockSettings,
merged: {
...defaultMergedSettings,
ui: {
...defaultMergedSettings.ui,
useAlternateBuffer: isAlternateMode,
},
},
} as unknown as LoadedSettings;
const testSettings = createMockSettings({
ui: { useAlternateBuffer: isAlternateMode },
});
function TestChild() {
useKeypress(childHandler || (() => {}), {
@@ -3384,13 +3287,7 @@ describe('AppContainer State Management', () => {
let unmount: () => void;
await act(async () => {
unmount = renderAppContainer({
settings: {
...mockSettings,
merged: {
...mockSettings.merged,
ui: { ...mockSettings.merged.ui, useAlternateBuffer: false },
},
} as LoadedSettings,
settings: createMockSettings({ ui: { useAlternateBuffer: false } }),
}).unmount;
});
@@ -3426,13 +3323,7 @@ describe('AppContainer State Management', () => {
let unmount: () => void;
await act(async () => {
unmount = renderAppContainer({
settings: {
...mockSettings,
merged: {
...mockSettings.merged,
ui: { ...mockSettings.merged.ui, useAlternateBuffer: true },
},
} as LoadedSettings,
settings: createMockSettings({ ui: { useAlternateBuffer: true } }),
}).unmount;
});
@@ -3701,16 +3592,9 @@ describe('AppContainer State Management', () => {
});
it('DOES set showIsExpandableHint when overflow occurs in Alternate Buffer Mode', async () => {
const alternateSettings = mergeSettings({}, {}, {}, {}, true);
const settingsWithAlternateBuffer = {
merged: {
...alternateSettings,
ui: {
...alternateSettings.ui,
useAlternateBuffer: true,
},
},
} as unknown as LoadedSettings;
const settingsWithAlternateBuffer = createMockSettings({
ui: { useAlternateBuffer: true },
});
vi.spyOn(mockConfig, 'getUseAlternateBuffer').mockReturnValue(true);
+87 -6
View File
@@ -127,6 +127,7 @@ import { useFolderTrust } from './hooks/useFolderTrust.js';
import { useIdeTrustListener } from './hooks/useIdeTrustListener.js';
import { type IdeIntegrationNudgeResult } from './IdeIntegrationNudge.js';
import { appEvents, AppEvent, TransientMessageType } from '../utils/events.js';
import { notifyResponse, markTasksWorking } from '../external-listener.js';
import { type UpdateObject } from './utils/updateCheck.js';
import { setUpdateHandler } from '../utils/handleAutoUpdate.js';
import { registerCleanup, runExitCleanup } from '../utils/cleanup.js';
@@ -1142,6 +1143,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
terminalHeight,
embeddedShellFocused,
consumePendingHints,
historyManager.pruneItems,
);
toggleBackgroundShellRef.current = toggleBackgroundShell;
@@ -1213,6 +1215,89 @@ Logging in with Google... Restarting Gemini CLI to continue.
isMcpReady,
});
// --- A2A listener integration ---
const [a2aListenerPort, setA2aListenerPort] = useState<number | null>(null);
useEffect(() => {
const handler = (port: number) => {
setA2aListenerPort(port);
};
appEvents.on(AppEvent.A2AListenerStarted, handler);
return () => {
appEvents.off(AppEvent.A2AListenerStarted, handler);
};
}, []);
// Bridge external messages from A2A HTTP listener to message queue
useEffect(() => {
const handler = (text: string) => {
addMessage(text);
};
appEvents.on(AppEvent.ExternalMessage, handler);
return () => {
appEvents.off(AppEvent.ExternalMessage, handler);
};
}, [addMessage]);
// Wire WorkScheduler: inject fired prompts and persist schedule changes
useEffect(() => {
const scheduler = config.getWorkScheduler();
const onFire = (prompt: string) => {
appEvents.emit(AppEvent.ExternalMessage, prompt);
};
const onChanged = () => {
// Persist pending items to the session file
const recordingService = config
.getGeminiClient()
?.getChatRecordingService();
if (recordingService) {
recordingService.recordScheduledWork(scheduler.serialize());
}
};
scheduler.on('fire', onFire);
scheduler.on('changed', onChanged);
return () => {
scheduler.off('fire', onFire);
scheduler.off('changed', onChanged);
};
}, [config]);
// Track streaming state transitions for A2A response capture
const prevStreamingStateRef = useRef(streamingState);
useEffect(() => {
const prev = prevStreamingStateRef.current;
prevStreamingStateRef.current = streamingState;
// Mark tasks as "working" when streaming starts
if (
prev === StreamingState.Idle &&
streamingState !== StreamingState.Idle
) {
markTasksWorking();
}
// Capture response when streaming ends (for A2A tasks or unsolicited output)
if (
prev !== StreamingState.Idle &&
streamingState === StreamingState.Idle
) {
const history = historyManager.history;
const parts: string[] = [];
for (let i = history.length - 1; i >= 0; i--) {
const item = history[i];
if (item.type !== 'gemini' && item.type !== 'gemini_content') break;
if (typeof item.text === 'string' && item.text) {
parts.unshift(item.text);
}
}
notifyResponse(parts.join('\n'));
}
}, [streamingState, historyManager.history]);
cancelHandlerRef.current = useCallback(
(shouldRestorePrompt: boolean = true) => {
const pendingHistoryItems = [
@@ -1677,11 +1762,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
const handleGlobalKeypress = useCallback(
(key: Key): boolean => {
// Debug log keystrokes if enabled
if (settings.merged.general.debugKeystrokeLogging) {
debugLogger.log('[DEBUG] Keystroke:', JSON.stringify(key));
}
if (shortcutsHelpVisible && isHelpDismissKey(key)) {
setShortcutsHelpVisible(false);
}
@@ -1860,7 +1940,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
activePtyId,
handleSuspend,
embeddedShellFocused,
settings.merged.general.debugKeystrokeLogging,
refreshStatic,
setCopyModeEnabled,
tabFocusTimeoutRef,
@@ -2306,6 +2385,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
adminSettingsChanged,
newAgents,
showIsExpandableHint,
a2aListenerPort,
hintMode:
config.isModelSteeringEnabled() &&
isToolExecuting([
@@ -2434,6 +2514,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
adminSettingsChanged,
newAgents,
showIsExpandableHint,
a2aListenerPort,
],
);
@@ -5,10 +5,9 @@
*/
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
import { render } from '../test-utils/render.js';
import { renderWithProviders } from '../test-utils/render.js';
import { act } from 'react';
import { IdeIntegrationNudge } from './IdeIntegrationNudge.js';
import { KeypressProvider } from './contexts/KeypressContext.js';
import { debugLogger } from '@google/gemini-cli-core';
// Mock debugLogger
@@ -54,10 +53,8 @@ describe('IdeIntegrationNudge', () => {
});
it('renders correctly with default options', async () => {
const { lastFrame, waitUntilReady, unmount } = render(
<KeypressProvider>
<IdeIntegrationNudge {...defaultProps} />
</KeypressProvider>,
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<IdeIntegrationNudge {...defaultProps} />,
);
await waitUntilReady();
const frame = lastFrame();
@@ -71,10 +68,8 @@ describe('IdeIntegrationNudge', () => {
it('handles "Yes" selection', async () => {
const onComplete = vi.fn();
const { stdin, waitUntilReady, unmount } = render(
<KeypressProvider>
<IdeIntegrationNudge {...defaultProps} onComplete={onComplete} />
</KeypressProvider>,
const { stdin, waitUntilReady, unmount } = renderWithProviders(
<IdeIntegrationNudge {...defaultProps} onComplete={onComplete} />,
);
await waitUntilReady();
@@ -94,10 +89,8 @@ describe('IdeIntegrationNudge', () => {
it('handles "No" selection', async () => {
const onComplete = vi.fn();
const { stdin, waitUntilReady, unmount } = render(
<KeypressProvider>
<IdeIntegrationNudge {...defaultProps} onComplete={onComplete} />
</KeypressProvider>,
const { stdin, waitUntilReady, unmount } = renderWithProviders(
<IdeIntegrationNudge {...defaultProps} onComplete={onComplete} />,
);
await waitUntilReady();
@@ -122,10 +115,8 @@ describe('IdeIntegrationNudge', () => {
it('handles "Dismiss" selection', async () => {
const onComplete = vi.fn();
const { stdin, waitUntilReady, unmount } = render(
<KeypressProvider>
<IdeIntegrationNudge {...defaultProps} onComplete={onComplete} />
</KeypressProvider>,
const { stdin, waitUntilReady, unmount } = renderWithProviders(
<IdeIntegrationNudge {...defaultProps} onComplete={onComplete} />,
);
await waitUntilReady();
@@ -155,10 +146,8 @@ describe('IdeIntegrationNudge', () => {
it('handles Escape key press', async () => {
const onComplete = vi.fn();
const { stdin, waitUntilReady, unmount } = render(
<KeypressProvider>
<IdeIntegrationNudge {...defaultProps} onComplete={onComplete} />
</KeypressProvider>,
const { stdin, waitUntilReady, unmount } = renderWithProviders(
<IdeIntegrationNudge {...defaultProps} onComplete={onComplete} />,
);
await waitUntilReady();
@@ -184,10 +173,8 @@ describe('IdeIntegrationNudge', () => {
vi.stubEnv('GEMINI_CLI_IDE_WORKSPACE_PATH', '/tmp');
const onComplete = vi.fn();
const { lastFrame, stdin, waitUntilReady, unmount } = render(
<KeypressProvider>
<IdeIntegrationNudge {...defaultProps} onComplete={onComplete} />
</KeypressProvider>,
const { lastFrame, stdin, waitUntilReady, unmount } = renderWithProviders(
<IdeIntegrationNudge {...defaultProps} onComplete={onComplete} />,
);
await waitUntilReady();
@@ -4,21 +4,14 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { render } from '../../test-utils/render.js';
import { renderWithProviders } from '../../test-utils/render.js';
import { waitFor } from '../../test-utils/async.js';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { act } from 'react';
import { AgentConfigDialog } from './AgentConfigDialog.js';
import { LoadedSettings, SettingScope } from '../../config/settings.js';
import { KeypressProvider } from '../contexts/KeypressContext.js';
import type { AgentDefinition } from '@google/gemini-cli-core';
vi.mock('../contexts/UIStateContext.js', () => ({
useUIState: () => ({
mainAreaWidth: 100,
}),
}));
enum TerminalKeys {
ENTER = '\u000D',
TAB = '\t',
@@ -122,17 +115,16 @@ describe('AgentConfigDialog', () => {
settings: LoadedSettings,
definition: AgentDefinition = createMockAgentDefinition(),
) => {
const result = render(
<KeypressProvider>
<AgentConfigDialog
agentName="test-agent"
displayName="Test Agent"
definition={definition}
settings={settings}
onClose={mockOnClose}
onSave={mockOnSave}
/>
</KeypressProvider>,
const result = renderWithProviders(
<AgentConfigDialog
agentName="test-agent"
displayName="Test Agent"
definition={definition}
settings={settings}
onClose={mockOnClose}
onSave={mockOnSave}
/>,
{ settings, uiState: { mainAreaWidth: 100 } },
);
await result.waitUntilReady();
return result;
@@ -331,18 +323,17 @@ describe('AgentConfigDialog', () => {
const settings = createMockSettings();
// Agent config has about 6 base items + 2 per tool
// Render with very small height (20)
const { lastFrame, unmount } = render(
<KeypressProvider>
<AgentConfigDialog
agentName="test-agent"
displayName="Test Agent"
definition={createMockAgentDefinition()}
settings={settings}
onClose={mockOnClose}
onSave={mockOnSave}
availableTerminalHeight={20}
/>
</KeypressProvider>,
const { lastFrame, unmount } = renderWithProviders(
<AgentConfigDialog
agentName="test-agent"
displayName="Test Agent"
definition={createMockAgentDefinition()}
settings={settings}
onClose={mockOnClose}
onSave={mockOnSave}
availableTerminalHeight={20}
/>,
{ settings, uiState: { mainAreaWidth: 100 } },
);
await waitFor(() =>
expect(lastFrame()).toContain('Configure: Test Agent'),
@@ -7,6 +7,8 @@
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
import { act } from 'react';
import { renderWithProviders } from '../../test-utils/render.js';
import { createMockSettings } from '../../test-utils/settings.js';
import { makeFakeConfig } from '@google/gemini-cli-core';
import { waitFor } from '../../test-utils/async.js';
import { AskUserDialog } from './AskUserDialog.js';
import { QuestionType, type Question } from '@google/gemini-cli-core';
@@ -313,7 +315,10 @@ describe('AskUserDialog', () => {
width={80}
availableHeight={10} // Small height to force scrolling
/>,
{ useAlternateBuffer },
{
config: makeFakeConfig({ useAlternateBuffer }),
settings: createMockSettings({ ui: { useAlternateBuffer } }),
},
);
await waitFor(async () => {
@@ -1291,7 +1296,10 @@ describe('AskUserDialog', () => {
width={80}
/>
</UIStateContext.Provider>,
{ useAlternateBuffer: false },
{
config: makeFakeConfig({ useAlternateBuffer: false }),
settings: createMockSettings({ ui: { useAlternateBuffer: false } }),
},
);
// With height 5 and alternate buffer disabled, it should show scroll arrows (▲)
@@ -1327,7 +1335,10 @@ describe('AskUserDialog', () => {
width={40} // Small width to force wrapping
/>
</UIStateContext.Provider>,
{ useAlternateBuffer: true },
{
config: makeFakeConfig({ useAlternateBuffer: true }),
settings: createMockSettings({ ui: { useAlternateBuffer: true } }),
},
);
// Should NOT contain the truncation message
@@ -15,6 +15,7 @@ describe('<ChecklistItem />', () => {
{ status: 'in_progress', label: 'Doing this' },
{ status: 'completed', label: 'Done this' },
{ status: 'cancelled', label: 'Skipped this' },
{ status: 'blocked', label: 'Blocked this' },
] as ChecklistItemData[])('renders %s item correctly', async (item) => {
const { lastFrame, waitUntilReady } = render(<ChecklistItem item={item} />);
await waitUntilReady();
@@ -13,7 +13,8 @@ export type ChecklistStatus =
| 'pending'
| 'in_progress'
| 'completed'
| 'cancelled';
| 'cancelled'
| 'blocked';
export interface ChecklistItemData {
status: ChecklistStatus;
@@ -48,6 +49,12 @@ const ChecklistStatusDisplay: React.FC<{ status: ChecklistStatus }> = ({
</Text>
);
case 'blocked':
return (
<Text color={theme.status.warning} aria-label="Blocked">
</Text>
);
default:
checkExhaustive(status);
}
@@ -70,6 +77,7 @@ export const ChecklistItem: React.FC<ChecklistItemProps> = ({
return theme.text.accent;
case 'completed':
case 'cancelled':
case 'blocked':
return theme.text.secondary;
case 'pending':
return theme.text.primary;
@@ -61,6 +61,10 @@ vi.mock('./StatusDisplay.js', () => ({
StatusDisplay: () => <Text>StatusDisplay</Text>,
}));
vi.mock('./ScheduledWorkDisplay.js', () => ({
ScheduledWorkDisplay: () => null,
}));
vi.mock('./ToastDisplay.js', () => ({
ToastDisplay: () => <Text>ToastDisplay</Text>,
shouldShowToast: (uiState: UIState) =>
@@ -202,6 +206,7 @@ const createMockUIState = (overrides: Partial<UIState> = {}): UIState =>
activeHooks: [],
isBackgroundShellVisible: false,
embeddedShellFocused: false,
a2aListenerPort: null,
quota: {
userTier: undefined,
stats: undefined,
@@ -408,7 +413,7 @@ describe('Composer', () => {
thought: { subject: 'Hidden', description: 'Should not show' },
});
const settings = createMockSettings({
merged: { ui: { loadingPhrases: 'off' } },
ui: { loadingPhrases: 'off' },
});
const { lastFrame } = await renderComposer(uiState, settings);
@@ -13,6 +13,7 @@ import {
} from '@google/gemini-cli-core';
import { LoadingIndicator } from './LoadingIndicator.js';
import { StatusDisplay } from './StatusDisplay.js';
import { ScheduledWorkDisplay } from './ScheduledWorkDisplay.js';
import { ToastDisplay, shouldShowToast } from './ToastDisplay.js';
import { ApprovalModeIndicator } from './ApprovalModeIndicator.js';
import { ShellModeIndicator } from './ShellModeIndicator.js';
@@ -336,6 +337,7 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => {
)}
{showShortcutsHelp && <ShortcutsHelp />}
{showUiDetails && <HorizontalLine />}
<ScheduledWorkDisplay />
{showUiDetails && (
<Box
justifyContent={
@@ -38,9 +38,7 @@ describe('DetailedMessagesDisplay', () => {
hasFocus={false}
/>,
{
settings: createMockSettings({
merged: { ui: { errorVerbosity: 'full' } },
}),
settings: createMockSettings({ ui: { errorVerbosity: 'full' } }),
},
);
await waitUntilReady();
@@ -64,9 +62,7 @@ describe('DetailedMessagesDisplay', () => {
hasFocus={true}
/>,
{
settings: createMockSettings({
merged: { ui: { errorVerbosity: 'full' } },
}),
settings: createMockSettings({ ui: { errorVerbosity: 'full' } }),
},
);
await waitUntilReady();
@@ -89,9 +85,7 @@ describe('DetailedMessagesDisplay', () => {
hasFocus={true}
/>,
{
settings: createMockSettings({
merged: { ui: { errorVerbosity: 'low' } },
}),
settings: createMockSettings({ ui: { errorVerbosity: 'low' } }),
},
);
await waitUntilReady();
@@ -112,9 +106,7 @@ describe('DetailedMessagesDisplay', () => {
hasFocus={true}
/>,
{
settings: createMockSettings({
merged: { ui: { errorVerbosity: 'full' } },
}),
settings: createMockSettings({ ui: { errorVerbosity: 'full' } }),
},
);
await waitUntilReady();
@@ -135,9 +127,7 @@ describe('DetailedMessagesDisplay', () => {
hasFocus={false}
/>,
{
settings: createMockSettings({
merged: { ui: { errorVerbosity: 'full' } },
}),
settings: createMockSettings({ ui: { errorVerbosity: 'full' } }),
},
);
await waitUntilReady();
@@ -4,11 +4,10 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { render } from '../../test-utils/render.js';
import { renderWithProviders } from '../../test-utils/render.js';
import { EditorSettingsDialog } from './EditorSettingsDialog.js';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { SettingScope, type LoadedSettings } from '../../config/settings.js';
import { KeypressProvider } from '../contexts/KeypressContext.js';
import { act } from 'react';
import { waitFor } from '../../test-utils/async.js';
import { debugLogger } from '@google/gemini-cli-core';
@@ -52,8 +51,8 @@ describe('EditorSettingsDialog', () => {
vi.clearAllMocks();
});
const renderWithProvider = (ui: React.ReactNode) =>
render(<KeypressProvider>{ui}</KeypressProvider>);
const renderWithProvider = (ui: React.ReactElement) =>
renderWithProviders(ui);
it('renders correctly', async () => {
const { lastFrame, waitUntilReady } = renderWithProvider(
@@ -7,6 +7,7 @@
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
import { act } from 'react';
import { renderWithProviders } from '../../test-utils/render.js';
import { createMockSettings } from '../../test-utils/settings.js';
import { waitFor } from '../../test-utils/async.js';
import { ExitPlanModeDialog } from './ExitPlanModeDialog.js';
import { useKeypress } from '../hooks/useKeypress.js';
@@ -138,8 +139,9 @@ Implement a comprehensive authentication system with multiple providers.
vi.restoreAllMocks();
});
const renderDialog = (options?: { useAlternateBuffer?: boolean }) =>
renderWithProviders(
const renderDialog = (options?: { useAlternateBuffer?: boolean }) => {
const useAlternateBuffer = options?.useAlternateBuffer ?? true;
return renderWithProviders(
<ExitPlanModeDialog
planPath={mockPlanFullPath}
onApprove={onApprove}
@@ -163,10 +165,12 @@ Implement a comprehensive authentication system with multiple providers.
readTextFile: vi.fn(),
writeTextFile: vi.fn(),
}),
getUseAlternateBuffer: () => options?.useAlternateBuffer ?? true,
getUseAlternateBuffer: () => useAlternateBuffer,
} as unknown as import('@google/gemini-cli-core').Config,
settings: createMockSettings({ ui: { useAlternateBuffer } }),
},
);
};
describe.each([{ useAlternateBuffer: true }, { useAlternateBuffer: false }])(
'useAlternateBuffer: $useAlternateBuffer',
@@ -429,7 +433,6 @@ Implement a comprehensive authentication system with multiple providers.
/>
</BubbleListener>,
{
useAlternateBuffer,
config: {
getTargetDir: () => mockTargetDir,
getIdeMode: () => false,
@@ -443,6 +446,9 @@ Implement a comprehensive authentication system with multiple providers.
}),
getUseAlternateBuffer: () => useAlternateBuffer ?? true,
} as unknown as import('@google/gemini-cli-core').Config,
settings: createMockSettings({
ui: { useAlternateBuffer: useAlternateBuffer ?? true },
}),
},
);
@@ -5,11 +5,12 @@
*/
import { renderWithProviders } from '../../test-utils/render.js';
import { createMockSettings } from '../../test-utils/settings.js';
import { makeFakeConfig, ExitCodes } from '@google/gemini-cli-core';
import { waitFor } from '../../test-utils/async.js';
import { act } from 'react';
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
import { FolderTrustDialog } from './FolderTrustDialog.js';
import { ExitCodes } from '@google/gemini-cli-core';
import * as processUtils from '../../utils/processUtils.js';
vi.mock('../../utils/processUtils.js', () => ({
@@ -78,7 +79,8 @@ describe('FolderTrustDialog', () => {
/>,
{
width: 80,
useAlternateBuffer: false,
config: makeFakeConfig({ useAlternateBuffer: false }),
settings: createMockSettings({ ui: { useAlternateBuffer: false } }),
uiState: { constrainHeight: true, terminalHeight: 24 },
},
);
@@ -108,7 +110,8 @@ describe('FolderTrustDialog', () => {
/>,
{
width: 80,
useAlternateBuffer: false,
config: makeFakeConfig({ useAlternateBuffer: false }),
settings: createMockSettings({ ui: { useAlternateBuffer: false } }),
uiState: { constrainHeight: true, terminalHeight: 14 },
},
);
@@ -139,7 +142,8 @@ describe('FolderTrustDialog', () => {
/>,
{
width: 80,
useAlternateBuffer: false,
config: makeFakeConfig({ useAlternateBuffer: false }),
settings: createMockSettings({ ui: { useAlternateBuffer: false } }),
uiState: { constrainHeight: true, terminalHeight: 10 },
},
);
@@ -168,7 +172,8 @@ describe('FolderTrustDialog', () => {
/>,
{
width: 80,
useAlternateBuffer: false,
config: makeFakeConfig({ useAlternateBuffer: false }),
settings: createMockSettings({ ui: { useAlternateBuffer: false } }),
// Initially constrained
uiState: { constrainHeight: true, terminalHeight: 24 },
},
@@ -194,7 +199,8 @@ describe('FolderTrustDialog', () => {
/>,
{
width: 80,
useAlternateBuffer: false,
config: makeFakeConfig({ useAlternateBuffer: false }),
settings: createMockSettings({ ui: { useAlternateBuffer: false } }),
uiState: { constrainHeight: false, terminalHeight: 24 },
},
);
@@ -434,7 +440,8 @@ describe('FolderTrustDialog', () => {
/>,
{
width: 80,
useAlternateBuffer: true,
config: makeFakeConfig({ useAlternateBuffer: true }),
settings: createMockSettings({ ui: { useAlternateBuffer: true } }),
uiState: { constrainHeight: false, terminalHeight: 15 },
},
);
@@ -673,9 +673,7 @@ describe('<Footer />', () => {
errorCount: 2,
showErrorDetails: false,
},
settings: createMockSettings({
merged: { ui: { errorVerbosity: 'low' } },
}),
settings: createMockSettings({ ui: { errorVerbosity: 'low' } }),
},
);
await waitUntilReady();
@@ -694,9 +692,7 @@ describe('<Footer />', () => {
errorCount: 2,
showErrorDetails: false,
},
settings: createMockSettings({
merged: { ui: { errorVerbosity: 'low' } },
}),
settings: createMockSettings({ ui: { errorVerbosity: 'low' } }),
},
);
await waitUntilReady();
@@ -715,9 +711,7 @@ describe('<Footer />', () => {
errorCount: 2,
showErrorDetails: false,
},
settings: createMockSettings({
merged: { ui: { errorVerbosity: 'full' } },
}),
settings: createMockSettings({ ui: { errorVerbosity: 'full' } }),
},
);
await waitUntilReady();
@@ -16,6 +16,7 @@ import {
import { ToolGroupMessage } from './messages/ToolGroupMessage.js';
import { renderWithProviders } from '../../test-utils/render.js';
import { createMockSettings } from '../../test-utils/settings.js';
import { makeFakeConfig } from '@google/gemini-cli-core';
// Mock child components
vi.mock('./messages/ToolGroupMessage.js', () => ({
@@ -84,7 +85,10 @@ describe('<HistoryItemDisplay />', () => {
};
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<HistoryItemDisplay {...baseItem} item={item} />,
{ useAlternateBuffer },
{
config: makeFakeConfig({ useAlternateBuffer }),
settings: createMockSettings({ ui: { useAlternateBuffer } }),
},
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
@@ -278,9 +282,7 @@ describe('<HistoryItemDisplay />', () => {
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<HistoryItemDisplay {...baseItem} item={item} />,
{
settings: createMockSettings({
merged: { ui: { inlineThinkingMode: 'full' } },
}),
settings: createMockSettings({ ui: { inlineThinkingMode: 'full' } }),
},
);
await waitUntilReady();
@@ -298,9 +300,7 @@ describe('<HistoryItemDisplay />', () => {
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<HistoryItemDisplay {...baseItem} item={item} isFirstThinking={true} />,
{
settings: createMockSettings({
merged: { ui: { inlineThinkingMode: 'full' } },
}),
settings: createMockSettings({ ui: { inlineThinkingMode: 'full' } }),
},
);
await waitUntilReady();
@@ -318,9 +318,7 @@ describe('<HistoryItemDisplay />', () => {
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<HistoryItemDisplay {...baseItem} item={item} />,
{
settings: createMockSettings({
merged: { ui: { inlineThinkingMode: 'off' } },
}),
settings: createMockSettings({ ui: { inlineThinkingMode: 'off' } }),
},
);
await waitUntilReady();
@@ -352,7 +350,10 @@ describe('<HistoryItemDisplay />', () => {
terminalWidth={80}
availableTerminalHeight={10}
/>,
{ useAlternateBuffer },
{
config: makeFakeConfig({ useAlternateBuffer }),
settings: createMockSettings({ ui: { useAlternateBuffer } }),
},
);
await waitUntilReady();
@@ -374,7 +375,10 @@ describe('<HistoryItemDisplay />', () => {
availableTerminalHeight={10}
availableTerminalHeightGemini={Number.MAX_SAFE_INTEGER}
/>,
{ useAlternateBuffer },
{
config: makeFakeConfig({ useAlternateBuffer }),
settings: createMockSettings({ ui: { useAlternateBuffer } }),
},
);
await waitUntilReady();
@@ -395,7 +399,10 @@ describe('<HistoryItemDisplay />', () => {
terminalWidth={80}
availableTerminalHeight={10}
/>,
{ useAlternateBuffer },
{
config: makeFakeConfig({ useAlternateBuffer }),
settings: createMockSettings({ ui: { useAlternateBuffer } }),
},
);
await waitUntilReady();
@@ -417,7 +424,10 @@ describe('<HistoryItemDisplay />', () => {
availableTerminalHeight={10}
availableTerminalHeightGemini={Number.MAX_SAFE_INTEGER}
/>,
{ useAlternateBuffer },
{
config: makeFakeConfig({ useAlternateBuffer }),
settings: createMockSettings({ ui: { useAlternateBuffer } }),
},
);
await waitUntilReady();
@@ -6,6 +6,7 @@
import { renderWithProviders } from '../../test-utils/render.js';
import { createMockSettings } from '../../test-utils/settings.js';
import { makeFakeConfig } from '@google/gemini-cli-core';
import { waitFor } from '../../test-utils/async.js';
import { act, useState } from 'react';
import {
@@ -3512,7 +3513,8 @@ describe('InputPrompt', () => {
<TestWrapper />,
{
mouseEventsEnabled: true,
useAlternateBuffer: true,
config: makeFakeConfig({ useAlternateBuffer: true }),
settings: createMockSettings({ ui: { useAlternateBuffer: true } }),
uiActions,
},
);
@@ -3603,7 +3605,8 @@ describe('InputPrompt', () => {
<TestWrapper />,
{
mouseEventsEnabled: true,
useAlternateBuffer: true,
config: makeFakeConfig({ useAlternateBuffer: true }),
settings: createMockSettings({ ui: { useAlternateBuffer: true } }),
uiActions,
},
);
@@ -5,6 +5,8 @@
*/
import { renderWithProviders } from '../../test-utils/render.js';
import { createMockSettings } from '../../test-utils/settings.js';
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';
@@ -18,7 +20,6 @@ import {
useUIState,
type UIState,
} from '../contexts/UIStateContext.js';
import { CoreToolCallStatus } from '@google/gemini-cli-core';
import { type IndividualToolCallDisplay } from '../types.js';
// Mock dependencies
@@ -482,7 +483,8 @@ describe('MainContent', () => {
<MainContent />,
{
uiState: uiState as Partial<UIState>,
useAlternateBuffer: true,
config: makeFakeConfig({ useAlternateBuffer: true }),
settings: createMockSettings({ ui: { useAlternateBuffer: true } }),
},
);
@@ -509,7 +511,8 @@ describe('MainContent', () => {
<MainContent />,
{
uiState: uiState as unknown as Partial<UIState>,
useAlternateBuffer: true,
config: makeFakeConfig({ useAlternateBuffer: true }),
settings: createMockSettings({ ui: { useAlternateBuffer: true } }),
},
);
@@ -733,7 +736,10 @@ describe('MainContent', () => {
<MainContent />,
{
uiState: uiState as Partial<UIState>,
useAlternateBuffer: isAlternateBuffer,
config: makeFakeConfig({ useAlternateBuffer: isAlternateBuffer }),
settings: createMockSettings({
ui: { useAlternateBuffer: isAlternateBuffer },
}),
},
);
await waitUntilReady();
@@ -0,0 +1,66 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { useState, useEffect } from 'react';
import { Box, Text } from 'ink';
import { theme } from '../semantic-colors.js';
import { useConfig } from '../contexts/ConfigContext.js';
import type { ScheduledItem } from '@google/gemini-cli-core';
/**
* Displays all pending scheduled work items above the context summary.
* Only renders when there are pending items.
*/
export const ScheduledWorkDisplay: React.FC = () => {
const config = useConfig();
const [items, setItems] = useState<readonly ScheduledItem[]>([]);
useEffect(() => {
const scheduler = config.getWorkScheduler();
const update = () => {
setItems(scheduler.getPendingItems());
};
scheduler.on('changed', update);
update();
return () => {
scheduler.off('changed', update);
};
}, [config]);
if (items.length === 0) {
return null;
}
return (
<Box flexDirection="column" paddingX={1}>
<Text color={theme.text.secondary}>
Scheduled work ({items.length}):
</Text>
{items.map((item) => {
const timeStr = item.fireAt.toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
});
const diffMs = item.fireAt.getTime() - Date.now();
const diffMins = Math.max(0, Math.ceil(diffMs / 60000));
const truncatedPrompt =
item.prompt.length > 60
? item.prompt.slice(0, 57) + '...'
: item.prompt;
return (
<Text key={item.id} color={theme.text.secondary}>
{' '}
{timeStr} (in {diffMins}m) {truncatedPrompt}
</Text>
);
})}
</Box>
);
};
@@ -110,78 +110,17 @@ const SESSIONS_PER_PAGE = 20;
// If the SessionItem layout changes, update this accordingly.
const FIXED_SESSION_COLUMNS_WIDTH = 30;
const Kbd = ({ name, shortcut }: { name: string; shortcut: string }) => (
<>
{name}: <Text bold>{shortcut}</Text>
</>
);
import {
SearchModeDisplay,
NavigationHelpDisplay,
NoResultsDisplay,
} from './SessionBrowser/SessionBrowserNav.js';
import { SessionListHeader } from './SessionBrowser/SessionListHeader.js';
import { SessionBrowserLoading } from './SessionBrowser/SessionBrowserLoading.js';
import { SessionBrowserError } from './SessionBrowser/SessionBrowserError.js';
import { SessionBrowserEmpty } from './SessionBrowser/SessionBrowserEmpty.js';
import { sortSessions, filterSessions } from './SessionBrowser/utils.js';
/**
* Search input display component.
*/
const SearchModeDisplay = ({
state,
}: {
state: SessionBrowserState;
}): React.JSX.Element => (
<Box marginTop={1}>
<Text color={Colors.Gray}>Search: </Text>
<Text color={Colors.AccentPurple}>{state.searchQuery}</Text>
<Text color={Colors.Gray}> (Esc to cancel)</Text>
</Box>
);
/**
* Header component showing session count and sort information.
*/
const SessionListHeader = ({
state,
}: {
state: SessionBrowserState;
}): React.JSX.Element => (
<Box flexDirection="row" justifyContent="space-between">
<Text color={Colors.AccentPurple}>
Chat Sessions ({state.totalSessions} total
{state.searchQuery ? `, filtered` : ''})
</Text>
<Text color={Colors.Gray}>
sorted by {state.sortOrder} {state.sortReverse ? 'asc' : 'desc'}
</Text>
</Box>
);
/**
* Navigation help component showing keyboard shortcuts.
*/
const NavigationHelp = (): React.JSX.Element => (
<Box flexDirection="column">
<Text color={Colors.Gray}>
<Kbd name="Navigate" shortcut="↑/↓" />
{' '}
<Kbd name="Resume" shortcut="Enter" />
{' '}
<Kbd name="Search" shortcut="/" />
{' '}
<Kbd name="Delete" shortcut="x" />
{' '}
<Kbd name="Quit" shortcut="q" />
</Text>
<Text color={Colors.Gray}>
<Kbd name="Sort" shortcut="s" />
{' '}
<Kbd name="Reverse" shortcut="r" />
{' '}
<Kbd name="First/Last" shortcut="g/G" />
</Text>
</Box>
);
/**
* Table header component with column labels and scroll indicators.
*/
@@ -219,21 +158,6 @@ const SessionTableHeader = ({
</Box>
);
/**
* No results display component for empty search results.
*/
const NoResultsDisplay = ({
state,
}: {
state: SessionBrowserState;
}): React.JSX.Element => (
<Box marginTop={1}>
<Text color={Colors.Gray} dimColor>
No sessions found matching &apos;{state.searchQuery}&apos;.
</Text>
</Box>
);
/**
* Match snippet display component for search results.
*/
@@ -398,7 +322,7 @@ const SessionList = ({
<Box flexDirection="column">
{/* Table Header */}
<Box flexDirection="column">
{!state.isSearchMode && <NavigationHelp />}
{!state.isSearchMode && <NavigationHelpDisplay />}
<SessionTableHeader state={state} />
</Box>
@@ -0,0 +1,72 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { Box, Text } from 'ink';
import { Colors } from '../../colors.js';
import type { SessionBrowserState } from '../SessionBrowser.js';
const Kbd = ({ name, shortcut }: { name: string; shortcut: string }) => (
<>
{name}: <Text bold>{shortcut}</Text>
</>
);
/**
* Navigation help component showing keyboard shortcuts.
*/
export const NavigationHelpDisplay = (): React.JSX.Element => (
<Box flexDirection="column">
<Text color={Colors.Gray}>
<Kbd name="Navigate" shortcut="↑/↓" />
{' '}
<Kbd name="Resume" shortcut="Enter" />
{' '}
<Kbd name="Search" shortcut="/" />
{' '}
<Kbd name="Delete" shortcut="x" />
{' '}
<Kbd name="Quit" shortcut="q" />
</Text>
<Text color={Colors.Gray}>
<Kbd name="Sort" shortcut="s" />
{' '}
<Kbd name="Reverse" shortcut="r" />
{' '}
<Kbd name="First/Last" shortcut="g/G" />
</Text>
</Box>
);
/**
* Search input display component.
*/
export const SearchModeDisplay = ({
state,
}: {
state: SessionBrowserState;
}): React.JSX.Element => (
<Box marginTop={1}>
<Text color={Colors.Gray}>Search: </Text>
<Text color={Colors.AccentPurple}>{state.searchQuery}</Text>
<Text color={Colors.Gray}> (Esc to cancel)</Text>
</Box>
);
/**
* No results display component for empty search results.
*/
export const NoResultsDisplay = ({
state,
}: {
state: SessionBrowserState;
}): React.JSX.Element => (
<Box marginTop={1}>
<Text color={Colors.Gray} dimColor>
No sessions found matching &apos;{state.searchQuery}&apos;.
</Text>
</Box>
);
@@ -0,0 +1,69 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { render } from '../../../test-utils/render.js';
import { describe, it, expect } from 'vitest';
import {
SearchModeDisplay,
NavigationHelpDisplay,
NoResultsDisplay,
} from './SessionBrowserNav.js';
import { SessionListHeader } from './SessionListHeader.js';
import type { SessionBrowserState } from '../SessionBrowser.js';
describe('SessionBrowser Search and Navigation Components', () => {
it('SearchModeDisplay renders correctly with query', async () => {
const mockState = { searchQuery: 'test query' } as SessionBrowserState;
const { lastFrame, waitUntilReady } = render(
<SearchModeDisplay state={mockState} />,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
});
it('NavigationHelp renders correctly', async () => {
const { lastFrame, waitUntilReady } = render(<NavigationHelpDisplay />);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
});
it('SessionListHeader renders correctly', async () => {
const mockState = {
totalSessions: 10,
searchQuery: '',
sortOrder: 'date',
sortReverse: false,
} as SessionBrowserState;
const { lastFrame, waitUntilReady } = render(
<SessionListHeader state={mockState} />,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
});
it('SessionListHeader renders correctly with filter', async () => {
const mockState = {
totalSessions: 5,
searchQuery: 'test',
sortOrder: 'name',
sortReverse: true,
} as SessionBrowserState;
const { lastFrame, waitUntilReady } = render(
<SessionListHeader state={mockState} />,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
});
it('NoResultsDisplay renders correctly', async () => {
const mockState = { searchQuery: 'no match' } as SessionBrowserState;
const { lastFrame, waitUntilReady } = render(
<NoResultsDisplay state={mockState} />,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
});
});
@@ -0,0 +1,29 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { Box, Text } from 'ink';
import { Colors } from '../../colors.js';
import type { SessionBrowserState } from '../SessionBrowser.js';
/**
* Header component showing session count and sort information.
*/
export const SessionListHeader = ({
state,
}: {
state: SessionBrowserState;
}): React.JSX.Element => (
<Box flexDirection="row" justifyContent="space-between">
<Text color={Colors.AccentPurple}>
Chat Sessions ({state.totalSessions} total
{state.searchQuery ? `, filtered` : ''})
</Text>
<Text color={Colors.Gray}>
sorted by {state.sortOrder} {state.sortReverse ? 'asc' : 'desc'}
</Text>
</Box>
);
@@ -0,0 +1,29 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`SessionBrowser Search and Navigation Components > NavigationHelp renders correctly 1`] = `
"Navigate: ↑/↓ Resume: Enter Search: / Delete: x Quit: q
Sort: s Reverse: r First/Last: g/G
"
`;
exports[`SessionBrowser Search and Navigation Components > NoResultsDisplay renders correctly 1`] = `
"
No sessions found matching 'no match'.
"
`;
exports[`SessionBrowser Search and Navigation Components > SearchModeDisplay renders correctly with query 1`] = `
"
Search: test query (Esc to cancel)
"
`;
exports[`SessionBrowser Search and Navigation Components > SessionListHeader renders correctly 1`] = `
"Chat Sessions (10 total) sorted by date desc
"
`;
exports[`SessionBrowser Search and Navigation Components > SessionListHeader renders correctly with filter 1`] = `
"Chat Sessions (5 total, filtered) sorted by name asc
"
`;
@@ -20,16 +20,14 @@
*
*/
import { render } from '../../test-utils/render.js';
import { renderWithProviders } from '../../test-utils/render.js';
import { waitFor } from '../../test-utils/async.js';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { SettingsDialog } from './SettingsDialog.js';
import { SettingScope } from '../../config/settings.js';
import { createMockSettings } from '../../test-utils/settings.js';
import { KeypressProvider } from '../contexts/KeypressContext.js';
import { act } from 'react';
import { TEST_ONLY } from '../../utils/settingsUtils.js';
import { SettingsContext } from '../contexts/SettingsContext.js';
import {
getSettingsSchema,
type SettingDefinition,
@@ -37,12 +35,6 @@ import {
} from '../../config/settingsSchema.js';
import { terminalCapabilityManager } from '../utils/terminalCapabilityManager.js';
vi.mock('../contexts/UIStateContext.js', () => ({
useUIState: () => ({
terminalWidth: 100, // Fixed width for consistent snapshots
}),
}));
enum TerminalKeys {
ENTER = '\u000D',
TAB = '\t',
@@ -96,7 +88,25 @@ const ENUM_SETTING: SettingDefinition = {
showInDialog: true,
};
// Minimal general schema for KeypressProvider
const MINIMAL_GENERAL_SCHEMA = {
general: {
showInDialog: false,
properties: {
debugKeystrokeLogging: {
type: 'boolean',
label: 'Debug Keystroke Logging',
category: 'General',
requiresRestart: false,
default: false,
showInDialog: false,
},
},
},
};
const ENUM_FAKE_SCHEMA: SettingsSchemaType = {
...MINIMAL_GENERAL_SCHEMA,
ui: {
showInDialog: false,
properties: {
@@ -108,6 +118,7 @@ const ENUM_FAKE_SCHEMA: SettingsSchemaType = {
} as unknown as SettingsSchemaType;
const ARRAY_FAKE_SCHEMA: SettingsSchemaType = {
...MINIMAL_GENERAL_SCHEMA,
context: {
type: 'object',
label: 'Context',
@@ -164,6 +175,7 @@ const ARRAY_FAKE_SCHEMA: SettingsSchemaType = {
} as unknown as SettingsSchemaType;
const TOOLS_SHELL_FAKE_SCHEMA: SettingsSchemaType = {
...MINIMAL_GENERAL_SCHEMA,
tools: {
type: 'object',
label: 'Tools',
@@ -224,16 +236,16 @@ const renderDialog = (
availableTerminalHeight?: number;
},
) =>
render(
<SettingsContext.Provider value={settings}>
<KeypressProvider>
<SettingsDialog
onSelect={onSelect}
onRestartRequest={options?.onRestartRequest}
availableTerminalHeight={options?.availableTerminalHeight}
/>
</KeypressProvider>
</SettingsContext.Provider>,
renderWithProviders(
<SettingsDialog
onSelect={onSelect}
onRestartRequest={options?.onRestartRequest}
availableTerminalHeight={options?.availableTerminalHeight}
/>,
{
settings,
uiState: { terminalBackgroundColor: undefined },
},
);
describe('SettingsDialog', () => {
@@ -1344,17 +1356,14 @@ describe('SettingsDialog', () => {
describe('String Settings Editing', () => {
it('should allow editing and committing a string setting', async () => {
let settings = createMockSettings({
const settings = createMockSettings({
'general.sessionCleanup.maxAge': 'initial',
});
const onSelect = vi.fn();
const { stdin, unmount, rerender, waitUntilReady } = render(
<SettingsContext.Provider value={settings}>
<KeypressProvider>
<SettingsDialog onSelect={onSelect} />
</KeypressProvider>
</SettingsContext.Provider>,
const { stdin, unmount, waitUntilReady } = renderWithProviders(
<SettingsDialog onSelect={onSelect} />,
{ settings },
);
await waitUntilReady();
@@ -1384,20 +1393,15 @@ describe('SettingsDialog', () => {
});
await waitUntilReady();
settings = createMockSettings({
user: {
settings: { 'general.sessionCleanup.maxAge': 'new value' },
originalSettings: { 'general.sessionCleanup.maxAge': 'new value' },
path: '',
},
// Simulate the settings file being updated on disk
await act(async () => {
settings.setValue(
SettingScope.User,
'general.sessionCleanup.maxAge',
'new value',
);
});
rerender(
<SettingsContext.Provider value={settings}>
<KeypressProvider>
<SettingsDialog onSelect={onSelect} />
</KeypressProvider>
</SettingsContext.Provider>,
);
await waitUntilReady();
// Press Escape to exit
await act(async () => {
@@ -52,6 +52,7 @@ const createMockUIState = (overrides: UIStateOverrides = {}): UIState =>
geminiMdFileCount: 0,
contextFileNames: [],
backgroundShellCount: 0,
a2aListenerPort: null,
buffer: { text: '' },
history: [{ id: 1, type: 'user', text: 'test' }],
...overrides,
@@ -171,4 +172,16 @@ describe('StatusDisplay', () => {
expect(lastFrame()).toContain('Shells: 3');
unmount();
});
it('renders A2A listener port when a2aListenerPort is set', async () => {
const uiState = createMockUIState({
a2aListenerPort: 8080,
});
const { lastFrame, unmount } = await renderStatusDisplay(
{ hideContextSummary: false },
uiState,
);
expect(lastFrame()).toContain('A2A :8080');
unmount();
});
});
@@ -28,6 +28,12 @@ export const StatusDisplay: React.FC<StatusDisplayProps> = ({
return <Text color={theme.status.error}>|_|</Text>;
}
if (uiState.a2aListenerPort !== null) {
return (
<Text color={theme.text.accent}> A2A :{uiState.a2aListenerPort}</Text>
);
}
if (
uiState.activeHooks.length > 0 &&
settings.merged.hooksConfig.notifications
@@ -9,6 +9,7 @@ import { Box } from 'ink';
import { ToolConfirmationQueue } from './ToolConfirmationQueue.js';
import { StreamingState } from '../types.js';
import { renderWithProviders } from '../../test-utils/render.js';
import { createMockSettings } from '../../test-utils/settings.js';
import { waitFor } from '../../test-utils/async.js';
import { type Config, CoreToolCallStatus } from '@google/gemini-cli-core';
import type { ConfirmingToolState } from '../hooks/useConfirmingTool.js';
@@ -162,8 +163,11 @@ describe('ToolConfirmationQueue', () => {
/>
</Box>,
{
config: mockConfig,
useAlternateBuffer: true,
config: {
...mockConfig,
getUseAlternateBuffer: () => true,
} as unknown as Config,
settings: createMockSettings({ ui: { useAlternateBuffer: true } }),
uiState: {
terminalWidth: 80,
terminalHeight: 20,
@@ -212,7 +216,7 @@ describe('ToolConfirmationQueue', () => {
/>,
{
config: mockConfig,
useAlternateBuffer: false,
settings: createMockSettings({ ui: { useAlternateBuffer: false } }),
uiState: {
terminalWidth: 80,
terminalHeight: 40,
@@ -1,5 +1,10 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`<ChecklistItem /> > renders { status: 'blocked', label: 'Blocked this' } item correctly 1`] = `
"⛔ Blocked this
"
`;
exports[`<ChecklistItem /> > renders { status: 'cancelled', label: 'Skipped this' } item correctly 1`] = `
"✗ Skipped this
"
@@ -6,6 +6,8 @@
import { OverflowProvider } from '../../contexts/OverflowContext.js';
import { renderWithProviders } from '../../../test-utils/render.js';
import { createMockSettings } from '../../../test-utils/settings.js';
import { makeFakeConfig } from '@google/gemini-cli-core';
import { waitFor } from '../../../test-utils/async.js';
import { DiffRenderer } from './DiffRenderer.js';
import * as CodeColorizer from '../../utils/CodeColorizer.js';
@@ -42,7 +44,10 @@ index 0000000..e69de29
terminalWidth={80}
/>
</OverflowProvider>,
{ useAlternateBuffer },
{
config: makeFakeConfig({ useAlternateBuffer }),
settings: createMockSettings({ ui: { useAlternateBuffer } }),
},
);
await waitFor(() =>
expect(mockColorizeCode).toHaveBeenCalledWith({
@@ -74,7 +79,10 @@ index 0000000..e69de29
terminalWidth={80}
/>
</OverflowProvider>,
{ useAlternateBuffer },
{
config: makeFakeConfig({ useAlternateBuffer }),
settings: createMockSettings({ ui: { useAlternateBuffer } }),
},
);
await waitFor(() =>
expect(mockColorizeCode).toHaveBeenCalledWith({
@@ -102,7 +110,10 @@ index 0000000..e69de29
<OverflowProvider>
<DiffRenderer diffContent={newFileDiffContent} terminalWidth={80} />
</OverflowProvider>,
{ useAlternateBuffer },
{
config: makeFakeConfig({ useAlternateBuffer }),
settings: createMockSettings({ ui: { useAlternateBuffer } }),
},
);
await waitFor(() =>
expect(mockColorizeCode).toHaveBeenCalledWith({
@@ -135,7 +146,10 @@ index 0000001..0000002 100644
terminalWidth={80}
/>
</OverflowProvider>,
{ useAlternateBuffer },
{
config: makeFakeConfig({ useAlternateBuffer }),
settings: createMockSettings({ ui: { useAlternateBuffer } }),
},
);
// colorizeCode is used internally by the line-by-line rendering, not for the whole block
await waitFor(() => expect(lastFrame()).toContain('new line'));
@@ -166,7 +180,10 @@ index 1234567..1234567 100644
terminalWidth={80}
/>
</OverflowProvider>,
{ useAlternateBuffer },
{
config: makeFakeConfig({ useAlternateBuffer }),
settings: createMockSettings({ ui: { useAlternateBuffer } }),
},
);
await waitFor(() => expect(lastFrame()).toBeDefined());
expect(lastFrame()).toMatchSnapshot();
@@ -178,7 +195,10 @@ index 1234567..1234567 100644
<OverflowProvider>
<DiffRenderer diffContent="" terminalWidth={80} />
</OverflowProvider>,
{ useAlternateBuffer },
{
config: makeFakeConfig({ useAlternateBuffer }),
settings: createMockSettings({ ui: { useAlternateBuffer } }),
},
);
await waitFor(() => expect(lastFrame()).toBeDefined());
expect(lastFrame()).toMatchSnapshot();
@@ -208,7 +228,10 @@ index 123..456 100644
terminalWidth={80}
/>
</OverflowProvider>,
{ useAlternateBuffer },
{
config: makeFakeConfig({ useAlternateBuffer }),
settings: createMockSettings({ ui: { useAlternateBuffer } }),
},
);
await waitFor(() => expect(lastFrame()).toContain('added line'));
expect(lastFrame()).toMatchSnapshot();
@@ -242,7 +265,10 @@ index abc..def 100644
terminalWidth={80}
/>
</OverflowProvider>,
{ useAlternateBuffer },
{
config: makeFakeConfig({ useAlternateBuffer }),
settings: createMockSettings({ ui: { useAlternateBuffer } }),
},
);
await waitFor(() => expect(lastFrame()).toContain('context line 15'));
expect(lastFrame()).toMatchSnapshot();
@@ -292,7 +318,10 @@ index 123..789 100644
availableTerminalHeight={height}
/>
</OverflowProvider>,
{ useAlternateBuffer },
{
config: makeFakeConfig({ useAlternateBuffer }),
settings: createMockSettings({ ui: { useAlternateBuffer } }),
},
);
await waitFor(() => expect(lastFrame()).toContain('anotherNew'));
const output = lastFrame();
@@ -326,7 +355,10 @@ fileDiff Index: file.txt
terminalWidth={80}
/>
</OverflowProvider>,
{ useAlternateBuffer },
{
config: makeFakeConfig({ useAlternateBuffer }),
settings: createMockSettings({ ui: { useAlternateBuffer } }),
},
);
await waitFor(() => expect(lastFrame()).toContain('newVar'));
expect(lastFrame()).toMatchSnapshot();
@@ -353,7 +385,10 @@ fileDiff Index: Dockerfile
terminalWidth={80}
/>
</OverflowProvider>,
{ useAlternateBuffer },
{
config: makeFakeConfig({ useAlternateBuffer }),
settings: createMockSettings({ ui: { useAlternateBuffer } }),
},
);
await waitFor(() => expect(lastFrame()).toContain('RUN npm run build'));
expect(lastFrame()).toMatchSnapshot();
@@ -16,6 +16,8 @@ import {
CoreToolCallStatus,
} from '@google/gemini-cli-core';
import { renderWithProviders } from '../../../test-utils/render.js';
import { createMockSettings } from '../../../test-utils/settings.js';
import { makeFakeConfig } from '@google/gemini-cli-core';
import { waitFor } from '../../../test-utils/async.js';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { SHELL_COMMAND_NAME, ACTIVE_SHELL_MAX_LINES } from '../../constants.js';
@@ -48,14 +50,6 @@ describe('<ShellToolMessage />', () => {
setEmbeddedShellFocused: mockSetEmbeddedShellFocused,
};
const renderShell = (
props: Partial<ShellToolMessageProps> = {},
options: Parameters<typeof renderWithProviders>[1] = {},
) =>
renderWithProviders(<ShellToolMessage {...baseProps} {...props} />, {
uiActions,
...options,
});
beforeEach(() => {
vi.clearAllMocks();
});
@@ -65,9 +59,9 @@ describe('<ShellToolMessage />', () => {
['SHELL_COMMAND_NAME', SHELL_COMMAND_NAME],
['SHELL_TOOL_NAME', SHELL_TOOL_NAME],
])('clicks inside the shell area sets focus for %s', async (_, name) => {
const { lastFrame, simulateClick, unmount } = renderShell(
{ name },
{ mouseEventsEnabled: true },
const { lastFrame, simulateClick, unmount } = renderWithProviders(
<ShellToolMessage {...baseProps} name={name} />,
{ uiActions, mouseEventsEnabled: true },
);
await waitFor(() => {
@@ -152,7 +146,8 @@ describe('<ShellToolMessage />', () => {
ptyId: 1,
},
{
useAlternateBuffer: true,
config: makeFakeConfig({ useAlternateBuffer: true }),
settings: createMockSettings({ ui: { useAlternateBuffer: true } }),
uiState: {
embeddedShellFocused: true,
activePtyId: 1,
@@ -166,7 +161,8 @@ describe('<ShellToolMessage />', () => {
ptyId: 1,
},
{
useAlternateBuffer: true,
config: makeFakeConfig({ useAlternateBuffer: true }),
settings: createMockSettings({ ui: { useAlternateBuffer: true } }),
uiState: {
embeddedShellFocused: false,
activePtyId: 1,
@@ -174,9 +170,9 @@ describe('<ShellToolMessage />', () => {
},
],
])('%s', async (_, props, options) => {
const { lastFrame, waitUntilReady, unmount } = renderShell(
props,
options,
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<ShellToolMessage {...baseProps} {...props} />,
{ uiActions, ...options },
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
@@ -223,16 +219,19 @@ describe('<ShellToolMessage />', () => {
focused,
constrainHeight,
) => {
const { lastFrame, waitUntilReady, unmount } = renderShell(
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<ShellToolMessage
{...baseProps}
resultDisplay={LONG_OUTPUT}
renderOutputAsMarkdown={false}
availableTerminalHeight={availableTerminalHeight}
ptyId={1}
status={CoreToolCallStatus.Executing}
/>,
{
resultDisplay: LONG_OUTPUT,
renderOutputAsMarkdown: false,
availableTerminalHeight,
ptyId: 1,
status: CoreToolCallStatus.Executing,
},
{
useAlternateBuffer: true,
uiActions,
config: makeFakeConfig({ useAlternateBuffer: true }),
settings: createMockSettings({ ui: { useAlternateBuffer: true } }),
uiState: {
activePtyId: focused ? 1 : 2,
embeddedShellFocused: focused,
@@ -250,14 +249,19 @@ describe('<ShellToolMessage />', () => {
);
it('fully expands in standard mode when availableTerminalHeight is undefined', async () => {
const { lastFrame, unmount } = renderShell(
const { lastFrame, unmount } = renderWithProviders(
<ShellToolMessage
{...baseProps}
resultDisplay={LONG_OUTPUT}
renderOutputAsMarkdown={false}
availableTerminalHeight={undefined}
status={CoreToolCallStatus.Executing}
/>,
{
resultDisplay: LONG_OUTPUT,
renderOutputAsMarkdown: false,
availableTerminalHeight: undefined,
status: CoreToolCallStatus.Executing,
uiActions,
config: makeFakeConfig({ useAlternateBuffer: false }),
settings: createMockSettings({ ui: { useAlternateBuffer: false } }),
},
{ useAlternateBuffer: false },
);
await waitFor(() => {
@@ -269,16 +273,19 @@ describe('<ShellToolMessage />', () => {
});
it('fully expands in alternate buffer mode when constrainHeight is false and isExpandable is true', async () => {
const { lastFrame, waitUntilReady, unmount } = renderShell(
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<ShellToolMessage
{...baseProps}
resultDisplay={LONG_OUTPUT}
renderOutputAsMarkdown={false}
availableTerminalHeight={undefined}
status={CoreToolCallStatus.Success}
isExpandable={true}
/>,
{
resultDisplay: LONG_OUTPUT,
renderOutputAsMarkdown: false,
availableTerminalHeight: undefined,
status: CoreToolCallStatus.Success,
isExpandable: true,
},
{
useAlternateBuffer: true,
uiActions,
config: makeFakeConfig({ useAlternateBuffer: true }),
settings: createMockSettings({ ui: { useAlternateBuffer: true } }),
uiState: {
constrainHeight: false,
},
@@ -296,16 +303,19 @@ describe('<ShellToolMessage />', () => {
});
it('stays constrained in alternate buffer mode when isExpandable is false even if constrainHeight is false', async () => {
const { lastFrame, waitUntilReady, unmount } = renderShell(
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<ShellToolMessage
{...baseProps}
resultDisplay={LONG_OUTPUT}
renderOutputAsMarkdown={false}
availableTerminalHeight={undefined}
status={CoreToolCallStatus.Success}
isExpandable={false}
/>,
{
resultDisplay: LONG_OUTPUT,
renderOutputAsMarkdown: false,
availableTerminalHeight: undefined,
status: CoreToolCallStatus.Success,
isExpandable: false,
},
{
useAlternateBuffer: true,
uiActions,
config: makeFakeConfig({ useAlternateBuffer: true }),
settings: createMockSettings({ ui: { useAlternateBuffer: true } }),
uiState: {
constrainHeight: false,
},
@@ -42,33 +42,19 @@ export interface ShellToolMessageProps extends ToolMessageProps {
export const ShellToolMessage: React.FC<ShellToolMessageProps> = ({
name,
description,
resultDisplay,
status,
availableTerminalHeight,
terminalWidth,
emphasis = 'medium',
renderOutputAsMarkdown = true,
ptyId,
config,
isFirst,
borderColor,
borderDimColor,
isExpandable,
originalRequestName,
}) => {
const {
@@ -142,11 +128,9 @@ export const ShellToolMessage: React.FC<ShellToolMessageProps> = ({
}, [isThisShellFocused, embeddedShellFocused, setEmbeddedShellFocused]);
const headerRef = React.useRef<DOMElement>(null);
const contentRef = React.useRef<DOMElement>(null);
// The shell is focusable if it's the shell command, it's executing, and the interactive shell is enabled.
const isThisShellFocusable = checkIsShellFocusable(name, status, config);
const handleFocus = () => {
@@ -156,7 +140,6 @@ export const ShellToolMessage: React.FC<ShellToolMessageProps> = ({
};
useMouseClick(headerRef, handleFocus, { isActive: !!isThisShellFocusable });
useMouseClick(contentRef, handleFocus, { isActive: !!isThisShellFocusable });
const { shouldShowFocusHint } = useFocusHint(
@@ -0,0 +1,128 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { waitFor } from '../../../test-utils/async.js';
import { renderWithProviders } from '../../../test-utils/render.js';
import { SubagentGroupDisplay } from './SubagentGroupDisplay.js';
import { Kind, CoreToolCallStatus } from '@google/gemini-cli-core';
import type { IndividualToolCallDisplay } from '../../types.js';
import { vi } from 'vitest';
import { Text } from 'ink';
vi.mock('../../utils/MarkdownDisplay.js', () => ({
MarkdownDisplay: ({ text }: { text: string }) => <Text>{text}</Text>,
}));
describe('<SubagentGroupDisplay />', () => {
const mockToolCalls: IndividualToolCallDisplay[] = [
{
callId: 'call-1',
name: 'agent_1',
description: 'Test agent 1',
confirmationDetails: undefined,
status: CoreToolCallStatus.Executing,
kind: Kind.Agent,
resultDisplay: {
isSubagentProgress: true,
agentName: 'api-monitor',
state: 'running',
recentActivity: [
{
id: 'act-1',
type: 'tool_call',
status: 'running',
content: '',
displayName: 'Action Required',
description: 'Verify server is running',
},
],
},
},
{
callId: 'call-2',
name: 'agent_2',
description: 'Test agent 2',
confirmationDetails: undefined,
status: CoreToolCallStatus.Success,
kind: Kind.Agent,
resultDisplay: {
isSubagentProgress: true,
agentName: 'db-manager',
state: 'completed',
result: 'Database schema validated',
recentActivity: [
{
id: 'act-2',
type: 'thought',
status: 'completed',
content: 'Database schema validated',
},
],
},
},
];
const renderSubagentGroup = (
toolCallsToRender: IndividualToolCallDisplay[],
height?: number,
) =>
renderWithProviders(
<SubagentGroupDisplay
toolCalls={toolCallsToRender}
terminalWidth={80}
availableTerminalHeight={height}
isExpandable={true}
/>,
);
it('renders nothing if there are no agent tool calls', async () => {
const { lastFrame } = renderSubagentGroup([], 40);
expect(lastFrame({ allowEmpty: true })).toBe('');
});
it('renders collapsed view by default with correct agent counts and states', async () => {
const { lastFrame, waitUntilReady } = renderSubagentGroup(
mockToolCalls,
40,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
});
it('expands when availableTerminalHeight is undefined', async () => {
const { lastFrame, rerender } = renderSubagentGroup(mockToolCalls, 40);
// Default collapsed view
await waitFor(() => {
expect(lastFrame()).toContain('(ctrl+o to expand)');
});
// Expand view
rerender(
<SubagentGroupDisplay
toolCalls={mockToolCalls}
terminalWidth={80}
availableTerminalHeight={undefined}
isExpandable={true}
/>,
);
await waitFor(() => {
expect(lastFrame()).toContain('(ctrl+o to collapse)');
});
// Collapse view
rerender(
<SubagentGroupDisplay
toolCalls={mockToolCalls}
terminalWidth={80}
availableTerminalHeight={40}
isExpandable={true}
/>,
);
await waitFor(() => {
expect(lastFrame()).toContain('(ctrl+o to expand)');
});
});
});
@@ -0,0 +1,269 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { useEffect, useId } from 'react';
import { Box, Text } from 'ink';
import { theme } from '../../semantic-colors.js';
import type { IndividualToolCallDisplay } from '../../types.js';
import {
isSubagentProgress,
checkExhaustive,
type SubagentActivityItem,
} from '@google/gemini-cli-core';
import {
SubagentProgressDisplay,
formatToolArgs,
} from './SubagentProgressDisplay.js';
import { useOverflowActions } from '../../contexts/OverflowContext.js';
export interface SubagentGroupDisplayProps {
toolCalls: IndividualToolCallDisplay[];
availableTerminalHeight?: number;
terminalWidth: number;
borderColor?: string;
borderDimColor?: boolean;
isFirst?: boolean;
isExpandable?: boolean;
}
export const SubagentGroupDisplay: React.FC<SubagentGroupDisplayProps> = ({
toolCalls,
availableTerminalHeight,
terminalWidth,
borderColor,
borderDimColor,
isFirst,
isExpandable = true,
}) => {
const isExpanded = availableTerminalHeight === undefined;
const overflowActions = useOverflowActions();
const uniqueId = useId();
const overflowId = `subagent-${uniqueId}`;
useEffect(() => {
if (isExpandable && overflowActions) {
// Register with the global overflow system so "ctrl+o to expand" shows in the sticky footer
// and AppContainer passes the shortcut through.
overflowActions.addOverflowingId(overflowId);
}
return () => {
if (overflowActions) {
overflowActions.removeOverflowingId(overflowId);
}
};
}, [isExpandable, overflowActions, overflowId]);
if (toolCalls.length === 0) {
return null;
}
let headerText = '';
if (toolCalls.length === 1) {
const singleAgent = toolCalls[0].resultDisplay;
if (isSubagentProgress(singleAgent)) {
switch (singleAgent.state) {
case 'completed':
headerText = 'Agent Completed';
break;
case 'cancelled':
headerText = 'Agent Cancelled';
break;
case 'error':
headerText = 'Agent Error';
break;
default:
headerText = 'Running Agent...';
break;
}
} else {
headerText = 'Running Agent...';
}
} else {
let completedCount = 0;
let runningCount = 0;
for (const tc of toolCalls) {
const progress = tc.resultDisplay;
if (isSubagentProgress(progress)) {
if (progress.state === 'completed') completedCount++;
else if (progress.state === 'running') runningCount++;
} else {
// It hasn't emitted progress yet, but it is "running"
runningCount++;
}
}
if (completedCount === toolCalls.length) {
headerText = `${toolCalls.length} Agents Completed`;
} else if (completedCount > 0) {
headerText = `${toolCalls.length} Agents (${runningCount} running, ${completedCount} completed)...`;
} else {
headerText = `Running ${toolCalls.length} Agents...`;
}
}
const toggleText = `(ctrl+o to ${isExpanded ? 'collapse' : 'expand'})`;
const renderCollapsedRow = (
key: string,
agentName: string,
icon: React.ReactNode,
content: string,
displayArgs?: string,
) => (
<Box key={key} flexDirection="row" marginLeft={0} marginTop={0}>
<Box minWidth={2} flexShrink={0}>
{icon}
</Box>
<Box flexShrink={0}>
<Text bold color={theme.text.primary} wrap="truncate">
{agentName}
</Text>
</Box>
<Box flexShrink={0}>
<Text color={theme.text.secondary}> · </Text>
</Box>
<Box flexShrink={1} minWidth={0}>
<Text color={theme.text.secondary} wrap="truncate">
{content}
{displayArgs && ` ${displayArgs}`}
</Text>
</Box>
</Box>
);
return (
<Box
flexDirection="column"
width={terminalWidth}
borderLeft={true}
borderRight={true}
borderTop={isFirst}
borderBottom={false}
borderColor={borderColor}
borderDimColor={borderDimColor}
borderStyle="round"
paddingLeft={1}
paddingTop={0}
paddingBottom={0}
>
<Box flexDirection="row" gap={1} marginBottom={isExpanded ? 1 : 0}>
<Text color={theme.text.secondary}></Text>
<Text bold color={theme.text.primary}>
{headerText}
</Text>
{isExpandable && <Text color={theme.text.secondary}>{toggleText}</Text>}
</Box>
{toolCalls.map((toolCall) => {
const progress = toolCall.resultDisplay;
if (!isSubagentProgress(progress)) {
const agentName = toolCall.name || 'agent';
if (!isExpanded) {
return renderCollapsedRow(
toolCall.callId,
agentName,
<Text color={theme.text.primary}>!</Text>,
'Starting...',
);
} else {
return (
<Box
key={toolCall.callId}
flexDirection="column"
marginLeft={0}
marginBottom={1}
>
<Box flexDirection="row" gap={1}>
<Text color={theme.text.primary}>!</Text>
<Text bold color={theme.text.primary}>
{agentName}
</Text>
</Box>
<Box marginLeft={2}>
<Text color={theme.text.secondary}>Starting...</Text>
</Box>
</Box>
);
}
}
const lastActivity: SubagentActivityItem | undefined =
progress.recentActivity[progress.recentActivity.length - 1];
// Collapsed View: Show single compact line per agent
if (!isExpanded) {
let content = 'Starting...';
let formattedArgs: string | undefined;
if (progress.state === 'completed') {
if (
progress.terminateReason &&
progress.terminateReason !== 'GOAL'
) {
content = `Finished Early (${progress.terminateReason})`;
} else {
content = 'Completed successfully';
}
} else if (lastActivity) {
// Match expanded view logic exactly:
// Primary text: displayName || content
content = lastActivity.displayName || lastActivity.content;
// Secondary text: description || formatToolArgs(args)
if (lastActivity.description) {
formattedArgs = lastActivity.description;
} else if (lastActivity.type === 'tool_call' && lastActivity.args) {
formattedArgs = formatToolArgs(lastActivity.args);
}
}
const displayArgs =
progress.state === 'completed' ? '' : formattedArgs;
const renderStatusIcon = () => {
const state = progress.state ?? 'running';
switch (state) {
case 'running':
return <Text color={theme.text.primary}>!</Text>;
case 'completed':
return <Text color={theme.status.success}></Text>;
case 'cancelled':
return <Text color={theme.status.warning}></Text>;
case 'error':
return <Text color={theme.status.error}></Text>;
default:
return checkExhaustive(state);
}
};
return renderCollapsedRow(
toolCall.callId,
progress.agentName,
renderStatusIcon(),
lastActivity?.type === 'thought' ? `💭 ${content}` : content,
displayArgs,
);
}
// Expanded View: Render full history
return (
<Box
key={toolCall.callId}
flexDirection="column"
marginLeft={0}
marginBottom={1}
>
<SubagentProgressDisplay
progress={progress}
terminalWidth={terminalWidth}
/>
</Box>
);
})}
</Box>
);
};
@@ -36,7 +36,7 @@ describe('<SubagentProgressDisplay />', () => {
};
const { lastFrame, waitUntilReady } = render(
<SubagentProgressDisplay progress={progress} />,
<SubagentProgressDisplay progress={progress} terminalWidth={80} />,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
@@ -60,7 +60,7 @@ describe('<SubagentProgressDisplay />', () => {
};
const { lastFrame, waitUntilReady } = render(
<SubagentProgressDisplay progress={progress} />,
<SubagentProgressDisplay progress={progress} terminalWidth={80} />,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
@@ -82,7 +82,7 @@ describe('<SubagentProgressDisplay />', () => {
};
const { lastFrame, waitUntilReady } = render(
<SubagentProgressDisplay progress={progress} />,
<SubagentProgressDisplay progress={progress} terminalWidth={80} />,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
@@ -104,7 +104,7 @@ describe('<SubagentProgressDisplay />', () => {
};
const { lastFrame, waitUntilReady } = render(
<SubagentProgressDisplay progress={progress} />,
<SubagentProgressDisplay progress={progress} terminalWidth={80} />,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
@@ -128,7 +128,7 @@ describe('<SubagentProgressDisplay />', () => {
};
const { lastFrame, waitUntilReady } = render(
<SubagentProgressDisplay progress={progress} />,
<SubagentProgressDisplay progress={progress} terminalWidth={80} />,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
@@ -149,7 +149,7 @@ describe('<SubagentProgressDisplay />', () => {
};
const { lastFrame, waitUntilReady } = render(
<SubagentProgressDisplay progress={progress} />,
<SubagentProgressDisplay progress={progress} terminalWidth={80} />,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
@@ -164,7 +164,7 @@ describe('<SubagentProgressDisplay />', () => {
};
const { lastFrame, waitUntilReady } = render(
<SubagentProgressDisplay progress={progress} />,
<SubagentProgressDisplay progress={progress} terminalWidth={80} />,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
@@ -185,7 +185,7 @@ describe('<SubagentProgressDisplay />', () => {
};
const { lastFrame, waitUntilReady } = render(
<SubagentProgressDisplay progress={progress} />,
<SubagentProgressDisplay progress={progress} terminalWidth={80} />,
);
await waitUntilReady();
expect(lastFrame()).toMatchSnapshot();
@@ -8,18 +8,21 @@ import type React from 'react';
import { Box, Text } from 'ink';
import { theme } from '../../semantic-colors.js';
import Spinner from 'ink-spinner';
import { MarkdownDisplay } from '../../utils/MarkdownDisplay.js';
import type {
SubagentProgress,
SubagentActivityItem,
} from '@google/gemini-cli-core';
import { TOOL_STATUS } from '../../constants.js';
import { STATUS_INDICATOR_WIDTH } from './ToolShared.js';
import { safeJsonToMarkdown } from '@google/gemini-cli-core';
export interface SubagentProgressDisplayProps {
progress: SubagentProgress;
terminalWidth: number;
}
const formatToolArgs = (args?: string): string => {
export const formatToolArgs = (args?: string): string => {
if (!args) return '';
try {
const parsed: unknown = JSON.parse(args);
@@ -54,7 +57,7 @@ const formatToolArgs = (args?: string): string => {
export const SubagentProgressDisplay: React.FC<
SubagentProgressDisplayProps
> = ({ progress }) => {
> = ({ progress, terminalWidth }) => {
let headerText: string | undefined;
let headerColor = theme.text.secondary;
@@ -67,6 +70,9 @@ export const SubagentProgressDisplay: React.FC<
} else if (progress.state === 'completed') {
headerText = `Subagent ${progress.agentName} completed.`;
headerColor = theme.status.success;
} else {
headerText = `Running subagent ${progress.agentName}...`;
headerColor = theme.text.primary;
}
return (
@@ -146,6 +152,23 @@ export const SubagentProgressDisplay: React.FC<
return null;
})}
</Box>
{progress.state === 'completed' && progress.result && (
<Box flexDirection="column" marginTop={1}>
{progress.terminateReason && progress.terminateReason !== 'GOAL' && (
<Box marginBottom={1}>
<Text color={theme.status.warning} bold>
Agent Finished Early ({progress.terminateReason})
</Text>
</Box>
)}
<MarkdownDisplay
text={safeJsonToMarkdown(progress.result)}
isPending={false}
terminalWidth={terminalWidth}
/>
</Box>
)}
</Box>
);
};
@@ -65,14 +65,10 @@ describe('<ToolGroupMessage />', () => {
enableInteractiveShell: true,
});
const fullVerbositySettings = createMockSettings({
merged: {
ui: { errorVerbosity: 'full' },
},
ui: { errorVerbosity: 'full' },
});
const lowVerbositySettings = createMockSettings({
merged: {
ui: { errorVerbosity: 'low' },
},
ui: { errorVerbosity: 'low' },
});
describe('Golden Snapshots', () => {
@@ -15,12 +15,14 @@ import type {
import { ToolCallStatus, mapCoreStatusToDisplayStatus } from '../../types.js';
import { ToolMessage } from './ToolMessage.js';
import { ShellToolMessage } from './ShellToolMessage.js';
import { SubagentGroupDisplay } from './SubagentGroupDisplay.js';
import { theme } from '../../semantic-colors.js';
import { useConfig } from '../../contexts/ConfigContext.js';
import { isShellTool } from './ToolShared.js';
import {
shouldHideToolCall,
CoreToolCallStatus,
Kind,
} from '@google/gemini-cli-core';
import { useUIState } from '../../contexts/UIStateContext.js';
import { getToolGroupBorderAppearance } from '../../utils/borderStyles.js';
@@ -125,12 +127,36 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
let countToolCallsWithResults = 0;
for (const tool of visibleToolCalls) {
if (tool.resultDisplay !== undefined && tool.resultDisplay !== '') {
if (
tool.kind !== Kind.Agent &&
tool.resultDisplay !== undefined &&
tool.resultDisplay !== ''
) {
countToolCallsWithResults++;
}
}
const countOneLineToolCalls =
visibleToolCalls.length - countToolCallsWithResults;
visibleToolCalls.filter((t) => t.kind !== Kind.Agent).length -
countToolCallsWithResults;
const groupedTools = useMemo(() => {
const groups: Array<
IndividualToolCallDisplay | IndividualToolCallDisplay[]
> = [];
for (const tool of visibleToolCalls) {
if (tool.kind === Kind.Agent) {
const lastGroup = groups[groups.length - 1];
if (Array.isArray(lastGroup)) {
lastGroup.push(tool);
} else {
groups.push([tool]);
}
} else {
groups.push(tool);
}
}
return groups;
}, [visibleToolCalls]);
const availableTerminalHeightPerToolMessage = availableTerminalHeight
? Math.max(
Math.floor(
@@ -167,8 +193,29 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
width={terminalWidth}
paddingRight={TOOL_MESSAGE_HORIZONTAL_MARGIN}
>
{visibleToolCalls.map((tool, index) => {
{groupedTools.map((group, index) => {
const isFirst = index === 0;
const resolvedIsFirst =
borderTopOverride !== undefined
? borderTopOverride && isFirst
: isFirst;
if (Array.isArray(group)) {
return (
<SubagentGroupDisplay
key={group[0].callId}
toolCalls={group}
availableTerminalHeight={availableTerminalHeight}
terminalWidth={contentWidth}
borderColor={borderColor}
borderDimColor={borderDimColor}
isFirst={resolvedIsFirst}
isExpandable={isExpandable}
/>
);
}
const tool = group;
const isShellToolCall = isShellTool(tool.name);
const commonProps = {
@@ -176,10 +223,7 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
availableTerminalHeight: availableTerminalHeightPerToolMessage,
terminalWidth: contentWidth,
emphasis: 'medium' as const,
isFirst:
borderTopOverride !== undefined
? borderTopOverride && isFirst
: isFirst,
isFirst: resolvedIsFirst,
borderColor,
borderDimColor,
isExpandable,
@@ -13,8 +13,10 @@ import {
type AnsiOutput,
CoreToolCallStatus,
Kind,
makeFakeConfig,
} from '@google/gemini-cli-core';
import { renderWithProviders } from '../../../test-utils/render.js';
import { createMockSettings } from '../../../test-utils/settings.js';
import { tryParseJSON } from '../../../utils/jsonoutput.js';
vi.mock('../GeminiRespondingSpinner.js', () => ({
@@ -462,7 +464,8 @@ describe('<ToolMessage />', () => {
constrainHeight: true,
},
width: 80,
useAlternateBuffer: false,
config: makeFakeConfig({ useAlternateBuffer: false }),
settings: createMockSettings({ ui: { useAlternateBuffer: false } }),
},
);
await waitUntilReady();
@@ -495,7 +498,8 @@ describe('<ToolMessage />', () => {
uiActions,
uiState: { streamingState: StreamingState.Idle },
width: 80,
useAlternateBuffer: false,
config: makeFakeConfig({ useAlternateBuffer: false }),
settings: createMockSettings({ ui: { useAlternateBuffer: false } }),
},
);
await waitUntilReady();
@@ -523,7 +527,8 @@ describe('<ToolMessage />', () => {
uiActions,
uiState: { streamingState: StreamingState.Idle },
width: 80,
useAlternateBuffer: false,
config: makeFakeConfig({ useAlternateBuffer: false }),
settings: createMockSettings({ ui: { useAlternateBuffer: false } }),
},
);
await waitUntilReady();
@@ -5,11 +5,12 @@
*/
import { describe, it, expect } from 'vitest';
import { ToolMessage, type ToolMessageProps } from './ToolMessage.js';
import { type ToolMessageProps, ToolMessage } from './ToolMessage.js';
import { StreamingState } from '../../types.js';
import { StreamingContext } from '../../contexts/StreamingContext.js';
import { renderWithProviders } from '../../../test-utils/render.js';
import { CoreToolCallStatus } from '@google/gemini-cli-core';
import { createMockSettings } from '../../../test-utils/settings.js';
import { CoreToolCallStatus, makeFakeConfig } from '@google/gemini-cli-core';
describe('<ToolMessage /> - Raw Markdown Display Snapshots', () => {
const baseProps: ToolMessageProps = {
@@ -72,7 +73,8 @@ describe('<ToolMessage /> - Raw Markdown Display Snapshots', () => {
</StreamingContext.Provider>,
{
uiState: { renderMarkdown, streamingState: StreamingState.Idle },
useAlternateBuffer,
config: makeFakeConfig({ useAlternateBuffer }),
settings: createMockSettings({ ui: { useAlternateBuffer } }),
},
);
await waitUntilReady();
@@ -7,9 +7,10 @@
import { describe, it, expect } from 'vitest';
import { ToolGroupMessage } from './ToolGroupMessage.js';
import { renderWithProviders } from '../../../test-utils/render.js';
import { createMockSettings } from '../../../test-utils/settings.js';
import { StreamingState, type IndividualToolCallDisplay } from '../../types.js';
import { waitFor } from '../../../test-utils/async.js';
import { CoreToolCallStatus } from '@google/gemini-cli-core';
import { CoreToolCallStatus, makeFakeConfig } from '@google/gemini-cli-core';
import { useOverflowState } from '../../contexts/OverflowContext.js';
describe('ToolOverflowConsistencyChecks: ToolGroupMessage and ToolResultDisplay synchronization', () => {
@@ -56,7 +57,8 @@ describe('ToolOverflowConsistencyChecks: ToolGroupMessage and ToolResultDisplay
streamingState: StreamingState.Idle,
constrainHeight: true,
},
useAlternateBuffer: true,
config: makeFakeConfig({ useAlternateBuffer: true }),
settings: createMockSettings({ ui: { useAlternateBuffer: true } }),
},
);
@@ -106,7 +108,8 @@ describe('ToolOverflowConsistencyChecks: ToolGroupMessage and ToolResultDisplay
streamingState: StreamingState.Idle,
constrainHeight: true,
},
useAlternateBuffer: false,
config: makeFakeConfig({ useAlternateBuffer: false }),
settings: createMockSettings({ ui: { useAlternateBuffer: false } }),
},
);
@@ -5,9 +5,10 @@
*/
import { renderWithProviders } from '../../../test-utils/render.js';
import { createMockSettings } from '../../../test-utils/settings.js';
import { ToolResultDisplay } from './ToolResultDisplay.js';
import { describe, it, expect, vi } from 'vitest';
import type { AnsiOutput } from '@google/gemini-cli-core';
import { makeFakeConfig, type AnsiOutput } from '@google/gemini-cli-core';
describe('ToolResultDisplay', () => {
beforeEach(() => {
@@ -36,7 +37,10 @@ describe('ToolResultDisplay', () => {
terminalWidth={80}
maxLines={10}
/>,
{ useAlternateBuffer: true },
{
config: makeFakeConfig({ useAlternateBuffer: true }),
settings: createMockSettings({ ui: { useAlternateBuffer: true } }),
},
);
await waitUntilReady();
const output = lastFrame();
@@ -52,7 +56,10 @@ describe('ToolResultDisplay', () => {
terminalWidth={80}
maxLines={10}
/>,
{ useAlternateBuffer: true },
{
config: makeFakeConfig({ useAlternateBuffer: true }),
settings: createMockSettings({ ui: { useAlternateBuffer: true } }),
},
);
await waitUntilReady();
const output = lastFrame();
@@ -69,7 +76,10 @@ describe('ToolResultDisplay', () => {
terminalWidth={80}
hasFocus={true}
/>,
{ useAlternateBuffer: true },
{
config: makeFakeConfig({ useAlternateBuffer: true }),
settings: createMockSettings({ ui: { useAlternateBuffer: true } }),
},
);
await waitUntilReady();
@@ -80,7 +90,10 @@ describe('ToolResultDisplay', () => {
it('renders string result as markdown by default', async () => {
const { lastFrame, waitUntilReady, unmount } = renderWithProviders(
<ToolResultDisplay resultDisplay="**Some result**" terminalWidth={80} />,
{ useAlternateBuffer: false },
{
config: makeFakeConfig({ useAlternateBuffer: false }),
settings: createMockSettings({ ui: { useAlternateBuffer: false } }),
},
);
await waitUntilReady();
const output = lastFrame();
@@ -98,7 +111,8 @@ describe('ToolResultDisplay', () => {
renderOutputAsMarkdown={false}
/>,
{
useAlternateBuffer: false,
config: makeFakeConfig({ useAlternateBuffer: false }),
settings: createMockSettings({ ui: { useAlternateBuffer: false } }),
uiState: { constrainHeight: true },
},
);
@@ -118,7 +132,8 @@ describe('ToolResultDisplay', () => {
availableTerminalHeight={20}
/>,
{
useAlternateBuffer: false,
config: makeFakeConfig({ useAlternateBuffer: false }),
settings: createMockSettings({ ui: { useAlternateBuffer: false } }),
uiState: { constrainHeight: true },
},
);
@@ -140,7 +155,10 @@ describe('ToolResultDisplay', () => {
terminalWidth={80}
availableTerminalHeight={20}
/>,
{ useAlternateBuffer: false },
{
config: makeFakeConfig({ useAlternateBuffer: false }),
settings: createMockSettings({ ui: { useAlternateBuffer: false } }),
},
);
await waitUntilReady();
const output = lastFrame();
@@ -170,7 +188,10 @@ describe('ToolResultDisplay', () => {
terminalWidth={80}
availableTerminalHeight={20}
/>,
{ useAlternateBuffer: false },
{
config: makeFakeConfig({ useAlternateBuffer: false }),
settings: createMockSettings({ ui: { useAlternateBuffer: false } }),
},
);
await waitUntilReady();
const output = lastFrame();
@@ -189,7 +210,10 @@ describe('ToolResultDisplay', () => {
terminalWidth={80}
availableTerminalHeight={20}
/>,
{ useAlternateBuffer: false },
{
config: makeFakeConfig({ useAlternateBuffer: false }),
settings: createMockSettings({ ui: { useAlternateBuffer: false } }),
},
);
await waitUntilReady();
const output = lastFrame({ allowEmpty: true });
@@ -208,7 +232,8 @@ describe('ToolResultDisplay', () => {
renderOutputAsMarkdown={true}
/>,
{
useAlternateBuffer: false,
config: makeFakeConfig({ useAlternateBuffer: false }),
settings: createMockSettings({ ui: { useAlternateBuffer: false } }),
uiState: { constrainHeight: true },
},
);
@@ -226,7 +251,10 @@ describe('ToolResultDisplay', () => {
availableTerminalHeight={20}
renderOutputAsMarkdown={true}
/>,
{ useAlternateBuffer: true },
{
config: makeFakeConfig({ useAlternateBuffer: true }),
settings: createMockSettings({ ui: { useAlternateBuffer: true } }),
},
);
await waitUntilReady();
const output = lastFrame();
@@ -306,7 +334,8 @@ describe('ToolResultDisplay', () => {
maxLines={3}
/>,
{
useAlternateBuffer: false,
config: makeFakeConfig({ useAlternateBuffer: false }),
settings: createMockSettings({ ui: { useAlternateBuffer: false } }),
uiState: { constrainHeight: true },
},
);
@@ -342,7 +371,8 @@ describe('ToolResultDisplay', () => {
availableTerminalHeight={undefined}
/>,
{
useAlternateBuffer: false,
config: makeFakeConfig({ useAlternateBuffer: false }),
settings: createMockSettings({ ui: { useAlternateBuffer: false } }),
uiState: { constrainHeight: true },
},
);
@@ -102,7 +102,12 @@ export const ToolResultDisplay: React.FC<ToolResultDisplayProps> = ({
</Text>
);
} else if (isSubagentProgress(contentData)) {
content = <SubagentProgressDisplay progress={contentData} />;
content = (
<SubagentProgressDisplay
progress={contentData}
terminalWidth={childWidth}
/>
);
} else if (typeof contentData === 'string' && renderOutputAsMarkdown) {
content = (
<MarkdownDisplay
@@ -5,9 +5,10 @@
*/
import { renderWithProviders } from '../../../test-utils/render.js';
import { createMockSettings } from '../../../test-utils/settings.js';
import { ToolResultDisplay } from './ToolResultDisplay.js';
import { describe, it, expect } from 'vitest';
import { type AnsiOutput } from '@google/gemini-cli-core';
import { makeFakeConfig, type AnsiOutput } from '@google/gemini-cli-core';
describe('ToolResultDisplay Overflow', () => {
it('shows the head of the content when overflowDirection is bottom (string)', async () => {
@@ -20,7 +21,8 @@ describe('ToolResultDisplay Overflow', () => {
overflowDirection="bottom"
/>,
{
useAlternateBuffer: false,
config: makeFakeConfig({ useAlternateBuffer: false }),
settings: createMockSettings({ ui: { useAlternateBuffer: false } }),
uiState: { constrainHeight: true },
},
);
@@ -46,7 +48,8 @@ describe('ToolResultDisplay Overflow', () => {
overflowDirection="top"
/>,
{
useAlternateBuffer: false,
config: makeFakeConfig({ useAlternateBuffer: false }),
settings: createMockSettings({ ui: { useAlternateBuffer: false } }),
uiState: { constrainHeight: true },
},
);
@@ -83,7 +86,8 @@ describe('ToolResultDisplay Overflow', () => {
overflowDirection="bottom"
/>,
{
useAlternateBuffer: false,
config: makeFakeConfig({ useAlternateBuffer: false }),
settings: createMockSettings({ ui: { useAlternateBuffer: false } }),
uiState: { constrainHeight: true },
},
);
@@ -0,0 +1,9 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`<SubagentGroupDisplay /> > renders collapsed view by default with correct agent counts and states 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
│ ≡ 2 Agents (1 running, 1 completed)... (ctrl+o to expand) │
│ ! api-monitor · Action Required Verify server is running │
│ ✓ db-manager · 💭 Completed successfully │
"
`;
@@ -1,7 +1,9 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`<SubagentProgressDisplay /> > renders "Request cancelled." with the info icon 1`] = `
" Request cancelled.
"Running subagent TestAgent...
Request cancelled.
"
`;
@@ -11,31 +13,43 @@ exports[`<SubagentProgressDisplay /> > renders cancelled state correctly 1`] = `
`;
exports[`<SubagentProgressDisplay /> > renders correctly with command fallback 1`] = `
"⠋ run_shell_command echo hello
"Running subagent TestAgent...
⠋ run_shell_command echo hello
"
`;
exports[`<SubagentProgressDisplay /> > renders correctly with description in args 1`] = `
"⠋ run_shell_command Say hello
"Running subagent TestAgent...
⠋ run_shell_command Say hello
"
`;
exports[`<SubagentProgressDisplay /> > renders correctly with displayName and description from item 1`] = `
"⠋ RunShellCommand Executing echo hello
"Running subagent TestAgent...
⠋ RunShellCommand Executing echo hello
"
`;
exports[`<SubagentProgressDisplay /> > renders correctly with file_path 1`] = `
"✓ write_file /tmp/test.txt
"Running subagent TestAgent...
✓ write_file /tmp/test.txt
"
`;
exports[`<SubagentProgressDisplay /> > renders thought bubbles correctly 1`] = `
"💭 Thinking about life
"Running subagent TestAgent...
💭 Thinking about life
"
`;
exports[`<SubagentProgressDisplay /> > truncates long args 1`] = `
"⠋ run_shell_command This is a very long description that should definitely be tr...
"Running subagent TestAgent...
⠋ run_shell_command This is a very long description that should definitely be tr...
"
`;
@@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { render } from '../../../test-utils/render.js';
import { renderWithProviders } from '../../../test-utils/render.js';
import { waitFor } from '../../../test-utils/async.js';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { act } from 'react';
@@ -14,15 +14,8 @@ import {
type BaseSettingsDialogProps,
type SettingsDialogItem,
} from './BaseSettingsDialog.js';
import { KeypressProvider } from '../../contexts/KeypressContext.js';
import { SettingScope } from '../../../config/settings.js';
vi.mock('../../contexts/UIStateContext.js', () => ({
useUIState: () => ({
mainAreaWidth: 100,
}),
}));
enum TerminalKeys {
ENTER = '\u000D',
TAB = '\t',
@@ -115,10 +108,8 @@ describe('BaseSettingsDialog', () => {
...props,
};
const result = render(
<KeypressProvider>
<BaseSettingsDialog {...defaultProps} />
</KeypressProvider>,
const result = renderWithProviders(
<BaseSettingsDialog {...defaultProps} />,
);
await result.waitUntilReady();
return result;
@@ -331,22 +322,18 @@ describe('BaseSettingsDialog', () => {
const filteredItems = [items[0], items[2], items[4]];
await act(async () => {
rerender(
<KeypressProvider>
<BaseSettingsDialog
title="Test Settings"
items={filteredItems}
selectedScope={SettingScope.User}
maxItemsToShow={5}
onItemToggle={mockOnItemToggle}
onEditCommit={mockOnEditCommit}
onItemClear={mockOnItemClear}
onClose={mockOnClose}
/>
</KeypressProvider>,
<BaseSettingsDialog
title="Test Settings"
items={filteredItems}
selectedScope={SettingScope.User}
maxItemsToShow={5}
onItemToggle={mockOnItemToggle}
onEditCommit={mockOnEditCommit}
onItemClear={mockOnItemClear}
onClose={mockOnClose}
/>,
);
});
await waitUntilReady();
// Verify the dialog hasn't crashed and the items are displayed
await waitFor(() => {
const frame = lastFrame();
@@ -391,22 +378,18 @@ describe('BaseSettingsDialog', () => {
const filteredItems = [items[0], items[1]];
await act(async () => {
rerender(
<KeypressProvider>
<BaseSettingsDialog
title="Test Settings"
items={filteredItems}
selectedScope={SettingScope.User}
maxItemsToShow={5}
onItemToggle={mockOnItemToggle}
onEditCommit={mockOnEditCommit}
onItemClear={mockOnItemClear}
onClose={mockOnClose}
/>
</KeypressProvider>,
<BaseSettingsDialog
title="Test Settings"
items={filteredItems}
selectedScope={SettingScope.User}
maxItemsToShow={5}
onItemToggle={mockOnItemToggle}
onEditCommit={mockOnEditCommit}
onItemClear={mockOnItemClear}
onClose={mockOnClose}
/>,
);
});
await waitUntilReady();
await waitFor(() => {
const frame = lastFrame();
expect(frame).toContain('Boolean Setting');
@@ -5,21 +5,12 @@
*/
import { useState, useEffect, useRef, act } from 'react';
import { render } from '../../../test-utils/render.js';
import { renderWithProviders } from '../../../test-utils/render.js';
import { Box, Text } from 'ink';
import { ScrollableList, type ScrollableListRef } from './ScrollableList.js';
import { ScrollProvider } from '../../contexts/ScrollProvider.js';
import { KeypressProvider } from '../../contexts/KeypressContext.js';
import { MouseProvider } from '../../contexts/MouseContext.js';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { waitFor } from '../../../test-utils/async.js';
vi.mock('../../contexts/UIStateContext.js', () => ({
useUIState: vi.fn(() => ({
copyModeEnabled: false,
})),
}));
// Mock useStdout to provide a fixed size for testing
vi.mock('ink', async (importOriginal) => {
const actual = await importOriginal<typeof import('ink')>();
@@ -85,51 +76,45 @@ const TestComponent = ({
}, [onRef]);
return (
<MouseProvider mouseEventsEnabled={false}>
<KeypressProvider>
<ScrollProvider>
<Box flexDirection="column" width={80} height={24} padding={1}>
<Box flexGrow={1} borderStyle="round" borderColor="cyan">
<ScrollableList
ref={listRef}
data={items}
renderItem={({ item, index }) => (
<Box flexDirection="column" paddingBottom={2}>
<Box flexDirection="column" width={80} height={24} padding={1}>
<Box flexGrow={1} borderStyle="round" borderColor="cyan">
<ScrollableList
ref={listRef}
data={items}
renderItem={({ item, index }) => (
<Box flexDirection="column" paddingBottom={2}>
<Box
sticky
flexDirection="column"
width={78}
opaque
stickyChildren={
<Box flexDirection="column" width={78} opaque>
<Text>{item.title}</Text>
<Box
sticky
flexDirection="column"
width={78}
opaque
stickyChildren={
<Box flexDirection="column" width={78} opaque>
<Text>{item.title}</Text>
<Box
borderStyle="single"
borderTop={true}
borderBottom={false}
borderLeft={false}
borderRight={false}
borderColor="gray"
/>
</Box>
}
>
<Text>{item.title}</Text>
</Box>
<Text color="gray">{getLorem(index)}</Text>
borderStyle="single"
borderTop={true}
borderBottom={false}
borderLeft={false}
borderRight={false}
borderColor="gray"
/>
</Box>
)}
estimatedItemHeight={() => 14}
keyExtractor={(item) => item.id}
hasFocus={true}
initialScrollIndex={Number.MAX_SAFE_INTEGER}
/>
}
>
<Text>{item.title}</Text>
</Box>
<Text color="gray">{getLorem(index)}</Text>
</Box>
<Text>Count: {items.length}</Text>
</Box>
</ScrollProvider>
</KeypressProvider>
</MouseProvider>
)}
estimatedItemHeight={() => 14}
keyExtractor={(item) => item.id}
hasFocus={true}
initialScrollIndex={Number.MAX_SAFE_INTEGER}
/>
</Box>
<Text>Count: {items.length}</Text>
</Box>
);
};
describe('ScrollableList Demo Behavior', () => {
@@ -147,10 +132,10 @@ describe('ScrollableList Demo Behavior', () => {
let lastFrame: (options?: { allowEmpty?: boolean }) => string | undefined;
let waitUntilReady: () => Promise<void>;
let result: ReturnType<typeof render>;
let result: ReturnType<typeof renderWithProviders>;
await act(async () => {
result = render(
result = renderWithProviders(
<TestComponent
onAddItem={(add) => {
addItem = add;
@@ -230,45 +215,39 @@ describe('ScrollableList Demo Behavior', () => {
}, []);
return (
<MouseProvider mouseEventsEnabled={false}>
<KeypressProvider>
<ScrollProvider>
<Box flexDirection="column" width={80} height={10}>
<ScrollableList
ref={ref}
data={items}
renderItem={({ item, index }) => (
<Box flexDirection="column" height={3}>
{index === 0 ? (
<Box
sticky
stickyChildren={<Text>[STICKY] {item.title}</Text>}
>
<Text>[Normal] {item.title}</Text>
</Box>
) : (
<Text>[Normal] {item.title}</Text>
)}
<Text>Content for {item.title}</Text>
<Text>More content for {item.title}</Text>
</Box>
)}
estimatedItemHeight={() => 3}
keyExtractor={(item) => item.id}
hasFocus={true}
/>
<Box flexDirection="column" width={80} height={10}>
<ScrollableList
ref={ref}
data={items}
renderItem={({ item, index }) => (
<Box flexDirection="column" height={3}>
{index === 0 ? (
<Box
sticky
stickyChildren={<Text>[STICKY] {item.title}</Text>}
>
<Text>[Normal] {item.title}</Text>
</Box>
) : (
<Text>[Normal] {item.title}</Text>
)}
<Text>Content for {item.title}</Text>
<Text>More content for {item.title}</Text>
</Box>
</ScrollProvider>
</KeypressProvider>
</MouseProvider>
)}
estimatedItemHeight={() => 3}
keyExtractor={(item) => item.id}
hasFocus={true}
/>
</Box>
);
};
let lastFrame: () => string | undefined;
let waitUntilReady: () => Promise<void>;
let result: ReturnType<typeof render>;
let result: ReturnType<typeof renderWithProviders>;
await act(async () => {
result = render(<StickyTestComponent />);
result = renderWithProviders(<StickyTestComponent />);
lastFrame = result.lastFrame;
waitUntilReady = result.waitUntilReady;
});
@@ -334,27 +313,21 @@ describe('ScrollableList Demo Behavior', () => {
title: `Item ${i}`,
}));
let result: ReturnType<typeof render>;
let result: ReturnType<typeof renderWithProviders>;
await act(async () => {
result = render(
<MouseProvider mouseEventsEnabled={false}>
<KeypressProvider>
<ScrollProvider>
<Box flexDirection="column" width={80} height={10}>
<ScrollableList
ref={(ref) => {
listRef = ref;
}}
data={items}
renderItem={({ item }) => <Text>{item.title}</Text>}
estimatedItemHeight={() => 1}
keyExtractor={(item) => item.id}
hasFocus={true}
/>
</Box>
</ScrollProvider>
</KeypressProvider>
</MouseProvider>,
result = renderWithProviders(
<Box flexDirection="column" width={80} height={10}>
<ScrollableList
ref={(ref) => {
listRef = ref;
}}
data={items}
renderItem={({ item }) => <Text>{item.title}</Text>}
estimatedItemHeight={() => 1}
keyExtractor={(item) => item.id}
hasFocus={true}
/>
</Box>,
);
lastFrame = result.lastFrame;
stdin = result.stdin;
@@ -444,25 +417,19 @@ describe('ScrollableList Demo Behavior', () => {
let lastFrame: (options?: { allowEmpty?: boolean }) => string | undefined;
let waitUntilReady: () => Promise<void>;
let result: ReturnType<typeof render>;
let result: ReturnType<typeof renderWithProviders>;
await act(async () => {
result = render(
<MouseProvider mouseEventsEnabled={false}>
<KeypressProvider>
<ScrollProvider>
<Box width={100} height={20}>
<ScrollableList
data={items}
renderItem={({ item }) => <Text>{item.title}</Text>}
estimatedItemHeight={() => 1}
keyExtractor={(item) => item.id}
hasFocus={true}
width={50}
/>
</Box>
</ScrollProvider>
</KeypressProvider>
</MouseProvider>,
result = renderWithProviders(
<Box width={100} height={20}>
<ScrollableList
data={items}
renderItem={({ item }) => <Text>{item.title}</Text>}
estimatedItemHeight={() => 1}
keyExtractor={(item) => item.id}
hasFocus={true}
width={50}
/>
</Box>,
);
lastFrame = result.lastFrame;
waitUntilReady = result.waitUntilReady;
@@ -497,31 +464,25 @@ describe('ScrollableList Demo Behavior', () => {
}, []);
return (
<MouseProvider mouseEventsEnabled={false}>
<KeypressProvider>
<ScrollProvider>
<Box flexDirection="column" width={80} height={5}>
<ScrollableList
ref={(ref) => {
listRef = ref;
}}
data={items}
renderItem={({ item }) => <Text>{item.title}</Text>}
estimatedItemHeight={() => 1}
keyExtractor={(item) => item.id}
hasFocus={true}
initialScrollIndex={Number.MAX_SAFE_INTEGER}
/>
</Box>
</ScrollProvider>
</KeypressProvider>
</MouseProvider>
<Box flexDirection="column" width={80} height={5}>
<ScrollableList
ref={(ref) => {
listRef = ref;
}}
data={items}
renderItem={({ item }) => <Text>{item.title}</Text>}
estimatedItemHeight={() => 1}
keyExtractor={(item) => item.id}
hasFocus={true}
initialScrollIndex={Number.MAX_SAFE_INTEGER}
/>
</Box>
);
};
let result: ReturnType<typeof render>;
let result: ReturnType<typeof renderWithProviders>;
await act(async () => {
result = render(<TestComp />);
result = renderWithProviders(<TestComp />);
});
await result!.waitUntilReady();
@@ -622,33 +583,27 @@ describe('ScrollableList Demo Behavior', () => {
);
return (
<MouseProvider mouseEventsEnabled={false}>
<KeypressProvider>
<ScrollProvider>
<Box flexDirection="column" width={80} height={4}>
<ScrollableList
ref={(ref) => {
listRef = ref;
}}
data={items}
renderItem={({ item, index }) => (
<ItemWithState item={item} isLast={index === 4} />
)}
estimatedItemHeight={() => 1}
keyExtractor={(item) => item.id}
hasFocus={true}
initialScrollIndex={Number.MAX_SAFE_INTEGER}
/>
</Box>
</ScrollProvider>
</KeypressProvider>
</MouseProvider>
<Box flexDirection="column" width={80} height={4}>
<ScrollableList
ref={(ref) => {
listRef = ref;
}}
data={items}
renderItem={({ item, index }) => (
<ItemWithState item={item} isLast={index === 4} />
)}
estimatedItemHeight={() => 1}
keyExtractor={(item) => item.id}
hasFocus={true}
initialScrollIndex={Number.MAX_SAFE_INTEGER}
/>
</Box>
);
};
let result: ReturnType<typeof render>;
let result: ReturnType<typeof renderWithProviders>;
await act(async () => {
result = render(<TestComp />);
result = renderWithProviders(<TestComp />);
});
await result!.waitUntilReady();
@@ -696,35 +651,29 @@ describe('ScrollableList Demo Behavior', () => {
}, []);
return (
<MouseProvider mouseEventsEnabled={false}>
<KeypressProvider>
<ScrollProvider>
<Box flexDirection="column" width={80} height={10}>
<ScrollableList
ref={(ref) => {
listRef = ref;
}}
data={items}
renderItem={({ item }) => (
<Box height={item.id === '1' ? 10 : 2}>
<Text>{item.title}</Text>
</Box>
)}
estimatedItemHeight={() => 2}
keyExtractor={(item) => item.id}
hasFocus={true}
initialScrollIndex={Number.MAX_SAFE_INTEGER}
/>
<Box flexDirection="column" width={80} height={10}>
<ScrollableList
ref={(ref) => {
listRef = ref;
}}
data={items}
renderItem={({ item }) => (
<Box height={item.id === '1' ? 10 : 2}>
<Text>{item.title}</Text>
</Box>
</ScrollProvider>
</KeypressProvider>
</MouseProvider>
)}
estimatedItemHeight={() => 2}
keyExtractor={(item) => item.id}
hasFocus={true}
initialScrollIndex={Number.MAX_SAFE_INTEGER}
/>
</Box>
);
};
let result: ReturnType<typeof render>;
let result: ReturnType<typeof renderWithProviders>;
await act(async () => {
result = render(<TestComp />);
result = renderWithProviders(<TestComp />);
});
await result!.waitUntilReady();
@@ -5,7 +5,7 @@
*/
import React from 'react';
import { render } from '../../../test-utils/render.js';
import { renderWithProviders } from '../../../test-utils/render.js';
import { waitFor } from '../../../test-utils/async.js';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import {
@@ -14,7 +14,6 @@ import {
type SearchListState,
type GenericListItem,
} from './SearchableList.js';
import { KeypressProvider } from '../../contexts/KeypressContext.js';
import { useTextBuffer } from './text-buffer.js';
const useMockSearch = (props: {
@@ -52,12 +51,6 @@ const useMockSearch = (props: {
};
};
vi.mock('../../contexts/UIStateContext.js', () => ({
useUIState: () => ({
mainAreaWidth: 100,
}),
}));
const mockItems: GenericListItem[] = [
{
key: 'item-1',
@@ -98,11 +91,7 @@ describe('SearchableList', () => {
...props,
};
return render(
<KeypressProvider>
<SearchableList {...defaultProps} />
</KeypressProvider>,
);
return renderWithProviders(<SearchableList {...defaultProps} />);
};
it('should render all items initially', async () => {
@@ -5,11 +5,10 @@
*/
import React from 'react';
import { render } from '../../../test-utils/render.js';
import { renderWithProviders } from '../../../test-utils/render.js';
import { waitFor } from '../../../test-utils/async.js';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { ExtensionDetails } from './ExtensionDetails.js';
import { KeypressProvider } from '../../contexts/KeypressContext.js';
import { type RegistryExtension } from '../../../config/extensionRegistryClient.js';
const mockExtension: RegistryExtension = {
@@ -43,15 +42,13 @@ describe('ExtensionDetails', () => {
});
const renderDetails = (isInstalled = false) =>
render(
<KeypressProvider>
<ExtensionDetails
extension={mockExtension}
onBack={mockOnBack}
onInstall={mockOnInstall}
isInstalled={isInstalled}
/>
</KeypressProvider>,
renderWithProviders(
<ExtensionDetails
extension={mockExtension}
onBack={mockOnBack}
onInstall={mockOnInstall}
isInstalled={isInstalled}
/>,
);
it('should render extension details correctly', async () => {
@@ -5,7 +5,7 @@
*/
import React from 'react';
import { render } from '../../../test-utils/render.js';
import { renderWithProviders } from '../../../test-utils/render.js';
import { waitFor } from '../../../test-utils/async.js';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { ExtensionRegistryView } from './ExtensionRegistryView.js';
@@ -14,9 +14,7 @@ import { useExtensionRegistry } from '../../hooks/useExtensionRegistry.js';
import { useExtensionUpdates } from '../../hooks/useExtensionUpdates.js';
import { useRegistrySearch } from '../../hooks/useRegistrySearch.js';
import { type RegistryExtension } from '../../../config/extensionRegistryClient.js';
import { useUIState } from '../../contexts/UIStateContext.js';
import { useConfig } from '../../contexts/ConfigContext.js';
import { KeypressProvider } from '../../contexts/KeypressContext.js';
import { type UIState } from '../../contexts/UIStateContext.js';
import {
type SearchListState,
type GenericListItem,
@@ -28,8 +26,6 @@ vi.mock('../../hooks/useExtensionRegistry.js');
vi.mock('../../hooks/useExtensionUpdates.js');
vi.mock('../../hooks/useRegistrySearch.js');
vi.mock('../../../config/extension-manager.js');
vi.mock('../../contexts/UIStateContext.js');
vi.mock('../../contexts/ConfigContext.js');
const mockExtensions: RegistryExtension[] = [
{
@@ -123,34 +119,27 @@ describe('ExtensionRegistryView', () => {
maxLabelWidth: 10,
}) as unknown as SearchListState<GenericListItem>,
);
vi.mocked(useUIState).mockReturnValue({
mainAreaWidth: 100,
terminalHeight: 40,
staticExtraHeight: 5,
} as unknown as ReturnType<typeof useUIState>);
vi.mocked(useConfig).mockReturnValue({
getEnableExtensionReloading: vi.fn().mockReturnValue(false),
getExtensionRegistryURI: vi
.fn()
.mockReturnValue('https://geminicli.com/extensions.json'),
} as unknown as ReturnType<typeof useConfig>);
});
const renderView = () =>
render(
<KeypressProvider>
<ExtensionRegistryView
extensionManager={mockExtensionManager}
onSelect={mockOnSelect}
onClose={mockOnClose}
/>
</KeypressProvider>,
renderWithProviders(
<ExtensionRegistryView
extensionManager={mockExtensionManager}
onSelect={mockOnSelect}
onClose={mockOnClose}
/>,
{
uiState: {
staticExtraHeight: 5,
terminalHeight: 40,
} as Partial<UIState>,
},
);
it('should render extensions', async () => {
const { lastFrame } = renderView();
const { lastFrame, waitUntilReady } = renderView();
await waitUntilReady();
await waitFor(() => {
expect(lastFrame()).toContain('Test Extension 1');
expect(lastFrame()).toContain('Test Extension 2');
@@ -5,13 +5,12 @@
*/
import { debugLogger } from '@google/gemini-cli-core';
import type React from 'react';
import { act } from 'react';
import { renderHook } from '../../test-utils/render.js';
import { renderHookWithProviders } from '../../test-utils/render.js';
import { createMockSettings } from '../../test-utils/settings.js';
import { waitFor } from '../../test-utils/async.js';
import { vi, afterAll, beforeAll, type Mock } from 'vitest';
import {
KeypressProvider,
useKeypressContext,
ESC_TIMEOUT,
FAST_RETURN_TIMEOUT,
@@ -52,11 +51,8 @@ class MockStdin extends EventEmitter {
// Helper function to setup keypress test with standard configuration
const setupKeypressTest = () => {
const keyHandler = vi.fn();
const wrapper = ({ children }: { children: React.ReactNode }) => (
<KeypressProvider>{children}</KeypressProvider>
);
const { result } = renderHook(() => useKeypressContext(), { wrapper });
const { result } = renderHookWithProviders(() => useKeypressContext());
act(() => result.current.subscribe(keyHandler));
return { result, keyHandler };
@@ -66,10 +62,6 @@ describe('KeypressContext', () => {
let stdin: MockStdin;
const mockSetRawMode = vi.fn();
const wrapper = ({ children }: { children: React.ReactNode }) => (
<KeypressProvider>{children}</KeypressProvider>
);
beforeAll(() => vi.useFakeTimers());
afterAll(() => vi.useRealTimers());
@@ -269,10 +261,7 @@ describe('KeypressContext', () => {
it('should handle double Escape', async () => {
const keyHandler = vi.fn();
const wrapper = ({ children }: { children: React.ReactNode }) => (
<KeypressProvider>{children}</KeypressProvider>
);
const { result } = renderHook(() => useKeypressContext(), { wrapper });
const { result } = renderHookWithProviders(() => useKeypressContext());
act(() => result.current.subscribe(keyHandler));
act(() => {
@@ -306,10 +295,7 @@ describe('KeypressContext', () => {
it('should handle lone Escape key (keycode 27) with timeout when kitty protocol is enabled', async () => {
// Use real timers for this test to avoid issues with stream/buffer timing
const keyHandler = vi.fn();
const wrapper = ({ children }: { children: React.ReactNode }) => (
<KeypressProvider>{children}</KeypressProvider>
);
const { result } = renderHook(() => useKeypressContext(), { wrapper });
const { result } = renderHookWithProviders(() => useKeypressContext());
act(() => result.current.subscribe(keyHandler));
// Send just ESC
@@ -432,7 +418,7 @@ describe('KeypressContext', () => {
])('should $name', async ({ pastedText, writeSequence }) => {
const keyHandler = vi.fn();
const { result } = renderHook(() => useKeypressContext(), { wrapper });
const { result } = renderHookWithProviders(() => useKeypressContext());
act(() => result.current.subscribe(keyHandler));
@@ -452,7 +438,7 @@ describe('KeypressContext', () => {
it('should parse valid OSC 52 response', async () => {
const keyHandler = vi.fn();
const { result } = renderHook(() => useKeypressContext(), { wrapper });
const { result } = renderHookWithProviders(() => useKeypressContext());
act(() => result.current.subscribe(keyHandler));
@@ -473,7 +459,7 @@ describe('KeypressContext', () => {
it('should handle split OSC 52 response', async () => {
const keyHandler = vi.fn();
const { result } = renderHook(() => useKeypressContext(), { wrapper });
const { result } = renderHookWithProviders(() => useKeypressContext());
act(() => result.current.subscribe(keyHandler));
@@ -499,7 +485,7 @@ describe('KeypressContext', () => {
it('should handle OSC 52 response terminated by ESC \\', async () => {
const keyHandler = vi.fn();
const { result } = renderHook(() => useKeypressContext(), { wrapper });
const { result } = renderHookWithProviders(() => useKeypressContext());
act(() => result.current.subscribe(keyHandler));
@@ -520,7 +506,7 @@ describe('KeypressContext', () => {
it('should ignore unknown OSC sequences', async () => {
const keyHandler = vi.fn();
const { result } = renderHook(() => useKeypressContext(), { wrapper });
const { result } = renderHookWithProviders(() => useKeypressContext());
act(() => result.current.subscribe(keyHandler));
@@ -537,7 +523,7 @@ describe('KeypressContext', () => {
it('should ignore invalid OSC 52 format', async () => {
const keyHandler = vi.fn();
const { result } = renderHook(() => useKeypressContext(), { wrapper });
const { result } = renderHookWithProviders(() => useKeypressContext());
act(() => result.current.subscribe(keyHandler));
@@ -569,13 +555,11 @@ describe('KeypressContext', () => {
it('should not log keystrokes when debugKeystrokeLogging is false', async () => {
const keyHandler = vi.fn();
const wrapper = ({ children }: { children: React.ReactNode }) => (
<KeypressProvider debugKeystrokeLogging={false}>
{children}
</KeypressProvider>
);
const { result } = renderHook(() => useKeypressContext(), { wrapper });
const { result } = renderHookWithProviders(() => useKeypressContext(), {
settings: createMockSettings({
general: { debugKeystrokeLogging: false },
}),
});
act(() => result.current.subscribe(keyHandler));
@@ -593,13 +577,11 @@ describe('KeypressContext', () => {
it('should log kitty buffer accumulation when debugKeystrokeLogging is true', async () => {
const keyHandler = vi.fn();
const wrapper = ({ children }: { children: React.ReactNode }) => (
<KeypressProvider debugKeystrokeLogging={true}>
{children}
</KeypressProvider>
);
const { result } = renderHook(() => useKeypressContext(), { wrapper });
const { result } = renderHookWithProviders(() => useKeypressContext(), {
settings: createMockSettings({
general: { debugKeystrokeLogging: true },
}),
});
act(() => result.current.subscribe(keyHandler));
@@ -614,13 +596,11 @@ describe('KeypressContext', () => {
it('should show char codes when debugKeystrokeLogging is true even without debug mode', async () => {
const keyHandler = vi.fn();
const wrapper = ({ children }: { children: React.ReactNode }) => (
<KeypressProvider debugKeystrokeLogging={true}>
{children}
</KeypressProvider>
);
const { result } = renderHook(() => useKeypressContext(), { wrapper });
const { result } = renderHookWithProviders(() => useKeypressContext(), {
settings: createMockSettings({
general: { debugKeystrokeLogging: true },
}),
});
act(() => result.current.subscribe(keyHandler));
@@ -765,7 +745,7 @@ describe('KeypressContext', () => {
'should recognize sequence "$sequence" as $expected.name',
({ sequence, expected }) => {
const keyHandler = vi.fn();
const { result } = renderHook(() => useKeypressContext(), { wrapper });
const { result } = renderHookWithProviders(() => useKeypressContext());
act(() => result.current.subscribe(keyHandler));
act(() => stdin.write(sequence));
@@ -1000,12 +980,7 @@ describe('KeypressContext', () => {
'should handle Alt+$key in $terminal',
({ chunk, expected }: { chunk: string; expected: Partial<Key> }) => {
const keyHandler = vi.fn();
const testWrapper = ({ children }: { children: React.ReactNode }) => (
<KeypressProvider>{children}</KeypressProvider>
);
const { result } = renderHook(() => useKeypressContext(), {
wrapper: testWrapper,
});
const { result } = renderHookWithProviders(() => useKeypressContext());
act(() => result.current.subscribe(keyHandler));
act(() => stdin.write(chunk));
@@ -1042,7 +1017,7 @@ describe('KeypressContext', () => {
it('should timeout and flush incomplete kitty sequences after 50ms', async () => {
const keyHandler = vi.fn();
const { result } = renderHook(() => useKeypressContext(), { wrapper });
const { result } = renderHookWithProviders(() => useKeypressContext());
act(() => result.current.subscribe(keyHandler));
@@ -1077,7 +1052,7 @@ describe('KeypressContext', () => {
it('should immediately flush non-kitty CSI sequences', async () => {
const keyHandler = vi.fn();
const { result } = renderHook(() => useKeypressContext(), { wrapper });
const { result } = renderHookWithProviders(() => useKeypressContext());
act(() => result.current.subscribe(keyHandler));
@@ -1099,7 +1074,7 @@ describe('KeypressContext', () => {
it('should parse valid kitty sequences immediately when complete', async () => {
const keyHandler = vi.fn();
const { result } = renderHook(() => useKeypressContext(), { wrapper });
const { result } = renderHookWithProviders(() => useKeypressContext());
act(() => result.current.subscribe(keyHandler));
@@ -1117,7 +1092,7 @@ describe('KeypressContext', () => {
it('should handle batched kitty sequences correctly', async () => {
const keyHandler = vi.fn();
const { result } = renderHook(() => useKeypressContext(), { wrapper });
const { result } = renderHookWithProviders(() => useKeypressContext());
act(() => result.current.subscribe(keyHandler));
@@ -1144,7 +1119,7 @@ describe('KeypressContext', () => {
it('should handle mixed valid and invalid sequences', async () => {
const keyHandler = vi.fn();
const { result } = renderHook(() => useKeypressContext(), { wrapper });
const { result } = renderHookWithProviders(() => useKeypressContext());
act(() => result.current.subscribe(keyHandler));
@@ -1172,7 +1147,7 @@ describe('KeypressContext', () => {
'should handle sequences arriving character by character with %s ms delay',
async (delay) => {
const keyHandler = vi.fn();
const { result } = renderHook(() => useKeypressContext(), { wrapper });
const { result } = renderHookWithProviders(() => useKeypressContext());
act(() => result.current.subscribe(keyHandler));
@@ -1196,7 +1171,7 @@ describe('KeypressContext', () => {
it('should reset timeout when new input arrives', async () => {
const keyHandler = vi.fn();
const { result } = renderHook(() => useKeypressContext(), { wrapper });
const { result } = renderHookWithProviders(() => useKeypressContext());
act(() => result.current.subscribe(keyHandler));
@@ -1231,7 +1206,7 @@ describe('KeypressContext', () => {
describe('SGR Mouse Handling', () => {
it('should ignore SGR mouse sequences', async () => {
const keyHandler = vi.fn();
const { result } = renderHook(() => useKeypressContext(), { wrapper });
const { result } = renderHookWithProviders(() => useKeypressContext());
act(() => result.current.subscribe(keyHandler));
@@ -1249,7 +1224,7 @@ describe('KeypressContext', () => {
it('should handle mixed SGR mouse and key sequences', async () => {
const keyHandler = vi.fn();
const { result } = renderHook(() => useKeypressContext(), { wrapper });
const { result } = renderHookWithProviders(() => useKeypressContext());
act(() => result.current.subscribe(keyHandler));
@@ -1275,7 +1250,7 @@ describe('KeypressContext', () => {
it('should ignore X11 mouse sequences', async () => {
const keyHandler = vi.fn();
const { result } = renderHook(() => useKeypressContext(), { wrapper });
const { result } = renderHookWithProviders(() => useKeypressContext());
act(() => result.current.subscribe(keyHandler));
@@ -1291,7 +1266,7 @@ describe('KeypressContext', () => {
it('should not flush slow SGR mouse sequences as garbage', async () => {
const keyHandler = vi.fn();
const { result } = renderHook(() => useKeypressContext(), { wrapper });
const { result } = renderHookWithProviders(() => useKeypressContext());
act(() => result.current.subscribe(keyHandler));
@@ -1311,7 +1286,7 @@ describe('KeypressContext', () => {
it('should ignore specific SGR mouse sequence sandwiched between keystrokes', async () => {
const keyHandler = vi.fn();
const { result } = renderHook(() => useKeypressContext(), { wrapper });
const { result } = renderHookWithProviders(() => useKeypressContext());
act(() => result.current.subscribe(keyHandler));
@@ -1342,12 +1317,7 @@ describe('KeypressContext', () => {
{ name: 'another mouse', sequence: '\u001b[<0;29;19m' },
])('should ignore $name sequence', async ({ sequence }) => {
const keyHandler = vi.fn();
const wrapper = ({ children }: { children: React.ReactNode }) => (
<KeypressProvider>{children}</KeypressProvider>
);
const { result } = renderHook(() => useKeypressContext(), {
wrapper,
});
const { result } = renderHookWithProviders(() => useKeypressContext());
act(() => result.current.subscribe(keyHandler));
for (const char of sequence) {
@@ -1372,10 +1342,7 @@ describe('KeypressContext', () => {
it('should handle F12', async () => {
const keyHandler = vi.fn();
const wrapper = ({ children }: { children: React.ReactNode }) => (
<KeypressProvider>{children}</KeypressProvider>
);
const { result } = renderHook(() => useKeypressContext(), { wrapper });
const { result } = renderHookWithProviders(() => useKeypressContext());
act(() => result.current.subscribe(keyHandler));
act(() => {
@@ -1404,7 +1371,7 @@ describe('KeypressContext', () => {
'A你B好C', // Mixed characters
])('should correctly handle string "%s"', async (inputString) => {
const keyHandler = vi.fn();
const { result } = renderHook(() => useKeypressContext(), { wrapper });
const { result } = renderHookWithProviders(() => useKeypressContext());
act(() => result.current.subscribe(keyHandler));
act(() => stdin.write(inputString));
@@ -13,6 +13,7 @@ import {
useCallback,
useContext,
useEffect,
useMemo,
useRef,
} from 'react';
@@ -21,6 +22,7 @@ import { parseMouseEvent } from '../utils/mouse.js';
import { FOCUS_IN, FOCUS_OUT } from '../hooks/useFocus.js';
import { appEvents, AppEvent } from '../../utils/events.js';
import { terminalCapabilityManager } from '../utils/terminalCapabilityManager.js';
import { useSettingsStore } from './SettingsContext.js';
export const BACKSLASH_ENTER_TIMEOUT = 5;
export const ESC_TIMEOUT = 50;
@@ -766,12 +768,13 @@ export function useKeypressContext() {
export function KeypressProvider({
children,
config,
debugKeystrokeLogging,
}: {
children: React.ReactNode;
config?: Config;
debugKeystrokeLogging?: boolean;
}) {
const { settings } = useSettingsStore();
const debugKeystrokeLogging = settings.merged.general.debugKeystrokeLogging;
const { stdin, setRawMode } = useStdin();
const subscribersToPriority = useRef<Map<KeypressHandler, number>>(
@@ -828,6 +831,9 @@ export function KeypressProvider({
const broadcast = useCallback(
(key: Key) => {
if (debugKeystrokeLogging) {
debugLogger.log('[DEBUG] Keystroke:', JSON.stringify(key));
}
// Use cached sorted priorities to avoid sorting on every keypress
for (const p of sortedPriorities.current) {
const set = subscribers.get(p);
@@ -842,7 +848,7 @@ export function KeypressProvider({
}
}
},
[subscribers],
[subscribers, debugKeystrokeLogging],
);
useEffect(() => {
@@ -882,8 +888,13 @@ export function KeypressProvider({
};
}, [stdin, setRawMode, config, debugKeystrokeLogging, broadcast]);
const contextValue = useMemo(
() => ({ subscribe, unsubscribe }),
[subscribe, unsubscribe],
);
return (
<KeypressContext.Provider value={{ subscribe, unsubscribe }}>
<KeypressContext.Provider value={contextValue}>
{children}
</KeypressContext.Provider>
);
@@ -4,10 +4,9 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { renderHook } from '../../test-utils/render.js';
import type React from 'react';
import { renderHookWithProviders } from '../../test-utils/render.js';
import { act } from 'react';
import { MouseProvider, useMouseContext, useMouse } from './MouseContext.js';
import { useMouseContext, useMouse } from './MouseContext.js';
import { vi, type Mock } from 'vitest';
import { useStdin } from 'ink';
import { EventEmitter } from 'node:events';
@@ -49,7 +48,6 @@ class MockStdin extends EventEmitter {
describe('MouseContext', () => {
let stdin: MockStdin;
let wrapper: React.FC<{ children: React.ReactNode }>;
beforeEach(() => {
stdin = new MockStdin();
@@ -57,9 +55,6 @@ describe('MouseContext', () => {
stdin,
setRawMode: vi.fn(),
});
wrapper = ({ children }: { children: React.ReactNode }) => (
<MouseProvider mouseEventsEnabled={true}>{children}</MouseProvider>
);
vi.mocked(appEvents.emit).mockClear();
});
@@ -69,7 +64,9 @@ describe('MouseContext', () => {
it('should subscribe and unsubscribe a handler', () => {
const handler = vi.fn();
const { result } = renderHook(() => useMouseContext(), { wrapper });
const { result } = renderHookWithProviders(() => useMouseContext(), {
mouseEventsEnabled: true,
});
act(() => {
result.current.subscribe(handler);
@@ -94,8 +91,8 @@ describe('MouseContext', () => {
it('should not call handler if not active', () => {
const handler = vi.fn();
renderHook(() => useMouse(handler, { isActive: false }), {
wrapper,
renderHookWithProviders(() => useMouse(handler, { isActive: false }), {
mouseEventsEnabled: true,
});
act(() => {
@@ -106,7 +103,9 @@ describe('MouseContext', () => {
});
it('should emit SelectionWarning when move event is unhandled and has coordinates', () => {
renderHook(() => useMouseContext(), { wrapper });
renderHookWithProviders(() => useMouseContext(), {
mouseEventsEnabled: true,
});
act(() => {
// Move event (32) at 10, 20
@@ -118,7 +117,9 @@ describe('MouseContext', () => {
it('should not emit SelectionWarning when move event is handled', () => {
const handler = vi.fn().mockReturnValue(true);
const { result } = renderHook(() => useMouseContext(), { wrapper });
const { result } = renderHookWithProviders(() => useMouseContext(), {
mouseEventsEnabled: true,
});
act(() => {
result.current.subscribe(handler);
@@ -218,7 +219,9 @@ describe('MouseContext', () => {
'should recognize sequence "$sequence" as $expected.name',
({ sequence, expected }) => {
const mouseHandler = vi.fn();
const { result } = renderHook(() => useMouseContext(), { wrapper });
const { result } = renderHookWithProviders(() => useMouseContext(), {
mouseEventsEnabled: true,
});
act(() => result.current.subscribe(mouseHandler));
act(() => stdin.write(sequence));
@@ -232,7 +235,9 @@ describe('MouseContext', () => {
it('should emit a double-click event when two left-presses occur quickly at the same position', () => {
const handler = vi.fn();
const { result } = renderHook(() => useMouseContext(), { wrapper });
const { result } = renderHookWithProviders(() => useMouseContext(), {
mouseEventsEnabled: true,
});
act(() => {
result.current.subscribe(handler);
@@ -262,7 +267,9 @@ describe('MouseContext', () => {
it('should NOT emit a double-click event if clicks are too far apart', () => {
const handler = vi.fn();
const { result } = renderHook(() => useMouseContext(), { wrapper });
const { result } = renderHookWithProviders(() => useMouseContext(), {
mouseEventsEnabled: true,
});
act(() => {
result.current.subscribe(handler);
@@ -287,7 +294,9 @@ describe('MouseContext', () => {
it('should NOT emit a double-click event if too much time passes', async () => {
vi.useFakeTimers();
const handler = vi.fn();
const { result } = renderHook(() => useMouseContext(), { wrapper });
const { result } = renderHookWithProviders(() => useMouseContext(), {
mouseEventsEnabled: true,
});
act(() => {
result.current.subscribe(handler);
+11 -3
View File
@@ -11,6 +11,7 @@ import {
useCallback,
useContext,
useEffect,
useMemo,
useRef,
} from 'react';
import { ESC } from '../utils/input.js';
@@ -25,6 +26,7 @@ import {
DOUBLE_CLICK_THRESHOLD_MS,
DOUBLE_CLICK_DISTANCE_TOLERANCE,
} from '../utils/mouse.js';
import { useSettingsStore } from './SettingsContext.js';
export type { MouseEvent, MouseEventName, MouseHandler };
@@ -61,12 +63,13 @@ export function useMouse(handler: MouseHandler, { isActive = true } = {}) {
export function MouseProvider({
children,
mouseEventsEnabled,
debugKeystrokeLogging,
}: {
children: React.ReactNode;
mouseEventsEnabled?: boolean;
debugKeystrokeLogging?: boolean;
}) {
const { settings } = useSettingsStore();
const debugKeystrokeLogging = settings.merged.general.debugKeystrokeLogging;
const { stdin } = useStdin();
const subscribers = useRef<Set<MouseHandler>>(new Set()).current;
const lastClickRef = useRef<{
@@ -189,8 +192,13 @@ export function MouseProvider({
};
}, [stdin, mouseEventsEnabled, subscribers, debugKeystrokeLogging]);
const contextValue = useMemo(
() => ({ subscribe, unsubscribe }),
[subscribe, unsubscribe],
);
return (
<MouseContext.Provider value={{ subscribe, unsubscribe }}>
<MouseContext.Provider value={contextValue}>
{children}
</MouseContext.Provider>
);
@@ -223,6 +223,7 @@ export interface UIState {
showIsExpandableHint: boolean;
hintMode: boolean;
hintBuffer: string;
a2aListenerPort: number | null;
transientMessage: {
text: string;
type: TransientMessageType;
@@ -57,6 +57,7 @@ describe('handleCreditsFlow', () => {
updateItem: vi.fn(),
clearItems: vi.fn(),
loadHistory: vi.fn(),
pruneItems: vi.fn(),
};
isDialogPending = { current: false };
mockSetOverageMenuRequest = vi.fn();
+2 -7
View File
@@ -4,12 +4,11 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { render } from '../../test-utils/render.js';
import { renderWithProviders } from '../../test-utils/render.js';
import { EventEmitter } from 'node:events';
import { useFocus } from './useFocus.js';
import { vi, type Mock } from 'vitest';
import { useStdin, useStdout } from 'ink';
import { KeypressProvider } from '../contexts/KeypressContext.js';
import { act } from 'react';
// Mock the ink hooks
@@ -54,11 +53,7 @@ describe('useFocus', () => {
hookResult = useFocus();
return null;
}
const { unmount } = render(
<KeypressProvider>
<TestComponent />
</KeypressProvider>,
);
const { unmount } = renderWithProviders(<TestComponent />);
return {
result: {
get current() {
+132 -19
View File
@@ -38,6 +38,7 @@ import {
GeminiCliOperation,
getPlanModeExitMessage,
isBackgroundExecutionData,
Kind,
} from '@google/gemini-cli-core';
import type {
Config,
@@ -219,6 +220,7 @@ export const useGeminiStream = (
terminalHeight: number,
isShellFocused?: boolean,
consumeUserHint?: () => string | null,
pruneItems?: () => void,
) => {
const [initError, setInitError] = useState<string | null>(null);
const [retryStatus, setRetryStatus] = useState<RetryAttemptPayload | null>(
@@ -255,6 +257,7 @@ export const useGeminiStream = (
const [_isFirstToolInGroup, isFirstToolInGroupRef, setIsFirstToolInGroup] =
useStateAndRef<boolean>(true);
const processedMemoryToolsRef = useRef<Set<string>>(new Set());
const { startNewPrompt, getPromptCount } = useSessionStats();
const storage = config.storage;
const logger = useLogger(storage);
@@ -408,7 +411,8 @@ export const useGeminiStream = (
// Push completed tools to history as they finish
useEffect(() => {
const toolsToPush: TrackedToolCall[] = [];
for (const tc of toolCalls) {
for (let i = 0; i < toolCalls.length; i++) {
const tc = toolCalls[i];
if (pushedToolCallIdsRef.current.has(tc.request.callId)) continue;
if (
@@ -416,6 +420,40 @@ export const useGeminiStream = (
tc.status === 'error' ||
tc.status === 'cancelled'
) {
// TODO(#22883): This lookahead logic is a tactical UI fix to prevent parallel agents
// from tearing visually when they finish at slightly different times.
// Architecturally, `useGeminiStream` should not be responsible for stitching
// together semantic batches using timing/refs. `packages/core` should be
// refactored to emit structured `ToolBatch` or `Turn` objects, and this layer
// should simply render those semantic boundaries.
// If this is an agent tool, look ahead to ensure all subsequent
// contiguous agents in the same batch are also finished before pushing.
const isAgent = tc.tool?.kind === Kind.Agent;
if (isAgent) {
let contigAgentsComplete = true;
for (let j = i + 1; j < toolCalls.length; j++) {
const nextTc = toolCalls[j];
if (nextTc.tool?.kind === Kind.Agent) {
if (
nextTc.status !== 'success' &&
nextTc.status !== 'error' &&
nextTc.status !== 'cancelled'
) {
contigAgentsComplete = false;
break;
}
} else {
// End of the contiguous agent block
break;
}
}
if (!contigAgentsComplete) {
// Wait for the entire contiguous block of agents to finish
break;
}
}
toolsToPush.push(tc);
} else {
// Stop at first non-terminal tool to preserve order
@@ -425,27 +463,27 @@ export const useGeminiStream = (
if (toolsToPush.length > 0) {
const newPushed = new Set(pushedToolCallIdsRef.current);
let isFirst = isFirstToolInGroupRef.current;
for (const tc of toolsToPush) {
newPushed.add(tc.request.callId);
const isLastInBatch = tc === toolCalls[toolCalls.length - 1];
const historyItem = mapTrackedToolCallsToDisplay(tc, {
borderTop: isFirst,
borderBottom: isLastInBatch,
...getToolGroupBorderAppearance(
{ type: 'tool_group', tools: toolCalls },
activeShellPtyId,
!!isShellFocused,
[],
backgroundShells,
),
});
addItem(historyItem);
isFirst = false;
}
const isLastInBatch =
toolsToPush[toolsToPush.length - 1] === toolCalls[toolCalls.length - 1];
const historyItem = mapTrackedToolCallsToDisplay(toolsToPush, {
borderTop: isFirstToolInGroupRef.current,
borderBottom: isLastInBatch,
...getToolGroupBorderAppearance(
{ type: 'tool_group', tools: toolCalls },
activeShellPtyId,
!!isShellFocused,
[],
backgroundShells,
),
});
addItem(historyItem);
setPushedToolCallIds(newPushed);
setIsFirstToolInGroup(false);
}
@@ -1127,8 +1165,12 @@ export const useGeminiStream = (
} as HistoryItemInfo,
userMessageTimestamp,
);
// Prune old UI history items to prevent unbounded memory growth
// in long-running sessions.
pruneItems?.();
},
[addItem, pendingHistoryItemRef, setPendingHistoryItem, config],
[addItem, pendingHistoryItemRef, setPendingHistoryItem, config, pruneItems],
);
const handleMaxSessionTurnsEvent = useCallback(
@@ -1470,9 +1512,20 @@ export const useGeminiStream = (
lastQueryRef.current = queryToSend;
lastPromptIdRef.current = prompt_id!;
// Prepend current time (and schedule if items exist) so the
// model can reason about time and scheduling in forever mode.
let queryWithContext = queryToSend;
if (
config.getIsForeverMode() &&
typeof queryWithContext === 'string'
) {
const scheduler = config.getWorkScheduler();
queryWithContext = `[${scheduler.formatScheduleSummary()}]\n\n${queryWithContext}`;
}
try {
const stream = geminiClient.sendMessageStream(
queryToSend,
queryWithContext,
abortSignal,
prompt_id!,
undefined,
@@ -1910,6 +1963,66 @@ export const useGeminiStream = (
storage,
]);
// Idle hook timer: fires after idleTimeout seconds of no activity.
// The timeout is read from the Idle hook definitions themselves.
// If hooks exist but don't declare a timeout, fall back to 300 seconds.
const DEFAULT_IDLE_TIMEOUT = 300;
const idleTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
// Clear any existing timer
if (idleTimerRef.current) {
clearTimeout(idleTimerRef.current);
idleTimerRef.current = null;
}
if (streamingState !== StreamingState.Idle || !config.getEnableHooks()) {
return;
}
// Derive timeout from registered Idle hook definitions.
const hookSystem = config.getHookSystem();
const idleHooks = hookSystem
?.getAllHooks()
.filter((h) => h.eventName === 'Idle' && h.enabled);
if (!idleHooks || idleHooks.length === 0) {
return;
}
// Use the max idleTimeout declared by any Idle hook, or the default.
const declaredTimeouts = idleHooks
.map((h) => h.idleTimeout)
.filter((t): t is number => typeof t === 'number' && t > 0);
const idleTimeoutSeconds =
declaredTimeouts.length > 0
? Math.max(...declaredTimeouts)
: DEFAULT_IDLE_TIMEOUT;
const startTime = Date.now();
idleTimerRef.current = setTimeout(async () => {
if (!hookSystem) return;
const elapsed = Math.round((Date.now() - startTime) / 1000);
try {
const result = await hookSystem.fireIdleEvent(elapsed);
const prompt = result?.finalOutput?.hookSpecificOutput?.['prompt'];
if (typeof prompt === 'string' && prompt.trim()) {
// Auto-submit the prompt returned by the hook
void submitQuery(prompt);
}
} catch {
// Idle hook failures are non-fatal
}
}, idleTimeoutSeconds * 1000);
return () => {
if (idleTimerRef.current) {
clearTimeout(idleTimerRef.current);
idleTimerRef.current = null;
}
};
}, [streamingState, config, submitQuery]);
const lastOutputTime = Math.max(
lastToolOutputTime,
lastShellOutputTime,
@@ -7,7 +7,7 @@
import { describe, it, expect } from 'vitest';
import { act } from 'react';
import { renderHook } from '../../test-utils/render.js';
import { useHistory } from './useHistoryManager.js';
import { useHistory, PRUNE_KEEP_COUNT } from './useHistoryManager.js';
import type { HistoryItem } from '../types.js';
describe('useHistoryManager', () => {
@@ -255,4 +255,82 @@ describe('useHistoryManager', () => {
expect(result.current.history[0].type).toBe('info');
});
});
describe('pruneItems', () => {
it('should prune history to PRUNE_KEEP_COUNT + 1 (marker) when over limit', () => {
const { result } = renderHook(() => useHistory());
const itemCount = PRUNE_KEEP_COUNT + 20;
act(() => {
for (let i = 0; i < itemCount; i++) {
result.current.addItem({
type: 'user',
text: `Message ${i}`,
});
}
});
expect(result.current.history).toHaveLength(itemCount);
act(() => {
result.current.pruneItems();
});
// PRUNE_KEEP_COUNT items + 1 prune marker
expect(result.current.history).toHaveLength(PRUNE_KEEP_COUNT + 1);
// First item should be the prune marker
expect(result.current.history[0].type).toBe('info');
expect(result.current.history[0].text).toContain('pruned');
// Last item should be the most recent message
expect(
result.current.history[result.current.history.length - 1].text,
).toBe(`Message ${itemCount - 1}`);
});
it('should be a no-op when history is under the threshold', () => {
const { result } = renderHook(() => useHistory());
const itemCount = 10;
act(() => {
for (let i = 0; i < itemCount; i++) {
result.current.addItem({
type: 'user',
text: `Message ${i}`,
});
}
});
const historyBefore = result.current.history;
act(() => {
result.current.pruneItems();
});
// Should be unchanged (same reference)
expect(result.current.history).toBe(historyBefore);
expect(result.current.history).toHaveLength(itemCount);
});
it('should be a no-op when history is exactly at the threshold', () => {
const { result } = renderHook(() => useHistory());
act(() => {
for (let i = 0; i < PRUNE_KEEP_COUNT; i++) {
result.current.addItem({
type: 'user',
text: `Message ${i}`,
});
}
});
const historyBefore = result.current.history;
act(() => {
result.current.pruneItems();
});
expect(result.current.history).toBe(historyBefore);
expect(result.current.history).toHaveLength(PRUNE_KEEP_COUNT);
});
});
});
+28 -1
View File
@@ -8,6 +8,12 @@ import { useState, useRef, useCallback, useMemo } from 'react';
import type { HistoryItem } from '../types.js';
import type { ChatRecordingService } from '@google/gemini-cli-core/src/services/chatRecordingService.js';
/**
* Number of history items to keep when pruning after context compression.
* Exported for testing purposes.
*/
export const PRUNE_KEEP_COUNT = 50;
// Type for the updater function passed to updateHistoryItem
type HistoryItemUpdater = (
prevItem: HistoryItem,
@@ -26,6 +32,7 @@ export interface UseHistoryManagerReturn {
) => void;
clearItems: () => void;
loadHistory: (newHistory: HistoryItem[]) => void;
pruneItems: () => void;
}
/**
@@ -156,6 +163,25 @@ export function useHistory({
messageIdCounterRef.current = 0;
}, []);
// Prunes old history items, keeping only the most recent PRUNE_KEEP_COUNT.
// Intended to be called after context compression to free memory in
// long-running sessions.
const pruneItems = useCallback(() => {
setHistory((prevHistory) => {
if (prevHistory.length <= PRUNE_KEEP_COUNT) {
return prevHistory;
}
const kept = prevHistory.slice(-PRUNE_KEEP_COUNT);
const marker = {
id: getNextMessageId(Date.now()),
type: 'info',
text: `️ Earlier history was pruned after context compression.`,
} as HistoryItem;
return [marker, ...kept];
});
}, [getNextMessageId]);
return useMemo(
() => ({
history,
@@ -163,7 +189,8 @@ export function useHistory({
updateItem,
clearItems,
loadHistory,
pruneItems,
}),
[history, addItem, updateItem, clearItems, loadHistory],
[history, addItem, updateItem, clearItems, loadHistory, pruneItems],
);
}
@@ -91,6 +91,7 @@ describe('useIncludeDirsTrust', () => {
updateItem: vi.fn(),
clearItems: vi.fn(),
loadHistory: vi.fn(),
pruneItems: vi.fn(),
};
mockSetCustomDialog = vi.fn();
});
+3 -13
View File
@@ -5,9 +5,8 @@
*/
import { act } from 'react';
import { render } from '../../test-utils/render.js';
import { renderHookWithProviders } from '../../test-utils/render.js';
import { useKeypress } from './useKeypress.js';
import { KeypressProvider } from '../contexts/KeypressContext.js';
import { useStdin } from 'ink';
import { EventEmitter } from 'node:events';
import type { Mock } from 'vitest';
@@ -44,17 +43,8 @@ describe(`useKeypress`, () => {
const onKeypress = vi.fn();
let originalNodeVersion: string;
const renderKeypressHook = (isActive = true) => {
function TestComponent() {
useKeypress(onKeypress, { isActive });
return null;
}
return render(
<KeypressProvider>
<TestComponent />
</KeypressProvider>,
);
};
const renderKeypressHook = (isActive = true) =>
renderHookWithProviders(() => useKeypress(onKeypress, { isActive }));
beforeEach(() => {
vi.clearAllMocks();
@@ -0,0 +1,62 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { renderHook } from '../../test-utils/render.js';
import { waitFor } from '../../test-utils/async.js';
import { useLogger } from './useLogger.js';
import {
sessionId as globalSessionId,
Logger,
type Storage,
type Config,
} from '@google/gemini-cli-core';
import { ConfigContext } from '../contexts/ConfigContext.js';
import type React from 'react';
// Mock Logger
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const actual =
await importOriginal<typeof import('@google/gemini-cli-core')>();
return {
...actual,
Logger: vi.fn().mockImplementation((id: string) => ({
initialize: vi.fn().mockResolvedValue(undefined),
sessionId: id,
})),
};
});
describe('useLogger', () => {
const mockStorage = {} as Storage;
const mockConfig = {
getSessionId: vi.fn().mockReturnValue('active-session-id'),
} as unknown as Config;
beforeEach(() => {
vi.clearAllMocks();
});
it('should initialize with the global sessionId by default', async () => {
const { result } = renderHook(() => useLogger(mockStorage));
await waitFor(() => expect(result.current).not.toBeNull());
expect(Logger).toHaveBeenCalledWith(globalSessionId, mockStorage);
});
it('should initialize with the active sessionId from ConfigContext when available', async () => {
const wrapper = ({ children }: { children: React.ReactNode }) => (
<ConfigContext.Provider value={mockConfig}>
{children}
</ConfigContext.Provider>
);
const { result } = renderHook(() => useLogger(mockStorage), { wrapper });
await waitFor(() => expect(result.current).not.toBeNull());
expect(Logger).toHaveBeenCalledWith('active-session-id', mockStorage);
});
});
+13 -5
View File
@@ -4,17 +4,25 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { useState, useEffect } from 'react';
import { sessionId, Logger, type Storage } from '@google/gemini-cli-core';
import { useState, useEffect, useContext } from 'react';
import {
sessionId as globalSessionId,
Logger,
type Storage,
} from '@google/gemini-cli-core';
import { ConfigContext } from '../contexts/ConfigContext.js';
/**
* Hook to manage the logger instance.
*/
export const useLogger = (storage: Storage) => {
export const useLogger = (storage: Storage): Logger | null => {
const [logger, setLogger] = useState<Logger | null>(null);
const config = useContext(ConfigContext);
useEffect(() => {
const newLogger = new Logger(sessionId, storage);
const activeSessionId = config?.getSessionId() ?? globalSessionId;
const newLogger = new Logger(activeSessionId, storage);
/**
* Start async initialization, no need to await. Using await slows down the
* time from launch to see the gemini-cli prompt and it's better to not save
@@ -26,7 +34,7 @@ export const useLogger = (storage: Storage) => {
setLogger(newLogger);
})
.catch(() => {});
}, [storage]);
}, [storage, config]);
return logger;
};
+7 -13
View File
@@ -7,7 +7,7 @@
import { vi } from 'vitest';
import { renderHook } from '../../test-utils/render.js';
import { useMouse } from './useMouse.js';
import { MouseProvider, useMouseContext } from '../contexts/MouseContext.js';
import { useMouseContext } from '../contexts/MouseContext.js';
vi.mock('../contexts/MouseContext.js', async (importOriginal) => {
const actual =
@@ -16,10 +16,10 @@ vi.mock('../contexts/MouseContext.js', async (importOriginal) => {
const unsubscribe = vi.fn();
return {
...actual,
useMouseContext: () => ({
useMouseContext: vi.fn(() => ({
subscribe,
unsubscribe,
}),
})),
};
});
@@ -31,27 +31,22 @@ describe('useMouse', () => {
});
it('should not subscribe when isActive is false', () => {
renderHook(() => useMouse(mockOnMouseEvent, { isActive: false }), {
wrapper: MouseProvider,
});
renderHook(() => useMouse(mockOnMouseEvent, { isActive: false }));
const { subscribe } = useMouseContext();
expect(subscribe).not.toHaveBeenCalled();
});
it('should subscribe when isActive is true', () => {
renderHook(() => useMouse(mockOnMouseEvent, { isActive: true }), {
wrapper: MouseProvider,
});
renderHook(() => useMouse(mockOnMouseEvent, { isActive: true }));
const { subscribe } = useMouseContext();
expect(subscribe).toHaveBeenCalledWith(mockOnMouseEvent);
});
it('should unsubscribe on unmount', () => {
const { unmount } = renderHook(
() => useMouse(mockOnMouseEvent, { isActive: true }),
{ wrapper: MouseProvider },
const { unmount } = renderHook(() =>
useMouse(mockOnMouseEvent, { isActive: true }),
);
const { unsubscribe } = useMouseContext();
@@ -65,7 +60,6 @@ describe('useMouse', () => {
useMouse(mockOnMouseEvent, { isActive }),
{
initialProps: { isActive: true },
wrapper: MouseProvider,
},
);
@@ -85,6 +85,7 @@ describe('useQuotaAndFallback', () => {
updateItem: vi.fn(),
clearItems: vi.fn(),
loadHistory: vi.fn(),
pruneItems: vi.fn(),
};
mockSetModelSwitchedFromQuotaError = vi.fn();
mockOnShowAuthSelection = vi.fn();
@@ -78,12 +78,17 @@ export const useSessionBrowser = (
// We've loaded it; tell the UI about it.
setIsSessionBrowserOpen(false);
const compressionIndex = conversation.lastCompressionIndex;
const historyData = convertSessionToHistoryFormats(
conversation.messages,
compressionIndex,
);
await onLoadHistory(
historyData.uiHistory,
convertSessionToClientHistory(conversation.messages),
convertSessionToClientHistory(
conversation.messages,
compressionIndex,
),
resumedSessionData,
);
} catch (error) {
@@ -33,6 +33,7 @@ describe('useSessionResume', () => {
updateItem: vi.fn(),
clearItems: vi.fn(),
loadHistory: vi.fn(),
pruneItems: vi.fn(),
});
let mockHistoryManager: UseHistoryManagerReturn;
@@ -529,5 +530,60 @@ describe('useSessionResume', () => {
// But UI history should have both
expect(mockHistoryManager.addItem).toHaveBeenCalledTimes(2);
});
it('should restore scheduled work from resumed session data', async () => {
const mockRestore = vi.fn();
const configWithScheduler = {
...mockConfig,
getWorkScheduler: vi.fn().mockReturnValue({
restore: mockRestore,
}),
};
const scheduledWork = [
{
id: 'test-1',
prompt: 'check status',
fireAt: new Date(Date.now() + 60_000).toISOString(),
createdAt: new Date().toISOString(),
},
];
const conversation: ConversationRecord = {
sessionId: 'auto-resume-scheduled',
projectHash: 'project-123',
startTime: '2025-01-01T00:00:00Z',
lastUpdated: '2025-01-01T01:00:00Z',
messages: [
{
id: 'msg-1',
timestamp: '2025-01-01T00:01:00Z',
content: 'Hello',
type: 'user',
},
] as MessageRecord[],
scheduledWork,
};
await act(async () => {
renderHook(() =>
useSessionResume({
...getDefaultProps(),
config: configWithScheduler as unknown as Config,
resumedSessionData: {
conversation,
filePath: '/path/to/session.json',
},
}),
);
});
await waitFor(() => {
expect(mockHistoryManager.clearItems).toHaveBeenCalled();
});
expect(configWithScheduler.getWorkScheduler).toHaveBeenCalled();
expect(mockRestore).toHaveBeenCalledWith(scheduledWork);
});
});
});
+17 -1
View File
@@ -83,6 +83,16 @@ export function useSessionResume({
workspaceContext.addDirectories(resumedData.conversation.directories);
}
// Restore scheduled work items from the resumed session.
// Past-due items fire immediately; future items get timers re-armed.
if (
resumedData.conversation.scheduledWork &&
resumedData.conversation.scheduledWork.length > 0
) {
const scheduler = config.getWorkScheduler();
scheduler.restore(resumedData.conversation.scheduledWork);
}
// Give the history to the Gemini client.
await config.getGeminiClient()?.resumeChat(clientHistory, resumedData);
} catch (error) {
@@ -109,12 +119,18 @@ export function useSessionResume({
!hasLoadedResumedSession.current
) {
hasLoadedResumedSession.current = true;
const compressionIndex =
resumedSessionData.conversation.lastCompressionIndex;
const historyData = convertSessionToHistoryFormats(
resumedSessionData.conversation.messages,
compressionIndex,
);
void loadHistoryForResume(
historyData.uiHistory,
convertSessionToClientHistory(resumedSessionData.conversation.messages),
convertSessionToClientHistory(
resumedSessionData.conversation.messages,
compressionIndex,
),
resumedSessionData,
);
}
@@ -6,10 +6,11 @@
import { describe, expect, it, vi } from 'vitest';
import { getToolGroupBorderAppearance } from './borderStyles.js';
import { CoreToolCallStatus } from '@google/gemini-cli-core';
import { CoreToolCallStatus, makeFakeConfig } from '@google/gemini-cli-core';
import { theme } from '../semantic-colors.js';
import type { IndividualToolCallDisplay } from '../types.js';
import { renderWithProviders } from '../../test-utils/render.js';
import { createMockSettings } from '../../test-utils/settings.js';
import { MainContent } from '../components/MainContent.js';
import { Text } from 'ink';
@@ -17,6 +18,11 @@ vi.mock('../components/CliSpinner.js', () => ({
CliSpinner: () => <Text></Text>,
}));
const altBufferOptions = {
config: makeFakeConfig({ useAlternateBuffer: true }),
settings: createMockSettings({ ui: { useAlternateBuffer: true } }),
};
describe('getToolGroupBorderAppearance', () => {
it('should use warning color for pending non-shell tools', () => {
const item = {
@@ -105,6 +111,7 @@ describe('getToolGroupBorderAppearance', () => {
describe('MainContent tool group border SVG snapshots', () => {
it('should render SVG snapshot for a pending search dialog (google_web_search)', async () => {
const renderResult = renderWithProviders(<MainContent />, {
...altBufferOptions,
uiState: {
history: [],
pendingHistoryItems: [
@@ -129,6 +136,7 @@ describe('MainContent tool group border SVG snapshots', () => {
it('should render SVG snapshot for an empty slice following a search tool', async () => {
const renderResult = renderWithProviders(<MainContent />, {
...altBufferOptions,
uiState: {
history: [],
pendingHistoryItems: [
@@ -157,6 +165,7 @@ describe('MainContent tool group border SVG snapshots', () => {
it('should render SVG snapshot for a shell tool', async () => {
const renderResult = renderWithProviders(<MainContent />, {
...altBufferOptions,
uiState: {
history: [],
pendingHistoryItems: [
+4
View File
@@ -23,6 +23,8 @@ export enum AppEvent {
PasteTimeout = 'paste-timeout',
TerminalBackground = 'terminal-background',
TransientMessage = 'transient-message',
ExternalMessage = 'external-message',
A2AListenerStarted = 'a2a-listener-started',
}
export interface AppEvents {
@@ -32,6 +34,8 @@ export interface AppEvents {
[AppEvent.PasteTimeout]: never[];
[AppEvent.TerminalBackground]: [string];
[AppEvent.TransientMessage]: [TransientMessagePayload];
[AppEvent.ExternalMessage]: [string];
[AppEvent.A2AListenerStarted]: [number];
}
export const appEvents = new EventEmitter<AppEvents>();

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