mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-07-31 12:11:05 -07:00
Compare commits
31 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9df044c836 | |||
| a5a461c234 | |||
| fac3661980 | |||
| d7dfcf7f99 | |||
| 81a97e78f1 | |||
| 1311e8c480 | |||
| 4ecb4bb24b | |||
| be7c7bb83d | |||
| 7bfe6ac418 | |||
| bd34a42ec3 | |||
| e2658ccda8 | |||
| b8719bcd47 | |||
| e1eefffcf1 | |||
| 7ae39fd622 | |||
| fb9264bf80 | |||
| d4397dbfc5 | |||
| 5fb0d1f01d | |||
| 95bca2c3b3 | |||
| e0be1b2afd | |||
| 5d4e4c2814 | |||
| 27a50191e3 | |||
| 77ca3c0e13 | |||
| 2504105a1c | |||
| a361a84708 | |||
| ff196fbe6f | |||
| 2f90b46537 | |||
| 82d8680dcc | |||
| 1f3f7247b1 | |||
| 69e2d8c7ae | |||
| 77a874cf65 | |||
| b211f30d95 |
@@ -22,9 +22,10 @@ powerful tool for developers.
|
||||
rendering.
|
||||
- `packages/core`: Backend logic, Gemini API orchestration, prompt
|
||||
construction, and tool execution.
|
||||
- `packages/core/src/tools/`: Built-in tools for file system, shell, and web
|
||||
operations.
|
||||
- `packages/a2a-server`: Experimental Agent-to-Agent server.
|
||||
- `packages/sdk`: Programmatic SDK for embedding Gemini CLI capabilities.
|
||||
- `packages/devtools`: Integrated developer tools (Network/Console inspector).
|
||||
- `packages/test-utils`: Shared test utilities and test rig.
|
||||
- `packages/vscode-ide-companion`: VS Code extension pairing with the CLI.
|
||||
|
||||
## Building and Running
|
||||
@@ -58,10 +59,6 @@ powerful tool for developers.
|
||||
|
||||
## Development Conventions
|
||||
|
||||
- **Legacy Snippets:** `packages/core/src/prompts/snippets.legacy.ts` is a
|
||||
snapshot of an older system prompt. Avoid changing the prompting verbiage to
|
||||
preserve its historical behavior; however, structural changes to ensure
|
||||
compilation or simplify the code are permitted.
|
||||
- **Contributions:** Follow the process outlined in `CONTRIBUTING.md`. Requires
|
||||
signing the Google CLA.
|
||||
- **Pull Requests:** Keep PRs small, focused, and linked to an existing issue.
|
||||
@@ -69,8 +66,6 @@ powerful tool for developers.
|
||||
`gh` CLI.
|
||||
- **Commit Messages:** Follow the
|
||||
[Conventional Commits](https://www.conventionalcommits.org/) standard.
|
||||
- **Coding Style:** Adhere to existing patterns in `packages/cli` (React/Ink)
|
||||
and `packages/core` (Backend logic).
|
||||
- **Imports:** Use specific imports and avoid restricted relative imports
|
||||
between packages (enforced by ESLint).
|
||||
- **License Headers:** For all new source code files (`.ts`, `.tsx`, `.js`),
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -25,7 +25,8 @@ confirmation.
|
||||
- `label` (string, required): Display text (1-5 words).
|
||||
- `description` (string, required): Brief explanation.
|
||||
- `multiSelect` (boolean, optional): For `'choice'` type, allows selecting
|
||||
multiple options.
|
||||
multiple options. Automatically adds an "All the above" option if there
|
||||
are multiple standard options.
|
||||
- `placeholder` (string, optional): Hint text for input fields.
|
||||
|
||||
- **Behavior:**
|
||||
|
||||
+2
-1
@@ -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
|
||||
|
||||
|
||||
Generated
+9
-10
@@ -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
@@ -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",
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
# Gemini CLI A2A Server (`@google/gemini-cli-a2a-server`)
|
||||
|
||||
Experimental Agent-to-Agent (A2A) server that exposes Gemini CLI capabilities
|
||||
over HTTP for inter-agent communication.
|
||||
|
||||
## Architecture
|
||||
|
||||
- `src/agent/`: Agent session management for A2A interactions.
|
||||
- `src/commands/`: CLI command definitions for the A2A server binary.
|
||||
- `src/config/`: Server configuration.
|
||||
- `src/http/`: HTTP server and route handlers.
|
||||
- `src/persistence/`: Session and state persistence.
|
||||
- `src/utils/`: Shared utility functions.
|
||||
- `src/types.ts`: Shared type definitions.
|
||||
|
||||
## Running
|
||||
|
||||
- Binary entry point: `gemini-cli-a2a-server`
|
||||
|
||||
## Testing
|
||||
|
||||
- Run tests: `npm test -w @google/gemini-cli-a2a-server`
|
||||
@@ -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');
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
- Always fix react-hooks/exhaustive-deps lint errors by adding the missing
|
||||
dependencies.
|
||||
- **Shortcuts**: only define keyboard shortcuts in
|
||||
`packages/cli/src/config/keyBindings.ts`
|
||||
`packages/cli/src/ui/key/keyBindings.ts`
|
||||
- Do not implement any logic performing custom string measurement or string
|
||||
truncation. Use Ink layout instead leveraging ResizeObserver as needed.
|
||||
- Avoid prop drilling when at all possible.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(),
|
||||
);
|
||||
|
||||
@@ -244,10 +244,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', {
|
||||
@@ -429,8 +430,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 +473,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 +671,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 +884,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 {
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -2800,7 +2828,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 +2839,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 {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -101,18 +101,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>
|
||||
|
||||
@@ -58,6 +58,12 @@ const mockSchedulerSchedule = vi.hoisted(() => vi.fn());
|
||||
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
const original =
|
||||
await importOriginal<typeof import('@google/gemini-cli-core')>();
|
||||
const { LegacyAgentSession } = await import(
|
||||
'../../core/src/agent/legacy-agent-session.js'
|
||||
);
|
||||
const { geminiPartsToContentParts } = await import(
|
||||
'../../core/src/agent/content-utils.js'
|
||||
);
|
||||
|
||||
class MockChatRecordingService {
|
||||
initialize = vi.fn();
|
||||
@@ -77,6 +83,8 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||
uiTelemetryService: {
|
||||
getMetrics: vi.fn(),
|
||||
},
|
||||
LegacyAgentSession,
|
||||
geminiPartsToContentParts,
|
||||
coreEvents: mockCoreEvents,
|
||||
createWorkingStdio: vi.fn(() => ({
|
||||
stdout: process.stdout,
|
||||
@@ -108,6 +116,8 @@ describe('runNonInteractive', () => {
|
||||
sendMessageStream: Mock;
|
||||
resumeChat: Mock;
|
||||
getChatRecordingService: Mock;
|
||||
getChat: Mock;
|
||||
getCurrentSequenceModel: Mock;
|
||||
};
|
||||
const MOCK_SESSION_METRICS: SessionMetrics = {
|
||||
models: {},
|
||||
@@ -163,6 +173,8 @@ describe('runNonInteractive', () => {
|
||||
recordMessageTokens: vi.fn(),
|
||||
recordToolCalls: vi.fn(),
|
||||
})),
|
||||
getChat: vi.fn(() => ({ recordCompletedToolCalls: vi.fn() })),
|
||||
getCurrentSequenceModel: vi.fn().mockReturnValue(null),
|
||||
};
|
||||
|
||||
mockConfig = {
|
||||
@@ -259,9 +271,6 @@ describe('runNonInteractive', () => {
|
||||
[{ text: 'Test input' }],
|
||||
expect.any(AbortSignal),
|
||||
'prompt-id-1',
|
||||
undefined,
|
||||
false,
|
||||
'Test input',
|
||||
);
|
||||
expect(getWrittenOutput()).toBe('Hello World\n');
|
||||
// Note: Telemetry shutdown is now handled in runExitCleanup() in cleanup.ts
|
||||
@@ -378,9 +387,6 @@ describe('runNonInteractive', () => {
|
||||
[{ text: 'Tool response' }],
|
||||
expect.any(AbortSignal),
|
||||
'prompt-id-2',
|
||||
undefined,
|
||||
false,
|
||||
undefined,
|
||||
);
|
||||
expect(getWrittenOutput()).toBe('Final answer\n');
|
||||
});
|
||||
@@ -538,9 +544,6 @@ describe('runNonInteractive', () => {
|
||||
],
|
||||
expect.any(AbortSignal),
|
||||
'prompt-id-3',
|
||||
undefined,
|
||||
false,
|
||||
undefined,
|
||||
);
|
||||
expect(getWrittenOutput()).toBe('Sorry, let me try again.\n');
|
||||
});
|
||||
@@ -558,7 +561,7 @@ describe('runNonInteractive', () => {
|
||||
input: 'Initial fail',
|
||||
prompt_id: 'prompt-id-4',
|
||||
}),
|
||||
).rejects.toThrow(apiError);
|
||||
).rejects.toThrow('API connection failed');
|
||||
});
|
||||
|
||||
it('should not exit if a tool is not found, and should send error back to model', async () => {
|
||||
@@ -680,9 +683,6 @@ describe('runNonInteractive', () => {
|
||||
processedParts,
|
||||
expect.any(AbortSignal),
|
||||
'prompt-id-7',
|
||||
undefined,
|
||||
false,
|
||||
rawInput,
|
||||
);
|
||||
|
||||
// 6. Assert the final output is correct
|
||||
@@ -716,9 +716,6 @@ describe('runNonInteractive', () => {
|
||||
[{ text: 'Test input' }],
|
||||
expect.any(AbortSignal),
|
||||
'prompt-id-1',
|
||||
undefined,
|
||||
false,
|
||||
'Test input',
|
||||
);
|
||||
expect(processStdoutSpy).toHaveBeenCalledWith(
|
||||
JSON.stringify(
|
||||
@@ -849,9 +846,6 @@ describe('runNonInteractive', () => {
|
||||
[{ text: 'Empty response test' }],
|
||||
expect.any(AbortSignal),
|
||||
'prompt-id-empty',
|
||||
undefined,
|
||||
false,
|
||||
'Empty response test',
|
||||
);
|
||||
|
||||
// This should output JSON with empty response but include stats
|
||||
@@ -941,7 +935,7 @@ describe('runNonInteractive', () => {
|
||||
{
|
||||
session_id: 'test-session-id',
|
||||
error: {
|
||||
type: 'FatalInputError',
|
||||
type: 'Error',
|
||||
message: 'Invalid command syntax provided',
|
||||
code: 42,
|
||||
},
|
||||
@@ -986,9 +980,6 @@ describe('runNonInteractive', () => {
|
||||
[{ text: 'Prompt from command' }],
|
||||
expect.any(AbortSignal),
|
||||
'prompt-id-slash',
|
||||
undefined,
|
||||
false,
|
||||
'/testcommand',
|
||||
);
|
||||
|
||||
expect(getWrittenOutput()).toBe('Response from command\n');
|
||||
@@ -1032,9 +1023,6 @@ describe('runNonInteractive', () => {
|
||||
[{ text: 'Slash command output' }],
|
||||
expect.any(AbortSignal),
|
||||
'prompt-id-slash',
|
||||
undefined,
|
||||
false,
|
||||
'/help',
|
||||
);
|
||||
expect(getWrittenOutput()).toBe('Response to slash command\n');
|
||||
handleSlashCommandSpy.mockRestore();
|
||||
@@ -1209,9 +1197,6 @@ describe('runNonInteractive', () => {
|
||||
[{ text: '/unknowncommand' }],
|
||||
expect.any(AbortSignal),
|
||||
'prompt-id-unknown',
|
||||
undefined,
|
||||
false,
|
||||
'/unknowncommand',
|
||||
);
|
||||
|
||||
expect(getWrittenOutput()).toBe('Response to unknown\n');
|
||||
@@ -1776,15 +1761,13 @@ describe('runNonInteractive', () => {
|
||||
throw new Error('Recording failed');
|
||||
}),
|
||||
};
|
||||
// @ts-expect-error - Mocking internal structure
|
||||
mockGeminiClient.getChat = vi.fn().mockReturnValue(mockChat);
|
||||
// @ts-expect-error - Mocking internal structure
|
||||
mockGeminiClient.getCurrentSequenceModel = vi
|
||||
.fn()
|
||||
.mockReturnValue('model-1');
|
||||
|
||||
// Mock debugLogger.error
|
||||
const { debugLogger } = await import('@google/gemini-cli-core');
|
||||
const { debugLogger } = await import('../../core/src/utils/debugLogger.js');
|
||||
const debugLoggerErrorSpy = vi
|
||||
.spyOn(debugLogger, 'error')
|
||||
.mockImplementation(() => {});
|
||||
@@ -1999,7 +1982,6 @@ describe('runNonInteractive', () => {
|
||||
expect(processStderrSpy).toHaveBeenCalledWith(
|
||||
'Agent execution stopped: Stopped by hook\n',
|
||||
);
|
||||
// Should exit without calling sendMessageStream again
|
||||
expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
@@ -2030,9 +2012,9 @@ describe('runNonInteractive', () => {
|
||||
expect(processStderrSpy).toHaveBeenCalledWith(
|
||||
'[WARNING] Agent execution blocked: Blocked by hook\n',
|
||||
);
|
||||
// sendMessageStream is called once, recursion is internal to it and transparent to the caller
|
||||
expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(1);
|
||||
// Stream continues after blocked event — content should be output
|
||||
expect(getWrittenOutput()).toBe('Final answer\n');
|
||||
expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2173,6 +2155,40 @@ describe('runNonInteractive', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should emit warning event for loop_detected custom event in streaming JSON mode', async () => {
|
||||
vi.mocked(mockConfig.getOutputFormat).mockReturnValue(
|
||||
OutputFormat.STREAM_JSON,
|
||||
);
|
||||
vi.mocked(uiTelemetryService.getMetrics).mockReturnValue(
|
||||
MOCK_SESSION_METRICS,
|
||||
);
|
||||
|
||||
const streamEvents: ServerGeminiStreamEvent[] = [
|
||||
{ type: GeminiEventType.LoopDetected } as ServerGeminiStreamEvent,
|
||||
{ type: GeminiEventType.Content, value: 'Continuing after loop' },
|
||||
{
|
||||
type: GeminiEventType.Finished,
|
||||
value: { reason: undefined, usageMetadata: { totalTokenCount: 5 } },
|
||||
},
|
||||
];
|
||||
mockGeminiClient.sendMessageStream.mockReturnValue(
|
||||
createStreamFromEvents(streamEvents),
|
||||
);
|
||||
|
||||
await runNonInteractive({
|
||||
config: mockConfig,
|
||||
settings: mockSettings,
|
||||
input: 'Loop test explicit',
|
||||
prompt_id: 'prompt-id-loop-explicit',
|
||||
});
|
||||
|
||||
const output = getWrittenOutput();
|
||||
// The STREAM_JSON output should contain an error event with warning severity
|
||||
expect(output).toContain('"type":"error"');
|
||||
expect(output).toContain('"severity":"warning"');
|
||||
expect(output).toContain('Loop detected');
|
||||
});
|
||||
|
||||
it('should report cancelled tool calls as success in stream-json mode (legacy parity)', async () => {
|
||||
const toolCallEvent: ServerGeminiStreamEvent = {
|
||||
type: GeminiEventType.ToolCallRequest,
|
||||
|
||||
@@ -6,15 +6,15 @@
|
||||
|
||||
import type {
|
||||
Config,
|
||||
ToolCallRequestInfo,
|
||||
ResumedSessionData,
|
||||
UserFeedbackPayload,
|
||||
AgentEvent,
|
||||
ContentPart,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { isSlashCommand } from './ui/utils/commandUtils.js';
|
||||
import type { LoadedSettings } from './config/settings.js';
|
||||
import {
|
||||
convertSessionToClientHistory,
|
||||
GeminiEventType,
|
||||
FatalInputError,
|
||||
promptIdContext,
|
||||
OutputFormat,
|
||||
@@ -22,17 +22,17 @@ import {
|
||||
StreamJsonFormatter,
|
||||
JsonStreamEventType,
|
||||
uiTelemetryService,
|
||||
debugLogger,
|
||||
coreEvents,
|
||||
CoreEvent,
|
||||
createWorkingStdio,
|
||||
recordToolCallInteractions,
|
||||
ToolErrorType,
|
||||
Scheduler,
|
||||
ROOT_SCHEDULER_ID,
|
||||
LegacyAgentSession,
|
||||
ToolErrorType,
|
||||
geminiPartsToContentParts,
|
||||
} from '@google/gemini-cli-core';
|
||||
|
||||
import type { Content, Part } from '@google/genai';
|
||||
import type { Part } from '@google/genai';
|
||||
import readline from 'node:readline';
|
||||
import stripAnsi from 'strip-ansi';
|
||||
|
||||
@@ -150,8 +150,6 @@ export async function runNonInteractive({
|
||||
}, 200);
|
||||
|
||||
abortController.abort();
|
||||
// Note: Don't exit here - let the abort flow through the system
|
||||
// and trigger handleCancellationError() which will exit with proper code
|
||||
}
|
||||
};
|
||||
|
||||
@@ -246,9 +244,6 @@ export async function runNonInteractive({
|
||||
config,
|
||||
settings,
|
||||
);
|
||||
// If a slash command is found and returns a prompt, use it.
|
||||
// Otherwise, slashCommandResult falls through to the default prompt
|
||||
// handling.
|
||||
if (slashCommandResult) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
query = slashCommandResult as Part[];
|
||||
@@ -266,8 +261,6 @@ export async function runNonInteractive({
|
||||
escapePastedAtSymbols: false,
|
||||
});
|
||||
if (error || !processedQuery) {
|
||||
// An error occurred during @include processing (e.g., file not found).
|
||||
// The error message is already logged by handleAtCommand.
|
||||
throw new FatalInputError(
|
||||
error || 'Exiting due to an error processing the @ command.',
|
||||
);
|
||||
@@ -286,235 +279,239 @@ export async function runNonInteractive({
|
||||
});
|
||||
}
|
||||
|
||||
let currentMessages: Content[] = [{ role: 'user', parts: query }];
|
||||
// Create LegacyAgentSession — owns the agentic loop
|
||||
const session = new LegacyAgentSession({
|
||||
client: geminiClient,
|
||||
scheduler,
|
||||
config,
|
||||
promptId: prompt_id,
|
||||
});
|
||||
|
||||
let turnCount = 0;
|
||||
while (true) {
|
||||
turnCount++;
|
||||
if (
|
||||
config.getMaxSessionTurns() >= 0 &&
|
||||
turnCount > config.getMaxSessionTurns()
|
||||
) {
|
||||
handleMaxTurnsExceededError(config);
|
||||
// Wire Ctrl+C to session abort
|
||||
abortController.signal.addEventListener('abort', () => {
|
||||
void session.abort();
|
||||
});
|
||||
|
||||
// Start the agentic loop (runs in background)
|
||||
await session.send({
|
||||
message: geminiPartsToContentParts(query),
|
||||
});
|
||||
|
||||
const getFirstText = (parts?: ContentPart[]): string | undefined => {
|
||||
const part = parts?.[0];
|
||||
return part?.type === 'text' ? part.text : undefined;
|
||||
};
|
||||
|
||||
const emitFinalSuccessResult = (): void => {
|
||||
if (streamFormatter) {
|
||||
const metrics = uiTelemetryService.getMetrics();
|
||||
const durationMs = Date.now() - startTime;
|
||||
streamFormatter.emitEvent({
|
||||
type: JsonStreamEventType.RESULT,
|
||||
timestamp: new Date().toISOString(),
|
||||
status: 'success',
|
||||
stats: streamFormatter.convertToStreamStats(metrics, durationMs),
|
||||
});
|
||||
} else if (config.getOutputFormat() === OutputFormat.JSON) {
|
||||
const formatter = new JsonFormatter();
|
||||
const stats = uiTelemetryService.getMetrics();
|
||||
textOutput.write(
|
||||
formatter.format(config.getSessionId(), responseText, stats),
|
||||
);
|
||||
} else {
|
||||
textOutput.ensureTrailingNewline();
|
||||
}
|
||||
const toolCallRequests: ToolCallRequestInfo[] = [];
|
||||
};
|
||||
|
||||
const responseStream = geminiClient.sendMessageStream(
|
||||
currentMessages[0]?.parts || [],
|
||||
abortController.signal,
|
||||
prompt_id,
|
||||
undefined,
|
||||
false,
|
||||
turnCount === 1 ? input : undefined,
|
||||
);
|
||||
const reconstructFatalError = (event: AgentEvent<'error'>): Error => {
|
||||
const errToThrow = new Error(event.message);
|
||||
const errorMeta = event._meta;
|
||||
if (errorMeta?.['exitCode'] !== undefined) {
|
||||
Object.defineProperty(errToThrow, 'exitCode', {
|
||||
value: errorMeta['exitCode'],
|
||||
enumerable: true,
|
||||
});
|
||||
}
|
||||
if (errorMeta?.['errorName'] !== undefined) {
|
||||
Object.defineProperty(errToThrow, 'name', {
|
||||
value: errorMeta['errorName'],
|
||||
enumerable: true,
|
||||
});
|
||||
}
|
||||
if (errorMeta?.['code'] !== undefined) {
|
||||
Object.defineProperty(errToThrow, 'code', {
|
||||
value: errorMeta['code'],
|
||||
enumerable: true,
|
||||
});
|
||||
}
|
||||
return errToThrow;
|
||||
};
|
||||
|
||||
let responseText = '';
|
||||
for await (const event of responseStream) {
|
||||
if (abortController.signal.aborted) {
|
||||
handleCancellationError(config);
|
||||
}
|
||||
|
||||
if (event.type === GeminiEventType.Content) {
|
||||
const isRaw =
|
||||
config.getRawOutput() || config.getAcceptRawOutputRisk();
|
||||
const output = isRaw ? event.value : stripAnsi(event.value);
|
||||
if (streamFormatter) {
|
||||
streamFormatter.emitEvent({
|
||||
type: JsonStreamEventType.MESSAGE,
|
||||
timestamp: new Date().toISOString(),
|
||||
role: 'assistant',
|
||||
content: output,
|
||||
delta: true,
|
||||
});
|
||||
} else if (config.getOutputFormat() === OutputFormat.JSON) {
|
||||
responseText += output;
|
||||
} else {
|
||||
if (event.value) {
|
||||
textOutput.write(output);
|
||||
// Consume AgentEvents for output formatting
|
||||
let responseText = '';
|
||||
let streamEnded = false;
|
||||
for await (const event of session.stream()) {
|
||||
if (streamEnded) break;
|
||||
switch (event.type) {
|
||||
case 'message': {
|
||||
if (event.role === 'agent') {
|
||||
for (const part of event.content) {
|
||||
if (part.type === 'text') {
|
||||
const isRaw =
|
||||
config.getRawOutput() || config.getAcceptRawOutputRisk();
|
||||
const output = isRaw ? part.text : stripAnsi(part.text);
|
||||
if (streamFormatter) {
|
||||
streamFormatter.emitEvent({
|
||||
type: JsonStreamEventType.MESSAGE,
|
||||
timestamp: new Date().toISOString(),
|
||||
role: 'assistant',
|
||||
content: output,
|
||||
delta: true,
|
||||
});
|
||||
} else if (config.getOutputFormat() === OutputFormat.JSON) {
|
||||
responseText += output;
|
||||
} else {
|
||||
if (part.text) {
|
||||
textOutput.write(output);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (event.type === GeminiEventType.ToolCallRequest) {
|
||||
break;
|
||||
}
|
||||
case 'tool_request': {
|
||||
if (streamFormatter) {
|
||||
streamFormatter.emitEvent({
|
||||
type: JsonStreamEventType.TOOL_USE,
|
||||
timestamp: new Date().toISOString(),
|
||||
tool_name: event.value.name,
|
||||
tool_id: event.value.callId,
|
||||
parameters: event.value.args,
|
||||
tool_name: event.name,
|
||||
tool_id: event.requestId,
|
||||
parameters: event.args,
|
||||
});
|
||||
}
|
||||
toolCallRequests.push(event.value);
|
||||
} else if (event.type === GeminiEventType.LoopDetected) {
|
||||
if (streamFormatter) {
|
||||
streamFormatter.emitEvent({
|
||||
type: JsonStreamEventType.ERROR,
|
||||
timestamp: new Date().toISOString(),
|
||||
severity: 'warning',
|
||||
message: 'Loop detected, stopping execution',
|
||||
});
|
||||
}
|
||||
} else if (event.type === GeminiEventType.MaxSessionTurns) {
|
||||
if (streamFormatter) {
|
||||
streamFormatter.emitEvent({
|
||||
type: JsonStreamEventType.ERROR,
|
||||
timestamp: new Date().toISOString(),
|
||||
severity: 'error',
|
||||
message: 'Maximum session turns exceeded',
|
||||
});
|
||||
}
|
||||
} else if (event.type === GeminiEventType.Error) {
|
||||
throw event.value.error;
|
||||
} else if (event.type === GeminiEventType.AgentExecutionStopped) {
|
||||
const stopMessage = `Agent execution stopped: ${event.value.systemMessage?.trim() || event.value.reason}`;
|
||||
if (config.getOutputFormat() === OutputFormat.TEXT) {
|
||||
process.stderr.write(`${stopMessage}\n`);
|
||||
}
|
||||
// Emit final result event for streaming JSON if needed
|
||||
if (streamFormatter) {
|
||||
const metrics = uiTelemetryService.getMetrics();
|
||||
const durationMs = Date.now() - startTime;
|
||||
streamFormatter.emitEvent({
|
||||
type: JsonStreamEventType.RESULT,
|
||||
timestamp: new Date().toISOString(),
|
||||
status: 'success',
|
||||
stats: streamFormatter.convertToStreamStats(
|
||||
metrics,
|
||||
durationMs,
|
||||
),
|
||||
});
|
||||
}
|
||||
return;
|
||||
} else if (event.type === GeminiEventType.AgentExecutionBlocked) {
|
||||
const blockMessage = `Agent execution blocked: ${event.value.systemMessage?.trim() || event.value.reason}`;
|
||||
if (config.getOutputFormat() === OutputFormat.TEXT) {
|
||||
process.stderr.write(`[WARNING] ${blockMessage}\n`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (toolCallRequests.length > 0) {
|
||||
textOutput.ensureTrailingNewline();
|
||||
const completedToolCalls = await scheduler.schedule(
|
||||
toolCallRequests,
|
||||
abortController.signal,
|
||||
);
|
||||
const toolResponseParts: Part[] = [];
|
||||
|
||||
for (const completedToolCall of completedToolCalls) {
|
||||
const toolResponse = completedToolCall.response;
|
||||
const requestInfo = completedToolCall.request;
|
||||
|
||||
case 'tool_response': {
|
||||
textOutput.ensureTrailingNewline();
|
||||
if (streamFormatter) {
|
||||
const displayText = getFirstText(event.displayContent);
|
||||
const errorMsg = getFirstText(event.content) ?? 'Tool error';
|
||||
streamFormatter.emitEvent({
|
||||
type: JsonStreamEventType.TOOL_RESULT,
|
||||
timestamp: new Date().toISOString(),
|
||||
tool_id: requestInfo.callId,
|
||||
status:
|
||||
completedToolCall.status === 'error' ? 'error' : 'success',
|
||||
output:
|
||||
typeof toolResponse.resultDisplay === 'string'
|
||||
? toolResponse.resultDisplay
|
||||
: undefined,
|
||||
error: toolResponse.error
|
||||
tool_id: event.requestId,
|
||||
status: event.isError ? 'error' : 'success',
|
||||
output: displayText,
|
||||
error: event.isError
|
||||
? {
|
||||
type: toolResponse.errorType || 'TOOL_EXECUTION_ERROR',
|
||||
message: toolResponse.error.message,
|
||||
type:
|
||||
typeof event.data?.['errorType'] === 'string'
|
||||
? event.data['errorType']
|
||||
: 'TOOL_EXECUTION_ERROR',
|
||||
message: errorMsg,
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
if (event.isError) {
|
||||
const displayText = getFirstText(event.displayContent);
|
||||
const errorMsg = getFirstText(event.content) ?? 'Tool error';
|
||||
|
||||
if (event.data?.['errorType'] === ToolErrorType.STOP_EXECUTION) {
|
||||
const stopMessage = `Agent execution stopped: ${errorMsg}`;
|
||||
if (config.getOutputFormat() === OutputFormat.TEXT) {
|
||||
process.stderr.write(`${stopMessage}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
if (toolResponse.error) {
|
||||
handleToolError(
|
||||
requestInfo.name,
|
||||
toolResponse.error,
|
||||
event.name,
|
||||
new Error(errorMsg),
|
||||
config,
|
||||
toolResponse.errorType || 'TOOL_EXECUTION_ERROR',
|
||||
typeof toolResponse.resultDisplay === 'string'
|
||||
? toolResponse.resultDisplay
|
||||
typeof event.data?.['errorType'] === 'string'
|
||||
? event.data['errorType']
|
||||
: undefined,
|
||||
displayText,
|
||||
);
|
||||
}
|
||||
|
||||
if (toolResponse.responseParts) {
|
||||
toolResponseParts.push(...toolResponse.responseParts);
|
||||
break;
|
||||
}
|
||||
case 'error': {
|
||||
if (event.fatal) {
|
||||
throw reconstructFatalError(event);
|
||||
}
|
||||
}
|
||||
|
||||
// Record tool calls with full metadata before sending responses to Gemini
|
||||
try {
|
||||
const currentModel =
|
||||
geminiClient.getCurrentSequenceModel() ?? config.getModel();
|
||||
geminiClient
|
||||
.getChat()
|
||||
.recordCompletedToolCalls(currentModel, completedToolCalls);
|
||||
const errorCode = event._meta?.['code'];
|
||||
|
||||
await recordToolCallInteractions(config, completedToolCalls);
|
||||
} catch (error) {
|
||||
debugLogger.error(
|
||||
`Error recording completed tool call information: ${error}`,
|
||||
);
|
||||
}
|
||||
if (errorCode === 'MAX_TURNS_EXCEEDED') {
|
||||
if (streamFormatter) {
|
||||
streamFormatter.emitEvent({
|
||||
type: JsonStreamEventType.ERROR,
|
||||
timestamp: new Date().toISOString(),
|
||||
severity: 'error',
|
||||
message: event.message,
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
// Check if any tool requested to stop execution immediately
|
||||
const stopExecutionTool = completedToolCalls.find(
|
||||
(tc) => tc.response.errorType === ToolErrorType.STOP_EXECUTION,
|
||||
);
|
||||
|
||||
if (stopExecutionTool && stopExecutionTool.response.error) {
|
||||
const stopMessage = `Agent execution stopped: ${stopExecutionTool.response.error.message}`;
|
||||
if (errorCode === 'AGENT_EXECUTION_BLOCKED') {
|
||||
if (config.getOutputFormat() === OutputFormat.TEXT) {
|
||||
process.stderr.write(`[WARNING] ${event.message}\n`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
const severity =
|
||||
event.status === 'RESOURCE_EXHAUSTED' ? 'error' : 'warning';
|
||||
if (config.getOutputFormat() === OutputFormat.TEXT) {
|
||||
process.stderr.write(`${stopMessage}\n`);
|
||||
process.stderr.write(`[WARNING] ${event.message}\n`);
|
||||
}
|
||||
|
||||
// Emit final result event for streaming JSON
|
||||
if (streamFormatter) {
|
||||
const metrics = uiTelemetryService.getMetrics();
|
||||
const durationMs = Date.now() - startTime;
|
||||
streamFormatter.emitEvent({
|
||||
type: JsonStreamEventType.RESULT,
|
||||
type: JsonStreamEventType.ERROR,
|
||||
timestamp: new Date().toISOString(),
|
||||
status: 'success',
|
||||
stats: streamFormatter.convertToStreamStats(
|
||||
metrics,
|
||||
durationMs,
|
||||
),
|
||||
severity,
|
||||
message: event.message,
|
||||
});
|
||||
} else if (config.getOutputFormat() === OutputFormat.JSON) {
|
||||
const formatter = new JsonFormatter();
|
||||
const stats = uiTelemetryService.getMetrics();
|
||||
textOutput.write(
|
||||
formatter.format(config.getSessionId(), responseText, stats),
|
||||
);
|
||||
} else {
|
||||
textOutput.ensureTrailingNewline(); // Ensure a final newline
|
||||
}
|
||||
return;
|
||||
break;
|
||||
}
|
||||
case 'stream_end': {
|
||||
if (event.reason === 'aborted') {
|
||||
handleCancellationError(config);
|
||||
} else if (event.reason === 'max_turns') {
|
||||
handleMaxTurnsExceededError(config);
|
||||
}
|
||||
|
||||
currentMessages = [{ role: 'user', parts: toolResponseParts }];
|
||||
} else {
|
||||
// Emit final result event for streaming JSON
|
||||
if (streamFormatter) {
|
||||
const metrics = uiTelemetryService.getMetrics();
|
||||
const durationMs = Date.now() - startTime;
|
||||
streamFormatter.emitEvent({
|
||||
type: JsonStreamEventType.RESULT,
|
||||
timestamp: new Date().toISOString(),
|
||||
status: 'success',
|
||||
stats: streamFormatter.convertToStreamStats(metrics, durationMs),
|
||||
});
|
||||
} else if (config.getOutputFormat() === OutputFormat.JSON) {
|
||||
const formatter = new JsonFormatter();
|
||||
const stats = uiTelemetryService.getMetrics();
|
||||
textOutput.write(
|
||||
formatter.format(config.getSessionId(), responseText, stats),
|
||||
);
|
||||
} else {
|
||||
textOutput.ensureTrailingNewline(); // Ensure a final newline
|
||||
const stopMessage =
|
||||
typeof event.data?.['message'] === 'string'
|
||||
? event.data['message']
|
||||
: '';
|
||||
if (stopMessage && config.getOutputFormat() === OutputFormat.TEXT) {
|
||||
process.stderr.write(`Agent execution stopped: ${stopMessage}\n`);
|
||||
}
|
||||
|
||||
emitFinalSuccessResult();
|
||||
streamEnded = true;
|
||||
break;
|
||||
}
|
||||
return;
|
||||
case 'custom': {
|
||||
if (event.kind === 'loop_detected') {
|
||||
if (streamFormatter) {
|
||||
streamFormatter.emitEvent({
|
||||
type: JsonStreamEventType.ERROR,
|
||||
timestamp: new Date().toISOString(),
|
||||
severity: 'warning',
|
||||
message: 'Loop detected, stopping execution',
|
||||
});
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -17,7 +17,6 @@ import {
|
||||
* Creates a mocked Config object with default values and allows overrides.
|
||||
*/
|
||||
export const createMockConfig = (overrides: Partial<Config> = {}): Config =>
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
({
|
||||
getSandbox: vi.fn(() => undefined),
|
||||
getQuestion: vi.fn(() => ''),
|
||||
@@ -79,6 +78,8 @@ export const createMockConfig = (overrides: Partial<Config> = {}): Config =>
|
||||
getFileService: vi.fn().mockReturnValue({}),
|
||||
getGitService: vi.fn().mockResolvedValue({}),
|
||||
getUserMemory: vi.fn().mockReturnValue(''),
|
||||
getSystemInstructionMemory: vi.fn().mockReturnValue(''),
|
||||
getSessionMemory: vi.fn().mockReturnValue(''),
|
||||
getGeminiMdFilePaths: vi.fn().mockReturnValue([]),
|
||||
getShowMemoryUsage: vi.fn().mockReturnValue(false),
|
||||
getAccessibility: vi.fn().mockReturnValue({}),
|
||||
@@ -182,11 +183,9 @@ export function createMockSettings(
|
||||
overrides: Record<string, unknown> = {},
|
||||
): LoadedSettings {
|
||||
const merged = createTestMergedSettings(
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
(overrides['merged'] as Partial<Settings>) || {},
|
||||
);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
return {
|
||||
system: { settings: {} },
|
||||
systemDefaults: { settings: {} },
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
@@ -97,7 +98,8 @@ describe('App', () => {
|
||||
<App />,
|
||||
{
|
||||
uiState: mockUIState,
|
||||
useAlternateBuffer: false,
|
||||
config: makeFakeConfig({ useAlternateBuffer: false }),
|
||||
settings: createMockSettings({ ui: { useAlternateBuffer: false } }),
|
||||
},
|
||||
);
|
||||
await waitUntilReady();
|
||||
@@ -118,7 +120,8 @@ describe('App', () => {
|
||||
<App />,
|
||||
{
|
||||
uiState: quittingUIState,
|
||||
useAlternateBuffer: false,
|
||||
config: makeFakeConfig({ useAlternateBuffer: false }),
|
||||
settings: createMockSettings({ ui: { useAlternateBuffer: false } }),
|
||||
},
|
||||
);
|
||||
await waitUntilReady();
|
||||
@@ -139,7 +142,8 @@ describe('App', () => {
|
||||
<App />,
|
||||
{
|
||||
uiState: quittingUIState,
|
||||
useAlternateBuffer: true,
|
||||
config: makeFakeConfig({ useAlternateBuffer: true }),
|
||||
settings: createMockSettings({ ui: { useAlternateBuffer: true } }),
|
||||
},
|
||||
);
|
||||
await waitUntilReady();
|
||||
@@ -159,6 +163,8 @@ describe('App', () => {
|
||||
<App />,
|
||||
{
|
||||
uiState: dialogUIState,
|
||||
config: makeFakeConfig({ useAlternateBuffer: true }),
|
||||
settings: createMockSettings({ ui: { useAlternateBuffer: true } }),
|
||||
},
|
||||
);
|
||||
await waitUntilReady();
|
||||
@@ -185,6 +191,8 @@ describe('App', () => {
|
||||
<App />,
|
||||
{
|
||||
uiState,
|
||||
config: makeFakeConfig({ useAlternateBuffer: true }),
|
||||
settings: createMockSettings({ ui: { useAlternateBuffer: true } }),
|
||||
},
|
||||
);
|
||||
await waitUntilReady();
|
||||
@@ -201,6 +209,8 @@ describe('App', () => {
|
||||
<App />,
|
||||
{
|
||||
uiState: mockUIState,
|
||||
config: makeFakeConfig({ useAlternateBuffer: true }),
|
||||
settings: createMockSettings({ ui: { useAlternateBuffer: true } }),
|
||||
},
|
||||
);
|
||||
await waitUntilReady();
|
||||
@@ -219,6 +229,8 @@ describe('App', () => {
|
||||
<App />,
|
||||
{
|
||||
uiState: mockUIState,
|
||||
config: makeFakeConfig({ useAlternateBuffer: true }),
|
||||
settings: createMockSettings({ ui: { useAlternateBuffer: true } }),
|
||||
},
|
||||
);
|
||||
await waitUntilReady();
|
||||
@@ -265,7 +277,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 +286,7 @@ describe('App', () => {
|
||||
{
|
||||
uiState: stateWithConfirmingTool,
|
||||
config: configWithExperiment,
|
||||
settings: createMockSettings({ ui: { useAlternateBuffer: true } }),
|
||||
},
|
||||
);
|
||||
await waitUntilReady();
|
||||
@@ -293,6 +306,8 @@ describe('App', () => {
|
||||
<App />,
|
||||
{
|
||||
uiState: mockUIState,
|
||||
config: makeFakeConfig({ useAlternateBuffer: true }),
|
||||
settings: createMockSettings({ ui: { useAlternateBuffer: true } }),
|
||||
},
|
||||
);
|
||||
await waitUntilReady();
|
||||
@@ -306,6 +321,8 @@ describe('App', () => {
|
||||
<App />,
|
||||
{
|
||||
uiState: mockUIState,
|
||||
config: makeFakeConfig({ useAlternateBuffer: true }),
|
||||
settings: createMockSettings({ ui: { useAlternateBuffer: true } }),
|
||||
},
|
||||
);
|
||||
await waitUntilReady();
|
||||
@@ -322,6 +339,8 @@ describe('App', () => {
|
||||
<App />,
|
||||
{
|
||||
uiState: dialogUIState,
|
||||
config: makeFakeConfig({ useAlternateBuffer: true }),
|
||||
settings: createMockSettings({ ui: { useAlternateBuffer: true } }),
|
||||
},
|
||||
);
|
||||
await waitUntilReady();
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -1677,11 +1677,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 +1855,6 @@ Logging in with Google... Restarting Gemini CLI to continue.
|
||||
activePtyId,
|
||||
handleSuspend,
|
||||
embeddedShellFocused,
|
||||
settings.merged.general.debugKeystrokeLogging,
|
||||
refreshStatic,
|
||||
setCopyModeEnabled,
|
||||
tabFocusTimeoutRef,
|
||||
|
||||
@@ -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';
|
||||
@@ -87,6 +89,31 @@ describe('AskUserDialog', () => {
|
||||
writeKey(stdin, '\r'); // Toggle TS
|
||||
writeKey(stdin, '\x1b[B'); // Down
|
||||
writeKey(stdin, '\r'); // Toggle ESLint
|
||||
writeKey(stdin, '\x1b[B'); // Down to All of the above
|
||||
writeKey(stdin, '\x1b[B'); // Down to Other
|
||||
writeKey(stdin, '\x1b[B'); // Down to Done
|
||||
writeKey(stdin, '\r'); // Done
|
||||
},
|
||||
expectedSubmit: { '0': 'TypeScript, ESLint' },
|
||||
},
|
||||
{
|
||||
name: 'All of the above',
|
||||
questions: [
|
||||
{
|
||||
question: 'Which features?',
|
||||
header: 'Features',
|
||||
type: QuestionType.CHOICE,
|
||||
options: [
|
||||
{ label: 'TypeScript', description: '' },
|
||||
{ label: 'ESLint', description: '' },
|
||||
],
|
||||
multiSelect: true,
|
||||
},
|
||||
] as Question[],
|
||||
actions: (stdin: { write: (data: string) => void }) => {
|
||||
writeKey(stdin, '\x1b[B'); // Down to ESLint
|
||||
writeKey(stdin, '\x1b[B'); // Down to All of the above
|
||||
writeKey(stdin, '\r'); // Toggle All of the above
|
||||
writeKey(stdin, '\x1b[B'); // Down to Other
|
||||
writeKey(stdin, '\x1b[B'); // Down to Done
|
||||
writeKey(stdin, '\r'); // Done
|
||||
@@ -131,6 +158,42 @@ describe('AskUserDialog', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('verifies "All of the above" visual state with snapshot', async () => {
|
||||
const questions = [
|
||||
{
|
||||
question: 'Which features?',
|
||||
header: 'Features',
|
||||
type: QuestionType.CHOICE,
|
||||
options: [
|
||||
{ label: 'TypeScript', description: '' },
|
||||
{ label: 'ESLint', description: '' },
|
||||
],
|
||||
multiSelect: true,
|
||||
},
|
||||
] as Question[];
|
||||
|
||||
const { stdin, lastFrame, waitUntilReady } = renderWithProviders(
|
||||
<AskUserDialog
|
||||
questions={questions}
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
width={120}
|
||||
/>,
|
||||
{ width: 120 },
|
||||
);
|
||||
|
||||
// Navigate to "All of the above" and toggle it
|
||||
writeKey(stdin, '\x1b[B'); // Down to ESLint
|
||||
writeKey(stdin, '\x1b[B'); // Down to All of the above
|
||||
writeKey(stdin, '\r'); // Toggle All of the above
|
||||
|
||||
await waitFor(async () => {
|
||||
await waitUntilReady();
|
||||
// Verify visual state (checkmarks on all options)
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
||||
it('handles custom option in single select with inline typing', async () => {
|
||||
const onSubmit = vi.fn();
|
||||
const { stdin, lastFrame, waitUntilReady } = renderWithProviders(
|
||||
@@ -252,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 () => {
|
||||
@@ -1230,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 (▲)
|
||||
@@ -1266,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
|
||||
|
||||
@@ -395,7 +395,7 @@ interface OptionItem {
|
||||
key: string;
|
||||
label: string;
|
||||
description: string;
|
||||
type: 'option' | 'other' | 'done';
|
||||
type: 'option' | 'other' | 'done' | 'all';
|
||||
index: number;
|
||||
}
|
||||
|
||||
@@ -407,6 +407,7 @@ interface ChoiceQuestionState {
|
||||
|
||||
type ChoiceQuestionAction =
|
||||
| { type: 'TOGGLE_INDEX'; payload: { index: number; multiSelect: boolean } }
|
||||
| { type: 'TOGGLE_ALL'; payload: { totalOptions: number } }
|
||||
| {
|
||||
type: 'SET_CUSTOM_SELECTED';
|
||||
payload: { selected: boolean; multiSelect: boolean };
|
||||
@@ -419,6 +420,25 @@ function choiceQuestionReducer(
|
||||
action: ChoiceQuestionAction,
|
||||
): ChoiceQuestionState {
|
||||
switch (action.type) {
|
||||
case 'TOGGLE_ALL': {
|
||||
const { totalOptions } = action.payload;
|
||||
const allSelected = state.selectedIndices.size === totalOptions;
|
||||
if (allSelected) {
|
||||
return {
|
||||
...state,
|
||||
selectedIndices: new Set(),
|
||||
};
|
||||
} else {
|
||||
const newIndices = new Set<number>();
|
||||
for (let i = 0; i < totalOptions; i++) {
|
||||
newIndices.add(i);
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
selectedIndices: newIndices,
|
||||
};
|
||||
}
|
||||
}
|
||||
case 'TOGGLE_INDEX': {
|
||||
const { index, multiSelect } = action.payload;
|
||||
const newIndices = new Set(multiSelect ? state.selectedIndices : []);
|
||||
@@ -703,6 +723,18 @@ const ChoiceQuestionView: React.FC<ChoiceQuestionViewProps> = ({
|
||||
},
|
||||
);
|
||||
|
||||
// Add 'All of the above' for multi-select
|
||||
if (question.multiSelect && questionOptions.length > 1) {
|
||||
const allItem: OptionItem = {
|
||||
key: 'all',
|
||||
label: 'All of the above',
|
||||
description: 'Select all options',
|
||||
type: 'all',
|
||||
index: list.length,
|
||||
};
|
||||
list.push({ key: 'all', value: allItem });
|
||||
}
|
||||
|
||||
// Only add custom option for choice type, not yesno
|
||||
if (question.type !== 'yesno') {
|
||||
const otherItem: OptionItem = {
|
||||
@@ -755,6 +787,11 @@ const ChoiceQuestionView: React.FC<ChoiceQuestionViewProps> = ({
|
||||
type: 'TOGGLE_CUSTOM_SELECTED',
|
||||
payload: { multiSelect: true },
|
||||
});
|
||||
} else if (itemValue.type === 'all') {
|
||||
dispatch({
|
||||
type: 'TOGGLE_ALL',
|
||||
payload: { totalOptions: questionOptions.length },
|
||||
});
|
||||
} else if (itemValue.type === 'done') {
|
||||
// Done just triggers navigation, selections already saved via useEffect
|
||||
onAnswer(
|
||||
@@ -783,6 +820,7 @@ const ChoiceQuestionView: React.FC<ChoiceQuestionViewProps> = ({
|
||||
},
|
||||
[
|
||||
question.multiSelect,
|
||||
questionOptions.length,
|
||||
selectedIndices,
|
||||
isCustomOptionSelected,
|
||||
customOptionText,
|
||||
@@ -857,11 +895,16 @@ const ChoiceQuestionView: React.FC<ChoiceQuestionViewProps> = ({
|
||||
renderItem={(item, context) => {
|
||||
const optionItem = item.value;
|
||||
const isChecked =
|
||||
selectedIndices.has(optionItem.index) ||
|
||||
(optionItem.type === 'other' && isCustomOptionSelected);
|
||||
(optionItem.type === 'option' &&
|
||||
selectedIndices.has(optionItem.index)) ||
|
||||
(optionItem.type === 'other' && isCustomOptionSelected) ||
|
||||
(optionItem.type === 'all' &&
|
||||
selectedIndices.size === questionOptions.length);
|
||||
const showCheck =
|
||||
question.multiSelect &&
|
||||
(optionItem.type === 'option' || optionItem.type === 'other');
|
||||
(optionItem.type === 'option' ||
|
||||
optionItem.type === 'other' ||
|
||||
optionItem.type === 'all');
|
||||
|
||||
// Render inline text input for custom option
|
||||
if (optionItem.type === 'other') {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -408,7 +408,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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 '{state.searchQuery}'.
|
||||
</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 '{state.searchQuery}'.
|
||||
</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>
|
||||
);
|
||||
+29
@@ -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',
|
||||
@@ -52,6 +44,8 @@ enum TerminalKeys {
|
||||
RIGHT_ARROW = '\u001B[C',
|
||||
ESCAPE = '\u001B',
|
||||
BACKSPACE = '\u0008',
|
||||
CTRL_P = '\u0010',
|
||||
CTRL_N = '\u000E',
|
||||
}
|
||||
|
||||
vi.mock('../../config/settingsSchema.js', async (importOriginal) => {
|
||||
@@ -94,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: {
|
||||
@@ -106,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',
|
||||
@@ -162,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',
|
||||
@@ -222,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', () => {
|
||||
@@ -357,9 +371,9 @@ describe('SettingsDialog', () => {
|
||||
up: TerminalKeys.UP_ARROW,
|
||||
},
|
||||
{
|
||||
name: 'vim keys (j/k)',
|
||||
down: 'j',
|
||||
up: 'k',
|
||||
name: 'emacs keys (Ctrl+P/N)',
|
||||
down: TerminalKeys.CTRL_N,
|
||||
up: TerminalKeys.CTRL_P,
|
||||
},
|
||||
])('should navigate with $name', async ({ down, up }) => {
|
||||
const settings = createMockSettings();
|
||||
@@ -397,6 +411,31 @@ describe('SettingsDialog', () => {
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('should allow j and k characters to be typed in search without triggering navigation', async () => {
|
||||
const settings = createMockSettings();
|
||||
const onSelect = vi.fn();
|
||||
const { lastFrame, stdin, waitUntilReady, unmount } = renderDialog(
|
||||
settings,
|
||||
onSelect,
|
||||
);
|
||||
await waitUntilReady();
|
||||
|
||||
// Enter 'j' and 'k' in search
|
||||
await act(async () => stdin.write('j'));
|
||||
await waitUntilReady();
|
||||
await act(async () => stdin.write('k'));
|
||||
await waitUntilReady();
|
||||
|
||||
await waitFor(() => {
|
||||
const frame = lastFrame();
|
||||
// The search box should contain 'jk'
|
||||
expect(frame).toContain('jk');
|
||||
// Since 'jk' doesn't match any setting labels, it should say "No matches found."
|
||||
expect(frame).toContain('No matches found.');
|
||||
});
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('wraps around when at the top of the list', async () => {
|
||||
const settings = createMockSettings();
|
||||
const onSelect = vi.fn();
|
||||
@@ -1317,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();
|
||||
|
||||
@@ -1357,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 () => {
|
||||
|
||||
@@ -43,6 +43,8 @@ import {
|
||||
BaseSettingsDialog,
|
||||
type SettingsDialogItem,
|
||||
} from './shared/BaseSettingsDialog.js';
|
||||
import { useKeyMatchers } from '../hooks/useKeyMatchers.js';
|
||||
import { Command, KeyBinding } from '../key/keyBindings.js';
|
||||
|
||||
interface FzfResult {
|
||||
item: string;
|
||||
@@ -60,6 +62,11 @@ interface SettingsDialogProps {
|
||||
|
||||
const MAX_ITEMS_TO_SHOW = 8;
|
||||
|
||||
const KEY_UP = new KeyBinding('up');
|
||||
const KEY_CTRL_P = new KeyBinding('ctrl+p');
|
||||
const KEY_DOWN = new KeyBinding('down');
|
||||
const KEY_CTRL_N = new KeyBinding('ctrl+n');
|
||||
|
||||
// Create a snapshot of the initial per-scope state of Restart Required Settings
|
||||
// This creates a nested map of the form
|
||||
// restartRequiredSetting -> Map { scopeName -> value }
|
||||
@@ -336,6 +343,18 @@ export function SettingsDialog({
|
||||
onSelect(undefined, selectedScope as SettingScope);
|
||||
}, [onSelect, selectedScope]);
|
||||
|
||||
const globalKeyMatchers = useKeyMatchers();
|
||||
const settingsKeyMatchers = useMemo(
|
||||
() => ({
|
||||
...globalKeyMatchers,
|
||||
[Command.DIALOG_NAVIGATION_UP]: (key: Key) =>
|
||||
KEY_UP.matches(key) || KEY_CTRL_P.matches(key),
|
||||
[Command.DIALOG_NAVIGATION_DOWN]: (key: Key) =>
|
||||
KEY_DOWN.matches(key) || KEY_CTRL_N.matches(key),
|
||||
}),
|
||||
[globalKeyMatchers],
|
||||
);
|
||||
|
||||
// Custom key handler for restart key
|
||||
const handleKeyPress = useCallback(
|
||||
(key: Key, _currentItem: SettingsDialogItem | undefined): boolean => {
|
||||
@@ -371,6 +390,7 @@ export function SettingsDialog({
|
||||
onItemClear={handleItemClear}
|
||||
onClose={handleClose}
|
||||
onKeyPress={handleKeyPress}
|
||||
keyMatchers={settingsKeyMatchers}
|
||||
footer={
|
||||
showRestartPrompt
|
||||
? {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -201,3 +201,19 @@ README → (not answered)
|
||||
Enter to submit · Tab/Shift+Tab to edit answers · Esc to cancel
|
||||
"
|
||||
`;
|
||||
|
||||
exports[`AskUserDialog > verifies "All of the above" visual state with snapshot 1`] = `
|
||||
"Which features?
|
||||
(Select all that apply)
|
||||
|
||||
1. [x] TypeScript
|
||||
2. [x] ESLint
|
||||
● 3. [x] All of the above
|
||||
Select all options
|
||||
4. [ ] Enter a custom value
|
||||
Done
|
||||
Finish selection
|
||||
|
||||
Enter to select · ↑/↓ to navigate · Esc to cancel
|
||||
"
|
||||
`;
|
||||
|
||||
@@ -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 },
|
||||
},
|
||||
);
|
||||
|
||||
+9
@@ -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 │
|
||||
"
|
||||
`;
|
||||
+21
-7
@@ -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');
|
||||
|
||||
@@ -19,7 +19,7 @@ import { TextInput } from './TextInput.js';
|
||||
import type { TextBuffer } from './text-buffer.js';
|
||||
import { cpSlice, cpLen, cpIndexToOffset } from '../../utils/textUtils.js';
|
||||
import { useKeypress, type Key } from '../../hooks/useKeypress.js';
|
||||
import { Command } from '../../key/keyMatchers.js';
|
||||
import { Command, type KeyMatchers } from '../../key/keyMatchers.js';
|
||||
import { useSettingsNavigation } from '../../hooks/useSettingsNavigation.js';
|
||||
import { useInlineEditBuffer } from '../../hooks/useInlineEditBuffer.js';
|
||||
import { formatCommand } from '../../key/keybindingUtils.js';
|
||||
@@ -103,6 +103,9 @@ export interface BaseSettingsDialogProps {
|
||||
currentItem: SettingsDialogItem | undefined,
|
||||
) => boolean;
|
||||
|
||||
/** Optional override for key matchers used for navigation. */
|
||||
keyMatchers?: KeyMatchers;
|
||||
|
||||
/** Available terminal height for dynamic windowing */
|
||||
availableHeight?: number;
|
||||
|
||||
@@ -134,10 +137,12 @@ export function BaseSettingsDialog({
|
||||
onItemClear,
|
||||
onClose,
|
||||
onKeyPress,
|
||||
keyMatchers: customKeyMatchers,
|
||||
availableHeight,
|
||||
footer,
|
||||
}: BaseSettingsDialogProps): React.JSX.Element {
|
||||
const keyMatchers = useKeyMatchers();
|
||||
const globalKeyMatchers = useKeyMatchers();
|
||||
const keyMatchers = customKeyMatchers ?? globalKeyMatchers;
|
||||
// Calculate effective max items and scope visibility based on terminal height
|
||||
const { effectiveMaxItemsToShow, finalShowScopeSelector } = useMemo(() => {
|
||||
const initialShowScope = showScopeSelector;
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -579,6 +579,47 @@ describe('textBufferReducer', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('kill_line_left action', () => {
|
||||
it('should clean up pastedContent when deleting a placeholder line-left', () => {
|
||||
const placeholder = '[Pasted Text: 6 lines]';
|
||||
const stateWithPlaceholder = createStateWithTransformations({
|
||||
lines: [placeholder],
|
||||
cursorRow: 0,
|
||||
cursorCol: cpLen(placeholder),
|
||||
pastedContent: {
|
||||
[placeholder]: 'line1\nline2\nline3\nline4\nline5\nline6',
|
||||
},
|
||||
});
|
||||
|
||||
const state = textBufferReducer(stateWithPlaceholder, {
|
||||
type: 'kill_line_left',
|
||||
});
|
||||
|
||||
expect(state.lines).toEqual(['']);
|
||||
expect(state.cursorCol).toBe(0);
|
||||
expect(Object.keys(state.pastedContent)).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('kill_line_right action', () => {
|
||||
it('should reset preferredCol when deleting to end of line', () => {
|
||||
const stateWithText: TextBufferState = {
|
||||
...initialState,
|
||||
lines: ['hello world'],
|
||||
cursorRow: 0,
|
||||
cursorCol: 5,
|
||||
preferredCol: 9,
|
||||
};
|
||||
|
||||
const state = textBufferReducer(stateWithText, {
|
||||
type: 'kill_line_right',
|
||||
});
|
||||
|
||||
expect(state.lines).toEqual(['hello']);
|
||||
expect(state.preferredCol).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toggle_paste_expansion action', () => {
|
||||
const placeholder = '[Pasted Text: 6 lines]';
|
||||
const content = 'line1\nline2\nline3\nline4\nline5\nline6';
|
||||
@@ -937,6 +978,107 @@ describe('useTextBuffer', () => {
|
||||
expect(Object.keys(result.current.pastedContent)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('deleteWordLeft: should clean up pastedContent and avoid #2 suffix on repaste', () => {
|
||||
const { result } = renderHook(() => useTextBuffer({ viewport }));
|
||||
const largeText = '1\n2\n3\n4\n5\n6';
|
||||
|
||||
act(() => result.current.insert(largeText, { paste: true }));
|
||||
expect(getBufferState(result).text).toBe('[Pasted Text: 6 lines]');
|
||||
expect(result.current.pastedContent['[Pasted Text: 6 lines]']).toBe(
|
||||
largeText,
|
||||
);
|
||||
|
||||
act(() => {
|
||||
for (let i = 0; i < 12; i++) {
|
||||
result.current.deleteWordLeft();
|
||||
}
|
||||
});
|
||||
expect(getBufferState(result).text).toBe('');
|
||||
expect(Object.keys(result.current.pastedContent)).toHaveLength(0);
|
||||
|
||||
act(() => result.current.insert(largeText, { paste: true }));
|
||||
expect(getBufferState(result).text).toBe('[Pasted Text: 6 lines]');
|
||||
expect(result.current.pastedContent['[Pasted Text: 6 lines]']).toBe(
|
||||
largeText,
|
||||
);
|
||||
});
|
||||
|
||||
it('deleteWordRight: should clean up pastedContent and avoid #2 suffix on repaste', () => {
|
||||
const { result } = renderHook(() => useTextBuffer({ viewport }));
|
||||
const largeText = '1\n2\n3\n4\n5\n6';
|
||||
|
||||
act(() => result.current.insert(largeText, { paste: true }));
|
||||
expect(getBufferState(result).text).toBe('[Pasted Text: 6 lines]');
|
||||
expect(result.current.pastedContent['[Pasted Text: 6 lines]']).toBe(
|
||||
largeText,
|
||||
);
|
||||
|
||||
act(() => result.current.move('home'));
|
||||
act(() => {
|
||||
for (let i = 0; i < 12; i++) {
|
||||
result.current.deleteWordRight();
|
||||
}
|
||||
});
|
||||
expect(getBufferState(result).text).not.toContain(
|
||||
'[Pasted Text: 6 lines]',
|
||||
);
|
||||
expect(Object.keys(result.current.pastedContent)).toHaveLength(0);
|
||||
|
||||
act(() => result.current.insert(largeText, { paste: true }));
|
||||
expect(getBufferState(result).text).toContain('[Pasted Text: 6 lines]');
|
||||
expect(getBufferState(result).text).not.toContain('#2');
|
||||
expect(result.current.pastedContent['[Pasted Text: 6 lines]']).toBe(
|
||||
largeText,
|
||||
);
|
||||
});
|
||||
|
||||
it('killLineLeft: should clean up pastedContent and avoid #2 suffix on repaste', () => {
|
||||
const { result } = renderHook(() => useTextBuffer({ viewport }));
|
||||
const largeText = '1\n2\n3\n4\n5\n6';
|
||||
|
||||
act(() => result.current.insert(largeText, { paste: true }));
|
||||
expect(getBufferState(result).text).toBe('[Pasted Text: 6 lines]');
|
||||
expect(result.current.pastedContent['[Pasted Text: 6 lines]']).toBe(
|
||||
largeText,
|
||||
);
|
||||
|
||||
act(() => result.current.killLineLeft());
|
||||
expect(getBufferState(result).text).toBe('');
|
||||
expect(Object.keys(result.current.pastedContent)).toHaveLength(0);
|
||||
|
||||
act(() => result.current.insert(largeText, { paste: true }));
|
||||
expect(getBufferState(result).text).toBe('[Pasted Text: 6 lines]');
|
||||
expect(result.current.pastedContent['[Pasted Text: 6 lines]']).toBe(
|
||||
largeText,
|
||||
);
|
||||
});
|
||||
|
||||
it('killLineRight: should clean up pastedContent and avoid #2 suffix on repaste', () => {
|
||||
const { result } = renderHook(() => useTextBuffer({ viewport }));
|
||||
const largeText = '1\n2\n3\n4\n5\n6';
|
||||
|
||||
act(() => result.current.insert(largeText, { paste: true }));
|
||||
expect(getBufferState(result).text).toBe('[Pasted Text: 6 lines]');
|
||||
expect(result.current.pastedContent['[Pasted Text: 6 lines]']).toBe(
|
||||
largeText,
|
||||
);
|
||||
|
||||
act(() => {
|
||||
for (let i = 0; i < 40; i++) {
|
||||
result.current.move('left');
|
||||
}
|
||||
});
|
||||
act(() => result.current.killLineRight());
|
||||
expect(getBufferState(result).text).toBe('');
|
||||
expect(Object.keys(result.current.pastedContent)).toHaveLength(0);
|
||||
|
||||
act(() => result.current.insert(largeText, { paste: true }));
|
||||
expect(getBufferState(result).text).toBe('[Pasted Text: 6 lines]');
|
||||
expect(result.current.pastedContent['[Pasted Text: 6 lines]']).toBe(
|
||||
largeText,
|
||||
);
|
||||
});
|
||||
|
||||
it('newline: should create a new line and move cursor', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTextBuffer({
|
||||
|
||||
@@ -1609,6 +1609,47 @@ function generatePastedTextId(
|
||||
return id;
|
||||
}
|
||||
|
||||
function collectPlaceholderIdsFromLines(lines: string[]): Set<string> {
|
||||
const ids = new Set<string>();
|
||||
const pasteRegex = new RegExp(PASTED_TEXT_PLACEHOLDER_REGEX.source, 'g');
|
||||
for (const line of lines) {
|
||||
if (!line) continue;
|
||||
for (const match of line.matchAll(pasteRegex)) {
|
||||
const placeholderId = match[0];
|
||||
if (placeholderId) {
|
||||
ids.add(placeholderId);
|
||||
}
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
function pruneOrphanedPastedContent(
|
||||
pastedContent: Record<string, string>,
|
||||
expandedPasteId: string | null,
|
||||
beforeChangedLines: string[],
|
||||
allLines: string[],
|
||||
): Record<string, string> {
|
||||
if (Object.keys(pastedContent).length === 0) return pastedContent;
|
||||
|
||||
const beforeIds = collectPlaceholderIdsFromLines(beforeChangedLines);
|
||||
if (beforeIds.size === 0) return pastedContent;
|
||||
|
||||
const afterIds = collectPlaceholderIdsFromLines(allLines);
|
||||
const removedIds = [...beforeIds].filter(
|
||||
(id) => !afterIds.has(id) && id !== expandedPasteId,
|
||||
);
|
||||
if (removedIds.length === 0) return pastedContent;
|
||||
|
||||
const pruned = { ...pastedContent };
|
||||
for (const id of removedIds) {
|
||||
if (pruned[id]) {
|
||||
delete pruned[id];
|
||||
}
|
||||
}
|
||||
return pruned;
|
||||
}
|
||||
|
||||
export type TextBufferAction =
|
||||
| { type: 'insert'; payload: string; isPaste?: boolean }
|
||||
| {
|
||||
@@ -2260,9 +2301,11 @@ function textBufferReducerLogic(
|
||||
const newLines = [...nextState.lines];
|
||||
let newCursorRow = cursorRow;
|
||||
let newCursorCol = cursorCol;
|
||||
let beforeChangedLines: string[] = [];
|
||||
|
||||
if (newCursorCol > 0) {
|
||||
const lineContent = currentLine(newCursorRow);
|
||||
beforeChangedLines = [lineContent];
|
||||
const prevWordStart = findPrevWordStartInLine(
|
||||
lineContent,
|
||||
newCursorCol,
|
||||
@@ -2275,6 +2318,7 @@ function textBufferReducerLogic(
|
||||
// Act as a backspace
|
||||
const prevLineContent = currentLine(cursorRow - 1);
|
||||
const currentLineContentVal = currentLine(cursorRow);
|
||||
beforeChangedLines = [prevLineContent, currentLineContentVal];
|
||||
const newCol = cpLen(prevLineContent);
|
||||
newLines[cursorRow - 1] = prevLineContent + currentLineContentVal;
|
||||
newLines.splice(cursorRow, 1);
|
||||
@@ -2282,12 +2326,20 @@ function textBufferReducerLogic(
|
||||
newCursorCol = newCol;
|
||||
}
|
||||
|
||||
const newPastedContent = pruneOrphanedPastedContent(
|
||||
nextState.pastedContent,
|
||||
nextState.expandedPaste?.id ?? null,
|
||||
beforeChangedLines,
|
||||
newLines,
|
||||
);
|
||||
|
||||
return {
|
||||
...nextState,
|
||||
lines: newLines,
|
||||
cursorRow: newCursorRow,
|
||||
cursorCol: newCursorCol,
|
||||
preferredCol: null,
|
||||
pastedContent: newPastedContent,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2304,23 +2356,34 @@ function textBufferReducerLogic(
|
||||
|
||||
const nextState = currentState;
|
||||
const newLines = [...nextState.lines];
|
||||
let beforeChangedLines: string[] = [];
|
||||
|
||||
if (cursorCol >= lineLen) {
|
||||
// Act as a delete, joining with the next line
|
||||
const nextLineContent = currentLine(cursorRow + 1);
|
||||
beforeChangedLines = [lineContent, nextLineContent];
|
||||
newLines[cursorRow] = lineContent + nextLineContent;
|
||||
newLines.splice(cursorRow + 1, 1);
|
||||
} else {
|
||||
beforeChangedLines = [lineContent];
|
||||
const nextWordStart = findNextWordStartInLine(lineContent, cursorCol);
|
||||
const end = nextWordStart === null ? lineLen : nextWordStart;
|
||||
newLines[cursorRow] =
|
||||
cpSlice(lineContent, 0, cursorCol) + cpSlice(lineContent, end);
|
||||
}
|
||||
|
||||
const newPastedContent = pruneOrphanedPastedContent(
|
||||
nextState.pastedContent,
|
||||
nextState.expandedPaste?.id ?? null,
|
||||
beforeChangedLines,
|
||||
newLines,
|
||||
);
|
||||
|
||||
return {
|
||||
...nextState,
|
||||
lines: newLines,
|
||||
preferredCol: null,
|
||||
pastedContent: newPastedContent,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2332,22 +2395,39 @@ function textBufferReducerLogic(
|
||||
if (cursorCol < currentLineLen(cursorRow)) {
|
||||
const nextState = currentState;
|
||||
const newLines = [...nextState.lines];
|
||||
const beforeChangedLines = [lineContent];
|
||||
newLines[cursorRow] = cpSlice(lineContent, 0, cursorCol);
|
||||
const newPastedContent = pruneOrphanedPastedContent(
|
||||
nextState.pastedContent,
|
||||
nextState.expandedPaste?.id ?? null,
|
||||
beforeChangedLines,
|
||||
newLines,
|
||||
);
|
||||
return {
|
||||
...nextState,
|
||||
lines: newLines,
|
||||
preferredCol: null,
|
||||
pastedContent: newPastedContent,
|
||||
};
|
||||
} else if (cursorRow < lines.length - 1) {
|
||||
// Act as a delete
|
||||
const nextState = currentState;
|
||||
const nextLineContent = currentLine(cursorRow + 1);
|
||||
const newLines = [...nextState.lines];
|
||||
const beforeChangedLines = [lineContent, nextLineContent];
|
||||
newLines[cursorRow] = lineContent + nextLineContent;
|
||||
newLines.splice(cursorRow + 1, 1);
|
||||
const newPastedContent = pruneOrphanedPastedContent(
|
||||
nextState.pastedContent,
|
||||
nextState.expandedPaste?.id ?? null,
|
||||
beforeChangedLines,
|
||||
newLines,
|
||||
);
|
||||
return {
|
||||
...nextState,
|
||||
lines: newLines,
|
||||
preferredCol: null,
|
||||
pastedContent: newPastedContent,
|
||||
};
|
||||
}
|
||||
return currentState;
|
||||
@@ -2361,12 +2441,20 @@ function textBufferReducerLogic(
|
||||
const nextState = currentState;
|
||||
const lineContent = currentLine(cursorRow);
|
||||
const newLines = [...nextState.lines];
|
||||
const beforeChangedLines = [lineContent];
|
||||
newLines[cursorRow] = cpSlice(lineContent, cursorCol);
|
||||
const newPastedContent = pruneOrphanedPastedContent(
|
||||
nextState.pastedContent,
|
||||
nextState.expandedPaste?.id ?? null,
|
||||
beforeChangedLines,
|
||||
newLines,
|
||||
);
|
||||
return {
|
||||
...nextState,
|
||||
lines: newLines,
|
||||
cursorCol: 0,
|
||||
preferredCol: null,
|
||||
pastedContent: newPastedContent,
|
||||
};
|
||||
}
|
||||
return currentState;
|
||||
|
||||
@@ -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,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>
|
||||
);
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
GeminiCliOperation,
|
||||
getPlanModeExitMessage,
|
||||
isBackgroundExecutionData,
|
||||
Kind,
|
||||
} from '@google/gemini-cli-core';
|
||||
import type {
|
||||
Config,
|
||||
@@ -408,7 +409,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 +418,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 +461,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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,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,
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -252,4 +252,154 @@ describe('Session Cleanup Integration', () => {
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('should delete subagent files and their artifacts when parent expires', async () => {
|
||||
// Create a temporary directory with test sessions
|
||||
const fs = await import('node:fs/promises');
|
||||
const path = await import('node:path');
|
||||
const os = await import('node:os');
|
||||
|
||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gemini-test-'));
|
||||
const chatsDir = path.join(tempDir, 'chats');
|
||||
const logsDir = path.join(tempDir, 'logs');
|
||||
const toolOutputsDir = path.join(tempDir, 'tool-outputs');
|
||||
|
||||
await fs.mkdir(chatsDir, { recursive: true });
|
||||
await fs.mkdir(logsDir, { recursive: true });
|
||||
await fs.mkdir(toolOutputsDir, { recursive: true });
|
||||
|
||||
const now = new Date();
|
||||
const oldDate = new Date(now.getTime() - 5 * 24 * 60 * 60 * 1000); // 5 days ago
|
||||
|
||||
// The shortId that ties them together
|
||||
const sharedShortId = 'abcdef12';
|
||||
|
||||
const parentSessionId = 'parent-uuid-123';
|
||||
const parentFile = path.join(
|
||||
chatsDir,
|
||||
`${SESSION_FILE_PREFIX}2024-01-01T10-00-00-${sharedShortId}.json`,
|
||||
);
|
||||
await fs.writeFile(
|
||||
parentFile,
|
||||
JSON.stringify({
|
||||
sessionId: parentSessionId,
|
||||
messages: [],
|
||||
startTime: oldDate.toISOString(),
|
||||
lastUpdated: oldDate.toISOString(),
|
||||
}),
|
||||
);
|
||||
|
||||
const subagentSessionId = 'subagent-uuid-456';
|
||||
const subagentFile = path.join(
|
||||
chatsDir,
|
||||
`${SESSION_FILE_PREFIX}2024-01-01T10-05-00-${sharedShortId}.json`,
|
||||
);
|
||||
await fs.writeFile(
|
||||
subagentFile,
|
||||
JSON.stringify({
|
||||
sessionId: subagentSessionId,
|
||||
messages: [],
|
||||
startTime: oldDate.toISOString(),
|
||||
lastUpdated: oldDate.toISOString(),
|
||||
}),
|
||||
);
|
||||
|
||||
const parentLogFile = path.join(
|
||||
logsDir,
|
||||
`session-${parentSessionId}.jsonl`,
|
||||
);
|
||||
await fs.writeFile(parentLogFile, '{"log": "parent"}');
|
||||
|
||||
const parentToolOutputsDir = path.join(
|
||||
toolOutputsDir,
|
||||
`session-${parentSessionId}`,
|
||||
);
|
||||
await fs.mkdir(parentToolOutputsDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(parentToolOutputsDir, 'some-output.txt'),
|
||||
'data',
|
||||
);
|
||||
|
||||
const subagentLogFile = path.join(
|
||||
logsDir,
|
||||
`session-${subagentSessionId}.jsonl`,
|
||||
);
|
||||
await fs.writeFile(subagentLogFile, '{"log": "subagent"}');
|
||||
|
||||
const subagentToolOutputsDir = path.join(
|
||||
toolOutputsDir,
|
||||
`session-${subagentSessionId}`,
|
||||
);
|
||||
await fs.mkdir(subagentToolOutputsDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(subagentToolOutputsDir, 'some-output.txt'),
|
||||
'data',
|
||||
);
|
||||
|
||||
const currentShortId = 'current1';
|
||||
const currentFile = path.join(
|
||||
chatsDir,
|
||||
`${SESSION_FILE_PREFIX}2025-01-20T10-00-00-${currentShortId}.json`,
|
||||
);
|
||||
await fs.writeFile(
|
||||
currentFile,
|
||||
JSON.stringify({
|
||||
sessionId: 'current-session',
|
||||
messages: [
|
||||
{
|
||||
type: 'user',
|
||||
content: [{ type: 'text', text: 'hello' }],
|
||||
timestamp: now.toISOString(),
|
||||
},
|
||||
],
|
||||
startTime: now.toISOString(),
|
||||
lastUpdated: now.toISOString(),
|
||||
}),
|
||||
);
|
||||
|
||||
// Configure test
|
||||
const config: Config = {
|
||||
storage: {
|
||||
getProjectTempDir: () => tempDir,
|
||||
},
|
||||
getSessionId: () => 'current-session', // Mock CLI instance ID
|
||||
getDebugMode: () => false,
|
||||
initialize: async () => undefined,
|
||||
} as unknown as Config;
|
||||
|
||||
const settings: Settings = {
|
||||
general: {
|
||||
sessionRetention: {
|
||||
enabled: true,
|
||||
maxAge: '1d', // Expire things older than 1 day
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await cleanupExpiredSessions(config, settings);
|
||||
|
||||
// Verify the cleanup result object
|
||||
// It scanned 3 files. It should delete 2 (parent + subagent), and keep 1 (current)
|
||||
expect(result.disabled).toBe(false);
|
||||
expect(result.scanned).toBe(3);
|
||||
expect(result.deleted).toBe(2);
|
||||
expect(result.skipped).toBe(1);
|
||||
|
||||
// Verify on-disk file states
|
||||
const chats = await fs.readdir(chatsDir);
|
||||
expect(chats).toHaveLength(1);
|
||||
expect(chats).toContain(
|
||||
`${SESSION_FILE_PREFIX}2025-01-20T10-00-00-${currentShortId}.json`,
|
||||
); // Only current is left
|
||||
|
||||
const logs = await fs.readdir(logsDir);
|
||||
expect(logs).toHaveLength(0); // Both parent and subagent logs were deleted
|
||||
|
||||
const tools = await fs.readdir(toolOutputsDir);
|
||||
expect(tools).toHaveLength(0); // Both parent and subagent tool output dirs were deleted
|
||||
} finally {
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -9,6 +9,7 @@ import * as path from 'node:path';
|
||||
import {
|
||||
debugLogger,
|
||||
sanitizeFilenamePart,
|
||||
SESSION_FILE_PREFIX,
|
||||
Storage,
|
||||
TOOL_OUTPUTS_DIR,
|
||||
type Config,
|
||||
@@ -26,6 +27,12 @@ const MULTIPLIERS = {
|
||||
m: 30 * 24 * 60 * 60 * 1000, // months (30 days) to ms
|
||||
};
|
||||
|
||||
/**
|
||||
* Matches a trailing hyphen followed by exactly 8 alphanumeric characters before the .json extension.
|
||||
* Example: session-20250110-abcdef12.json -> captures "abcdef12"
|
||||
*/
|
||||
const SHORT_ID_REGEX = /-([a-zA-Z0-9]{8})\.json$/;
|
||||
|
||||
/**
|
||||
* Result of session cleanup operation
|
||||
*/
|
||||
@@ -37,6 +44,65 @@ export interface CleanupResult {
|
||||
failed: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helpers for session cleanup.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Derives an 8-character shortId from a session filename.
|
||||
*/
|
||||
function deriveShortIdFromFileName(fileName: string): string | null {
|
||||
if (fileName.startsWith(SESSION_FILE_PREFIX) && fileName.endsWith('.json')) {
|
||||
const match = fileName.match(SHORT_ID_REGEX);
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the log path for a session ID.
|
||||
*/
|
||||
function getSessionLogPath(tempDir: string, safeSessionId: string): string {
|
||||
return path.join(tempDir, 'logs', `session-${safeSessionId}.jsonl`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleans up associated artifacts (logs, tool-outputs, directory) for a session.
|
||||
*/
|
||||
async function deleteSessionArtifactsAsync(
|
||||
sessionId: string,
|
||||
config: Config,
|
||||
): Promise<void> {
|
||||
const tempDir = config.storage.getProjectTempDir();
|
||||
|
||||
// Cleanup logs
|
||||
const logsDir = path.join(tempDir, 'logs');
|
||||
const safeSessionId = sanitizeFilenamePart(sessionId);
|
||||
const logPath = getSessionLogPath(tempDir, safeSessionId);
|
||||
if (logPath.startsWith(logsDir)) {
|
||||
await fs.unlink(logPath).catch(() => {});
|
||||
}
|
||||
|
||||
// Cleanup tool outputs
|
||||
const toolOutputDir = path.join(
|
||||
tempDir,
|
||||
TOOL_OUTPUTS_DIR,
|
||||
`session-${safeSessionId}`,
|
||||
);
|
||||
const toolOutputsBase = path.join(tempDir, TOOL_OUTPUTS_DIR);
|
||||
if (toolOutputDir.startsWith(toolOutputsBase)) {
|
||||
await fs
|
||||
.rm(toolOutputDir, { recursive: true, force: true })
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
// Cleanup session directory
|
||||
const sessionDir = path.join(tempDir, safeSessionId);
|
||||
if (safeSessionId && sessionDir.startsWith(tempDir + path.sep)) {
|
||||
await fs.rm(sessionDir, { recursive: true, force: true }).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Main entry point for session cleanup during CLI startup
|
||||
*/
|
||||
@@ -72,7 +138,6 @@ export async function cleanupExpiredSessions(
|
||||
return { ...result, disabled: true };
|
||||
}
|
||||
|
||||
// Get all session files (including corrupted ones) for this project
|
||||
const allFiles = await getAllSessionFiles(chatsDir, config.getSessionId());
|
||||
result.scanned = allFiles.length;
|
||||
|
||||
@@ -86,78 +151,110 @@ export async function cleanupExpiredSessions(
|
||||
retentionConfig,
|
||||
);
|
||||
|
||||
const processedShortIds = new Set<string>();
|
||||
|
||||
// Delete all sessions that need to be deleted
|
||||
for (const sessionToDelete of sessionsToDelete) {
|
||||
try {
|
||||
const sessionPath = path.join(chatsDir, sessionToDelete.fileName);
|
||||
await fs.unlink(sessionPath);
|
||||
const shortId = deriveShortIdFromFileName(sessionToDelete.fileName);
|
||||
|
||||
// ALSO cleanup Activity logs in the project logs directory
|
||||
const sessionId = sessionToDelete.sessionInfo?.id;
|
||||
if (sessionId) {
|
||||
const logsDir = path.join(config.storage.getProjectTempDir(), 'logs');
|
||||
const logPath = path.join(logsDir, `session-${sessionId}.jsonl`);
|
||||
try {
|
||||
await fs.unlink(logPath);
|
||||
} catch {
|
||||
/* ignore if log doesn't exist */
|
||||
if (shortId) {
|
||||
if (processedShortIds.has(shortId)) {
|
||||
continue;
|
||||
}
|
||||
processedShortIds.add(shortId);
|
||||
|
||||
// ALSO cleanup tool outputs for this session
|
||||
const safeSessionId = sanitizeFilenamePart(sessionId);
|
||||
const toolOutputDir = path.join(
|
||||
config.storage.getProjectTempDir(),
|
||||
TOOL_OUTPUTS_DIR,
|
||||
`session-${safeSessionId}`,
|
||||
);
|
||||
try {
|
||||
await fs.rm(toolOutputDir, { recursive: true, force: true });
|
||||
} catch {
|
||||
/* ignore if doesn't exist */
|
||||
}
|
||||
|
||||
// ALSO cleanup the session-specific directory (contains plans, tasks, etc.)
|
||||
const sessionDir = path.join(
|
||||
config.storage.getProjectTempDir(),
|
||||
sessionId,
|
||||
);
|
||||
try {
|
||||
await fs.rm(sessionDir, { recursive: true, force: true });
|
||||
} catch {
|
||||
/* ignore if doesn't exist */
|
||||
}
|
||||
}
|
||||
|
||||
if (config.getDebugMode()) {
|
||||
if (sessionToDelete.sessionInfo === null) {
|
||||
debugLogger.debug(
|
||||
`Deleted corrupted session file: ${sessionToDelete.fileName}`,
|
||||
const matchingFiles = allFiles
|
||||
.map((f) => f.fileName)
|
||||
.filter(
|
||||
(f) =>
|
||||
f.startsWith(SESSION_FILE_PREFIX) &&
|
||||
f.endsWith(`-${shortId}.json`),
|
||||
);
|
||||
} else {
|
||||
|
||||
for (const file of matchingFiles) {
|
||||
const filePath = path.join(chatsDir, file);
|
||||
let fullSessionId: string | undefined;
|
||||
|
||||
try {
|
||||
// Try to read file to get full sessionId
|
||||
try {
|
||||
const fileContent = await fs.readFile(filePath, 'utf8');
|
||||
const content: unknown = JSON.parse(fileContent);
|
||||
if (
|
||||
content &&
|
||||
typeof content === 'object' &&
|
||||
'sessionId' in content
|
||||
) {
|
||||
const record = content as Record<string, unknown>;
|
||||
const id = record['sessionId'];
|
||||
if (typeof id === 'string') {
|
||||
fullSessionId = id;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// If read/parse fails, skip getting sessionId, just delete the file
|
||||
}
|
||||
|
||||
// Delete the session file
|
||||
if (!fullSessionId || fullSessionId !== config.getSessionId()) {
|
||||
await fs.unlink(filePath);
|
||||
|
||||
if (fullSessionId) {
|
||||
await deleteSessionArtifactsAsync(fullSessionId, config);
|
||||
}
|
||||
result.deleted++;
|
||||
} else {
|
||||
result.skipped++;
|
||||
}
|
||||
} catch (error) {
|
||||
// Ignore ENOENT (file already deleted)
|
||||
if (
|
||||
error instanceof Error &&
|
||||
'code' in error &&
|
||||
error.code === 'ENOENT'
|
||||
) {
|
||||
// File already deleted, do nothing.
|
||||
} else {
|
||||
debugLogger.warn(
|
||||
`Failed to delete matching file ${file}: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||
);
|
||||
result.failed++;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Fallback to old logic
|
||||
const sessionPath = path.join(chatsDir, sessionToDelete.fileName);
|
||||
await fs.unlink(sessionPath);
|
||||
|
||||
const sessionId = sessionToDelete.sessionInfo?.id;
|
||||
if (sessionId) {
|
||||
await deleteSessionArtifactsAsync(sessionId, config);
|
||||
}
|
||||
|
||||
if (config.getDebugMode()) {
|
||||
debugLogger.debug(
|
||||
`Deleted expired session: ${sessionToDelete.sessionInfo.id} (${sessionToDelete.sessionInfo.lastUpdated})`,
|
||||
`Deleted fallback session: ${sessionToDelete.fileName}`,
|
||||
);
|
||||
}
|
||||
result.deleted++;
|
||||
}
|
||||
result.deleted++;
|
||||
} catch (error) {
|
||||
// Ignore ENOENT errors (file already deleted)
|
||||
// Ignore ENOENT (file already deleted)
|
||||
if (
|
||||
error instanceof Error &&
|
||||
'code' in error &&
|
||||
error.code === 'ENOENT'
|
||||
) {
|
||||
// File already deleted, do nothing.
|
||||
// File already deleted
|
||||
} else {
|
||||
// Log error directly to console
|
||||
const sessionId =
|
||||
sessionToDelete.sessionInfo === null
|
||||
? sessionToDelete.fileName
|
||||
: sessionToDelete.sessionInfo.id;
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : 'Unknown error';
|
||||
debugLogger.warn(
|
||||
`Failed to delete session ${sessionId}: ${errorMessage}`,
|
||||
`Failed to delete session ${sessionId}: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||
);
|
||||
result.failed++;
|
||||
}
|
||||
@@ -182,9 +279,6 @@ export async function cleanupExpiredSessions(
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Identifies sessions that should be deleted (corrupted or expired based on retention policy)
|
||||
*/
|
||||
/**
|
||||
* Identifies sessions that should be deleted (corrupted or expired based on retention policy)
|
||||
*/
|
||||
@@ -248,13 +342,19 @@ export async function identifySessionsToDelete(
|
||||
let shouldDelete = false;
|
||||
|
||||
// Age-based retention check
|
||||
if (cutoffDate && new Date(session.lastUpdated) < cutoffDate) {
|
||||
shouldDelete = true;
|
||||
if (cutoffDate) {
|
||||
const lastUpdatedDate = new Date(session.lastUpdated);
|
||||
const isExpired = lastUpdatedDate < cutoffDate;
|
||||
if (isExpired) {
|
||||
shouldDelete = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Count-based retention check (keep only N most recent deletable sessions)
|
||||
if (maxDeletableSessions !== undefined && i >= maxDeletableSessions) {
|
||||
shouldDelete = true;
|
||||
if (maxDeletableSessions !== undefined) {
|
||||
if (i >= maxDeletableSessions) {
|
||||
shouldDelete = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldDelete) {
|
||||
|
||||
@@ -239,6 +239,44 @@ describe('SessionSelector', () => {
|
||||
expect(result.sessionData.messages[0].content).toBe('Latest session');
|
||||
});
|
||||
|
||||
it('should resolve session by UUID with whitespace (trimming)', async () => {
|
||||
const sessionId = randomUUID();
|
||||
|
||||
// Create test session files
|
||||
const chatsDir = path.join(tmpDir, 'chats');
|
||||
await fs.mkdir(chatsDir, { recursive: true });
|
||||
|
||||
const session = {
|
||||
sessionId,
|
||||
projectHash: 'test-hash',
|
||||
startTime: '2024-01-01T10:00:00.000Z',
|
||||
lastUpdated: '2024-01-01T10:30:00.000Z',
|
||||
messages: [
|
||||
{
|
||||
type: 'user',
|
||||
content: 'Test message',
|
||||
id: 'msg1',
|
||||
timestamp: '2024-01-01T10:00:00.000Z',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
await fs.writeFile(
|
||||
path.join(
|
||||
chatsDir,
|
||||
`${SESSION_FILE_PREFIX}2024-01-01T10-00-${sessionId.slice(0, 8)}.json`,
|
||||
),
|
||||
JSON.stringify(session, null, 2),
|
||||
);
|
||||
|
||||
const sessionSelector = new SessionSelector(config);
|
||||
|
||||
// Test resolving by UUID with leading/trailing spaces
|
||||
const result = await sessionSelector.resolveSession(` ${sessionId} `);
|
||||
expect(result.sessionData.sessionId).toBe(sessionId);
|
||||
expect(result.sessionData.messages[0].content).toBe('Test message');
|
||||
});
|
||||
|
||||
it('should deduplicate sessions by ID', async () => {
|
||||
const sessionId = randomUUID();
|
||||
|
||||
|
||||
@@ -57,10 +57,14 @@ export class SessionError extends Error {
|
||||
/**
|
||||
* Creates an error for when a session identifier is invalid.
|
||||
*/
|
||||
static invalidSessionIdentifier(identifier: string): SessionError {
|
||||
static invalidSessionIdentifier(
|
||||
identifier: string,
|
||||
chatsDir?: string,
|
||||
): SessionError {
|
||||
const dirInfo = chatsDir ? ` in ${chatsDir}` : '';
|
||||
return new SessionError(
|
||||
'INVALID_SESSION_IDENTIFIER',
|
||||
`Invalid session identifier "${identifier}".\n Use --list-sessions to see available sessions, then use --resume {number}, --resume {uuid}, or --resume latest.`,
|
||||
`Invalid session identifier "${identifier}".\n Searched for sessions${dirInfo}.\n Use --list-sessions to see available sessions, then use --resume {number}, --resume {uuid}, or --resume latest.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -416,6 +420,7 @@ export class SessionSelector {
|
||||
* @throws Error if the session is not found or identifier is invalid
|
||||
*/
|
||||
async findSession(identifier: string): Promise<SessionInfo> {
|
||||
const trimmedIdentifier = identifier.trim();
|
||||
const sessions = await this.listSessions();
|
||||
|
||||
if (sessions.length === 0) {
|
||||
@@ -430,24 +435,28 @@ export class SessionSelector {
|
||||
|
||||
// Try to find by UUID first
|
||||
const sessionByUuid = sortedSessions.find(
|
||||
(session) => session.id === identifier,
|
||||
(session) => session.id === trimmedIdentifier,
|
||||
);
|
||||
if (sessionByUuid) {
|
||||
return sessionByUuid;
|
||||
}
|
||||
|
||||
// Parse as index number (1-based) - only allow numeric indexes
|
||||
const index = parseInt(identifier, 10);
|
||||
const index = parseInt(trimmedIdentifier, 10);
|
||||
if (
|
||||
!isNaN(index) &&
|
||||
index.toString() === identifier &&
|
||||
index.toString() === trimmedIdentifier &&
|
||||
index > 0 &&
|
||||
index <= sortedSessions.length
|
||||
) {
|
||||
return sortedSessions[index - 1];
|
||||
}
|
||||
|
||||
throw SessionError.invalidSessionIdentifier(identifier);
|
||||
const chatsDir = path.join(
|
||||
this.config.storage.getProjectTempDir(),
|
||||
'chats',
|
||||
);
|
||||
throw SessionError.invalidSessionIdentifier(trimmedIdentifier, chatsDir);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -458,8 +467,9 @@ export class SessionSelector {
|
||||
*/
|
||||
async resolveSession(resumeArg: string): Promise<SessionSelectionResult> {
|
||||
let selectedSession: SessionInfo;
|
||||
const trimmedResumeArg = resumeArg.trim();
|
||||
|
||||
if (resumeArg === RESUME_LATEST) {
|
||||
if (trimmedResumeArg === RESUME_LATEST) {
|
||||
const sessions = await this.listSessions();
|
||||
|
||||
if (sessions.length === 0) {
|
||||
@@ -475,7 +485,7 @@ export class SessionSelector {
|
||||
selectedSession = sessions[sessions.length - 1];
|
||||
} else {
|
||||
try {
|
||||
selectedSession = await this.findSession(resumeArg);
|
||||
selectedSession = await this.findSession(trimmedResumeArg);
|
||||
} catch (error) {
|
||||
// SessionError already has detailed messages - just rethrow
|
||||
if (error instanceof SessionError) {
|
||||
@@ -483,7 +493,7 @@ export class SessionSelector {
|
||||
}
|
||||
// Wrap unexpected errors with context
|
||||
throw new Error(
|
||||
`Failed to find session "${resumeArg}": ${error instanceof Error ? error.message : String(error)}`,
|
||||
`Failed to find session "${trimmedResumeArg}": ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
# Gemini CLI Core (`@google/gemini-cli-core`)
|
||||
|
||||
Backend logic for Gemini CLI: API orchestration, prompt construction, tool
|
||||
execution, and agent management.
|
||||
|
||||
## Architecture
|
||||
|
||||
- `src/agent/` & `src/agents/`: Agent lifecycle and sub-agent management.
|
||||
- `src/availability/`: Model availability checks.
|
||||
- `src/billing/`: Billing and usage tracking.
|
||||
- `src/code_assist/`: Code assistance features.
|
||||
- `src/commands/`: Built-in CLI command implementations.
|
||||
- `src/config/`: Configuration management.
|
||||
- `src/confirmation-bus/`: User confirmation flow for tool execution.
|
||||
- `src/core/`: Core types and shared logic.
|
||||
- `src/fallback/`: Fallback and retry strategies.
|
||||
- `src/hooks/`: Hook system for extensibility.
|
||||
- `src/ide/`: IDE integration interfaces.
|
||||
- `src/mcp/`: MCP (Model Context Protocol) client and server integration.
|
||||
- `src/output/`: Output formatting and rendering.
|
||||
- `src/policy/`: Policy enforcement (e.g., tool confirmation policies).
|
||||
- `src/prompts/`: System prompt construction and prompt snippets.
|
||||
- `src/resources/`: Resource management.
|
||||
- `src/routing/`: Model routing and selection logic.
|
||||
- `src/safety/`: Safety filtering and guardrails.
|
||||
- `src/scheduler/`: Task scheduling.
|
||||
- `src/services/`: Shared service layer.
|
||||
- `src/skills/`: Skill discovery and activation.
|
||||
- `src/telemetry/`: Usage telemetry and logging.
|
||||
- `src/tools/`: Built-in tool implementations (file system, shell, web, MCP).
|
||||
- `src/utils/`: Shared utility functions.
|
||||
- `src/voice/`: Voice input/output support.
|
||||
|
||||
## Coding Conventions
|
||||
|
||||
- **Legacy Snippets:** `src/prompts/snippets.legacy.ts` is a snapshot of an
|
||||
older system prompt. Avoid changing the prompting verbiage to preserve its
|
||||
historical behavior; however, structural changes to ensure compilation or
|
||||
simplify the code are permitted.
|
||||
- **Style:** Follow existing backend logic patterns. This package has no UI
|
||||
dependencies — keep it framework-agnostic.
|
||||
|
||||
## Testing
|
||||
|
||||
- Run tests: `npm test -w @google/gemini-cli-core`
|
||||
- Run a specific test:
|
||||
`npm test -w @google/gemini-cli-core -- src/path/to/file.test.ts`
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user