From f13cb832aa59c72e1dfda2638c1ecfa448f59e15 Mon Sep 17 00:00:00 2001 From: Keith Guerin Date: Fri, 20 Mar 2026 14:57:56 -0700 Subject: [PATCH] feat: introduce UX Extension and Base Folder Strategy --- .gemini/settings.json | 3 +- .gemini/skills/docs-writer/SKILL.md | 39 +- .geminiignore | 1 - .github/ISSUE_TEMPLATE/website_issue.yml | 2 - CONTRIBUTING.md | 15 + GEMINI.md | 105 +- README.md | 11 + docs/admin/enterprise-controls.md | 61 - docs/changelogs/index.md | 11 - docs/changelogs/latest.md | 671 ++--- docs/changelogs/preview.md | 815 +++--- docs/cli/checkpointing.md | 4 +- docs/cli/cli-reference.md | 1 - docs/cli/custom-commands.md | 12 +- docs/cli/enterprise.md | 24 +- docs/cli/git-worktrees.md | 107 - docs/cli/model-steering.md | 7 +- docs/cli/model.md | 4 +- docs/cli/notifications.md | 7 +- docs/cli/plan-mode.md | 36 +- docs/cli/sandbox.md | 28 +- docs/cli/session-management.md | 6 - docs/cli/settings.md | 8 +- docs/cli/skills.md | 6 +- docs/cli/system-prompt.md | 4 +- docs/cli/telemetry.md | 9 +- docs/cli/themes.md | 20 +- docs/cli/tutorials/file-management.md | 14 +- docs/cli/tutorials/mcp-setup.md | 8 +- docs/cli/tutorials/memory-management.md | 12 +- docs/cli/tutorials/plan-mode-steering.md | 7 +- docs/cli/tutorials/shell-commands.md | 6 +- docs/core/remote-agents.md | 11 +- docs/core/subagents.md | 34 +- docs/extensions/reference.md | 11 +- docs/get-started/authentication.md | 110 +- docs/get-started/examples.md | 4 +- docs/get-started/gemini-3.md | 16 +- docs/hooks/index.md | 4 +- docs/ide-integration/ide-companion-spec.md | 8 +- docs/ide-integration/index.md | 12 +- docs/issue-and-pr-automation.md | 4 +- docs/local-development.md | 4 +- docs/reference/commands.md | 4 +- docs/reference/configuration.md | 216 +- docs/reference/policy-engine.md | 43 +- docs/reference/tools.md | 4 +- docs/release-confidence.md | 10 +- docs/releases.md | 18 +- docs/resources/faq.md | 13 - docs/resources/tos-privacy.md | 6 +- docs/resources/troubleshooting.md | 4 +- docs/sidebar.json | 5 - docs/tools/mcp-server.md | 26 +- docs/tools/planning.md | 4 +- docs/tools/shell.md | 8 +- docs/tools/todos.md | 3 +- eslint.config.js | 11 +- evals/plan_mode.eval.ts | 117 +- integration-tests/browser-policy.responses | 5 - integration-tests/browser-policy.test.ts | 178 -- package-lock.json | 10 +- package.json | 3 +- packages/a2a-server/src/agent/task.ts | 2 - packages/a2a-server/src/config/config.test.ts | 60 - packages/a2a-server/src/config/config.ts | 26 +- .../a2a-server/src/config/settings.test.ts | 12 - packages/a2a-server/src/config/settings.ts | 3 - packages/cli/package.json | 3 +- packages/cli/src/acp/acpClient.test.ts | 100 +- packages/cli/src/acp/acpClient.ts | 149 +- packages/cli/src/acp/acpResume.test.ts | 8 +- packages/cli/src/acp/commands/extensions.ts | 19 +- packages/cli/src/acp/commands/init.ts | 2 +- packages/cli/src/acp/commands/memory.ts | 12 +- packages/cli/src/acp/commands/restore.ts | 5 +- packages/cli/src/acp/commands/types.ts | 4 +- packages/cli/src/acp/fileSystemService.ts | 2 +- .../src/commands/extensions/install.test.ts | 124 +- .../cli/src/commands/extensions/install.ts | 10 +- packages/cli/src/commands/hooks/migrate.ts | 5 +- packages/cli/src/commands/mcp/list.test.ts | 1 - packages/cli/src/config/config.test.ts | 113 - packages/cli/src/config/config.ts | 171 +- .../extension-manager-permissions.test.ts | 133 - .../config/extension-manager-skills.test.ts | 9 - packages/cli/src/config/extension-manager.ts | 21 - .../cli/src/config/extensions/consent.test.ts | 5 +- .../extensions/extensionUpdates.test.ts | 7 - .../config/policy-engine.integration.test.ts | 6 +- packages/cli/src/config/sandboxConfig.test.ts | 7 - packages/cli/src/config/sandboxConfig.ts | 19 +- packages/cli/src/config/settings.test.ts | 22 - packages/cli/src/config/settings.ts | 5 - .../cli/src/config/settingsSchema.test.ts | 24 - packages/cli/src/config/settingsSchema.ts | 181 +- packages/cli/src/gemini.test.tsx | 2 - packages/cli/src/gemini.tsx | 10 - packages/cli/src/gemini_cleanup.test.tsx | 2 - .../integration-tests/modelSteering.test.tsx | 2 +- packages/cli/src/interactiveCli.tsx | 14 +- packages/cli/src/nonInteractiveCliCommands.ts | 6 +- .../prompt-processors/atFileProcessor.test.ts | 7 +- .../prompt-processors/atFileProcessor.ts | 2 +- .../prompt-processors/shellProcessor.test.ts | 7 +- .../prompt-processors/shellProcessor.ts | 2 +- packages/cli/src/test-utils/AppRig.test.tsx | 10 +- packages/cli/src/test-utils/AppRig.tsx | 15 +- packages/cli/src/test-utils/customMatchers.ts | 3 +- .../src/test-utils/mockCommandContext.test.ts | 12 +- .../cli/src/test-utils/mockCommandContext.ts | 2 +- packages/cli/src/test-utils/mockConfig.ts | 1 - packages/cli/src/test-utils/render.test.tsx | 53 +- packages/cli/src/test-utils/render.tsx | 205 +- packages/cli/src/ui/App.test.tsx | 129 +- packages/cli/src/ui/AppContainer.test.tsx | 1258 +++++---- packages/cli/src/ui/AppContainer.tsx | 47 +- .../cli/src/ui/IdeIntegrationNudge.test.tsx | 53 +- .../cli/src/ui/auth/ApiAuthDialog.test.tsx | 15 +- packages/cli/src/ui/auth/AuthDialog.test.tsx | 75 +- .../cli/src/ui/auth/AuthInProgress.test.tsx | 17 +- .../src/ui/auth/BannedAccountDialog.test.tsx | 30 +- .../LoginWithGoogleRestartDialog.test.tsx | 9 +- packages/cli/src/ui/auth/useAuth.test.tsx | 203 +- .../cli/src/ui/commands/aboutCommand.test.ts | 23 +- packages/cli/src/ui/commands/aboutCommand.ts | 7 +- .../cli/src/ui/commands/agentsCommand.test.ts | 14 +- packages/cli/src/ui/commands/agentsCommand.ts | 19 +- .../cli/src/ui/commands/authCommand.test.ts | 25 +- packages/cli/src/ui/commands/authCommand.ts | 2 +- .../cli/src/ui/commands/bugCommand.test.ts | 52 +- packages/cli/src/ui/commands/bugCommand.ts | 8 +- .../cli/src/ui/commands/chatCommand.test.ts | 29 +- packages/cli/src/ui/commands/chatCommand.ts | 14 +- .../cli/src/ui/commands/clearCommand.test.ts | 37 +- packages/cli/src/ui/commands/clearCommand.ts | 4 +- .../src/ui/commands/compressCommand.test.ts | 9 +- .../cli/src/ui/commands/compressCommand.ts | 8 +- .../cli/src/ui/commands/copyCommand.test.ts | 8 +- packages/cli/src/ui/commands/copyCommand.ts | 2 +- .../src/ui/commands/directoryCommand.test.tsx | 5 +- .../cli/src/ui/commands/directoryCommand.tsx | 29 +- .../src/ui/commands/extensionsCommand.test.ts | 48 +- .../cli/src/ui/commands/extensionsCommand.ts | 58 +- .../cli/src/ui/commands/hooksCommand.test.ts | 14 +- packages/cli/src/ui/commands/hooksCommand.ts | 21 +- .../cli/src/ui/commands/ideCommand.test.ts | 10 +- packages/cli/src/ui/commands/ideCommand.ts | 20 +- .../cli/src/ui/commands/initCommand.test.ts | 8 +- packages/cli/src/ui/commands/initCommand.ts | 4 +- .../cli/src/ui/commands/mcpCommand.test.ts | 18 +- packages/cli/src/ui/commands/mcpCommand.ts | 29 +- .../cli/src/ui/commands/memoryCommand.test.ts | 22 +- packages/cli/src/ui/commands/memoryCommand.ts | 6 +- .../cli/src/ui/commands/modelCommand.test.ts | 20 +- packages/cli/src/ui/commands/modelCommand.ts | 10 +- .../cli/src/ui/commands/oncallCommand.tsx | 6 +- .../cli/src/ui/commands/planCommand.test.ts | 48 +- packages/cli/src/ui/commands/planCommand.ts | 4 +- .../src/ui/commands/policiesCommand.test.ts | 25 +- .../cli/src/ui/commands/policiesCommand.ts | 8 +- .../src/ui/commands/restoreCommand.test.ts | 9 +- .../cli/src/ui/commands/restoreCommand.ts | 12 +- .../src/ui/commands/rewindCommand.test.tsx | 28 +- .../cli/src/ui/commands/rewindCommand.tsx | 7 +- .../cli/src/ui/commands/setupGithubCommand.ts | 2 +- .../cli/src/ui/commands/skillsCommand.test.ts | 46 +- packages/cli/src/ui/commands/skillsCommand.ts | 20 +- .../cli/src/ui/commands/statsCommand.test.ts | 10 +- packages/cli/src/ui/commands/statsCommand.ts | 32 +- .../cli/src/ui/commands/toolsCommand.test.ts | 34 +- packages/cli/src/ui/commands/toolsCommand.ts | 2 +- packages/cli/src/ui/commands/types.ts | 4 +- .../src/ui/commands/upgradeCommand.test.ts | 20 +- .../cli/src/ui/commands/upgradeCommand.ts | 6 +- .../cli/src/ui/components/AboutBox.test.tsx | 12 +- .../AdminSettingsChangedDialog.test.tsx | 9 +- .../ui/components/AgentConfigDialog.test.tsx | 54 +- .../AlternateBufferQuittingDisplay.test.tsx | 18 +- .../cli/src/ui/components/AnsiOutput.test.tsx | 24 +- packages/cli/src/ui/components/AnsiOutput.tsx | 6 +- .../cli/src/ui/components/AppHeader.test.tsx | 54 +- .../src/ui/components/AppHeaderIcon.test.tsx | 4 +- .../components/ApprovalModeIndicator.test.tsx | 18 +- .../src/ui/components/AskUserDialog.test.tsx | 96 +- .../BackgroundShellDisplay.test.tsx | 36 +- .../cli/src/ui/components/Banner.test.tsx | 19 +- packages/cli/src/ui/components/Banner.tsx | 15 +- .../ui/components/BubblingRegression.test.tsx | 2 +- .../cli/src/ui/components/Checklist.test.tsx | 15 +- .../src/ui/components/ChecklistItem.test.tsx | 10 +- .../cli/src/ui/components/ChecklistItem.tsx | 10 +- .../cli/src/ui/components/CliSpinner.test.tsx | 11 +- .../src/ui/components/ColorsDisplay.test.tsx | 3 +- .../cli/src/ui/components/Composer.test.tsx | 13 +- packages/cli/src/ui/components/Composer.tsx | 1 + .../ui/components/ConfigInitDisplay.test.tsx | 11 +- .../src/ui/components/ConsentPrompt.test.tsx | 15 +- .../components/ConsoleSummaryDisplay.test.tsx | 6 +- .../components/ContextSummaryDisplay.test.tsx | 3 +- .../components/ContextUsageDisplay.test.tsx | 15 +- .../ui/components/CopyModeWarning.test.tsx | 6 +- .../src/ui/components/DebugProfiler.test.tsx | 12 +- .../DetailedMessagesDisplay.test.tsx | 99 +- .../ui/components/DetailedMessagesDisplay.tsx | 17 +- .../src/ui/components/DialogManager.test.tsx | 6 +- .../components/EditorSettingsDialog.test.tsx | 22 +- .../ui/components/EmptyWalletDialog.test.tsx | 33 +- .../ui/components/ExitPlanModeDialog.test.tsx | 120 +- .../src/ui/components/ExitWarning.test.tsx | 12 +- .../ui/components/FolderTrustDialog.test.tsx | 85 +- .../cli/src/ui/components/Footer.test.tsx | 748 +++--- .../ui/components/FooterConfigDialog.test.tsx | 29 +- .../GeminiRespondingSpinner.test.tsx | 21 +- .../ui/components/GradientRegression.test.tsx | 28 +- .../cli/src/ui/components/Header.test.tsx | 22 +- packages/cli/src/ui/components/Help.test.tsx | 9 +- .../ui/components/HistoryItemDisplay.test.tsx | 99 +- .../ui/components/HookStatusDisplay.test.tsx | 12 +- .../src/ui/components/HooksDialog.test.tsx | 56 +- .../components/IdeTrustChangeDialog.test.tsx | 18 +- .../src/ui/components/InputPrompt.test.tsx | 539 ++-- .../ui/components/LoadingIndicator.test.tsx | 56 +- .../LogoutConfirmationDialog.test.tsx | 15 +- .../LoopDetectionConfirmation.test.tsx | 6 +- .../src/ui/components/MainContent.test.tsx | 83 +- .../cli/src/ui/components/MainContent.tsx | 5 +- .../ui/components/MemoryUsageDisplay.test.tsx | 8 +- .../src/ui/components/ModelDialog.test.tsx | 12 +- .../cli/src/ui/components/ModelDialog.tsx | 112 +- .../ui/components/ModelStatsDisplay.test.tsx | 6 +- .../MultiFolderTrustDialog.test.tsx | 24 +- .../components/NewAgentsNotification.test.tsx | 8 +- .../src/ui/components/Notifications.test.tsx | 71 +- .../ui/components/OverageMenuDialog.test.tsx | 44 +- .../PermissionsModifyTrustDialog.test.tsx | 36 +- .../ui/components/PolicyUpdateDialog.test.tsx | 9 +- .../src/ui/components/ProQuotaDialog.test.tsx | 40 +- .../components/QueuedMessageDisplay.test.tsx | 15 +- .../ui/components/QuittingDisplay.test.tsx | 6 +- .../src/ui/components/QuotaDisplay.test.tsx | 27 +- .../components/RawMarkdownIndicator.test.tsx | 10 +- .../ui/components/RewindConfirmation.test.tsx | 12 +- .../src/ui/components/RewindViewer.test.tsx | 113 +- .../src/ui/components/SessionBrowser.test.tsx | 18 +- .../cli/src/ui/components/SessionBrowser.tsx | 90 +- .../SessionBrowser/SessionBrowserNav.tsx | 72 - .../SessionBrowserSearchNav.test.tsx | 56 - .../SessionBrowserStates.test.tsx | 9 +- .../SessionBrowser/SessionListHeader.tsx | 29 - .../SessionBrowserSearchNav.test.tsx.snap | 29 - .../components/SessionSummaryDisplay.test.tsx | 49 +- .../ui/components/SessionSummaryDisplay.tsx | 14 +- .../src/ui/components/SettingsDialog.test.tsx | 279 +- .../ui/components/ShellInputPrompt.test.tsx | 27 +- .../ui/components/ShellModeIndicator.test.tsx | 5 +- .../src/ui/components/ShortcutsHelp.test.tsx | 7 +- .../src/ui/components/ShowMoreLines.test.tsx | 15 +- .../components/ShowMoreLinesLayout.test.tsx | 6 +- .../src/ui/components/StatsDisplay.test.tsx | 57 +- .../src/ui/components/StatusDisplay.test.tsx | 3 +- .../src/ui/components/StickyHeader.test.tsx | 3 +- .../ui/components/SuggestionsDisplay.test.tsx | 21 +- packages/cli/src/ui/components/Table.test.tsx | 27 +- .../src/ui/components/ThemeDialog.test.tsx | 34 +- .../src/ui/components/ThemedGradient.test.tsx | 3 +- packages/cli/src/ui/components/Tips.test.tsx | 5 +- .../src/ui/components/ToastDisplay.test.tsx | 32 +- .../components/ToolConfirmationQueue.test.tsx | 56 +- .../ui/components/ToolStatsDisplay.test.tsx | 3 +- .../ui/components/UpdateNotification.test.tsx | 3 +- .../src/ui/components/UserIdentity.test.tsx | 25 +- .../ui/components/ValidationDialog.test.tsx | 24 +- ...r-Banner-handles-newlines-in-text.snap.svg | 20 - ...anner-Banner-renders-in-info-mode.snap.svg | 23 - ...ner-renders-in-multi-line-warning.snap.svg | 19 - ...er-Banner-renders-in-warning-mode.snap.svg | 13 - .../__snapshots__/Banner.test.tsx.snap | 17 +- .../__snapshots__/ChecklistItem.test.tsx.snap | 5 - .../DetailedMessagesDisplay.test.tsx.snap | 2 +- .../__snapshots__/MainContent.test.tsx.snap | 10 +- .../messages/CompressionMessage.test.tsx | 30 +- .../components/messages/DiffRenderer.test.tsx | 67 +- .../components/messages/ErrorMessage.test.tsx | 6 +- .../messages/GeminiMessage.test.tsx | 9 +- .../components/messages/InfoMessage.test.tsx | 11 +- .../messages/RedirectionConfirmation.test.tsx | 3 +- .../messages/ShellToolMessage.test.tsx | 118 +- .../components/messages/ShellToolMessage.tsx | 17 + .../messages/SubagentGroupDisplay.test.tsx | 127 - .../messages/SubagentGroupDisplay.tsx | 269 -- .../messages/SubagentProgressDisplay.test.tsx | 40 +- .../messages/SubagentProgressDisplay.tsx | 27 +- .../messages/ThinkingMessage.test.tsx | 16 +- .../src/ui/components/messages/Todo.test.tsx | 6 +- .../messages/ToolConfirmationMessage.test.tsx | 127 +- .../messages/ToolConfirmationMessage.tsx | 19 +- .../messages/ToolGroupMessage.test.tsx | 92 +- .../components/messages/ToolGroupMessage.tsx | 58 +- .../components/messages/ToolMessage.test.tsx | 89 +- .../messages/ToolMessageFocusHint.test.tsx | 9 +- .../messages/ToolMessageRawMarkdown.test.tsx | 11 +- .../ToolOverflowConsistencyChecks.test.tsx | 13 +- .../messages/ToolResultDisplay.test.tsx | 84 +- .../components/messages/ToolResultDisplay.tsx | 7 +- .../ToolResultDisplayOverflow.test.tsx | 18 +- .../components/messages/ToolShared.test.tsx | 15 +- .../ToolStickyHeaderRegression.test.tsx | 4 +- .../components/messages/UserMessage.test.tsx | 12 +- .../messages/WarningMessage.test.tsx | 6 +- .../SubagentGroupDisplay.test.tsx.snap | 9 - .../SubagentProgressDisplay.test.tsx.snap | 28 +- .../shared/BaseSelectionList.test.tsx | 8 +- .../shared/BaseSettingsDialog.test.tsx | 63 +- .../DescriptiveRadioButtonSelect.test.tsx | 2 +- .../components/shared/EnumSelector.test.tsx | 27 +- .../components/shared/ExpandableText.test.tsx | 35 +- .../shared/HalfLinePaddedBox.test.tsx | 12 +- .../ui/components/shared/MaxSizedBox.test.tsx | 26 +- .../shared/RadioButtonSelect.test.tsx | 14 +- .../ui/components/shared/Scrollable.test.tsx | 45 +- .../components/shared/ScrollableList.test.tsx | 333 ++- .../components/shared/SearchableList.test.tsx | 36 +- .../components/shared/SectionHeader.test.tsx | 3 +- .../shared/SlicingMaxSizedBox.test.tsx | 15 +- .../components/shared/SlicingMaxSizedBox.tsx | 4 +- .../ui/components/shared/TabHeader.test.tsx | 39 +- .../ui/components/shared/TextInput.test.tsx | 39 +- .../shared/VirtualizedList.test.tsx | 31 +- .../ui/components/shared/performance.test.ts | 8 +- .../ui/components/shared/text-buffer.test.ts | 538 ++-- .../src/ui/components/views/ChatList.test.tsx | 13 +- .../views/ExtensionDetails.test.tsx | 81 +- .../ui/components/views/ExtensionDetails.tsx | 27 +- .../views/ExtensionRegistryView.test.tsx | 56 +- .../views/ExtensionRegistryView.tsx | 24 - .../components/views/ExtensionsList.test.tsx | 15 +- .../ui/components/views/McpStatus.test.tsx | 52 +- .../ui/components/views/SkillsList.test.tsx | 18 +- .../ui/components/views/ToolsList.test.tsx | 9 +- packages/cli/src/ui/constants/tips.ts | 1 + .../src/ui/contexts/KeypressContext.test.tsx | 206 +- .../cli/src/ui/contexts/KeypressContext.tsx | 19 +- .../cli/src/ui/contexts/MouseContext.test.tsx | 63 +- packages/cli/src/ui/contexts/MouseContext.tsx | 14 +- .../ui/contexts/ScrollProvider.drag.test.tsx | 12 +- .../src/ui/contexts/ScrollProvider.test.tsx | 22 +- .../src/ui/contexts/SessionContext.test.tsx | 20 +- .../src/ui/contexts/SettingsContext.test.tsx | 20 +- .../src/ui/contexts/TerminalContext.test.tsx | 6 +- .../ui/contexts/ToolActionsContext.test.tsx | 40 +- .../cli/src/ui/contexts/UIStateContext.tsx | 2 + .../ui/hooks/shellCommandProcessor.test.tsx | 74 +- .../ui/hooks/slashCommandProcessor.test.tsx | 4 +- .../cli/src/ui/hooks/slashCommandProcessor.ts | 2 +- .../src/ui/hooks/useAlternateBuffer.test.ts | 12 +- .../ui/hooks/useAnimatedScrollbar.test.tsx | 26 +- .../ui/hooks/useApprovalModeIndicator.test.ts | 84 +- .../cli/src/ui/hooks/useAtCompletion.test.ts | 122 +- .../ui/hooks/useAtCompletion_agents.test.ts | 4 +- .../hooks/useBackgroundShellManager.test.tsx | 28 +- packages/cli/src/ui/hooks/useBanner.test.ts | 20 +- .../cli/src/ui/hooks/useBatchedScroll.test.ts | 28 +- .../ui/hooks/useCommandCompletion.test.tsx | 85 +- .../src/ui/hooks/useConsoleMessages.test.tsx | 24 +- .../cli/src/ui/hooks/useConsoleMessages.ts | 44 - .../src/ui/hooks/useEditorSettings.test.tsx | 40 +- .../src/ui/hooks/useExtensionUpdates.test.tsx | 8 +- .../src/ui/hooks/useFlickerDetector.test.ts | 26 +- packages/cli/src/ui/hooks/useFocus.test.tsx | 39 +- .../cli/src/ui/hooks/useFolderTrust.test.ts | 40 +- .../cli/src/ui/hooks/useGeminiStream.test.tsx | 144 +- packages/cli/src/ui/hooks/useGeminiStream.ts | 70 +- .../src/ui/hooks/useGitBranchName.test.tsx | 143 +- .../src/ui/hooks/useHistoryManager.test.ts | 44 +- .../src/ui/hooks/useHookDisplayState.test.ts | 24 +- .../src/ui/hooks/useIdeTrustListener.test.tsx | 29 +- .../src/ui/hooks/useIncludeDirsTrust.test.tsx | 18 +- .../src/ui/hooks/useInlineEditBuffer.test.ts | 36 +- .../cli/src/ui/hooks/useInputHistory.test.ts | 56 +- .../src/ui/hooks/useInputHistoryStore.test.ts | 40 +- .../cli/src/ui/hooks/useKeypress.test.tsx | 65 +- .../src/ui/hooks/useLoadingIndicator.test.tsx | 36 +- packages/cli/src/ui/hooks/useLogger.test.tsx | 33 +- .../cli/src/ui/hooks/useMcpStatus.test.tsx | 20 +- .../src/ui/hooks/useMemoryMonitor.test.tsx | 12 +- .../cli/src/ui/hooks/useMessageQueue.test.tsx | 58 +- .../cli/src/ui/hooks/useModelCommand.test.tsx | 12 +- packages/cli/src/ui/hooks/useMouse.test.ts | 30 +- .../cli/src/ui/hooks/useMouseClick.test.ts | 4 +- .../hooks/usePermissionsModifyTrust.test.ts | 34 +- .../cli/src/ui/hooks/usePhraseCycler.test.tsx | 41 +- .../src/ui/hooks/usePrivacySettings.test.tsx | 34 +- .../src/ui/hooks/useQuotaAndFallback.test.ts | 62 +- .../hooks/useReverseSearchCompletion.test.tsx | 40 +- packages/cli/src/ui/hooks/useRewind.test.ts | 20 +- .../src/ui/hooks/useSelectionList.test.tsx | 6 +- .../src/ui/hooks/useSessionBrowser.test.ts | 6 +- .../cli/src/ui/hooks/useSessionResume.test.ts | 46 +- .../ui/hooks/useSettingsNavigation.test.ts | 32 +- .../cli/src/ui/hooks/useShellHistory.test.ts | 14 +- .../ui/hooks/useShellInactivityStatus.test.ts | 14 +- .../src/ui/hooks/useSlashCompletion.test.ts | 535 ++-- .../cli/src/ui/hooks/useSnowfall.test.tsx | 61 +- packages/cli/src/ui/hooks/useSuspend.test.ts | 12 +- .../src/ui/hooks/useTabbedNavigation.test.ts | 104 +- .../src/ui/hooks/useTerminalTheme.test.tsx | 33 +- packages/cli/src/ui/hooks/useTimer.test.tsx | 36 +- packages/cli/src/ui/hooks/useTips.test.ts | 12 +- .../cli/src/ui/hooks/useToolScheduler.test.ts | 42 +- .../ui/hooks/useTurnActivityMonitor.test.ts | 19 +- .../cli/src/ui/hooks/vim-passthrough.test.tsx | 4 +- packages/cli/src/ui/hooks/vim.test.tsx | 498 ++-- .../src/ui/layouts/DefaultAppLayout.test.tsx | 9 +- .../privacy/CloudFreePrivacyNotice.test.tsx | 9 +- .../privacy/CloudPaidPrivacyNotice.test.tsx | 6 +- .../ui/privacy/GeminiPrivacyNotice.test.tsx | 6 +- .../cli/src/ui/privacy/PrivacyNotice.test.tsx | 3 +- .../cli/src/ui/utils/CodeColorizer.test.tsx | 7 +- .../cli/src/ui/utils/MarkdownDisplay.test.tsx | 48 +- .../cli/src/ui/utils/TableRenderer.test.tsx | 71 +- .../cli/src/ui/utils/borderStyles.test.tsx | 17 +- .../cli/src/ui/utils/toolLayoutUtils.test.ts | 208 -- packages/cli/src/ui/utils/toolLayoutUtils.ts | 18 +- .../cli/src/utils/handleAutoUpdate.test.ts | 7 +- packages/cli/src/utils/handleAutoUpdate.ts | 9 +- .../cli/src/utils/installationInfo.test.ts | 13 - packages/cli/src/utils/installationInfo.ts | 11 - .../utils/sessionCleanup.integration.test.ts | 150 -- packages/cli/src/utils/sessionCleanup.test.ts | 2347 +++++++++++------ packages/cli/src/utils/sessionCleanup.ts | 220 +- packages/cli/src/utils/worktreeSetup.test.ts | 124 - packages/cli/src/utils/worktreeSetup.ts | 43 - packages/core/package.json | 1 - .../core/scripts/compile-windows-sandbox.js | 121 - packages/core/src/agent/agent-session.test.ts | 279 -- packages/core/src/agent/agent-session.ts | 212 -- packages/core/src/agent/content-utils.test.ts | 258 -- packages/core/src/agent/content-utils.ts | 139 - packages/core/src/agent/mock.test.ts | 278 +- packages/core/src/agent/mock.ts | 304 ++- packages/core/src/agent/types.ts | 44 +- .../src/agents/a2a-client-manager.test.ts | 17 +- .../core/src/agents/a2a-client-manager.ts | 25 +- .../core/src/agents/agent-scheduler.test.ts | 6 - packages/core/src/agents/agent-scheduler.ts | 11 +- .../agents/browser/browserAgentDefinition.ts | 12 +- .../browser/browserAgentFactory.test.ts | 2 - .../browser/browserAgentInvocation.test.ts | 48 - .../agents/browser/browserAgentInvocation.ts | 137 +- .../src/agents/browser/browserManager.test.ts | 44 - .../core/src/agents/browser/browserManager.ts | 16 - .../core/src/agents/browser/mcpToolWrapper.ts | 22 +- .../mcpToolWrapperConfirmation.test.ts | 6 +- .../core/src/agents/local-executor.test.ts | 461 +--- packages/core/src/agents/local-executor.ts | 213 +- .../core/src/agents/local-invocation.test.ts | 119 +- packages/core/src/agents/local-invocation.ts | 83 +- .../src/agents/memory-manager-agent.test.ts | 153 -- .../core/src/agents/memory-manager-agent.ts | 156 -- packages/core/src/agents/registry.test.ts | 49 +- packages/core/src/agents/registry.ts | 30 +- .../core/src/agents/remote-invocation.test.ts | 104 +- packages/core/src/agents/remote-invocation.ts | 18 +- .../core/src/agents/subagent-tool-wrapper.ts | 1 - packages/core/src/agents/types.ts | 14 - .../src/availability/policyHelpers.test.ts | 62 - .../core/src/availability/policyHelpers.ts | 55 - .../code_assist/admin/admin_controls.test.ts | 83 - .../src/code_assist/admin/admin_controls.ts | 11 - .../src/code_assist/admin/mcpUtils.test.ts | 148 +- .../core/src/code_assist/admin/mcpUtils.ts | 58 +- packages/core/src/code_assist/types.ts | 35 - .../core/src/config/agent-loop-context.ts | 8 - packages/core/src/config/config.test.ts | 31 +- packages/core/src/config/config.ts | 123 +- .../core/src/config/defaultModelConfigs.ts | 146 +- packages/core/src/config/models.test.ts | 8 + packages/core/src/config/models.ts | 15 +- .../core/src/config/path-validation.test.ts | 68 - .../core/__snapshots__/prompts.test.ts.snap | 142 +- packages/core/src/core/client.test.ts | 19 +- packages/core/src/core/client.ts | 6 - .../core/src/core/contentGenerator.test.ts | 141 +- packages/core/src/core/contentGenerator.ts | 46 +- packages/core/src/core/prompts.test.ts | 15 - packages/core/src/hooks/hookAggregator.ts | 1 - .../core/src/ide/ide-connection-utils.test.ts | 10 - packages/core/src/ide/ide-connection-utils.ts | 9 +- packages/core/src/index.ts | 4 - packages/core/src/policy/config.test.ts | 28 - packages/core/src/policy/config.ts | 2 - .../src/policy/memory-manager-policy.test.ts | 119 - .../src/policy/policies/memory-manager.toml | 10 - packages/core/src/policy/policies/plan.toml | 14 - packages/core/src/policy/policies/yolo.toml | 1 - .../core/src/policy/policy-engine.test.ts | 122 +- packages/core/src/policy/policy-engine.ts | 14 - .../core/src/policy/policy-updater.test.ts | 62 - packages/core/src/policy/toml-loader.ts | 2 - packages/core/src/policy/types.ts | 13 - .../core/src/prompts/promptProvider.test.ts | 1 - packages/core/src/prompts/promptProvider.ts | 4 +- .../prompts/snippets-memory-manager.test.ts | 34 - packages/core/src/prompts/snippets.legacy.ts | 39 +- packages/core/src/prompts/snippets.ts | 12 +- .../MacOsSandboxManager.integration.test.ts | 202 -- .../sandbox/macos/MacOsSandboxManager.test.ts | 107 - .../src/sandbox/macos/MacOsSandboxManager.ts | 60 - .../core/src/sandbox/macos/baseProfile.ts | 94 - .../sandbox/macos/seatbeltArgsBuilder.test.ts | 97 - .../src/sandbox/macos/seatbeltArgsBuilder.ts | 80 - packages/core/src/scheduler/scheduler.ts | 2 - .../src/services/chatCompressionService.ts | 2 - .../core/src/services/contextManager.test.ts | 2 +- packages/core/src/services/contextManager.ts | 8 +- .../core/src/services/loopDetectionService.ts | 5 - .../core/src/services/modelConfigService.ts | 25 - .../core/src/services/sandboxManager.test.ts | 46 +- packages/core/src/services/sandboxManager.ts | 20 +- .../src/services/sandboxManagerFactory.ts | 45 - .../sandboxedFileSystemService.test.ts | 133 - .../services/sandboxedFileSystemService.ts | 128 - .../src/services/scripts/GeminiSandbox.cs | 370 --- .../src/services/shellExecutionService.ts | 209 +- packages/core/src/services/trackerTypes.ts | 1 - .../services/windowsSandboxManager.test.ts | 68 - .../src/services/windowsSandboxManager.ts | 228 -- .../core/src/services/worktreeService.test.ts | 311 --- packages/core/src/services/worktreeService.ts | 225 -- .../clearcut-logger/clearcut-logger.ts | 5 - .../clearcut-logger/event-metadata-key.ts | 3 - packages/core/src/telemetry/loggers.test.ts | 108 +- packages/core/src/telemetry/semantic.ts | 2 - packages/core/src/telemetry/types.ts | 3 - .../core/src/test-utils/mock-message-bus.ts | 5 +- .../src/test-utils/mockWorkspaceContext.ts | 1 - .../coreToolsModelSnapshots.test.ts.snap | 4 - .../model-family-sets/default-legacy.ts | 9 +- .../definitions/model-family-sets/gemini-3.ts | 9 +- .../core/src/tools/exit-plan-mode.test.ts | 36 +- packages/core/src/tools/exit-plan-mode.ts | 34 +- packages/core/src/tools/mcp-tool.ts | 2 +- packages/core/src/tools/tools.ts | 7 +- packages/core/src/tools/trackerTools.test.ts | 10 +- packages/core/src/tools/trackerTools.ts | 8 +- packages/core/src/tools/write-todos.test.ts | 5 +- packages/core/src/tools/write-todos.ts | 1 - .../utils/agent-sanitization-utils.test.ts | 103 - .../src/utils/agent-sanitization-utils.ts | 154 -- .../core/src/utils/browserConsent.test.ts | 117 - packages/core/src/utils/browserConsent.ts | 94 - packages/core/src/utils/editCorrector.ts | 1 - packages/core/src/utils/googleErrors.ts | 2 +- .../core/src/utils/memoryDiscovery.test.ts | 164 +- packages/core/src/utils/memoryDiscovery.ts | 42 +- .../core/src/utils/memoryImportProcessor.ts | 10 +- packages/core/src/utils/oauth-flow.ts | 6 +- packages/core/src/utils/surface.ts | 7 +- packages/core/src/utils/toolCallContext.ts | 2 - packages/extensions/ux-extension/GEMINI.md | 25 + packages/extensions/ux-extension/WELCOME.md | 40 + .../ux-extension/commands/ux-help.toml | 4 + .../ux-extension/gemini-extension.json | 13 + .../ux-extension/skills/_ux_designer/SKILL.md | 40 + .../_ux_designer/references/components.md | 46 + .../skills/_ux_finish-pr/SKILL.md | 37 + .../skills/_ux_git-worktree/SKILL.md | 56 + .../references/architecture.md | 24 + .../scripts/worktree-manager.sh | 69 + .../skills/_ux_string-reviewer/SKILL.md | 99 + .../references/settings.md | 28 + .../references/word-list.md | 61 + schemas/settings.schema.json | 520 +--- scripts/copy_files.js | 2 +- 575 files changed, 11311 insertions(+), 19877 deletions(-) delete mode 100644 .geminiignore delete mode 100644 docs/cli/git-worktrees.md delete mode 100644 integration-tests/browser-policy.responses delete mode 100644 integration-tests/browser-policy.test.ts delete mode 100644 packages/cli/src/config/extension-manager-permissions.test.ts delete mode 100644 packages/cli/src/ui/components/SessionBrowser/SessionBrowserNav.tsx delete mode 100644 packages/cli/src/ui/components/SessionBrowser/SessionBrowserSearchNav.test.tsx delete mode 100644 packages/cli/src/ui/components/SessionBrowser/SessionListHeader.tsx delete mode 100644 packages/cli/src/ui/components/SessionBrowser/__snapshots__/SessionBrowserSearchNav.test.tsx.snap delete mode 100644 packages/cli/src/ui/components/__snapshots__/Banner-Banner-handles-newlines-in-text.snap.svg delete mode 100644 packages/cli/src/ui/components/__snapshots__/Banner-Banner-renders-in-info-mode.snap.svg delete mode 100644 packages/cli/src/ui/components/__snapshots__/Banner-Banner-renders-in-multi-line-warning.snap.svg delete mode 100644 packages/cli/src/ui/components/__snapshots__/Banner-Banner-renders-in-warning-mode.snap.svg delete mode 100644 packages/cli/src/ui/components/messages/SubagentGroupDisplay.test.tsx delete mode 100644 packages/cli/src/ui/components/messages/SubagentGroupDisplay.tsx delete mode 100644 packages/cli/src/ui/components/messages/__snapshots__/SubagentGroupDisplay.test.tsx.snap delete mode 100644 packages/cli/src/ui/utils/toolLayoutUtils.test.ts delete mode 100644 packages/cli/src/utils/worktreeSetup.test.ts delete mode 100644 packages/cli/src/utils/worktreeSetup.ts delete mode 100644 packages/core/scripts/compile-windows-sandbox.js delete mode 100644 packages/core/src/agent/agent-session.test.ts delete mode 100644 packages/core/src/agent/agent-session.ts delete mode 100644 packages/core/src/agent/content-utils.test.ts delete mode 100644 packages/core/src/agent/content-utils.ts delete mode 100644 packages/core/src/agents/memory-manager-agent.test.ts delete mode 100644 packages/core/src/agents/memory-manager-agent.ts delete mode 100644 packages/core/src/config/path-validation.test.ts delete mode 100644 packages/core/src/policy/memory-manager-policy.test.ts delete mode 100644 packages/core/src/policy/policies/memory-manager.toml delete mode 100644 packages/core/src/prompts/snippets-memory-manager.test.ts delete mode 100644 packages/core/src/sandbox/macos/MacOsSandboxManager.integration.test.ts delete mode 100644 packages/core/src/sandbox/macos/MacOsSandboxManager.test.ts delete mode 100644 packages/core/src/sandbox/macos/MacOsSandboxManager.ts delete mode 100644 packages/core/src/sandbox/macos/baseProfile.ts delete mode 100644 packages/core/src/sandbox/macos/seatbeltArgsBuilder.test.ts delete mode 100644 packages/core/src/sandbox/macos/seatbeltArgsBuilder.ts delete mode 100644 packages/core/src/services/sandboxManagerFactory.ts delete mode 100644 packages/core/src/services/sandboxedFileSystemService.test.ts delete mode 100644 packages/core/src/services/sandboxedFileSystemService.ts delete mode 100644 packages/core/src/services/scripts/GeminiSandbox.cs delete mode 100644 packages/core/src/services/windowsSandboxManager.test.ts delete mode 100644 packages/core/src/services/windowsSandboxManager.ts delete mode 100644 packages/core/src/services/worktreeService.test.ts delete mode 100644 packages/core/src/services/worktreeService.ts delete mode 100644 packages/core/src/utils/agent-sanitization-utils.test.ts delete mode 100644 packages/core/src/utils/agent-sanitization-utils.ts delete mode 100644 packages/core/src/utils/browserConsent.test.ts delete mode 100644 packages/core/src/utils/browserConsent.ts create mode 100644 packages/extensions/ux-extension/GEMINI.md create mode 100644 packages/extensions/ux-extension/WELCOME.md create mode 100644 packages/extensions/ux-extension/commands/ux-help.toml create mode 100644 packages/extensions/ux-extension/gemini-extension.json create mode 100644 packages/extensions/ux-extension/skills/_ux_designer/SKILL.md create mode 100644 packages/extensions/ux-extension/skills/_ux_designer/references/components.md create mode 100644 packages/extensions/ux-extension/skills/_ux_finish-pr/SKILL.md create mode 100644 packages/extensions/ux-extension/skills/_ux_git-worktree/SKILL.md create mode 100644 packages/extensions/ux-extension/skills/_ux_git-worktree/references/architecture.md create mode 100755 packages/extensions/ux-extension/skills/_ux_git-worktree/scripts/worktree-manager.sh create mode 100644 packages/extensions/ux-extension/skills/_ux_string-reviewer/SKILL.md create mode 100644 packages/extensions/ux-extension/skills/_ux_string-reviewer/references/settings.md create mode 100644 packages/extensions/ux-extension/skills/_ux_string-reviewer/references/word-list.md diff --git a/.gemini/settings.json b/.gemini/settings.json index 9051dc78de..1a4c889066 100644 --- a/.gemini/settings.json +++ b/.gemini/settings.json @@ -2,8 +2,7 @@ "experimental": { "plan": true, "extensionReloading": true, - "modelSteering": true, - "memoryManager": true + "modelSteering": true }, "general": { "devtools": true diff --git a/.gemini/skills/docs-writer/SKILL.md b/.gemini/skills/docs-writer/SKILL.md index 6d9788a3b0..d7cf7b81be 100644 --- a/.gemini/skills/docs-writer/SKILL.md +++ b/.gemini/skills/docs-writer/SKILL.md @@ -71,44 +71,12 @@ accessible. tables). - **Media:** Use lowercase hyphenated filenames. Provide descriptive alt text for all images. -- **Details section:** Use the `
` tag to create a collapsible section. - This is useful for supplementary or data-heavy information that isn't critical - to the main flow. - - Example: - -
- Title - - - First entry - - Second entry - -
- -- **Callouts**: Use GitHub-flavored markdown alerts to highlight important - information. To ensure the formatting is preserved by `npm run format`, place - an empty line, then the `` comment directly before - the callout block. The callout type (`[!TYPE]`) should be on the first line, - followed by a newline, and then the content, with each subsequent line of - content starting with `>`. Available types are `NOTE`, `TIP`, `IMPORTANT`, - `WARNING`, and `CAUTION`. - - Example: - - -> [!NOTE] -> This is an example of a multi-line note that will be preserved -> by Prettier. ### Structure - **BLUF:** Start with an introduction explaining what to expect. - **Experimental features:** If a feature is clearly noted as experimental, - add the following note immediately after the introductory paragraph: - - -> [!NOTE] -> This is an experimental feature currently under active development. - +add the following note immediately after the introductory paragraph: + `> **Note:** This is a preview feature currently under active development.` - **Headings:** Use hierarchical headings to support the user journey. - **Procedures:** - Introduce lists of steps with a complete sentence. @@ -117,7 +85,8 @@ accessible. - Put conditions before instructions (e.g., "On the Settings page, click..."). - Provide clear context for where the action takes place. - Indicate optional steps clearly (e.g., "Optional: ..."). -- **Elements:** Use bullet lists, tables, details, and callouts. +- **Elements:** Use bullet lists, tables, notes (`> **Note:**`), and warnings + (`> **Warning:**`). - **Avoid using a table of contents:** If a table of contents is present, remove it. - **Next steps:** Conclude with a "Next steps" section if applicable. diff --git a/.geminiignore b/.geminiignore deleted file mode 100644 index e40b6ba36e..0000000000 --- a/.geminiignore +++ /dev/null @@ -1 +0,0 @@ -packages/core/src/services/scripts/*.exe diff --git a/.github/ISSUE_TEMPLATE/website_issue.yml b/.github/ISSUE_TEMPLATE/website_issue.yml index d9b30e1127..02146381ab 100644 --- a/.github/ISSUE_TEMPLATE/website_issue.yml +++ b/.github/ISSUE_TEMPLATE/website_issue.yml @@ -1,9 +1,7 @@ name: 'Website issue' description: 'Report an issue with the Gemini CLI Website and Gemini CLI Extensions Gallery' -title: 'GeminiCLI.com Feedback: [ISSUE]' labels: - 'area/extensions' - - 'area/documentation' body: - type: 'markdown' attributes: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c6c619219c..c71fbe2e22 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -352,6 +352,21 @@ npm run lint - **Imports:** Pay special attention to import paths. The project uses ESLint to enforce restrictions on relative imports between packages. +### Project structure + +- `packages/`: Contains the individual sub-packages of the project. + - `a2a-server`: A2A server implementation for the Gemini CLI. (Experimental) + - `cli/`: The command-line interface. + - `core/`: The core backend logic for the Gemini CLI. + - `test-utils` Utilities for creating and cleaning temporary file systems for + testing. + - `vscode-ide-companion/`: The Gemini CLI Companion extension pairs with + Gemini CLI. +- `docs/`: Contains all project documentation. +- `scripts/`: Utility scripts for building, testing, and development tasks. + +For more detailed architecture, see `docs/architecture.md`. + ### Debugging #### VS Code diff --git a/GEMINI.md b/GEMINI.md index c08e486b22..6539916a35 100644 --- a/GEMINI.md +++ b/GEMINI.md @@ -4,86 +4,43 @@ Gemini CLI is an open-source AI agent that brings the power of Gemini directly into the terminal. It is designed to be a terminal-first, extensible, and powerful tool for developers. -## Project Overview +## 🚨 Directive for AI Agents (Read First) -- **Purpose:** Provide a seamless terminal interface for Gemini models, - supporting code understanding, generation, automation, and integration via MCP - (Model Context Protocol). -- **Main Technologies:** - - **Runtime:** Node.js (>=20.0.0, recommended ~20.19.0 for development) - - **Language:** TypeScript - - **UI Framework:** React (using [Ink](https://github.com/vadimdemedes/ink) - for CLI rendering) - - **Testing:** Vitest - - **Bundling:** esbuild - - **Linting/Formatting:** ESLint, Prettier -- **Architecture:** Monorepo structure using npm workspaces. - - `packages/cli`: User-facing terminal UI, input processing, and display - rendering. - - `packages/core`: Backend logic, Gemini API orchestration, prompt - construction, and tool execution. - - `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. +**This branch requires the UX Extension.** If you are an AI agent operating +here, you MUST perform these onboarding steps before fulfilling any other user +request: -## Building and Running +1. **Proactive Cleanup**: Check `~/.gemini/skills/` for `_ux_git-worktree` or + `_ux_finish-pr`. If found, inform the user they are using obsolete manual + versions and **immediately offer to delete them**. +2. **Mandatory Global Setup**: Check if the **UX Extension** is installed. If + it is missing, you MUST stop and explain: "I've detected you're in the UX + Extension branch but don't have the extension installed globally." Then, + **ask for permission to run**: + `gemini extensions install ./packages/extensions/ux-extension` +3. **Inherit Standards**: Once the extension is active, you MUST strictly + adhere to the standards in `packages/extensions/ux-extension/GEMINI.md` for + all work in this repository. -- **Install Dependencies:** `npm install` -- **Build All:** `npm run build:all` (Builds packages, sandbox, and VS Code - companion) -- **Build Packages:** `npm run build` -- **Run in Development:** `npm run start` -- **Run in Debug Mode:** `npm run debug` (Enables Node.js inspector) -- **Bundle Project:** `npm run bundle` -- **Clean Artifacts:** `npm run clean` +## 📦 UX Extension -## Testing and Quality +The AI DevTools UX team maintains a specialized toolset for this repository. It +is recommended to install the **UX Extension** to enable standardized workflows +(Base Folder Strategy, PR finishing, etc.). -- **Test Commands:** - - **Unit (All):** `npm run test` - - **Integration (E2E):** `npm run test:e2e` - - **Workspace-Specific:** `npm test -w -- ` (Note: `` must - be relative to the workspace root, e.g., - `-w @google/gemini-cli-core -- src/routing/modelRouterService.test.ts`) -- **Full Validation:** `npm run preflight` (Heaviest check; runs clean, install, - build, lint, type check, and tests. Recommended before submitting PRs. Due to - its long runtime, only run this at the very end of a code implementation task. - If it fails, use faster, targeted commands (e.g., `npm run test`, - `npm run lint`, or workspace-specific tests) to iterate on fixes before - re-running `preflight`. For simple, non-code changes like documentation or - prompting updates, skip `preflight` at the end of the task and wait for PR - validation.) -- **Individual Checks:** `npm run lint` / `npm run format` / `npm run typecheck` +### Installation -## Development Conventions +```bash +gemini extensions install ./packages/extensions/ux-extension +``` -- **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. - Always activate the `pr-creator` skill for PR generation, even when using the - `gh` CLI. -- **Commit Messages:** Follow the - [Conventional Commits](https://www.conventionalcommits.org/) standard. -- **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`), - include the Apache-2.0 license header with the current year. (e.g., - `Copyright 2026 Google LLC`). This is enforced by ESLint. +After installation, run `/_ux_help` to see available commands. -## Testing Conventions +## 🤝 Team Contributions -- **Environment Variables:** When testing code that depends on environment - variables, use `vi.stubEnv('NAME', 'value')` in `beforeEach` and - `vi.unstubAllEnvs()` in `afterEach`. Avoid modifying `process.env` directly as - it can lead to test leakage and is less reliable. To "unset" a variable, use - an empty string `vi.stubEnv('NAME', '')`. - -## Documentation - -- Always use the `docs-writer` skill when you are asked to write, edit, or - review any documentation. -- Documentation is located in the `docs/` directory. -- Suggest documentation updates when code changes render existing documentation - obsolete or incomplete. +- Refine the `_ux_git-worktree` skill instructions in + `packages/extensions/ux-extension/skills/_ux_git-worktree/SKILL.md`. +- Refine the `_ux_finish-pr` skill instructions in + `packages/extensions/ux-extension/skills/_ux_finish-pr/SKILL.md`. +- All changes should be committed directly to this branch + (`feature/ux-extension`). diff --git a/README.md b/README.md index 03a7be1296..1d516936c6 100644 --- a/README.md +++ b/README.md @@ -115,6 +115,16 @@ npm install -g @google/gemini-cli@nightly ### Automation & Integration +- **UX Extension Extension**: This branch introduces a formal extension for the + AI DevTools UX team. Install it to enable specialized workflows: \`gemini + extensions install ./packages/extensions/ux-extension\` + - **\_ux_git-worktree**: Manage Git Worktrees using the "Base Folder + Strategy". + - **\_ux_finish-pr**: Co-author assistant for authors to cross the finish line + with UX polish and CI fixes. + - **\_ux_designer**: Lead UX Designer expert to review React/Ink components + against the v1.0 Design Principles (Density, Progressive Disclosure, State). + - **\_ux_help**: Show the welcome guide and documentation. - Automate operational tasks like querying pull requests or handling complex rebases - Use MCP servers to connect new capabilities, including @@ -314,6 +324,7 @@ gemini - [**Headless Mode (Scripting)**](./docs/cli/headless.md) - Use Gemini CLI in automated workflows. +- [**Architecture Overview**](./docs/architecture.md) - How Gemini CLI works. - [**IDE Integration**](./docs/ide-integration/index.md) - VS Code companion. - [**Sandboxing & Security**](./docs/cli/sandbox.md) - Safe execution environments. diff --git a/docs/admin/enterprise-controls.md b/docs/admin/enterprise-controls.md index 5792a6c5bc..8c9ba60a13 100644 --- a/docs/admin/enterprise-controls.md +++ b/docs/admin/enterprise-controls.md @@ -106,67 +106,6 @@ organization. ensures users maintain final control over which permitted servers are actually active in their environment. -#### Required MCP Servers (preview) - -**Default**: empty - -Allows administrators to define MCP servers that are **always injected** into -the user's environment. Unlike the allowlist (which filters user-configured -servers), required servers are automatically added regardless of the user's -local configuration. - -**Required Servers Format:** - -```json -{ - "requiredMcpServers": { - "corp-compliance-tool": { - "url": "https://mcp.corp/compliance", - "type": "http", - "trust": true, - "description": "Corporate compliance tool" - }, - "internal-registry": { - "url": "https://registry.corp/mcp", - "type": "sse", - "authProviderType": "google_credentials", - "oauth": { - "scopes": ["https://www.googleapis.com/auth/scope"] - } - } - } -} -``` - -**Supported Fields:** - -- `url`: (Required) The full URL of the MCP server endpoint. -- `type`: (Required) The connection type (`sse` or `http`). -- `trust`: (Optional) If set to `true`, tool execution will not require user - approval. Defaults to `true` for required servers. -- `description`: (Optional) Human-readable description of the server. -- `authProviderType`: (Optional) Authentication provider (`dynamic_discovery`, - `google_credentials`, or `service_account_impersonation`). -- `oauth`: (Optional) OAuth configuration including `scopes`, `clientId`, and - `clientSecret`. -- `targetAudience`: (Optional) OAuth target audience for service-to-service - auth. -- `targetServiceAccount`: (Optional) Service account email to impersonate. -- `headers`: (Optional) Additional HTTP headers to send with requests. -- `includeTools` / `excludeTools`: (Optional) Tool filtering lists. -- `timeout`: (Optional) Timeout in milliseconds for MCP requests. - -**Client Enforcement Logic:** - -- Required servers are injected **after** allowlist filtering, so they are - always available even if the allowlist is active. -- If a required server has the **same name** as a locally configured server, the - admin configuration **completely overrides** the local one. -- Required servers only support remote transports (`sse`, `http`). Local - execution fields (`command`, `args`, `env`, `cwd`) are not supported. -- Required servers can coexist with allowlisted servers — both features work - independently. - ### Unmanaged Capabilities **Enabled/Disabled** | Default: disabled diff --git a/docs/changelogs/index.md b/docs/changelogs/index.md index d79bd910d1..84b499c7a6 100644 --- a/docs/changelogs/index.md +++ b/docs/changelogs/index.md @@ -18,17 +18,6 @@ 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 diff --git a/docs/changelogs/latest.md b/docs/changelogs/latest.md index e49ef1c652..9b0724e2a9 100644 --- a/docs/changelogs/latest.md +++ b/docs/changelogs/latest.md @@ -1,6 +1,6 @@ -# Latest stable release: v0.34.0 +# Latest stable release: v0.33.2 -Released: March 17, 2026 +Released: March 16, 2026 For most users, our latest stable release is the recommended release. Install the latest stable version with: @@ -11,474 +11,227 @@ npm install -g @google/gemini-cli ## Highlights -- **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. +- **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. ## What's Changed -- 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 +- fix(patch): cherry-pick 48130eb to release/v0.33.1-pr-22665 [CONFLICTS] by @gemini-cli-robot in - [#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 + [#22720](https://github.com/google-gemini/gemini-cli/pull/22720) +- 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 @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 + [#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 @aishaneeshah in - [#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 + [#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 by @skeshive in - [#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 + [#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 @gemini-cli-robot in - [#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 + [#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 [CONFLICTS] by @gemini-cli-robot in - [#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 + [#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 @gemini-cli-robot in - [#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 + [#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 @gemini-cli-robot in - [#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 + [#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 @gemini-cli-robot in - [#22719](https://github.com/google-gemini/gemini-cli/pull/22719) + [#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) **Full Changelog**: -https://github.com/google-gemini/gemini-cli/compare/v0.33.2...v0.34.0 +https://github.com/google-gemini/gemini-cli/compare/v0.32.1...v0.33.2 diff --git a/docs/changelogs/preview.md b/docs/changelogs/preview.md index 39e1e0a2ed..370ee8010a 100644 --- a/docs/changelogs/preview.md +++ b/docs/changelogs/preview.md @@ -1,6 +1,6 @@ -# Preview release: v0.35.0-preview.2 +# Preview release: v0.34.0-preview.4 -Released: March 19, 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). @@ -13,368 +13,471 @@ npm install -g @google/gemini-cli@preview ## Highlights -- **Subagents & Architecture Enhancements**: Enabled subagents and laid the - foundation for subagent tool isolation. Added proxy routing support for remote - A2A subagents and integrated `SandboxManager` to sandbox all process-spawning - tools. -- **CLI & UI Improvements**: Introduced customizable keyboard shortcuts and - support for literal character keybindings. Added missing vim mode motions and - CJK input support. Enabled code splitting and deferred UI loading for improved - performance. -- **Context & Tools Optimization**: JIT context loading is now enabled by - default with deduplication for project memory. Introduced a model-driven - parallel tool scheduler and allowed safe tools to execute concurrently. -- **Security & Extensions**: Implemented cryptographic integrity verification - for extension updates and added a `disableAlwaysAllow` setting to prevent - auto-approvals for enhanced security. -- **Plan Mode & Web Fetch Updates**: Added an 'All the above' option for - multi-select AskUser questions in Plan Mode. Rolled out Stage 1 and Stage 2 - security and consistency improvements for the `web_fetch` tool. +- **Plan Mode Enabled by Default:** Plan Mode is now enabled out-of-the-box, + providing a structured planning workflow and keeping approved plans during + chat compression. +- **Sandboxing Enhancements:** Added experimental LXC container sandbox support + and native gVisor (`runsc`) sandboxing for improved security and isolation. +- **Tracker Visualization and Tools:** Introduced CRUD tools and visualization + for trackers, along with task tracker strategy improvements. +- **Browser Agent Improvements:** Enhanced the browser agent with progress + emission, a new automation overlay, and additional integration tests. +- **CLI and UI Updates:** Standardized semantic focus colors, polished shell + autocomplete rendering, unified keybinding infrastructure, and added custom + footer configuration options. ## What's Changed -- fix(patch): cherry-pick 4e5dfd0 to release/v0.35.0-preview.1-pr-23074 to patch - version v0.35.0-preview.1 and create version 0.35.0-preview.2 by +- 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 - [#23134](https://github.com/google-gemini/gemini-cli/pull/23134) -- feat(cli): customizable keyboard shortcuts by @scidomino in - [#21945](https://github.com/google-gemini/gemini-cli/pull/21945) -- feat(core): Thread `AgentLoopContext` through core. by @joshualitt in - [#21944](https://github.com/google-gemini/gemini-cli/pull/21944) -- chore(release): bump version to 0.35.0-nightly.20260311.657f19c1f by + [#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 - [#21966](https://github.com/google-gemini/gemini-cli/pull/21966) -- refactor(a2a): remove legacy CoreToolScheduler by @adamfweidman in - [#21955](https://github.com/google-gemini/gemini-cli/pull/21955) -- feat(ui): add missing vim mode motions (X, ~, r, f/F/t/T, df/dt and friends) - by @aanari in [#21932](https://github.com/google-gemini/gemini-cli/pull/21932) -- Feat/retry fetch notifications by @aishaneeshah in - [#21813](https://github.com/google-gemini/gemini-cli/pull/21813) -- fix(core): remove OAuth check from handleFallback and clean up stray file by - @sehoon38 in [#21962](https://github.com/google-gemini/gemini-cli/pull/21962) -- feat(cli): support literal character keybindings and extended Kitty protocol - keys by @scidomino in - [#21972](https://github.com/google-gemini/gemini-cli/pull/21972) -- fix(ui): clamp cursor to last char after all NORMAL mode deletes by @aanari in - [#21973](https://github.com/google-gemini/gemini-cli/pull/21973) -- test(core): add missing tests for prompts/utils.ts by @krrishverma1805-web in - [#19941](https://github.com/google-gemini/gemini-cli/pull/19941) -- fix(cli): allow scrolling keys in copy mode (Ctrl+S selection mode) by - @nsalerni in [#19933](https://github.com/google-gemini/gemini-cli/pull/19933) -- docs(cli): add custom keybinding documentation by @scidomino in - [#21980](https://github.com/google-gemini/gemini-cli/pull/21980) -- docs: fix misleading YOLO mode description in defaultApprovalMode by - @Gyanranjan-Priyam in - [#21878](https://github.com/google-gemini/gemini-cli/pull/21878) -- fix: clean up /clear and /resume by @jackwotherspoon in - [#22007](https://github.com/google-gemini/gemini-cli/pull/22007) -- fix(core)#20941: reap orphaned descendant processes on PTY abort by @manavmax - in [#21124](https://github.com/google-gemini/gemini-cli/pull/21124) -- fix(core): update language detection to use LSP 3.18 identifiers by @yunaseoul - in [#21931](https://github.com/google-gemini/gemini-cli/pull/21931) -- feat(cli): support removing keybindings via '-' prefix by @scidomino in - [#22042](https://github.com/google-gemini/gemini-cli/pull/22042) -- feat(policy): add --admin-policy flag for supplemental admin policies by - @galz10 in [#20360](https://github.com/google-gemini/gemini-cli/pull/20360) -- merge duplicate imports packages/cli/src subtask1 by @Nixxx19 in - [#22040](https://github.com/google-gemini/gemini-cli/pull/22040) -- perf(core): parallelize user quota and experiments fetching in refreshAuth by - @sehoon38 in [#21648](https://github.com/google-gemini/gemini-cli/pull/21648) -- Changelog for v0.34.0-preview.0 by @gemini-cli-robot in - [#21965](https://github.com/google-gemini/gemini-cli/pull/21965) -- Changelog for v0.33.0 by @gemini-cli-robot in - [#21967](https://github.com/google-gemini/gemini-cli/pull/21967) -- fix(core): handle EISDIR in robustRealpath on Windows by @sehoon38 in - [#21984](https://github.com/google-gemini/gemini-cli/pull/21984) -- feat(core): include initiationMethod in conversation interaction telemetry by - @yunaseoul in [#22054](https://github.com/google-gemini/gemini-cli/pull/22054) -- feat(ui): add vim yank/paste (y/p/P) with unnamed register by @aanari in - [#22026](https://github.com/google-gemini/gemini-cli/pull/22026) -- fix(core): enable numerical routing for api key users by @sehoon38 in - [#21977](https://github.com/google-gemini/gemini-cli/pull/21977) -- feat(telemetry): implement retry attempt telemetry for network related retries - by @aishaneeshah in - [#22027](https://github.com/google-gemini/gemini-cli/pull/22027) -- fix(policy): remove unnecessary escapeRegex from pattern builders by - @spencer426 in - [#21921](https://github.com/google-gemini/gemini-cli/pull/21921) -- fix(core): preserve dynamic tool descriptions on session resume by @sehoon38 - in [#18835](https://github.com/google-gemini/gemini-cli/pull/18835) -- chore: allow 'gemini-3.1' in sensitive keyword linter by @scidomino in - [#22065](https://github.com/google-gemini/gemini-cli/pull/22065) -- feat(core): support custom base URL via env vars by @junaiddshaukat in - [#21561](https://github.com/google-gemini/gemini-cli/pull/21561) -- merge duplicate imports packages/cli/src subtask2 by @Nixxx19 in - [#22051](https://github.com/google-gemini/gemini-cli/pull/22051) -- fix(core): silently retry API errors up to 3 times before halting session by - @spencer426 in - [#21989](https://github.com/google-gemini/gemini-cli/pull/21989) -- feat(core): simplify subagent success UI and improve early termination display + [#22391](https://github.com/google-gemini/gemini-cli/pull/22391) +- 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 + [#22205](https://github.com/google-gemini/gemini-cli/pull/22205) +- fix(patch): cherry-pick 45faf4d to release/v0.34.0-preview.0-pr-22148 + [CONFLICTS] by @gemini-cli-robot in + [#22174](https://github.com/google-gemini/gemini-cli/pull/22174) +- 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 + [#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 + [#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 + [#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 - [#21917](https://github.com/google-gemini/gemini-cli/pull/21917) -- merge duplicate imports packages/cli/src subtask3 by @Nixxx19 in - [#22056](https://github.com/google-gemini/gemini-cli/pull/22056) -- fix(hooks): fix BeforeAgent/AfterAgent inconsistencies (#18514) by @krishdef7 - in [#21383](https://github.com/google-gemini/gemini-cli/pull/21383) -- feat(core): implement SandboxManager interface and config schema by @galz10 in - [#21774](https://github.com/google-gemini/gemini-cli/pull/21774) -- docs: document npm deprecation warnings as safe to ignore by @h30s in - [#20692](https://github.com/google-gemini/gemini-cli/pull/20692) -- fix: remove status/need-triage from maintainer-only issues by @SandyTao520 in - [#22044](https://github.com/google-gemini/gemini-cli/pull/22044) -- fix(core): propagate subagent context to policy engine by @NTaylorMullen in - [#22086](https://github.com/google-gemini/gemini-cli/pull/22086) -- fix(cli): resolve skill uninstall failure when skill name is updated by - @NTaylorMullen in - [#22085](https://github.com/google-gemini/gemini-cli/pull/22085) -- docs(plan): clarify interactive plan editing with Ctrl+X by @Adib234 in - [#22076](https://github.com/google-gemini/gemini-cli/pull/22076) -- fix(policy): ensure user policies are loaded when policyPaths is empty by - @NTaylorMullen in - [#22090](https://github.com/google-gemini/gemini-cli/pull/22090) -- Docs: Add documentation for model steering (experimental). by @jkcinouye in - [#21154](https://github.com/google-gemini/gemini-cli/pull/21154) -- Add issue for automated changelogs by @g-samroberts in - [#21912](https://github.com/google-gemini/gemini-cli/pull/21912) -- fix(core): secure argsPattern and revert WEB_FETCH_TOOL_NAME escalation by + [#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 - [#22104](https://github.com/google-gemini/gemini-cli/pull/22104) -- feat(core): differentiate User-Agent for a2a-server and ACP clients by - @bdmorgan in [#22059](https://github.com/google-gemini/gemini-cli/pull/22059) -- refactor(core): extract ExecutionLifecycleService for tool backgrounding by - @adamfweidman in - [#21717](https://github.com/google-gemini/gemini-cli/pull/21717) -- feat: Display pending and confirming tool calls by @sripasg in - [#22106](https://github.com/google-gemini/gemini-cli/pull/22106) -- feat(browser): implement input blocker overlay during automation by + [#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 @Solventerritory + 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 - [#21132](https://github.com/google-gemini/gemini-cli/pull/21132) -- fix: register themes on extension load not start by @jackwotherspoon in - [#22148](https://github.com/google-gemini/gemini-cli/pull/22148) -- feat(ui): Do not show Ultra users /upgrade hint (#22154) by @sehoon38 in - [#22156](https://github.com/google-gemini/gemini-cli/pull/22156) -- chore: remove unnecessary log for themes by @jackwotherspoon in - [#22165](https://github.com/google-gemini/gemini-cli/pull/22165) -- fix(core): resolve MCP tool FQN validation, schema export, and wildcards in - subagents by @abhipatel12 in - [#22069](https://github.com/google-gemini/gemini-cli/pull/22069) -- fix(cli): validate --model argument at startup by @JaisalJain in - [#21393](https://github.com/google-gemini/gemini-cli/pull/21393) -- fix(core): handle policy ALLOW for exit_plan_mode by @backnotprop in - [#21802](https://github.com/google-gemini/gemini-cli/pull/21802) -- feat(telemetry): add Clearcut instrumentation for AI credits billing events by - @gsquared94 in - [#22153](https://github.com/google-gemini/gemini-cli/pull/22153) -- feat(core): add google credentials provider for remote agents by @adamfweidman - in [#21024](https://github.com/google-gemini/gemini-cli/pull/21024) -- test(cli): add integration test for node deprecation warnings by @Nixxx19 in - [#20215](https://github.com/google-gemini/gemini-cli/pull/20215) -- feat(cli): allow safe tools to execute concurrently while agent is busy by - @spencer426 in - [#21988](https://github.com/google-gemini/gemini-cli/pull/21988) -- feat(core): implement model-driven parallel tool scheduler by @abhipatel12 in - [#21933](https://github.com/google-gemini/gemini-cli/pull/21933) -- update vulnerable deps by @scidomino in - [#22180](https://github.com/google-gemini/gemini-cli/pull/22180) -- fix(core): fix startup stats to use int values for timestamps and durations by - @yunaseoul in [#22201](https://github.com/google-gemini/gemini-cli/pull/22201) -- fix(core): prevent duplicate tool schemas for instantiated tools by - @abhipatel12 in - [#22204](https://github.com/google-gemini/gemini-cli/pull/22204) -- fix(core): add proxy routing support for remote A2A subagents by @adamfweidman - in [#22199](https://github.com/google-gemini/gemini-cli/pull/22199) -- fix(core/ide): add Antigravity CLI fallbacks by @apfine in - [#22030](https://github.com/google-gemini/gemini-cli/pull/22030) -- fix(browser): fix duplicate function declaration error in browser agent by - @gsquared94 in - [#22207](https://github.com/google-gemini/gemini-cli/pull/22207) -- feat(core): implement Stage 1 improvements for webfetch tool by @aishaneeshah - in [#21313](https://github.com/google-gemini/gemini-cli/pull/21313) -- Changelog for v0.34.0-preview.1 by @gemini-cli-robot in - [#22194](https://github.com/google-gemini/gemini-cli/pull/22194) -- perf(cli): enable code splitting and deferred UI loading by @sehoon38 in - [#22117](https://github.com/google-gemini/gemini-cli/pull/22117) -- fix: remove unused img.png from project root by @SandyTao520 in - [#22222](https://github.com/google-gemini/gemini-cli/pull/22222) -- docs(local model routing): add docs on how to use Gemma for local model - routing by @douglas-reid in - [#21365](https://github.com/google-gemini/gemini-cli/pull/21365) -- feat(a2a): enable native gRPC support and protocol routing by @alisa-alisa in - [#21403](https://github.com/google-gemini/gemini-cli/pull/21403) -- fix(cli): escape @ symbols on paste to prevent unintended file expansion by - @krishdef7 in [#21239](https://github.com/google-gemini/gemini-cli/pull/21239) -- feat(core): add trajectoryId to ConversationOffered telemetry by @yunaseoul in - [#22214](https://github.com/google-gemini/gemini-cli/pull/22214) -- docs: clarify that tools.core is an allowlist for ALL built-in tools by - @hobostay in [#18813](https://github.com/google-gemini/gemini-cli/pull/18813) -- docs(plan): document hooks with plan mode by @ruomengz in - [#22197](https://github.com/google-gemini/gemini-cli/pull/22197) -- Changelog for v0.33.1 by @gemini-cli-robot in - [#22235](https://github.com/google-gemini/gemini-cli/pull/22235) -- build(ci): fix false positive evals trigger on merge commits by @gundermanc in - [#22237](https://github.com/google-gemini/gemini-cli/pull/22237) -- fix(core): explicitly pass messageBus to policy engine for MCP tool saves by - @abhipatel12 in - [#22255](https://github.com/google-gemini/gemini-cli/pull/22255) -- feat(core): Fully migrate packages/core to AgentLoopContext. by @joshualitt in - [#22115](https://github.com/google-gemini/gemini-cli/pull/22115) -- feat(core): increase sub-agent turn and time limits by @bdmorgan in - [#22196](https://github.com/google-gemini/gemini-cli/pull/22196) -- feat(core): instrument file system tools for JIT context discovery by - @SandyTao520 in - [#22082](https://github.com/google-gemini/gemini-cli/pull/22082) -- refactor(ui): extract pure session browser utilities by @abhipatel12 in - [#22256](https://github.com/google-gemini/gemini-cli/pull/22256) -- fix(plan): Fix AskUser evals by @Adib234 in - [#22074](https://github.com/google-gemini/gemini-cli/pull/22074) -- fix(settings): prevent j/k navigation keys from intercepting edit buffer input - by @student-ankitpandit in - [#21865](https://github.com/google-gemini/gemini-cli/pull/21865) -- feat(skills): improve async-pr-review workflow and logging by @mattKorwel in - [#21790](https://github.com/google-gemini/gemini-cli/pull/21790) -- refactor(cli): consolidate getErrorMessage utility to core by @scidomino in - [#22190](https://github.com/google-gemini/gemini-cli/pull/22190) -- fix(core): show descriptive error messages when saving settings fails by - @afarber in [#18095](https://github.com/google-gemini/gemini-cli/pull/18095) -- docs(core): add authentication guide for remote subagents by @adamfweidman in - [#22178](https://github.com/google-gemini/gemini-cli/pull/22178) -- docs: overhaul subagents documentation and add /agents command by @abhipatel12 - in [#22345](https://github.com/google-gemini/gemini-cli/pull/22345) -- refactor(ui): extract SessionBrowser static ui components by @abhipatel12 in - [#22348](https://github.com/google-gemini/gemini-cli/pull/22348) -- test: add Object.create context regression test and tool confirmation - integration test by @gsquared94 in - [#22356](https://github.com/google-gemini/gemini-cli/pull/22356) -- feat(tracker): return TodoList display for tracker tools by @anj-s in - [#22060](https://github.com/google-gemini/gemini-cli/pull/22060) -- feat(agent): add allowed domain restrictions for browser agent by - @cynthialong0-0 in - [#21775](https://github.com/google-gemini/gemini-cli/pull/21775) -- chore/release: bump version to 0.35.0-nightly.20260313.bb060d7a9 by + [#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 - [#22251](https://github.com/google-gemini/gemini-cli/pull/22251) -- Move keychain fallback to keychain service by @chrstnb in - [#22332](https://github.com/google-gemini/gemini-cli/pull/22332) -- feat(core): integrate SandboxManager to sandbox all process-spawning tools by - @galz10 in [#22231](https://github.com/google-gemini/gemini-cli/pull/22231) -- fix(cli): support CJK input and full Unicode scalar values in terminal - protocols by @scidomino in - [#22353](https://github.com/google-gemini/gemini-cli/pull/22353) -- Promote stable tests. by @gundermanc in - [#22253](https://github.com/google-gemini/gemini-cli/pull/22253) -- feat(tracker): add tracker policy by @anj-s in - [#22379](https://github.com/google-gemini/gemini-cli/pull/22379) -- feat(security): add disableAlwaysAllow setting to disable auto-approvals by - @galz10 in [#21941](https://github.com/google-gemini/gemini-cli/pull/21941) -- Revert "fix(cli): validate --model argument at startup" by @sehoon38 in - [#22378](https://github.com/google-gemini/gemini-cli/pull/22378) -- fix(mcp): handle equivalent root resource URLs in OAuth validation by @galz10 - in [#20231](https://github.com/google-gemini/gemini-cli/pull/20231) -- fix(core): use session-specific temp directory for task tracker by @anj-s in - [#22382](https://github.com/google-gemini/gemini-cli/pull/22382) -- Fix issue where config was undefined. by @gundermanc in - [#22397](https://github.com/google-gemini/gemini-cli/pull/22397) -- fix(core): deduplicate project memory when JIT context is enabled by - @SandyTao520 in - [#22234](https://github.com/google-gemini/gemini-cli/pull/22234) -- feat(prompts): implement Topic-Action-Summary model for verbosity reduction by - @Abhijit-2592 in - [#21503](https://github.com/google-gemini/gemini-cli/pull/21503) -- fix(core): fix manual deletion of subagent histories by @abhipatel12 in - [#22407](https://github.com/google-gemini/gemini-cli/pull/22407) -- Add registry var by @kevinjwang1 in - [#22224](https://github.com/google-gemini/gemini-cli/pull/22224) -- Add ModelDefinitions to ModelConfigService by @kevinjwang1 in - [#22302](https://github.com/google-gemini/gemini-cli/pull/22302) -- fix(cli): improve command conflict handling for skills by @NTaylorMullen in - [#21942](https://github.com/google-gemini/gemini-cli/pull/21942) -- fix(core): merge user settings with extension-provided MCP servers by - @abhipatel12 in - [#22484](https://github.com/google-gemini/gemini-cli/pull/22484) -- fix(core): skip discovery for incomplete MCP configs and resolve merge race - condition by @abhipatel12 in - [#22494](https://github.com/google-gemini/gemini-cli/pull/22494) -- fix(automation): harden stale PR closer permissions and maintainer detection - by @bdmorgan in - [#22558](https://github.com/google-gemini/gemini-cli/pull/22558) -- fix(automation): evaluate staleness before checking protected labels by - @bdmorgan in [#22561](https://github.com/google-gemini/gemini-cli/pull/22561) -- feat(agent): replace the runtime npx for browser agent chrome devtool mcp with - pre-built bundle by @cynthialong0-0 in - [#22213](https://github.com/google-gemini/gemini-cli/pull/22213) -- perf: optimize TrackerService dependency checks by @anj-s in - [#22384](https://github.com/google-gemini/gemini-cli/pull/22384) -- docs(policy): remove trailing space from commandPrefix examples by @kawasin73 - in [#22264](https://github.com/google-gemini/gemini-cli/pull/22264) -- fix(a2a-server): resolve unsafe assignment lint errors by @ehedlund in - [#22661](https://github.com/google-gemini/gemini-cli/pull/22661) -- fix: Adjust ToolGroupMessage filtering to hide Confirming and show Canceled - tool calls. by @sripasg in - [#22230](https://github.com/google-gemini/gemini-cli/pull/22230) -- Disallow Object.create() and reflect. by @gundermanc in - [#22408](https://github.com/google-gemini/gemini-cli/pull/22408) -- Guard pro model usage by @sehoon38 in - [#22665](https://github.com/google-gemini/gemini-cli/pull/22665) -- refactor(core): Creates AgentSession abstraction for consolidated agent - interface. by @mbleigh in - [#22270](https://github.com/google-gemini/gemini-cli/pull/22270) -- docs(changelog): remove internal commands from release notes by - @jackwotherspoon in - [#22529](https://github.com/google-gemini/gemini-cli/pull/22529) -- feat: enable subagents by @abhipatel12 in - [#22386](https://github.com/google-gemini/gemini-cli/pull/22386) -- feat(extensions): implement cryptographic integrity verification for extension - updates by @ehedlund in - [#21772](https://github.com/google-gemini/gemini-cli/pull/21772) -- feat(tracker): polish UI sorting and formatting by @anj-s in - [#22437](https://github.com/google-gemini/gemini-cli/pull/22437) -- Changelog for v0.34.0-preview.2 by @gemini-cli-robot in - [#22220](https://github.com/google-gemini/gemini-cli/pull/22220) -- fix(core): fix three JIT context bugs in read_file, read_many_files, and - memoryDiscovery by @SandyTao520 in - [#22679](https://github.com/google-gemini/gemini-cli/pull/22679) -- refactor(core): introduce InjectionService with source-aware injection and - backend-native background completions by @adamfweidman in - [#22544](https://github.com/google-gemini/gemini-cli/pull/22544) -- Linux sandbox bubblewrap by @DavidAPierce in - [#22680](https://github.com/google-gemini/gemini-cli/pull/22680) -- feat(core): increase thought signature retry resilience by @bdmorgan in - [#22202](https://github.com/google-gemini/gemini-cli/pull/22202) -- feat(core): implement Stage 2 security and consistency improvements for - web_fetch by @aishaneeshah in - [#22217](https://github.com/google-gemini/gemini-cli/pull/22217) -- refactor(core): replace positional execute params with ExecuteOptions bag by - @adamfweidman in - [#22674](https://github.com/google-gemini/gemini-cli/pull/22674) -- feat(config): enable JIT context loading by default by @SandyTao520 in - [#22736](https://github.com/google-gemini/gemini-cli/pull/22736) -- fix(config): ensure discoveryMaxDirs is passed to global config during - initialization by @kevin-ramdass in - [#22744](https://github.com/google-gemini/gemini-cli/pull/22744) -- fix(plan): allowlist get_internal_docs in Plan Mode by @Adib234 in - [#22668](https://github.com/google-gemini/gemini-cli/pull/22668) -- Changelog for v0.34.0-preview.3 by @gemini-cli-robot in - [#22393](https://github.com/google-gemini/gemini-cli/pull/22393) -- feat(core): add foundation for subagent tool isolation by @akh64bit in - [#22708](https://github.com/google-gemini/gemini-cli/pull/22708) -- fix(core): handle surrogate pairs in truncateString by @sehoon38 in - [#22754](https://github.com/google-gemini/gemini-cli/pull/22754) -- fix(cli): override j/k navigation in settings dialog to fix search input - conflict by @sehoon38 in - [#22800](https://github.com/google-gemini/gemini-cli/pull/22800) -- feat(plan): add 'All the above' option to multi-select AskUser questions by - @Adib234 in [#22365](https://github.com/google-gemini/gemini-cli/pull/22365) -- docs: distribute package-specific GEMINI.md context to each package by - @SandyTao520 in - [#22734](https://github.com/google-gemini/gemini-cli/pull/22734) -- fix(cli): clean up stale pasted placeholder metadata after word/line deletions - by @Jomak-x in - [#20375](https://github.com/google-gemini/gemini-cli/pull/20375) -- refactor(core): align JIT memory placement with tiered context model by - @SandyTao520 in - [#22766](https://github.com/google-gemini/gemini-cli/pull/22766) -- Linux sandbox seccomp by @DavidAPierce in - [#22815](https://github.com/google-gemini/gemini-cli/pull/22815) + [#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) **Full Changelog**: -https://github.com/google-gemini/gemini-cli/compare/v0.34.0-preview.4...v0.35.0-preview.2 +https://github.com/google-gemini/gemini-cli/compare/v0.33.0-preview.15...v0.34.0-preview.4 diff --git a/docs/cli/checkpointing.md b/docs/cli/checkpointing.md index 3a4a690cea..0be8bd9508 100644 --- a/docs/cli/checkpointing.md +++ b/docs/cli/checkpointing.md @@ -39,9 +39,7 @@ file in your project's temporary directory, typically located at The Checkpointing feature is disabled by default. To enable it, you need to edit your `settings.json` file. - -> [!CAUTION] -> The `--checkpointing` command-line flag was removed in version +> **Note:** The `--checkpointing` command-line flag was removed in version > 0.11.0. Checkpointing can now only be enabled through the `settings.json` > configuration file. diff --git a/docs/cli/cli-reference.md b/docs/cli/cli-reference.md index bc8f8b44ce..167801ca05 100644 --- a/docs/cli/cli-reference.md +++ b/docs/cli/cli-reference.md @@ -50,7 +50,6 @@ These commands are available within the interactive REPL. | `--model` | `-m` | string | `auto` | Model to use. See [Model Selection](#model-selection) for available values. | | `--prompt` | `-p` | string | - | Prompt text. Appended to stdin input if provided. Forces non-interactive mode. | | `--prompt-interactive` | `-i` | string | - | Execute prompt and continue in interactive mode | -| `--worktree` | `-w` | string | - | Start Gemini in a new git worktree. If no name is provided, one is generated automatically. Requires `experimental.worktrees: true` in settings. | | `--sandbox` | `-s` | boolean | `false` | Run in a sandboxed environment for safer execution | | `--approval-mode` | - | string | `default` | Approval mode for tool execution. Choices: `default`, `auto_edit`, `yolo` | | `--yolo` | `-y` | boolean | `false` | **Deprecated.** Auto-approve all actions. Use `--approval-mode=yolo` instead. | diff --git a/docs/cli/custom-commands.md b/docs/cli/custom-commands.md index 6fcce4e825..dd2698290e 100644 --- a/docs/cli/custom-commands.md +++ b/docs/cli/custom-commands.md @@ -30,9 +30,7 @@ separator (`/` or `\`) being converted to a colon (`:`). - A file at `/.gemini/commands/git/commit.toml` becomes the namespaced command `/git:commit`. - -> [!TIP] -> After creating or modifying `.toml` command files, run +> [!TIP] After creating or modifying `.toml` command files, run > `/commands reload` to pick up your changes without restarting the CLI. ## TOML file format (v1) @@ -179,10 +177,10 @@ ensure that only intended commands can be run. automatically shell-escaped (see [Context-Aware Injection](#1-context-aware-injection-with-args) above). 3. **Robust parsing:** The parser correctly handles complex shell commands that - include nested braces, such as JSON payloads. The content inside `!{...}` - must have balanced braces (`{` and `}`). If you need to execute a command - containing unbalanced braces, consider wrapping it in an external script - file and calling the script within the `!{...}` block. + include nested braces, such as JSON payloads. **Note:** The content inside + `!{...}` must have balanced braces (`{` and `}`). If you need to execute a + command containing unbalanced braces, consider wrapping it in an external + script file and calling the script within the `!{...}` block. 4. **Security check and confirmation:** The CLI performs a security check on the final, resolved command (after arguments are escaped and substituted). A dialog will appear showing the exact command(s) to be executed. diff --git a/docs/cli/enterprise.md b/docs/cli/enterprise.md index 5e9cede33a..39c0f7c5c1 100644 --- a/docs/cli/enterprise.md +++ b/docs/cli/enterprise.md @@ -5,11 +5,9 @@ and managing Gemini CLI in an enterprise environment. By leveraging system-level settings, administrators can enforce security policies, manage tool access, and ensure a consistent experience for all users. - -> [!WARNING] -> The patterns described in this document are intended to help -> administrators create a more controlled and secure environment for using -> Gemini CLI. However, they should not be considered a foolproof security +> **A note on security:** The patterns described in this document are intended +> to help administrators create a more controlled and secure environment for +> using Gemini CLI. However, they should not be considered a foolproof security > boundary. A determined user with sufficient privileges on their local machine > may still be able to circumvent these configurations. These measures are > designed to prevent accidental misuse and enforce corporate policy in a @@ -282,12 +280,10 @@ environment to a blocklist. } ``` - -> [!WARNING] -> Blocklisting with `excludeTools` is less secure than -> allowlisting with `coreTools`, as it relies on blocking known-bad commands, -> and clever users may find ways to bypass simple string-based blocks. -> **Allowlisting is the recommended approach.** +**Security note:** Blocklisting with `excludeTools` is less secure than +allowlisting with `coreTools`, as it relies on blocking known-bad commands, and +clever users may find ways to bypass simple string-based blocks. **Allowlisting +is the recommended approach.** ### Disabling YOLO mode @@ -498,10 +494,8 @@ other events. For more information, see the } ``` - -> [!NOTE] -> Ensure that `logPrompts` is set to `false` in an enterprise setting to -> avoid collecting potentially sensitive information from user prompts. +**Note:** Ensure that `logPrompts` is set to `false` in an enterprise setting to +avoid collecting potentially sensitive information from user prompts. ## Authentication diff --git a/docs/cli/git-worktrees.md b/docs/cli/git-worktrees.md deleted file mode 100644 index 5020b3fa9a..0000000000 --- a/docs/cli/git-worktrees.md +++ /dev/null @@ -1,107 +0,0 @@ -# Git Worktrees (experimental) - -When working on multiple tasks at once, you can use Git worktrees to give each -Gemini session its own copy of the codebase. Git worktrees create separate -working directories that each have their own files and branch while sharing the -same repository history. This prevents changes in one session from colliding -with another. - -Learn more about [session management](./session-management.md). - - -> [!NOTE] -> This is an experimental feature currently under active development. Your -> feedback is invaluable as we refine this feature. If you have ideas, -> suggestions, or encounter issues: -> -> - [Open an issue](https://github.com/google-gemini/gemini-cli/issues/new?template=bug_report.yml) on GitHub. -> - Use the **/bug** command within Gemini CLI to file an issue. - -Learn more in the official Git worktree -[documentation](https://git-scm.com/docs/git-worktree). - -## How to enable Git worktrees - -Git worktrees are an experimental feature. You must enable them in your settings -using the `/settings` command or by manually editing your `settings.json` file. - -1. Use the `/settings` command. -2. Search for and set **Enable Git Worktrees** to `true`. - -Alternatively, add the following to your `settings.json`: - -```json -{ - "experimental": { - "worktrees": true - } -} -``` - -## How to use Git worktrees - -Use the `--worktree` (`-w`) flag to create an isolated worktree and start Gemini -CLI in it. - -- **Start with a specific name:** The value you pass becomes both the directory - name (within `.gemini/worktrees/`) and the branch name. - - ```bash - gemini --worktree feature-search - ``` - -- **Start with a random name:** If you omit the name, Gemini generates a random - one automatically (for example, `worktree-a1b2c3d4`). - - ```bash - gemini --worktree - ``` - - -> [!NOTE] -> Remember to initialize your development environment in each new -> worktree according to your project's setup. Depending on your stack, this -> might include running dependency installation (`npm install`, `yarn`), setting -> up virtual environments, or following your project's standard build process. - -## How to exit a Git worktree session - -When you exit a worktree session (using `/quit` or `Ctrl+C`), Gemini leaves the -worktree intact so your work is not lost. This includes your uncommitted changes -(modified files, staged changes, or untracked files) and any new commits you -have made. - -Gemini prioritizes a fast and safe exit: it **does not automatically delete** -your worktree or branch. You are responsible for cleaning up your worktrees -manually once you are finished with them. - -When you exit, Gemini displays instructions on how to resume your work or how to -manually remove the worktree if you no longer need it. - -## Resuming work in a Git worktree - -To resume a session in a worktree, navigate to the worktree directory and start -Gemini CLI with the `--resume` flag and the session ID: - -```bash -cd .gemini/worktrees/feature-search -gemini --resume -``` - -## Managing Git worktrees manually - -For more control over worktree location and branch configuration, or to clean up -a preserved worktree, you can use Git directly: - -- **Clean up a preserved Git worktree:** - ```bash - git worktree remove .gemini/worktrees/feature-search --force - git branch -D worktree-feature-search - ``` -- **Create a Git worktree manually:** - ```bash - git worktree add ../project-feature-search -b feature-search - cd ../project-feature-search && gemini - ``` - -[Open an issue]: https://github.com/google-gemini/gemini-cli/issues diff --git a/docs/cli/model-steering.md b/docs/cli/model-steering.md index 26ff4e1209..12b581c530 100644 --- a/docs/cli/model-steering.md +++ b/docs/cli/model-steering.md @@ -4,10 +4,9 @@ Model steering lets you provide real-time guidance and feedback to Gemini CLI while it is actively executing a task. This lets you correct course, add missing context, or skip unnecessary steps without having to stop and restart the agent. - -> [!NOTE] -> This is an experimental feature currently under active development and -> may need to be enabled under `/settings`. +> **Note:** This is a preview feature under active development. Preview features +> may only be available in the **Preview** channel or may need to be enabled +> under `/settings`. Model steering is particularly useful during complex [Plan Mode](./plan-mode.md) workflows or long-running subagent executions where you want to ensure the agent diff --git a/docs/cli/model.md b/docs/cli/model.md index b85f597e08..3da5ea4cbc 100644 --- a/docs/cli/model.md +++ b/docs/cli/model.md @@ -5,9 +5,7 @@ used by Gemini CLI, giving you more control over your results. Use **Pro** models for complex tasks and reasoning, **Flash** models for high speed results, or the (recommended) **Auto** setting to choose the best model for your tasks. - -> [!NOTE] -> The `/model` command (and the `--model` flag) does not override the +> **Note:** The `/model` command (and the `--model` flag) does not override the > model used by sub-agents. Consequently, even when using the `/model` flag you > may see other models used in your model usage reports. diff --git a/docs/cli/notifications.md b/docs/cli/notifications.md index 8cff6c54f3..8326a1efb2 100644 --- a/docs/cli/notifications.md +++ b/docs/cli/notifications.md @@ -4,10 +4,9 @@ Gemini CLI can send system notifications to alert you when a session completes or when it needs your attention, such as when it's waiting for you to approve a tool call. - -> [!NOTE] -> This is an experimental feature currently under active development and -> may need to be enabled under `/settings`. +> **Note:** This is a preview feature currently under active development. +> Preview features may be available on the **Preview** channel or may need to be +> enabled under `/settings`. Notifications are particularly useful when running long-running tasks or using [Plan Mode](./plan-mode.md), letting you switch to other windows while Gemini diff --git a/docs/cli/plan-mode.md b/docs/cli/plan-mode.md index 5299bb3463..379eb71030 100644 --- a/docs/cli/plan-mode.md +++ b/docs/cli/plan-mode.md @@ -35,17 +35,19 @@ To launch Gemini CLI in Plan Mode once: To start Plan Mode while using Gemini CLI: - **Keyboard shortcut:** Press `Shift+Tab` to cycle through approval modes - (`Default` -> `Auto-Edit` -> `Plan`). Plan Mode is automatically removed from - the rotation when Gemini CLI is actively processing or showing confirmation - dialogs. + (`Default` -> `Auto-Edit` -> `Plan`). + + > **Note:** Plan Mode is automatically removed from the rotation when Gemini + > CLI is actively processing or showing confirmation dialogs. - **Command:** Type `/plan` in the input box. - **Natural Language:** Ask Gemini CLI to "start a plan for...". Gemini CLI calls the [`enter_plan_mode`](../tools/planning.md#1-enter_plan_mode-enterplanmode) tool - to switch modes. This tool is not available when Gemini CLI is in - [YOLO mode](../reference/configuration.md#command-line-arguments). + to switch modes. + > **Note:** This tool is not available when Gemini CLI is in + > [YOLO mode](../reference/configuration.md#command-line-arguments). ## How to use Plan Mode @@ -405,9 +407,7 @@ To build a custom planning workflow, you can use: [custom plan directories](#custom-plan-directory-and-policies) and [custom policies](#custom-policies). - -> [!TIP] -> Use [Conductor] as a reference when building your own custom +> **Note:** Use [Conductor] as a reference when building your own custom > planning workflow. By using Plan Mode as its execution environment, your custom methodology can @@ -460,26 +460,6 @@ Manual deletion also removes all associated artifacts: If you use a [custom plans directory](#custom-plan-directory-and-policies), those files are not automatically deleted and must be managed manually. -## Non-interactive execution - -When running Gemini CLI in non-interactive environments (such as headless -scripts or CI/CD pipelines), Plan Mode optimizes for automated workflows: - -- **Automatic transitions:** The policy engine automatically approves the - `enter_plan_mode` and `exit_plan_mode` tools without prompting for user - confirmation. -- **Automated implementation:** When exiting Plan Mode to execute the plan, - Gemini CLI automatically switches to - [YOLO mode](../reference/policy-engine.md#approval-modes) instead of the - standard Default mode. This allows the CLI to execute the implementation steps - automatically without hanging on interactive tool approvals. - -**Example:** - -```bash -gemini --approval-mode plan -p "Analyze telemetry and suggest improvements" -``` - [`plan.toml`]: https://github.com/google-gemini/gemini-cli/blob/main/packages/core/src/policy/policies/plan.toml [Conductor]: https://github.com/gemini-cli-extensions/conductor diff --git a/docs/cli/sandbox.md b/docs/cli/sandbox.md index b34433a878..ec7e88f624 100644 --- a/docs/cli/sandbox.md +++ b/docs/cli/sandbox.md @@ -50,25 +50,7 @@ Cross-platform sandboxing with complete process isolation. **Note**: Requires building the sandbox image locally or using a published image from your organization's registry. -### 3. Windows Native Sandbox (Windows only) - -... **Troubleshooting and Side Effects:** - -The Windows Native sandbox uses the `icacls` command to set a "Low Mandatory -Level" on files and directories it needs to write to. - -- **Persistence**: These integrity level changes are persistent on the - filesystem. Even after the sandbox session ends, files created or modified by - the sandbox will retain their "Low" integrity level. -- **Manual Reset**: If you need to reset the integrity level of a file or - directory, you can use: - ```powershell - icacls "C:\path\to\dir" /setintegritylevel Medium - ``` -- **System Folders**: The sandbox manager automatically skips setting integrity - levels on system folders (like `C:\Windows`) for safety. - -### 4. gVisor / runsc (Linux only) +### 3. gVisor / runsc (Linux only) Strongest isolation available: runs containers inside a user-space kernel via [gVisor](https://github.com/google/gvisor). gVisor intercepts all container @@ -271,11 +253,9 @@ $env:SANDBOX_SET_UID_GID="false" # Disable UID/GID mapping DEBUG=1 gemini -s -p "debug command" ``` - -> [!NOTE] -> If you have `DEBUG=true` in a project's `.env` file, it won't affect -> gemini-cli due to automatic exclusion. Use `.gemini/.env` files for -> gemini-cli specific debug settings. +**Note:** If you have `DEBUG=true` in a project's `.env` file, it won't affect +gemini-cli due to automatic exclusion. Use `.gemini/.env` files for gemini-cli +specific debug settings. ### Inspect sandbox diff --git a/docs/cli/session-management.md b/docs/cli/session-management.md index 74bc4a4337..8e60f61630 100644 --- a/docs/cli/session-management.md +++ b/docs/cli/session-management.md @@ -96,12 +96,6 @@ Compatibility aliases: - `/chat ...` works for the same commands. - `/resume checkpoints ...` also remains supported during migration. -## Parallel sessions with Git worktrees - -When working on multiple tasks at once, you can use -[Git worktrees](./git-worktrees.md) to give each Gemini session its own copy of -the codebase. This prevents changes in one session from colliding with another. - ## Managing sessions You can list and delete sessions to keep your history organized and manage disk diff --git a/docs/cli/settings.md b/docs/cli/settings.md index ead0050fbd..eb9ba4158e 100644 --- a/docs/cli/settings.md +++ b/docs/cli/settings.md @@ -11,9 +11,7 @@ locations: - **User settings**: `~/.gemini/settings.json` - **Workspace settings**: `your-project/.gemini/settings.json` - -> [!IMPORTANT] -> Workspace settings override user settings. +Note: Workspace settings override user settings. ## Settings reference @@ -117,8 +115,6 @@ they appear in the UI. | UI Label | Setting | Description | Default | | -------------------------------- | ------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- | -| Sandbox Allowed Paths | `tools.sandboxAllowedPaths` | List of additional paths that the sandbox is allowed to access. | `[]` | -| Sandbox Network Access | `tools.sandboxNetworkAccess` | Whether the sandbox is allowed to access the network. | `false` | | Enable Interactive Shell | `tools.shell.enableInteractiveShell` | Use node-pty for an interactive shell experience. Fallback to child_process still applies. | `true` | | Show Color | `tools.shell.showColor` | Show color in shell output. | `false` | | Use Ripgrep | `tools.useRipgrep` | Use ripgrep for file content search instead of the fallback implementation. Provides faster search performance. | `true` | @@ -151,13 +147,11 @@ they appear in the UI. | UI Label | Setting | Description | Default | | -------------------------- | ---------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- | | Enable Tool Output Masking | `experimental.toolOutputMasking.enabled` | Enables tool output masking to save tokens. | `true` | -| Enable Git Worktrees | `experimental.worktrees` | Enable automated Git worktree management for parallel work. | `false` | | Use OSC 52 Paste | `experimental.useOSC52Paste` | Use OSC 52 for pasting. This may be more robust than the default system when using remote terminal sessions (if your terminal is configured to allow it). | `false` | | Use OSC 52 Copy | `experimental.useOSC52Copy` | Use OSC 52 for copying. This may be more robust than the default system when using remote terminal sessions (if your terminal is configured to allow it). | `false` | | Plan | `experimental.plan` | Enable Plan Mode. | `true` | | Model Steering | `experimental.modelSteering` | Enable model steering (user hints) to guide the model during tool execution. | `false` | | Direct Web Fetch | `experimental.directWebFetch` | Enable web fetch behavior that bypasses LLM summarization. | `false` | -| Memory Manager Agent | `experimental.memoryManager` | Replace the built-in save_memory tool with a memory manager subagent that supports adding, removing, de-duplicating, and organizing memories. | `false` | | Topic & Update Narration | `experimental.topicUpdateNarration` | Enable the experimental Topic & Update communication model for reduced chattiness and structured progress reporting. | `false` | ### Skills diff --git a/docs/cli/skills.md b/docs/cli/skills.md index 73e5eb66eb..d3e8d4e84f 100644 --- a/docs/cli/skills.md +++ b/docs/cli/skills.md @@ -63,10 +63,8 @@ Use the `/skills` slash command to view and manage available expertise: - `/skills enable `: Re-enables a disabled skill. - `/skills reload`: Refreshes the list of discovered skills from all tiers. - -> [!NOTE] -> `/skills disable` and `/skills enable` default to the `user` scope. Use -> `--scope workspace` to manage workspace-specific settings. +_Note: `/skills disable` and `/skills enable` default to the `user` scope. Use +`--scope workspace` to manage workspace-specific settings._ ### From the Terminal diff --git a/docs/cli/system-prompt.md b/docs/cli/system-prompt.md index c249d55cec..b1ff43e3fd 100644 --- a/docs/cli/system-prompt.md +++ b/docs/cli/system-prompt.md @@ -14,9 +14,7 @@ core instructions will apply unless you include them yourself. This feature is intended for advanced users who need to enforce strict, project-specific behavior or create a customized persona. - -> [!TIP] -> You can export the current default system prompt to a file first, review +> Tip: You can export the current default system prompt to a file first, review > it, and then selectively modify or replace it (see > [“Export the default prompt”](#export-the-default-prompt-recommended)). diff --git a/docs/cli/telemetry.md b/docs/cli/telemetry.md index fec0fb41c3..211d877071 100644 --- a/docs/cli/telemetry.md +++ b/docs/cli/telemetry.md @@ -125,11 +125,9 @@ You must complete several setup steps before enabling Google Cloud telemetry. } ``` - -> [!NOTE] -> This setting requires **Direct export** (in-process exporters) -> and cannot be used when `useCollector` is `true`. If both are enabled, -> telemetry will be disabled. + > **Note:** This setting requires **Direct export** (in-process exporters) + > and cannot be used when `useCollector` is `true`. If both are enabled, + > telemetry will be disabled. 3. Ensure your account or service account has these IAM roles: - Cloud Trace Agent @@ -306,7 +304,6 @@ Emitted at startup with the CLI configuration. - `extension_ids` (string) - `extensions_count` (int) - `auth_type` (string) -- `worktree_active` (boolean) - `github_workflow_name` (string, optional) - `github_repository_hash` (string, optional) - `github_event_name` (string, optional) diff --git a/docs/cli/themes.md b/docs/cli/themes.md index 55acc75625..adfe64d081 100644 --- a/docs/cli/themes.md +++ b/docs/cli/themes.md @@ -36,11 +36,9 @@ using the `/theme` command within Gemini CLI: preview or highlight as you select. 4. Confirm your selection to apply the theme. - -> [!NOTE] -> If a theme is defined in your `settings.json` file (either by name or -> by a file path), you must remove the `"theme"` setting from the file before -> you can change the theme using the `/theme` command. +**Note:** If a theme is defined in your `settings.json` file (either by name or +by a file path), you must remove the `"theme"` setting from the file before you +can change the theme using the `/theme` command. ### Theme persistence @@ -181,13 +179,11 @@ custom theme defined in `settings.json`. } ``` - -> [!WARNING] -> For your safety, Gemini CLI will only load theme files that -> are located within your home directory. If you attempt to load a theme from -> outside your home directory, a warning will be displayed and the theme will -> not be loaded. This is to prevent loading potentially malicious theme files -> from untrusted sources. +**Security note:** For your safety, Gemini CLI will only load theme files that +are located within your home directory. If you attempt to load a theme from +outside your home directory, a warning will be displayed and the theme will not +be loaded. This is to prevent loading potentially malicious theme files from +untrusted sources. ### Example custom theme diff --git a/docs/cli/tutorials/file-management.md b/docs/cli/tutorials/file-management.md index 37112d3bc7..0f4fa09575 100644 --- a/docs/cli/tutorials/file-management.md +++ b/docs/cli/tutorials/file-management.md @@ -7,9 +7,9 @@ create files, and control what Gemini CLI can see. ## Prerequisites - Gemini CLI installed and authenticated. -- A project directory to work with (for example, a git repository). +- A project directory to work with (e.g., a git repository). -## Providing context by reading files +## How to give the agent context (Reading files) Gemini CLI will generally try to read relevant files, sometimes prompting you for access (depending on your settings). To ensure that Gemini CLI uses a file, @@ -58,13 +58,11 @@ You know there's a `UserProfile` component, but you don't know where it lives. ``` Gemini uses the `glob` or `list_directory` tools to search your project -structure. It will return the specific path (for example, +structure. It will return the specific path (e.g., `src/components/UserProfile.tsx`), which you can then use with `@` in your next turn. - -> [!TIP] -> You can also ask for lists of files, like "Show me all the TypeScript +> **Tip:** You can also ask for lists of files, like "Show me all the TypeScript > configuration files in the root directory." ## How to modify code @@ -113,8 +111,8 @@ or, better yet, run your project's tests. `Run the tests for the UserProfile component.` ``` -Gemini CLI uses the `run_shell_command` tool to execute your test runner (for -example, `npm test` or `jest`). This ensures the changes didn't break existing +Gemini CLI uses the `run_shell_command` tool to execute your test runner (e.g., +`npm test` or `jest`). This ensures the changes didn't break existing functionality. ## Advanced: Controlling what Gemini sees diff --git a/docs/cli/tutorials/mcp-setup.md b/docs/cli/tutorials/mcp-setup.md index 1eff7452ab..76c2806f9d 100644 --- a/docs/cli/tutorials/mcp-setup.md +++ b/docs/cli/tutorials/mcp-setup.md @@ -52,7 +52,7 @@ You tell Gemini about new servers by editing your `settings.json`. "--rm", "-e", "GITHUB_PERSONAL_ACCESS_TOKEN", - "ghcr.io/github/github-mcp-server:latest" + "ghcr.io/modelcontextprotocol/servers/github:latest" ], "env": { "GITHUB_PERSONAL_ACCESS_TOKEN": "${GITHUB_PERSONAL_ACCESS_TOKEN}" @@ -62,10 +62,8 @@ You tell Gemini about new servers by editing your `settings.json`. } ``` - -> [!NOTE] -> The `command` is `docker`, and the rest are arguments passed to it. We -> map the local environment variable into the container so your secret isn't +> **Note:** The `command` is `docker`, and the rest are arguments passed to it. +> We map the local environment variable into the container so your secret isn't > hardcoded in the config file. ## How to verify the connection diff --git a/docs/cli/tutorials/memory-management.md b/docs/cli/tutorials/memory-management.md index 2268ebd923..4cbca4bda9 100644 --- a/docs/cli/tutorials/memory-management.md +++ b/docs/cli/tutorials/memory-management.md @@ -11,8 +11,8 @@ persistent facts, and inspect the active context. ## Why manage context? -Gemini CLI is powerful but general. It doesn't know your preferred testing -framework, your indentation style, or your preference against `any` in +Out of the box, Gemini CLI is smart but generic. It doesn't know your preferred +testing framework, your indentation style, or that you hate using `any` in TypeScript. Context management solves this by giving the agent persistent memory. @@ -109,11 +109,11 @@ immediately. Force a reload with: ## Best practices -- **Keep it focused:** Avoid adding excessive content to `GEMINI.md`. Keep - instructions actionable and relevant to code generation. +- **Keep it focused:** Don't dump your entire internal wiki into `GEMINI.md`. + Keep instructions actionable and relevant to code generation. - **Use negative constraints:** Explicitly telling the agent what _not_ to do - (for example, "Do not use class components") is often more effective than - vague positive instructions. + (e.g., "Do not use class components") is often more effective than vague + positive instructions. - **Review often:** Periodically check your `GEMINI.md` files to remove outdated rules. diff --git a/docs/cli/tutorials/plan-mode-steering.md b/docs/cli/tutorials/plan-mode-steering.md index 0384425848..86bc63edac 100644 --- a/docs/cli/tutorials/plan-mode-steering.md +++ b/docs/cli/tutorials/plan-mode-steering.md @@ -5,10 +5,9 @@ structured environment with model steering's real-time feedback, you can guide Gemini CLI through the research and design phases to ensure the final implementation plan is exactly what you need. - -> [!NOTE] -> This is an experimental feature currently under active development and -> may need to be enabled under `/settings`. +> **Note:** This is a preview feature under active development. Preview features +> may only be available in the **Preview** channel or may need to be enabled +> under `/settings`. ## Prerequisites diff --git a/docs/cli/tutorials/shell-commands.md b/docs/cli/tutorials/shell-commands.md index 390c8acab9..3eaaf2049e 100644 --- a/docs/cli/tutorials/shell-commands.md +++ b/docs/cli/tutorials/shell-commands.md @@ -7,7 +7,7 @@ automate complex workflows, and manage background processes safely. ## Prerequisites - Gemini CLI installed and authenticated. -- Basic familiarity with your system's shell (Bash, Zsh, PowerShell, and so on). +- Basic familiarity with your system's shell (Bash, Zsh, PowerShell, etc.). ## How to run commands directly (`!`) @@ -49,7 +49,7 @@ You want to run tests and fix any failures. 6. Gemini uses `replace` to fix the bug. 7. Gemini runs `npm test` again to verify the fix. -This loop lets Gemini work autonomously. +This loop turns Gemini into an autonomous engineer. ## How to manage background processes @@ -75,7 +75,7 @@ confirmation prompts) by streaming the output to you. However, for highly interactive tools (like `vim` or `top`), it's often better to run them yourself in a separate terminal window or use the `!` prefix. -## Safety features +## Safety first Giving an AI access to your shell is powerful but risky. Gemini CLI includes several safety layers. diff --git a/docs/core/remote-agents.md b/docs/core/remote-agents.md index 2e34a9dbc4..1c48df00a3 100644 --- a/docs/core/remote-agents.md +++ b/docs/core/remote-agents.md @@ -10,9 +10,7 @@ agents in the following repositories: - [ADK Samples (Python)](https://github.com/google/adk-samples/tree/main/python) - [ADK Python Contributing Samples](https://github.com/google/adk-python/tree/main/contributing/samples) - -> [!NOTE] -> Remote subagents are currently an experimental feature. +> **Note: Remote subagents are currently an experimental feature.** ## Configuration @@ -84,8 +82,7 @@ Markdown file. --- ``` - -> [!NOTE] Mixed local and remote agents, or multiple local agents, are not +> **Note:** Mixed local and remote agents, or multiple local agents, are not > supported in a single file; the list format is currently remote-only. ## Authentication @@ -365,7 +362,5 @@ Users can manage subagents using the following commands within the Gemini CLI: - `/agents enable `: Enables a specific subagent. - `/agents disable `: Disables a specific subagent. - -> [!TIP] -> You can use the `@cli_help` agent within Gemini CLI for assistance +> **Tip:** You can use the `@cli_help` agent within Gemini CLI for assistance > with configuring subagents. diff --git a/docs/core/subagents.md b/docs/core/subagents.md index b0cffca3b5..6d863f489e 100644 --- a/docs/core/subagents.md +++ b/docs/core/subagents.md @@ -5,18 +5,16 @@ session. They are designed to handle specific, complex tasks—like deep codebas analysis, documentation lookup, or domain-specific reasoning—without cluttering the main agent's context or toolset. - -> [!NOTE] -> Subagents are currently an experimental feature. -> -To use custom subagents, you must ensure they are enabled in your -`settings.json` (enabled by default): - -```json -{ - "experimental": { "enableAgents": true } -} -``` +> **Note: Subagents are currently an experimental feature.** +> +> To use custom subagents, you must ensure they are enabled in your +> `settings.json` (enabled by default): +> +> ```json +> { +> "experimental": { "enableAgents": true } +> } +> ``` ## What are subagents? @@ -116,9 +114,7 @@ Gemini CLI comes with the following built-in subagents: the pricing table from this page," "Click the login button and enter my credentials." - -> [!NOTE] -> This is a preview feature currently under active development. +> **Note:** This is a preview feature currently under active development. #### Prerequisites @@ -221,9 +217,7 @@ captures a screenshot and sends it to the vision model for analysis. The model returns coordinates and element descriptions that the browser agent uses with the `click_at` tool for precise, coordinate-based interactions. - -> [!NOTE] -> The visual agent requires API key or Vertex AI authentication. It is +> **Note:** The visual agent requires API key or Vertex AI authentication. It is > not available when using "Sign in with Google". ## Creating custom subagents @@ -411,9 +405,7 @@ that your subagent was called with a specific prompt and the given description. Gemini CLI can also delegate tasks to remote subagents using the Agent-to-Agent (A2A) protocol. - -> [!NOTE] -> Remote subagents are currently an experimental feature. +> **Note: Remote subagents are currently an experimental feature.** See the [Remote Subagents documentation](remote-agents) for detailed configuration, authentication, and usage instructions. diff --git a/docs/extensions/reference.md b/docs/extensions/reference.md index 56c51d30df..e6012f4d33 100644 --- a/docs/extensions/reference.md +++ b/docs/extensions/reference.md @@ -23,7 +23,7 @@ Gemini CLI creates a copy of the extension during installation. You must run GitHub, you must have `git` installed on your machine. ```bash -gemini extensions install [--ref ] [--auto-update] [--pre-release] [--consent] [--skip-settings] +gemini extensions install [--ref ] [--auto-update] [--pre-release] [--consent] ``` - ``: The GitHub URL or local path of the extension. @@ -31,7 +31,6 @@ gemini extensions install [--ref ] [--auto-update] [--pre-release] - `--auto-update`: Enable automatic updates for this extension. - `--pre-release`: Enable installation of pre-release versions. - `--consent`: Acknowledge security risks and skip the confirmation prompt. -- `--skip-settings`: Skip the configuration on install process. ### Uninstall an extension @@ -235,9 +234,7 @@ skill definitions in a `skills/` directory. For example, ### Sub-agents - -> [!NOTE] -> Sub-agents are a preview feature currently under active development. +> **Note:** Sub-agents are a preview feature currently under active development. Provide [sub-agents](../core/subagents.md) that users can delegate tasks to. Add agent definition files (`.md`) to an `agents/` directory in your extension root. @@ -256,9 +253,7 @@ Rules contributed by extensions run in their own tier (tier 2), alongside workspace-defined policies. This tier has higher priority than the default rules but lower priority than user or admin policies. - -> [!WARNING] -> For security, Gemini CLI ignores any `allow` decisions or `yolo` +> **Warning:** For security, Gemini CLI ignores any `allow` decisions or `yolo` > mode configurations in extension policies. This ensures that an extension > cannot automatically approve tool calls or bypass security measures without > your confirmation. diff --git a/docs/get-started/authentication.md b/docs/get-started/authentication.md index 6d8758b958..964e776567 100644 --- a/docs/get-started/authentication.md +++ b/docs/get-started/authentication.md @@ -4,9 +4,7 @@ To use Gemini CLI, you'll need to authenticate with Google. This guide helps you quickly find the best way to sign in based on your account type and how you're using the CLI. - -> [!TIP] -> Looking for a high-level comparison of all available subscriptions? +> **Note:** Looking for a high-level comparison of all available subscriptions? > To compare features and find the right quota for your needs, see our > [Plans page](https://geminicli.com/plans/). @@ -42,11 +40,11 @@ Select the authentication method that matches your situation in the table below: If you run Gemini CLI on your local machine, the simplest authentication method is logging in with your Google account. This method requires a web browser on a -machine that can communicate with the terminal running Gemini CLI (for example, -your local machine). +machine that can communicate with the terminal running Gemini CLI (e.g., your +local machine). -If you are a **Google AI Pro** or **Google AI Ultra** subscriber, use the Google -account associated with your subscription. +> **Important:** If you are a **Google AI Pro** or **Google AI Ultra** +> subscriber, use the Google account associated with your subscription. To authenticate and use Gemini CLI: @@ -109,9 +107,7 @@ To authenticate and use Gemini CLI with a Gemini API key: 4. Select **Use Gemini API key**. - -> [!WARNING] -> Treat API keys, especially for services like Gemini, as sensitive +> **Warning:** Treat API keys, especially for services like Gemini, as sensitive > credentials. Protect them to prevent unauthorized access and potential misuse > of the service under your account. @@ -134,7 +130,7 @@ For example: **macOS/Linux** ```bash -# Replace with your project ID and desired location (for example, us-central1) +# Replace with your project ID and desired location (e.g., us-central1) export GOOGLE_CLOUD_PROJECT="YOUR_PROJECT_ID" export GOOGLE_CLOUD_LOCATION="YOUR_PROJECT_LOCATION" ``` @@ -142,7 +138,7 @@ export GOOGLE_CLOUD_LOCATION="YOUR_PROJECT_LOCATION" **Windows (PowerShell)** ```powershell -# Replace with your project ID and desired location (for example, us-central1) +# Replace with your project ID and desired location (e.g., us-central1) $env:GOOGLE_CLOUD_PROJECT="YOUR_PROJECT_ID" $env:GOOGLE_CLOUD_LOCATION="YOUR_PROJECT_LOCATION" ``` @@ -154,20 +150,20 @@ To make any Vertex AI environment variable settings persistent, see Consider this authentication method if you have Google Cloud CLI installed. -If you have previously set `GOOGLE_API_KEY` or `GEMINI_API_KEY`, you must unset -them to use ADC. - -**macOS/Linux** - -```bash -unset GOOGLE_API_KEY GEMINI_API_KEY -``` - -**Windows (PowerShell)** - -```powershell -Remove-Item Env:\GOOGLE_API_KEY, Env:\GEMINI_API_KEY -ErrorAction Ignore -``` +> **Note:** If you have previously set `GOOGLE_API_KEY` or `GEMINI_API_KEY`, you +> must unset them to use ADC: +> +> **macOS/Linux** +> +> ```bash +> unset GOOGLE_API_KEY GEMINI_API_KEY +> ``` +> +> **Windows (PowerShell)** +> +> ```powershell +> Remove-Item Env:\GOOGLE_API_KEY, Env:\GEMINI_API_KEY -ErrorAction Ignore +> ``` 1. Verify you have a Google Cloud project and Vertex AI API is enabled. @@ -192,20 +188,20 @@ Remove-Item Env:\GOOGLE_API_KEY, Env:\GEMINI_API_KEY -ErrorAction Ignore Consider this method of authentication in non-interactive environments, CI/CD pipelines, or if your organization restricts user-based ADC or API key creation. -If you have previously set `GOOGLE_API_KEY` or `GEMINI_API_KEY`, you must unset -them: - -**macOS/Linux** - -```bash -unset GOOGLE_API_KEY GEMINI_API_KEY -``` - -**Windows (PowerShell)** - -```powershell -Remove-Item Env:\GOOGLE_API_KEY, Env:\GEMINI_API_KEY -ErrorAction Ignore -``` +> **Note:** If you have previously set `GOOGLE_API_KEY` or `GEMINI_API_KEY`, you +> must unset them: +> +> **macOS/Linux** +> +> ```bash +> unset GOOGLE_API_KEY GEMINI_API_KEY +> ``` +> +> **Windows (PowerShell)** +> +> ```powershell +> Remove-Item Env:\GOOGLE_API_KEY, Env:\GEMINI_API_KEY -ErrorAction Ignore +> ``` 1. [Create a service account and key](https://cloud.google.com/iam/docs/keys-create-delete) and download the provided JSON file. Assign the "Vertex AI User" role to the @@ -237,11 +233,8 @@ Remove-Item Env:\GOOGLE_API_KEY, Env:\GEMINI_API_KEY -ErrorAction Ignore ``` 5. Select **Vertex AI**. - - -> [!WARNING] -> Protect your service account key file as it gives access to -> your resources. + > **Warning:** Protect your service account key file as it gives access to + > your resources. #### C. Vertex AI - Google Cloud API key @@ -264,9 +257,10 @@ Remove-Item Env:\GOOGLE_API_KEY, Env:\GEMINI_API_KEY -ErrorAction Ignore $env:GOOGLE_API_KEY="YOUR_GOOGLE_API_KEY" ``` - If you see errors like `"API keys are not supported by this API..."`, your - organization might restrict API key usage for this service. Try the other - Vertex AI authentication methods instead. + > **Note:** If you see errors like + > `"API keys are not supported by this API..."`, your organization might + > restrict API key usage for this service. Try the other Vertex AI + > authentication methods instead. 3. [Configure your Google Cloud Project](#set-gcp). @@ -280,9 +274,7 @@ Remove-Item Env:\GOOGLE_API_KEY, Env:\GEMINI_API_KEY -ErrorAction Ignore ## Set your Google Cloud project - -> [!IMPORTANT] -> Most individual Google accounts (free and paid) don't require a +> **Important:** Most individual Google accounts (free and paid) don't require a > Google Cloud project for authentication. When you sign in using your Google account, you may need to configure a Google @@ -333,31 +325,29 @@ persist them with the following methods: 1. **Add your environment variables to your shell configuration file:** Append the environment variable commands to your shell's startup file. - **macOS/Linux** (for example, `~/.bashrc`, `~/.zshrc`, or `~/.profile`): + **macOS/Linux** (e.g., `~/.bashrc`, `~/.zshrc`, or `~/.profile`): ```bash echo 'export GOOGLE_CLOUD_PROJECT="YOUR_PROJECT_ID"' >> ~/.bashrc source ~/.bashrc ``` - **Windows (PowerShell)** (for example, `$PROFILE`): + **Windows (PowerShell)** (e.g., `$PROFILE`): ```powershell Add-Content -Path $PROFILE -Value '$env:GOOGLE_CLOUD_PROJECT="YOUR_PROJECT_ID"' . $PROFILE ``` - -> [!WARNING] -> Be aware that when you export API keys or service account -> paths in your shell configuration file, any process launched from that -> shell can read them. + > **Warning:** Be aware that when you export API keys or service account + > paths in your shell configuration file, any process launched from that + > shell can read them. 2. **Use a `.env` file:** Create a `.gemini/.env` file in your project directory or home directory. Gemini CLI automatically loads variables from the first `.env` file it finds, searching up from the current directory, - then in your home directory's `.gemini/.env` (for example, `~/.gemini/.env` - or `%USERPROFILE%\.gemini\.env`). + then in your home directory's `.gemini/.env` (e.g., `~/.gemini/.env` or + `%USERPROFILE%\.gemini\.env`). Example for user-wide settings: diff --git a/docs/get-started/examples.md b/docs/get-started/examples.md index 18ebf865b4..5d31ddedb8 100644 --- a/docs/get-started/examples.md +++ b/docs/get-started/examples.md @@ -4,9 +4,7 @@ Gemini CLI helps you automate common engineering tasks by combining AI reasoning with local system tools. This document provides examples of how to use the CLI for file management, code analysis, and data transformation. - -> [!NOTE] -> These examples demonstrate potential capabilities. Your actual +> **Note:** These examples demonstrate potential capabilities. Your actual > results can vary based on the model used and your project environment. ## Rename your photographs based on content diff --git a/docs/get-started/gemini-3.md b/docs/get-started/gemini-3.md index 8e0af1a9ce..d22baaa0c0 100644 --- a/docs/get-started/gemini-3.md +++ b/docs/get-started/gemini-3.md @@ -2,9 +2,7 @@ Gemini 3 Pro and Gemini 3 Flash are available on Gemini CLI for all users! - -> [!NOTE] -> Gemini 3.1 Pro Preview is rolling out. To determine whether you have +> **Note:** Gemini 3.1 Pro Preview is rolling out. To determine whether you have > access to Gemini 3.1, use the `/model` command and select **Manual**. If you > have access, you will see `gemini-3.1-pro-preview`. > @@ -27,7 +25,7 @@ Get started by upgrading Gemini CLI to the latest version: npm install -g @google/gemini-cli@latest ``` -If your version is 0.21.1 or later: +After you’ve confirmed your version is 0.21.1 or later: 1. Run `/model`. 2. Select **Auto (Gemini 3)**. @@ -41,9 +39,7 @@ When you encounter that limit, you’ll be given the option to switch to Gemini 2.5 Pro, upgrade for higher limits, or stop. You’ll also be told when your usage limit resets and Gemini 3 Pro can be used again. - -> [!TIP] -> Looking to upgrade for higher limits? To compare subscription +> **Note:** Looking to upgrade for higher limits? To compare subscription > options and find the right quota for your needs, see our > [Plans page](https://geminicli.com/plans/). @@ -56,9 +52,7 @@ There may be times when the Gemini 3 Pro model is overloaded. When that happens, Gemini CLI will ask you to decide whether you want to keep trying Gemini 3 Pro or fallback to Gemini 2.5 Pro. - -> [!NOTE] -> The **Keep trying** option uses exponential backoff, in which Gemini +> **Note:** The **Keep trying** option uses exponential backoff, in which Gemini > CLI waits longer between each retry, when the system is busy. If the retry > doesn't happen immediately, please wait a few minutes for the request to > process. @@ -115,7 +109,7 @@ then: Restart Gemini CLI and you should have access to Gemini 3. -## Next steps +## Need help? If you need help, we recommend searching for an existing [GitHub issue](https://github.com/google-gemini/gemini-cli/issues). If you diff --git a/docs/hooks/index.md b/docs/hooks/index.md index 71fdec268f..7d526dd885 100644 --- a/docs/hooks/index.md +++ b/docs/hooks/index.md @@ -143,9 +143,7 @@ Hooks are executed with a sanitized environment. ## Security and risks - -> [!WARNING] -> Hooks execute arbitrary code with your user privileges. By +> **Warning: Hooks execute arbitrary code with your user privileges.** By > configuring hooks, you are allowing scripts to run shell commands on your > machine. diff --git a/docs/ide-integration/ide-companion-spec.md b/docs/ide-integration/ide-companion-spec.md index 7ae22b7eb5..8f17cd896e 100644 --- a/docs/ide-integration/ide-companion-spec.md +++ b/docs/ide-integration/ide-companion-spec.md @@ -132,11 +132,9 @@ to the CLI whenever the user's context changes. } ``` - -> [!NOTE] -> The `openFiles` list should only include files that exist on disk. -> Virtual files (e.g., unsaved files without a path, editor settings pages) -> **MUST** be excluded. + **Note:** The `openFiles` list should only include files that exist on disk. + Virtual files (e.g., unsaved files without a path, editor settings pages) + **MUST** be excluded. ### How the CLI uses this context diff --git a/docs/ide-integration/index.md b/docs/ide-integration/index.md index 6ff893a684..6686421ca4 100644 --- a/docs/ide-integration/index.md +++ b/docs/ide-integration/index.md @@ -66,11 +66,9 @@ You can also install the extension directly from a marketplace. Follow your editor's instructions for installing extensions from this registry. - -> [!NOTE] -> The "Gemini CLI Companion" extension may appear towards the bottom of -> search results. If you don't see it immediately, try scrolling down or -> sorting by "Newly Published". +> NOTE: The "Gemini CLI Companion" extension may appear towards the bottom of +> search results. If you don't see it immediately, try scrolling down or sorting +> by "Newly Published". > > After manually installing the extension, you must run `/ide enable` in the CLI > to activate the integration. @@ -105,9 +103,7 @@ IDE, run: If connected, this command will show the IDE it's connected to and a list of recently opened files it is aware of. - -> [!NOTE] -> The file list is limited to 10 recently accessed files within your +> [!NOTE] The file list is limited to 10 recently accessed files within your > workspace and only includes local files on disk.) ### Working with diffs diff --git a/docs/issue-and-pr-automation.md b/docs/issue-and-pr-automation.md index 6f27592833..6c023b651b 100644 --- a/docs/issue-and-pr-automation.md +++ b/docs/issue-and-pr-automation.md @@ -14,9 +14,7 @@ feature), while the PR is the "how" (the implementation). This separation helps us track work, prioritize features, and maintain clear historical context. Our automation is built around this principle. - -> [!NOTE] -> Issues tagged as "🔒Maintainers only" are reserved for project +> **Note:** Issues tagged as "🔒Maintainers only" are reserved for project > maintainers. We will not accept pull requests related to these issues. --- diff --git a/docs/local-development.md b/docs/local-development.md index 83520c7506..a31fa4aa11 100644 --- a/docs/local-development.md +++ b/docs/local-development.md @@ -79,9 +79,7 @@ You can view traces in the Jaeger UI for local development. You can use an OpenTelemetry collector to forward telemetry data to Google Cloud Trace for custom processing or routing. - -> [!WARNING] -> Ensure you complete the +> **Warning:** Ensure you complete the > [Google Cloud telemetry prerequisites](./cli/telemetry.md#prerequisites) > (Project ID, authentication, IAM roles, and APIs) before using this method. diff --git a/docs/reference/commands.md b/docs/reference/commands.md index aa4a0d38db..e9383152d2 100644 --- a/docs/reference/commands.md +++ b/docs/reference/commands.md @@ -60,8 +60,8 @@ Slash commands provide meta-level control over the CLI itself. - `list` (selecting this opens the auto-saved session browser) - `-- checkpoints --` - `list`, `save`, `resume`, `delete`, `share` (manual tagged checkpoints) - - Unique prefixes (for example `/cha` or `/resu`) resolve to the same grouped - menu. + - **Note:** Unique prefixes (for example `/cha` or `/resum`) resolve to the + same grouped menu. - **Sub-commands:** - **`debug`** - **Description:** Export the most recent API request as a JSON payload. diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index 5791bbf457..7df1de61f1 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -25,9 +25,7 @@ overridden by higher numbers): Gemini CLI uses JSON settings files for persistent configuration. There are four locations for these files: - -> [!TIP] -> JSON-aware editors can use autocomplete and validation by pointing to +> **Tip:** JSON-aware editors can use autocomplete and validation by pointing to > the generated schema at `schemas/settings.schema.json` in this repository. > When working outside the repo, reference the hosted schema at > `https://raw.githubusercontent.com/google-gemini/gemini-cli/main/schemas/settings.schema.json`. @@ -68,9 +66,9 @@ an environment variable `MY_API_TOKEN`, you could use it in `settings.json` like this: `"apiKey": "$MY_API_TOKEN"`. Additionally, each extension can have its own `.env` file in its directory, which will be loaded automatically. -**Note for Enterprise Users:** For guidance on deploying and managing Gemini CLI -in a corporate environment, please see the -[Enterprise Configuration](../cli/enterprise.md) documentation. +> **Note for Enterprise Users:** For guidance on deploying and managing Gemini +> CLI in a corporate environment, please see the +> [Enterprise Configuration](../cli/enterprise.md) documentation. ### The `.gemini` directory in your project @@ -686,16 +684,6 @@ their corresponding top-level category object in your `settings.json` file. ```json { - "gemini-3.1-flash-lite-preview": { - "tier": "flash-lite", - "family": "gemini-3", - "isPreview": true, - "isVisible": true, - "features": { - "thinking": false, - "multimodalToolUse": true - } - }, "gemini-3.1-pro-preview": { "tier": "pro", "family": "gemini-3", @@ -807,7 +795,7 @@ their corresponding top-level category object in your `settings.json` file. "tier": "auto", "isPreview": true, "isVisible": true, - "dialogDescription": "Let Gemini CLI decide the best model for the task: gemini-3-pro, gemini-3-flash", + "dialogDescription": "Let Gemini CLI decide the best model for the task: gemini-3.1-pro, gemini-3-flash", "features": { "thinking": true, "multimodalToolUse": false @@ -836,39 +824,6 @@ their corresponding top-level category object in your `settings.json` file. ```json { - "gemini-3.1-pro-preview": { - "default": "gemini-3.1-pro-preview", - "contexts": [ - { - "condition": { - "hasAccessToPreview": false - }, - "target": "gemini-2.5-pro" - } - ] - }, - "gemini-3.1-pro-preview-customtools": { - "default": "gemini-3.1-pro-preview-customtools", - "contexts": [ - { - "condition": { - "hasAccessToPreview": false - }, - "target": "gemini-2.5-pro" - } - ] - }, - "gemini-3-flash-preview": { - "default": "gemini-3-flash-preview", - "contexts": [ - { - "condition": { - "hasAccessToPreview": false - }, - "target": "gemini-2.5-flash" - } - ] - }, "gemini-3-pro-preview": { "default": "gemini-3-pro-preview", "contexts": [ @@ -1040,132 +995,6 @@ their corresponding top-level category object in your `settings.json` file. - **Requires restart:** Yes -- **`modelConfigs.modelChains`** (object): - - **Description:** Availability policy chains defining fallback behavior for - models. - - **Default:** - - ```json - { - "preview": [ - { - "model": "gemini-3-pro-preview", - "actions": { - "terminal": "prompt", - "transient": "prompt", - "not_found": "prompt", - "unknown": "prompt" - }, - "stateTransitions": { - "terminal": "terminal", - "transient": "terminal", - "not_found": "terminal", - "unknown": "terminal" - } - }, - { - "model": "gemini-3-flash-preview", - "isLastResort": true, - "actions": { - "terminal": "prompt", - "transient": "prompt", - "not_found": "prompt", - "unknown": "prompt" - }, - "stateTransitions": { - "terminal": "terminal", - "transient": "terminal", - "not_found": "terminal", - "unknown": "terminal" - } - } - ], - "default": [ - { - "model": "gemini-2.5-pro", - "actions": { - "terminal": "prompt", - "transient": "prompt", - "not_found": "prompt", - "unknown": "prompt" - }, - "stateTransitions": { - "terminal": "terminal", - "transient": "terminal", - "not_found": "terminal", - "unknown": "terminal" - } - }, - { - "model": "gemini-2.5-flash", - "isLastResort": true, - "actions": { - "terminal": "prompt", - "transient": "prompt", - "not_found": "prompt", - "unknown": "prompt" - }, - "stateTransitions": { - "terminal": "terminal", - "transient": "terminal", - "not_found": "terminal", - "unknown": "terminal" - } - } - ], - "lite": [ - { - "model": "gemini-2.5-flash-lite", - "actions": { - "terminal": "silent", - "transient": "silent", - "not_found": "silent", - "unknown": "silent" - }, - "stateTransitions": { - "terminal": "terminal", - "transient": "terminal", - "not_found": "terminal", - "unknown": "terminal" - } - }, - { - "model": "gemini-2.5-flash", - "actions": { - "terminal": "silent", - "transient": "silent", - "not_found": "silent", - "unknown": "silent" - }, - "stateTransitions": { - "terminal": "terminal", - "transient": "terminal", - "not_found": "terminal", - "unknown": "terminal" - } - }, - { - "model": "gemini-2.5-pro", - "isLastResort": true, - "actions": { - "terminal": "silent", - "transient": "silent", - "not_found": "silent", - "unknown": "silent" - }, - "stateTransitions": { - "terminal": "terminal", - "transient": "terminal", - "not_found": "terminal", - "unknown": "terminal" - } - } - ] - } - ``` - - - **Requires restart:** Yes - #### `agents` - **`agents.overrides`** (object): @@ -1276,21 +1105,10 @@ their corresponding top-level category object in your `settings.json` file. - **Description:** Legacy full-process sandbox execution environment. Set to a boolean to enable or disable the sandbox, provide a string path to a sandbox profile, or specify an explicit sandbox command (e.g., "docker", "podman", - "lxc", "windows-native"). + "lxc"). - **Default:** `undefined` - **Requires restart:** Yes -- **`tools.sandboxAllowedPaths`** (array): - - **Description:** List of additional paths that the sandbox is allowed to - access. - - **Default:** `[]` - - **Requires restart:** Yes - -- **`tools.sandboxNetworkAccess`** (boolean): - - **Description:** Whether the sandbox is allowed to access the network. - - **Default:** `false` - - **Requires restart:** Yes - - **`tools.shell.enableInteractiveShell`** (boolean): - **Description:** Use node-pty for an interactive shell experience. Fallback to child_process still applies. @@ -1527,11 +1345,6 @@ their corresponding top-level category object in your `settings.json` file. - **Default:** `true` - **Requires restart:** Yes -- **`experimental.worktrees`** (boolean): - - **Description:** Enable automated Git worktree management for parallel work. - - **Default:** `false` - - **Requires restart:** Yes - - **`experimental.extensionManagement`** (boolean): - **Description:** Enable extension management features. - **Default:** `true` @@ -1618,13 +1431,6 @@ their corresponding top-level category object in your `settings.json` file. - **Default:** `"gemma3-1b-gpu-custom"` - **Requires restart:** Yes -- **`experimental.memoryManager`** (boolean): - - **Description:** Replace the built-in save_memory tool with a memory manager - subagent that supports adding, removing, de-duplicating, and organizing - memories. - - **Default:** `false` - - **Requires restart:** Yes - - **`experimental.topicUpdateNarration`** (boolean): - **Description:** Enable the experimental Topic & Update communication model for reduced chattiness and structured progress reporting. @@ -1733,11 +1539,7 @@ their corresponding top-level category object in your `settings.json` file. - **Default:** `true` - **`admin.mcp.config`** (object): - - **Description:** Admin-configured MCP servers (allowlist). - - **Default:** `{}` - -- **`admin.mcp.requiredConfig`** (object): - - **Description:** Admin-required MCP servers that are always injected. + - **Description:** Admin-configured MCP servers. - **Default:** `{}` - **`admin.skills.enabled`** (boolean): @@ -1757,9 +1559,7 @@ for compatibility. At least one of `command`, `url`, or `httpUrl` must be provided. If multiple are specified, the order of precedence is `httpUrl`, then `url`, then `command`. - -> [!WARNING] -> Avoid using underscores (`_`) in your server aliases (e.g., use +> **Warning:** Avoid using underscores (`_`) in your server aliases (e.g., use > `my-server` instead of `my_server`). The underlying policy engine parses Fully > Qualified Names (`mcp_server_tool`) using the first underscore after the > `mcp_` prefix. An underscore in your server alias will cause the parser to diff --git a/docs/reference/policy-engine.md b/docs/reference/policy-engine.md index c0ce814793..495a4584e1 100644 --- a/docs/reference/policy-engine.md +++ b/docs/reference/policy-engine.md @@ -90,17 +90,6 @@ If `argsPattern` is specified, the tool's arguments are converted to a stable JSON string, which is then tested against the provided regular expression. If the arguments don't match the pattern, the rule does not apply. -#### Execution environment - -If `interactive` is specified, the rule will only apply if the CLI's execution -environment matches the specified boolean value: - -- `true`: The rule applies only in interactive mode. -- `false`: The rule applies only in non-interactive (headless) mode. - -If omitted, the rule applies to both interactive and non-interactive -environments. - ### Decisions There are three possible decisions a rule can enforce: @@ -113,9 +102,7 @@ There are three possible decisions a rule can enforce: - `ask_user`: The user is prompted to approve or deny the tool call. (In non-interactive mode, this is treated as `deny`.) - -> [!NOTE] -> The `deny` decision is the recommended way to exclude tools. The +> **Note:** The `deny` decision is the recommended way to exclude tools. The > legacy `tools.exclude` setting in `settings.json` is deprecated in favor of > policy rules with a `deny` decision. @@ -241,17 +228,15 @@ directory are **ignored**. - **Linux / macOS:** Must be owned by `root` (UID 0) and NOT writable by group or others (e.g., `chmod 755`). - **Windows:** Must be in `C:\ProgramData`. Standard users (`Users`, `Everyone`) - must NOT have `Write`, `Modify`, or `Full Control` permissions. If you see a - security warning, use the folder properties to remove write permissions for - non-admin groups. You may need to "Disable inheritance" in Advanced Security - Settings. + must NOT have `Write`, `Modify`, or `Full Control` permissions. _Tip: If you + see a security warning, use the folder properties to remove write permissions + for non-admin groups. You may need to "Disable inheritance" in Advanced + Security Settings._ - -> [!NOTE] -> Supplemental admin policies (provided via `--admin-policy` or -> `adminPolicyPaths` settings) are **NOT** subject to these strict ownership -> checks, as they are explicitly provided by the user or administrator in their -> current execution context. +**Note:** Supplemental admin policies (provided via `--admin-policy` or +`adminPolicyPaths` settings) are **NOT** subject to these strict ownership +checks, as they are explicitly provided by the user or administrator in their +current execution context. ### TOML rule schema @@ -301,10 +286,6 @@ deny_message = "Deletion is permanent" # (Optional) An array of approval modes where this rule is active. modes = ["autoEdit"] - -# (Optional) A boolean to restrict the rule to interactive (true) or non-interactive (false) environments. -# If omitted, the rule applies to both. -interactive = true ``` ### Using arrays (lists) @@ -352,9 +333,7 @@ using the `mcpName` field. **This is the recommended approach** for defining MCP policies, as it is much more robust than manually writing Fully Qualified Names (FQNs) or string wildcards. - -> [!WARNING] -> Do not use underscores (`_`) in your MCP server names (e.g., use +> **Warning:** Do not use underscores (`_`) in your MCP server names (e.g., use > `my-server` rather than `my_server`). The policy parser splits Fully Qualified > Names (`mcp_server_tool`) on the _first_ underscore following the `mcp_` > prefix. If your server name contains an underscore, the parser will @@ -381,8 +360,6 @@ priority = 200 Specify only the `mcpName` to apply a rule to every tool provided by that server. -**Note:** This applies to all decision types (`allow`, `deny`, `ask_user`). - ```toml # Denies all tools from the `untrusted-server` MCP [[rule]] diff --git a/docs/reference/tools.md b/docs/reference/tools.md index c72888d072..e1a0958866 100644 --- a/docs/reference/tools.md +++ b/docs/reference/tools.md @@ -95,9 +95,7 @@ For developers, the tool system is designed to be extensible and robust. The You can extend Gemini CLI with custom tools by configuring `tools.discoveryCommand` in your settings or by connecting to MCP servers. - -> [!NOTE] -> For a deep dive into the internal Tool API and how to implement your +> **Note:** For a deep dive into the internal Tool API and how to implement your > own tools in the codebase, see the `packages/core/src/tools/` directory in > GitHub. diff --git a/docs/release-confidence.md b/docs/release-confidence.md index c46a702820..536e49772c 100644 --- a/docs/release-confidence.md +++ b/docs/release-confidence.md @@ -21,13 +21,9 @@ All workflows in `.github/workflows/ci.yml` must pass on the `main` branch (for nightly) or the release branch (for preview/stable). - **Platforms:** Tests must pass on **Linux and macOS**. - - -> [!NOTE] -> Windows tests currently run with `continue-on-error: true`. While a -> failure here doesn't block the release technically, it should be -> investigated. - + - _Note:_ Windows tests currently run with `continue-on-error: true`. While a + failure here doesn't block the release technically, it should be + investigated. - **Checks:** - **Linting:** No linting errors (ESLint, Prettier, etc.). - **Typechecking:** No TypeScript errors. diff --git a/docs/releases.md b/docs/releases.md index 23fb9fcf90..8b506d45a8 100644 --- a/docs/releases.md +++ b/docs/releases.md @@ -234,12 +234,10 @@ This workflow will automatically: Review the automatically created pull request(s) to ensure the cherry-pick was successful and the changes are correct. Once approved, merge the pull request. - -> [!WARNING] -> The `release/*` branches are protected by branch protection -> rules. A pull request to one of these branches requires at least one review from -> a code owner before it can be merged. This ensures that no unauthorized code is -> released. +**Security note:** The `release/*` branches are protected by branch protection +rules. A pull request to one of these branches requires at least one review from +a code owner before it can be merged. This ensures that no unauthorized code is +released. #### 2.5. Adding multiple commits to a hotfix (advanced) @@ -526,11 +524,9 @@ Notifications use [GitHub for Google Chat](https://workspace.google.com/marketplace/app/github_for_google_chat/536184076190). To modify the notifications, use `/github-settings` within the chat space. - -> [!WARNING] -> The following instructions describe a fragile workaround that depends on the -> internal structure of the chat application's UI. It is likely to break with -> future updates. +> [!WARNING] The following instructions describe a fragile workaround that +> depends on the internal structure of the chat application's UI. It is likely +> to break with future updates. The list of available labels is not currently populated correctly. If you want to add a label that does not appear alphabetically in the first 30 labels in the diff --git a/docs/resources/faq.md b/docs/resources/faq.md index 8d1b42d032..580d7875f3 100644 --- a/docs/resources/faq.md +++ b/docs/resources/faq.md @@ -58,19 +58,6 @@ your total token usage using the `/stats` command in Gemini CLI. ## Installation and updates -### How do I check which version of Gemini CLI I'm currently running? - -You can check your current Gemini CLI version using one of these methods: - -- Run `gemini --version` or `gemini -v` from your terminal -- Check the globally installed version using your package manager: - - npm: `npm list -g @google/gemini-cli` - - pnpm: `pnpm list -g @google/gemini-cli` - - yarn: `yarn global list @google/gemini-cli` - - bun: `bun pm ls -g @google/gemini-cli` - - homebrew: `brew list --versions gemini-cli` -- Inside an active Gemini CLI session, use the `/about` command - ### How do I update Gemini CLI to the latest version? If you installed it globally via `npm`, update it using the command diff --git a/docs/resources/tos-privacy.md b/docs/resources/tos-privacy.md index 2aaa14cb90..00de950e74 100644 --- a/docs/resources/tos-privacy.md +++ b/docs/resources/tos-privacy.md @@ -16,10 +16,8 @@ account. Your Gemini CLI Usage Statistics are handled in accordance with Google's Privacy Policy. - -> [!NOTE] -> See [quotas and pricing](quota-and-pricing.md) for the quota and -> pricing details that apply to your usage of the Gemini CLI. +**Note:** See [quotas and pricing](quota-and-pricing.md) for the quota and +pricing details that apply to your usage of the Gemini CLI. ## Supported authentication methods diff --git a/docs/resources/troubleshooting.md b/docs/resources/troubleshooting.md index f490d41ffe..53b0262d36 100644 --- a/docs/resources/troubleshooting.md +++ b/docs/resources/troubleshooting.md @@ -187,7 +187,5 @@ guide_, consider searching the Gemini CLI If you can't find an issue similar to yours, consider creating a new GitHub Issue with a detailed description. Pull requests are also welcome! - -> [!NOTE] -> Issues tagged as "🔒Maintainers only" are reserved for project +> **Note:** Issues tagged as "🔒Maintainers only" are reserved for project > maintainers. We will not accept pull requests related to these issues. diff --git a/docs/sidebar.json b/docs/sidebar.json index 7198a0336b..6cac5ec9fd 100644 --- a/docs/sidebar.json +++ b/docs/sidebar.json @@ -99,11 +99,6 @@ { "label": "Agent Skills", "slug": "docs/cli/skills" }, { "label": "Checkpointing", "slug": "docs/cli/checkpointing" }, { "label": "Headless mode", "slug": "docs/cli/headless" }, - { - "label": "Git worktrees", - "badge": "🔬", - "slug": "docs/cli/git-worktrees" - }, { "label": "Hooks", "collapsed": true, diff --git a/docs/tools/mcp-server.md b/docs/tools/mcp-server.md index 9fc84d54c0..5cdbbacf1c 100644 --- a/docs/tools/mcp-server.md +++ b/docs/tools/mcp-server.md @@ -176,8 +176,8 @@ Each server configuration supports the following properties: enabled by default. - **`excludeTools`** (string[]): List of tool names to exclude from this MCP server. Tools listed here will not be available to the model, even if they are - exposed by the server. `excludeTools` takes precedence over `includeTools`. If - a tool is in both lists, it will be excluded. + exposed by the server. **Note:** `excludeTools` takes precedence over + `includeTools` - if a tool is in both lists, it will be excluded. - **`targetAudience`** (string): The OAuth Client ID allowlisted on the IAP-protected application you are trying to access. Used with `authProviderType: 'service_account_impersonation'`. @@ -238,9 +238,7 @@ This follows the security principle that if a variable is explicitly configured by the user for a specific server, it constitutes informed consent to share that specific data with that server. - -> [!NOTE] -> Even when explicitly defined, you should avoid hardcoding secrets. +> **Note:** Even when explicitly defined, you should avoid hardcoding secrets. > Instead, use environment variable expansion (e.g., `"MY_KEY": "$MY_KEY"`) to > securely pull the value from your host environment at runtime. @@ -285,12 +283,10 @@ When connecting to an OAuth-enabled server: #### Browser redirect requirements - -> [!IMPORTANT] -> OAuth authentication requires that your local machine can: -> -> - Open a web browser for authentication -> - Receive redirects on `http://localhost:7777/oauth/callback` +**Important:** OAuth authentication requires that your local machine can: + +- Open a web browser for authentication +- Receive redirects on `http://localhost:7777/oauth/callback` This feature will not work in: @@ -581,9 +577,7 @@ every discovered MCP tool is assigned a strict namespace. [Special syntax for MCP tools](../reference/policy-engine.md#special-syntax-for-mcp-tools) in the Policy Engine documentation. - -> [!WARNING] -> Do not use underscores (`_`) in your MCP server names (e.g., use +> **Warning:** Do not use underscores (`_`) in your MCP server names (e.g., use > `my-server` rather than `my_server`). The policy parser splits Fully Qualified > Names (`mcp_server_tool`) on the _first_ underscore following the `mcp_` > prefix. If your server name contains an underscore, the parser will @@ -1122,9 +1116,7 @@ command has no flags. gemini mcp list ``` - -> [!NOTE] -> For security, `stdio` MCP servers (those using the +> **Note on Trust:** For security, `stdio` MCP servers (those using the > `command` property) are only tested and displayed as "Connected" if the > current folder is trusted. If the folder is untrusted, they will show as > "Disconnected". Use `gemini trust` to trust the current folder. diff --git a/docs/tools/planning.md b/docs/tools/planning.md index e554e47a34..9e9ab3d044 100644 --- a/docs/tools/planning.md +++ b/docs/tools/planning.md @@ -11,9 +11,7 @@ by the agent when you ask it to "start a plan" using natural language. In this mode, the agent is restricted to read-only tools to allow for safe exploration and planning. - -> [!NOTE] -> This tool is not available when the CLI is in YOLO mode. +> **Note:** This tool is not available when the CLI is in YOLO mode. - **Tool name:** `enter_plan_mode` - **Display name:** Enter Plan Mode diff --git a/docs/tools/shell.md b/docs/tools/shell.md index 26f0769e98..f31f571eca 100644 --- a/docs/tools/shell.md +++ b/docs/tools/shell.md @@ -57,8 +57,8 @@ implementation, which does not support interactive commands. ### Showing color in output To show color in the shell output, you need to set the `tools.shell.showColor` -setting to `true`. This setting only applies when -`tools.shell.enableInteractiveShell` is enabled. +setting to `true`. **Note: This setting only applies when +`tools.shell.enableInteractiveShell` is enabled.** **Example `settings.json`:** @@ -75,8 +75,8 @@ setting to `true`. This setting only applies when ### Setting the pager You can set a custom pager for the shell output by setting the -`tools.shell.pager` setting. The default pager is `cat`. This setting only -applies when `tools.shell.enableInteractiveShell` is enabled. +`tools.shell.pager` setting. The default pager is `cat`. **Note: This setting +only applies when `tools.shell.enableInteractiveShell` is enabled.** **Example `settings.json`:** diff --git a/docs/tools/todos.md b/docs/tools/todos.md index d198b872ea..abb44c0927 100644 --- a/docs/tools/todos.md +++ b/docs/tools/todos.md @@ -13,8 +13,7 @@ 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`, `cancelled`, or - `blocked`. + - `status` (enum): `pending`, `in_progress`, `completed`, or `cancelled`. ## Technical behavior diff --git a/eslint.config.js b/eslint.config.js index 38dec43857..99b1b28f4b 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -41,7 +41,7 @@ export default tseslint.config( { // Global ignores ignores: [ - '**/node_modules/**', + 'node_modules/*', 'eslint.config.js', 'packages/**/dist/**', 'bundle/**', @@ -50,7 +50,7 @@ export default tseslint.config( 'dist/**', 'evals/**', 'packages/test-utils/**', - '.gemini/**', + '.gemini/skills/**', '**/*.d.ts', ], }, @@ -319,12 +319,7 @@ export default tseslint.config( }, }, { - files: [ - './scripts/**/*.js', - 'packages/*/scripts/**/*.js', - 'esbuild.config.js', - 'packages/core/scripts/**/*.{js,mjs}', - ], + files: ['./scripts/**/*.js', 'esbuild.config.js', 'packages/core/scripts/**/*.{js,mjs}'], languageOptions: { globals: { ...globals.node, diff --git a/evals/plan_mode.eval.ts b/evals/plan_mode.eval.ts index a37e5f91b4..29566eab86 100644 --- a/evals/plan_mode.eval.ts +++ b/evals/plan_mode.eval.ts @@ -18,18 +18,6 @@ describe('plan_mode', () => { experimental: { plan: true }, }; - const getWriteTargets = (logs: any[]) => - logs - .filter((log) => ['write_file', 'replace'].includes(log.toolRequest.name)) - .map((log) => { - try { - return JSON.parse(log.toolRequest.args).file_path as string; - } catch { - return ''; - } - }) - .filter(Boolean); - evalTest('ALWAYS_PASSES', { name: 'should refuse file modification when in plan mode', approvalMode: ApprovalMode.PLAN, @@ -44,23 +32,27 @@ describe('plan_mode', () => { await rig.waitForTelemetryReady(); const toolLogs = rig.readToolLogs(); - const exitPlanIndex = toolLogs.findIndex( - (log) => log.toolRequest.name === 'exit_plan_mode', - ); - - const writeTargetsBeforeExitPlan = getWriteTargets( - toolLogs.slice(0, exitPlanIndex !== -1 ? exitPlanIndex : undefined), - ); + const writeTargets = toolLogs + .filter((log) => + ['write_file', 'replace'].includes(log.toolRequest.name), + ) + .map((log) => { + try { + return JSON.parse(log.toolRequest.args).file_path; + } catch { + return null; + } + }); expect( - writeTargetsBeforeExitPlan, + writeTargets, 'Should not attempt to modify README.md in plan mode', ).not.toContain('README.md'); assertModelHasOutput(result); checkModelOutputContent(result, { expectedContent: [/plan mode|read-only|cannot modify|refuse|exiting/i], - testName: `${TEST_PREFIX}should refuse file modification in plan mode`, + testName: `${TEST_PREFIX}should refuse file modification`, }); }, }); @@ -77,20 +69,24 @@ describe('plan_mode', () => { await rig.waitForTelemetryReady(); const toolLogs = rig.readToolLogs(); - const exitPlanIndex = toolLogs.findIndex( - (log) => log.toolRequest.name === 'exit_plan_mode', - ); - - const writeTargetsBeforeExit = getWriteTargets( - toolLogs.slice(0, exitPlanIndex !== -1 ? exitPlanIndex : undefined), - ); + const writeTargets = toolLogs + .filter((log) => + ['write_file', 'replace'].includes(log.toolRequest.name), + ) + .map((log) => { + try { + return JSON.parse(log.toolRequest.args).file_path; + } catch { + return null; + } + }); // It should NOT write to the docs folder or any other repo path - const hasRepoWriteBeforeExit = writeTargetsBeforeExit.some( + const hasRepoWrite = writeTargets.some( (path) => path && !path.includes('/plans/'), ); expect( - hasRepoWriteBeforeExit, + hasRepoWrite, 'Should not attempt to create files in the repository while in plan mode', ).toBe(false); @@ -170,65 +166,4 @@ describe('plan_mode', () => { assertModelHasOutput(result); }, }); - - evalTest('USUALLY_PASSES', { - name: 'should create a plan in plan mode and implement it for a refactoring task', - params: { - settings, - }, - files: { - 'src/mathUtils.ts': - 'export const sum = (a: number, b: number) => a + b;\nexport const multiply = (a: number, b: number) => a * b;', - 'src/main.ts': - 'import { sum } from "./mathUtils";\nconsole.log(sum(1, 2));', - }, - prompt: - 'I want to refactor our math utilities. Move the `sum` function from `src/mathUtils.ts` to a new file `src/basicMath.ts` and update `src/main.ts` to use the new file. Please create a detailed implementation plan first, then execute it.', - assert: async (rig, result) => { - const enterPlanCalled = await rig.waitForToolCall('enter_plan_mode'); - expect( - enterPlanCalled, - 'Expected enter_plan_mode tool to be called', - ).toBe(true); - - const exitPlanCalled = await rig.waitForToolCall('exit_plan_mode'); - expect(exitPlanCalled, 'Expected exit_plan_mode tool to be called').toBe( - true, - ); - - await rig.waitForTelemetryReady(); - const toolLogs = rig.readToolLogs(); - - // Check if plan was written - const planWrite = toolLogs.find( - (log) => - log.toolRequest.name === 'write_file' && - log.toolRequest.args.includes('/plans/'), - ); - expect( - planWrite, - 'Expected a plan file to be written in the plans directory', - ).toBeDefined(); - - // Check for implementation files - const newFileWrite = toolLogs.find( - (log) => - log.toolRequest.name === 'write_file' && - log.toolRequest.args.includes('src/basicMath.ts'), - ); - expect( - newFileWrite, - 'Expected src/basicMath.ts to be created', - ).toBeDefined(); - - const mainUpdate = toolLogs.find( - (log) => - ['write_file', 'replace'].includes(log.toolRequest.name) && - log.toolRequest.args.includes('src/main.ts'), - ); - expect(mainUpdate, 'Expected src/main.ts to be updated').toBeDefined(); - - assertModelHasOutput(result); - }, - }); }); diff --git a/integration-tests/browser-policy.responses b/integration-tests/browser-policy.responses deleted file mode 100644 index 23d14e0cb3..0000000000 --- a/integration-tests/browser-policy.responses +++ /dev/null @@ -1,5 +0,0 @@ -{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"I'll help you with that."},{"functionCall":{"name":"browser_agent","args":{"task":"Open https://example.com and check if there is a heading"}}}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":100,"candidatesTokenCount":50,"totalTokenCount":150}}]} -{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"new_page","args":{"url":"https://example.com"}}}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":100,"candidatesTokenCount":50,"totalTokenCount":150}}]} -{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"take_snapshot","args":{}}}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":100,"candidatesTokenCount":50,"totalTokenCount":150}}]} -{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"complete_task","args":{"success":true,"summary":"SUCCESS_POLICY_TEST_COMPLETED"}}}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":100,"candidatesTokenCount":50,"totalTokenCount":150}}]} -{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"Task completed successfully. The page has the heading \"Example Domain\"."}],"role":"model"},"finishReason":"STOP","index":0}],"usageMetadata":{"promptTokenCount":200,"candidatesTokenCount":50,"totalTokenCount":250}}]} diff --git a/integration-tests/browser-policy.test.ts b/integration-tests/browser-policy.test.ts deleted file mode 100644 index 1bfdc27415..0000000000 --- a/integration-tests/browser-policy.test.ts +++ /dev/null @@ -1,178 +0,0 @@ -/** - * @license - * Copyright 2026 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { TestRig, poll } from './test-helper.js'; -import { dirname, join } from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { execSync } from 'node:child_process'; -import { existsSync, writeFileSync, readFileSync, mkdirSync } from 'node:fs'; -import stripAnsi from 'strip-ansi'; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = dirname(__filename); - -const chromeAvailable = (() => { - try { - if (process.platform === 'darwin') { - execSync( - 'test -d "/Applications/Google Chrome.app" || test -d "/Applications/Chromium.app"', - { - stdio: 'ignore', - }, - ); - } else if (process.platform === 'linux') { - execSync( - 'which google-chrome || which chromium-browser || which chromium', - { stdio: 'ignore' }, - ); - } else if (process.platform === 'win32') { - const chromePaths = [ - 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe', - 'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe', - `${process.env['LOCALAPPDATA'] ?? ''}\\Google\\Chrome\\Application\\chrome.exe`, - ]; - const found = chromePaths.some((p) => existsSync(p)); - if (!found) { - execSync('where chrome || where chromium', { stdio: 'ignore' }); - } - } else { - return false; - } - return true; - } catch { - return false; - } -})(); - -describe.skipIf(!chromeAvailable)('browser-policy', () => { - let rig: TestRig; - - beforeEach(() => { - rig = new TestRig(); - }); - - afterEach(async () => { - await rig.cleanup(); - }); - - it('should skip confirmation when "Allow all server tools for this session" is chosen', async () => { - rig.setup('browser-policy-skip-confirmation', { - fakeResponsesPath: join(__dirname, 'browser-policy.responses'), - settings: { - agents: { - overrides: { - browser_agent: { - enabled: true, - }, - }, - browser: { - headless: true, - sessionMode: 'isolated', - allowedDomains: ['example.com'], - }, - }, - }, - }); - - // Manually trust the folder to avoid the dialog and enable option 3 - const geminiDir = join(rig.homeDir!, '.gemini'); - mkdirSync(geminiDir, { recursive: true }); - - // Write to trustedFolders.json - const trustedFoldersPath = join(geminiDir, 'trustedFolders.json'); - const trustedFolders = { - [rig.testDir!]: 'TRUST_FOLDER', - }; - writeFileSync(trustedFoldersPath, JSON.stringify(trustedFolders, null, 2)); - - // Force confirmation for browser agent. - // NOTE: We don't force confirm browser tools here because "Allow all server tools" - // adds a rule with ALWAYS_ALLOW_PRIORITY (3.9x) which would be overshadowed by - // a rule in the user tier (4.x) like the one from this TOML. - // By removing the explicit mcp rule, the first MCP tool will still prompt - // due to default approvalMode = 'default', and then "Allow all" will correctly - // bypass subsequent tools. - const policyFile = join(rig.testDir!, 'force-confirm.toml'); - writeFileSync( - policyFile, - ` -[[rule]] -name = "Force confirm browser_agent" -toolName = "browser_agent" -decision = "ask_user" -priority = 200 -`, - ); - - // Update settings.json in both project and home directories to point to the policy file - for (const baseDir of [rig.testDir!, rig.homeDir!]) { - const settingsPath = join(baseDir, '.gemini', 'settings.json'); - if (existsSync(settingsPath)) { - const settings = JSON.parse(readFileSync(settingsPath, 'utf-8')); - settings.policyPaths = [policyFile]; - // Ensure folder trust is enabled - settings.security = settings.security || {}; - settings.security.folderTrust = settings.security.folderTrust || {}; - settings.security.folderTrust.enabled = true; - writeFileSync(settingsPath, JSON.stringify(settings, null, 2)); - } - } - - const run = await rig.runInteractive({ - approvalMode: 'default', - env: { - GEMINI_CLI_INTEGRATION_TEST: 'true', - }, - }); - - await run.sendKeys( - 'Open https://example.com and check if there is a heading\r', - ); - await run.sendKeys('\r'); - - // Handle confirmations. - // 1. Initial browser_agent delegation (likely only 3 options, so use option 1: Allow once) - await poll( - () => stripAnsi(run.output).toLowerCase().includes('action required'), - 60000, - 1000, - ); - await run.sendKeys('1\r'); - await new Promise((r) => setTimeout(r, 2000)); - - // Handle privacy notice - await poll( - () => stripAnsi(run.output).toLowerCase().includes('privacy notice'), - 5000, - 100, - ); - await run.sendKeys('1\r'); - await new Promise((r) => setTimeout(r, 5000)); - - // new_page (MCP tool, should have 4 options, use option 3: Allow all server tools) - await poll( - () => { - const stripped = stripAnsi(run.output).toLowerCase(); - return ( - stripped.includes('new_page') && - stripped.includes('allow all server tools for this session') - ); - }, - 60000, - 1000, - ); - - // Select "Allow all server tools for this session" (option 3) - await run.sendKeys('3\r'); - await new Promise((r) => setTimeout(r, 30000)); - - const output = stripAnsi(run.output).toLowerCase(); - - expect(output).toContain('browser_agent'); - expect(output).toContain('completed successfully'); - }); -}); diff --git a/package-lock.json b/package-lock.json index b70dc1413b..914d66d3ac 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,7 +22,7 @@ "gemini": "bundle/gemini.js" }, "devDependencies": { - "@agentclientprotocol/sdk": "^0.16.1", + "@agentclientprotocol/sdk": "^0.12.0", "@octokit/rest": "^22.0.0", "@types/marked": "^5.0.2", "@types/mime-types": "^3.0.1", @@ -84,9 +84,9 @@ } }, "node_modules/@agentclientprotocol/sdk": { - "version": "0.16.1", - "resolved": "https://registry.npmjs.org/@agentclientprotocol/sdk/-/sdk-0.16.1.tgz", - "integrity": "sha512-1ad+Sc/0sCtZGHthxxvgEUo5Wsbw16I+aF+YwdiLnPwkZG8KAGUEAPK6LM6Pf69lCyJPt1Aomk1d+8oE3C4ZEw==", + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@agentclientprotocol/sdk/-/sdk-0.12.0.tgz", + "integrity": "sha512-V8uH/KK1t7utqyJmTA7y7DzKu6+jKFIXM+ZVouz8E55j8Ej2RV42rEvPKn3/PpBJlliI5crcGk1qQhZ7VwaepA==", "license": "Apache-2.0", "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" @@ -17531,7 +17531,7 @@ "version": "0.36.0-nightly.20260317.2f90b4653", "license": "Apache-2.0", "dependencies": { - "@agentclientprotocol/sdk": "^0.16.1", + "@agentclientprotocol/sdk": "^0.12.0", "@google/gemini-cli-core": "file:../core", "@google/genai": "1.30.0", "@iarna/toml": "^2.2.5", diff --git a/package.json b/package.json index 72676cf90b..54f7700934 100644 --- a/package.json +++ b/package.json @@ -43,7 +43,6 @@ "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", @@ -87,7 +86,7 @@ "LICENSE" ], "devDependencies": { - "@agentclientprotocol/sdk": "^0.16.1", + "@agentclientprotocol/sdk": "^0.12.0", "@octokit/rest": "^22.0.0", "@types/marked": "^5.0.2", "@types/mime-types": "^3.0.1", diff --git a/packages/a2a-server/src/agent/task.ts b/packages/a2a-server/src/agent/task.ts index a76054263f..b6654abb72 100644 --- a/packages/a2a-server/src/agent/task.ts +++ b/packages/a2a-server/src/agent/task.ts @@ -880,9 +880,7 @@ export class Task { if ( part.kind !== 'data' || !part.data || - // eslint-disable-next-line no-restricted-syntax typeof part.data['callId'] !== 'string' || - // eslint-disable-next-line no-restricted-syntax typeof part.data['outcome'] !== 'string' ) { return false; diff --git a/packages/a2a-server/src/config/config.test.ts b/packages/a2a-server/src/config/config.test.ts index cfe77311ea..bd8771d1b5 100644 --- a/packages/a2a-server/src/config/config.test.ts +++ b/packages/a2a-server/src/config/config.test.ts @@ -19,8 +19,6 @@ import { AuthType, isHeadlessMode, FatalAuthenticationError, - PolicyDecision, - PRIORITY_YOLO_ALLOW_ALL, } from '@google/gemini-cli-core'; // Mock dependencies @@ -327,29 +325,6 @@ 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); @@ -374,41 +349,6 @@ 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'); diff --git a/packages/a2a-server/src/config/config.ts b/packages/a2a-server/src/config/config.ts index 9474c4d9c5..607695f173 100644 --- a/packages/a2a-server/src/config/config.ts +++ b/packages/a2a-server/src/config/config.ts @@ -26,8 +26,6 @@ import { isHeadlessMode, FatalAuthenticationError, isCloudShell, - PolicyDecision, - PRIORITY_YOLO_ALLOW_ALL, type TelemetryTarget, type ConfigParameters, type ExtensionLoader, @@ -62,11 +60,6 @@ export async function loadConfig( } } - const approvalMode = - process.env['GEMINI_YOLO_MODE'] === 'true' - ? ApprovalMode.YOLO - : ApprovalMode.DEFAULT; - const configParams: ConfigParameters = { sessionId: taskId, clientName: 'a2a-server', @@ -81,20 +74,10 @@ export async function loadConfig( excludeTools: settings.excludeTools || settings.tools?.exclude || undefined, allowedTools: settings.allowedTools || settings.tools?.allowed || undefined, showMemoryUsage: settings.showMemoryUsage || false, - approvalMode, - policyEngineConfig: { - rules: - approvalMode === ApprovalMode.YOLO - ? [ - { - decision: PolicyDecision.ALLOW, - priority: PRIORITY_YOLO_ALLOW_ALL, - modes: [ApprovalMode.YOLO], - allowRedirection: true, - }, - ] - : [], - }, + approvalMode: + process.env['GEMINI_YOLO_MODE'] === 'true' + ? ApprovalMode.YOLO + : ApprovalMode.DEFAULT, mcpServers: settings.mcpServers, cwd: workspaceDir, telemetry: { @@ -127,7 +110,6 @@ export async function loadConfig( interactive: !isHeadlessMode(), enableInteractiveShell: !isHeadlessMode(), ptyInfo: 'auto', - enableAgents: settings.experimental?.enableAgents ?? true, }; const fileService = new FileDiscoveryService(workspaceDir, { diff --git a/packages/a2a-server/src/config/settings.test.ts b/packages/a2a-server/src/config/settings.test.ts index ab80bced24..7c51950535 100644 --- a/packages/a2a-server/src/config/settings.test.ts +++ b/packages/a2a-server/src/config/settings.test.ts @@ -112,18 +112,6 @@ 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, diff --git a/packages/a2a-server/src/config/settings.ts b/packages/a2a-server/src/config/settings.ts index ced11a4daa..da9db4e069 100644 --- a/packages/a2a-server/src/config/settings.ts +++ b/packages/a2a-server/src/config/settings.ts @@ -48,9 +48,6 @@ export interface Settings { enableRecursiveFileSearch?: boolean; customIgnoreFilePaths?: string[]; }; - experimental?: { - enableAgents?: boolean; - }; } export interface SettingsError { diff --git a/packages/cli/package.json b/packages/cli/package.json index 40acd6cf88..95de41454d 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -20,7 +20,6 @@ "format": "prettier --write .", "test": "vitest run", "test:ci": "vitest run", - "posttest": "npm run build", "typecheck": "tsc --noEmit" }, "files": [ @@ -30,7 +29,7 @@ "sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.36.0-nightly.20260317.2f90b4653" }, "dependencies": { - "@agentclientprotocol/sdk": "^0.16.1", + "@agentclientprotocol/sdk": "^0.12.0", "@google/gemini-cli-core": "file:../core", "@google/genai": "1.30.0", "@iarna/toml": "^2.2.5", diff --git a/packages/cli/src/acp/acpClient.test.ts b/packages/cli/src/acp/acpClient.test.ts index 0f9c4a8e5b..65b23247ef 100644 --- a/packages/cli/src/acp/acpClient.test.ts +++ b/packages/cli/src/acp/acpClient.test.ts @@ -177,9 +177,6 @@ describe('GeminiAgent', () => { getHasAccessToPreviewModel: vi.fn().mockReturnValue(false), getCheckpointingEnabled: vi.fn().mockReturnValue(false), getDisableAlwaysAllow: vi.fn().mockReturnValue(false), - get config() { - return this; - }, } as unknown as Mocked>>; mockSettings = { merged: { @@ -551,7 +548,7 @@ describe('GeminiAgent', () => { }); expect(session.prompt).toHaveBeenCalled(); - expect(result).toMatchObject({ stopReason: 'end_turn' }); + expect(result).toEqual({ stopReason: 'end_turn' }); }); it('should delegate setMode to session', async () => { @@ -659,12 +656,6 @@ describe('Session', () => { getGitService: vi.fn().mockResolvedValue({} as GitService), waitForMcpInit: vi.fn(), getDisableAlwaysAllow: vi.fn().mockReturnValue(false), - get config() { - return this; - }, - get toolRegistry() { - return mockToolRegistry; - }, } as unknown as Mocked; mockConnection = { sessionUpdate: vi.fn(), @@ -750,7 +741,7 @@ describe('Session', () => { content: { type: 'text', text: 'Hello' }, }, }); - expect(result).toMatchObject({ stopReason: 'end_turn' }); + expect(result).toEqual({ stopReason: 'end_turn' }); }); it('should handle /memory command', async () => { @@ -767,7 +758,7 @@ describe('Session', () => { prompt: [{ type: 'text', text: '/memory view' }], }); - expect(result).toMatchObject({ stopReason: 'end_turn' }); + expect(result).toEqual({ stopReason: 'end_turn' }); expect(handleCommandSpy).toHaveBeenCalledWith( '/memory view', expect.any(Object), @@ -789,7 +780,7 @@ describe('Session', () => { prompt: [{ type: 'text', text: '/extensions list' }], }); - expect(result).toMatchObject({ stopReason: 'end_turn' }); + expect(result).toEqual({ stopReason: 'end_turn' }); expect(handleCommandSpy).toHaveBeenCalledWith( '/extensions list', expect.any(Object), @@ -811,7 +802,7 @@ describe('Session', () => { prompt: [{ type: 'text', text: '/extensions explore' }], }); - expect(result).toMatchObject({ stopReason: 'end_turn' }); + expect(result).toEqual({ stopReason: 'end_turn' }); expect(handleCommandSpy).toHaveBeenCalledWith( '/extensions explore', expect.any(Object), @@ -833,7 +824,7 @@ describe('Session', () => { prompt: [{ type: 'text', text: '/restore' }], }); - expect(result).toMatchObject({ stopReason: 'end_turn' }); + expect(result).toEqual({ stopReason: 'end_turn' }); expect(handleCommandSpy).toHaveBeenCalledWith( '/restore', expect.any(Object), @@ -855,7 +846,7 @@ describe('Session', () => { prompt: [{ type: 'text', text: '/init' }], }); - expect(result).toMatchObject({ stopReason: 'end_turn' }); + expect(result).toEqual({ stopReason: 'end_turn' }); expect(handleCommandSpy).toHaveBeenCalledWith('/init', expect.any(Object)); expect(mockChat.sendMessageStream).not.toHaveBeenCalled(); }); @@ -903,13 +894,10 @@ describe('Session', () => { update: expect.objectContaining({ sessionUpdate: 'tool_call_update', status: 'completed', - title: 'Test Tool', - locations: [], - kind: 'read', }), }), ); - expect(result).toMatchObject({ stopReason: 'end_turn' }); + expect(result).toEqual({ stopReason: 'end_turn' }); }); it('should handle tool call permission request', async () => { @@ -1318,18 +1306,6 @@ describe('Session', () => { expect(path.resolve).toHaveBeenCalled(); expect(fs.stat).toHaveBeenCalled(); - expect(mockConnection.sessionUpdate).toHaveBeenCalledWith( - expect.objectContaining({ - update: expect.objectContaining({ - sessionUpdate: 'tool_call_update', - status: 'completed', - title: 'Read files', - locations: [], - kind: 'read', - }), - }), - ); - // Verify ReadManyFilesTool was used (implicitly by checking if sendMessageStream was called with resolved content) // Since we mocked ReadManyFilesTool to return specific content, we can check the args passed to sendMessageStream expect(mockChat.sendMessageStream).toHaveBeenCalledWith( @@ -1345,65 +1321,6 @@ describe('Session', () => { ); }); - it('should handle @path resolution error', async () => { - (path.resolve as unknown as Mock).mockReturnValue('/tmp/error.txt'); - (fs.stat as unknown as Mock).mockResolvedValue({ - isDirectory: () => false, - }); - (isWithinRoot as unknown as Mock).mockReturnValue(true); - - const MockReadManyFilesTool = ReadManyFilesTool as unknown as Mock; - MockReadManyFilesTool.mockImplementationOnce(() => ({ - name: 'read_many_files', - kind: 'read', - build: vi.fn().mockReturnValue({ - getDescription: () => 'Read files', - toolLocations: () => [], - execute: vi.fn().mockRejectedValue(new Error('File read failed')), - }), - })); - - const stream = createMockStream([ - { - type: StreamEventType.CHUNK, - value: { candidates: [] }, - }, - ]); - mockChat.sendMessageStream.mockResolvedValue(stream); - - await expect( - session.prompt({ - sessionId: 'session-1', - prompt: [ - { type: 'text', text: 'Read' }, - { - type: 'resource_link', - uri: 'file://error.txt', - mimeType: 'text/plain', - name: 'error.txt', - }, - ], - }), - ).rejects.toThrow('File read failed'); - - expect(mockConnection.sessionUpdate).toHaveBeenCalledWith( - expect.objectContaining({ - update: expect.objectContaining({ - sessionUpdate: 'tool_call_update', - status: 'failed', - content: expect.arrayContaining([ - expect.objectContaining({ - content: expect.objectContaining({ - text: expect.stringMatching(/File read failed/), - }), - }), - ]), - kind: 'read', - }), - }), - ); - }); - it('should handle cancellation during prompt', async () => { let streamController: ReadableStreamDefaultController; const stream = new ReadableStream({ @@ -1517,7 +1434,6 @@ describe('Session', () => { content: expect.objectContaining({ text: 'Tool failed' }), }), ]), - kind: 'read', }), }), ); diff --git a/packages/cli/src/acp/acpClient.ts b/packages/cli/src/acp/acpClient.ts index 5e3f3666b1..072d91c20a 100644 --- a/packages/cli/src/acp/acpClient.ts +++ b/packages/cli/src/acp/acpClient.ts @@ -47,7 +47,6 @@ import { DEFAULT_GEMINI_MODEL_AUTO, PREVIEW_GEMINI_MODEL_AUTO, getDisplayString, - type AgentLoopContext, } from '@google/gemini-cli-core'; import * as acp from '@agentclientprotocol/sdk'; import { AcpFileSystemService } from './fileSystemService.js'; @@ -105,7 +104,7 @@ export class GeminiAgent { private customHeaders: Record | undefined; constructor( - private context: AgentLoopContext, + private config: Config, private settings: LoadedSettings, private argv: CliArgs, private connection: acp.AgentSideConnection, @@ -149,7 +148,7 @@ export class GeminiAgent { }, ]; - await this.context.config.initialize(); + await this.config.initialize(); const version = await getVersion(); return { protocolVersion: acp.PROTOCOL_VERSION, @@ -221,7 +220,7 @@ export class GeminiAgent { this.baseUrl = baseUrl; this.customHeaders = headers; - await this.context.config.refreshAuth( + await this.config.refreshAuth( method, apiKey ?? this.apiKey, baseUrl, @@ -538,7 +537,7 @@ export class Session { constructor( private readonly id: string, private readonly chat: GeminiChat, - private readonly context: AgentLoopContext, + private readonly config: Config, private readonly connection: acp.AgentSideConnection, private readonly settings: LoadedSettings, ) {} @@ -553,15 +552,13 @@ export class Session { } setMode(modeId: acp.SessionModeId): acp.SetSessionModeResponse { - const availableModes = buildAvailableModes( - this.context.config.isPlanEnabled(), - ); + const availableModes = buildAvailableModes(this.config.isPlanEnabled()); const mode = availableModes.find((m) => m.id === modeId); if (!mode) { throw new Error(`Invalid or unavailable mode: ${modeId}`); } // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - this.context.config.setApprovalMode(mode.id as ApprovalMode); + this.config.setApprovalMode(mode.id as ApprovalMode); return {}; } @@ -582,7 +579,7 @@ export class Session { } setModel(modelId: acp.ModelId): acp.SetSessionModelResponse { - this.context.config.setModel(modelId); + this.config.setModel(modelId); return {}; } @@ -637,7 +634,7 @@ export class Session { } } - const tool = this.context.toolRegistry.getTool(toolCall.name); + const tool = this.config.getToolRegistry().getTool(toolCall.name); await this.sendUpdate({ sessionUpdate: 'tool_call', @@ -661,7 +658,7 @@ export class Session { const pendingSend = new AbortController(); this.pendingPrompt = pendingSend; - await this.context.config.waitForMcpInit(); + await this.config.waitForMcpInit(); const promptId = Math.random().toString(16).slice(2); const chat = this.chat; @@ -699,22 +696,10 @@ export class Session { // It uses `parts` argument but effectively ignores it in current implementation const handled = await this.handleCommand(commandText, parts); if (handled) { - return { - stopReason: 'end_turn', - _meta: { - quota: { - token_count: { input_tokens: 0, output_tokens: 0 }, - model_usage: [], - }, - }, - }; + return { stopReason: 'end_turn' }; } } - let totalInputTokens = 0; - let totalOutputTokens = 0; - const modelUsageMap = new Map(); - let nextMessage: Content | null = { role: 'user', parts }; while (nextMessage !== null) { @@ -727,8 +712,8 @@ export class Session { try { const model = resolveModel( - this.context.config.getModel(), - (await this.context.config.getGemini31Launched?.()) ?? false, + this.config.getModel(), + (await this.config.getGemini31Launched?.()) ?? false, ); const responseStream = await chat.sendMessageStream( { model }, @@ -739,25 +724,11 @@ export class Session { ); nextMessage = null; - let turnInputTokens = 0; - let turnOutputTokens = 0; - let turnModelId = model; - for await (const resp of responseStream) { if (pendingSend.signal.aborted) { return { stopReason: CoreToolCallStatus.Cancelled }; } - if (resp.type === StreamEventType.CHUNK && resp.value.usageMetadata) { - turnInputTokens = - resp.value.usageMetadata.promptTokenCount ?? turnInputTokens; - turnOutputTokens = - resp.value.usageMetadata.candidatesTokenCount ?? turnOutputTokens; - if (resp.value.modelVersion) { - turnModelId = resp.value.modelVersion; - } - } - if ( resp.type === StreamEventType.CHUNK && resp.value.candidates && @@ -789,19 +760,6 @@ export class Session { } } - totalInputTokens += turnInputTokens; - totalOutputTokens += turnOutputTokens; - - if (turnInputTokens > 0 || turnOutputTokens > 0) { - const existing = modelUsageMap.get(turnModelId) ?? { - input: 0, - output: 0, - }; - existing.input += turnInputTokens; - existing.output += turnOutputTokens; - modelUsageMap.set(turnModelId, existing); - } - if (pendingSend.signal.aborted) { return { stopReason: CoreToolCallStatus.Cancelled }; } @@ -838,28 +796,7 @@ export class Session { } } - const modelUsageArray = Array.from(modelUsageMap.entries()).map( - ([modelName, counts]) => ({ - model: modelName, - token_count: { - input_tokens: counts.input, - output_tokens: counts.output, - }, - }), - ); - - return { - stopReason: 'end_turn', - _meta: { - quota: { - token_count: { - input_tokens: totalInputTokens, - output_tokens: totalOutputTokens, - }, - model_usage: modelUsageArray, - }, - }, - }; + return { stopReason: 'end_turn' }; } private async handleCommand( @@ -867,9 +804,9 @@ export class Session { // eslint-disable-next-line @typescript-eslint/no-unused-vars parts: Part[], ): Promise { - const gitService = await this.context.config.getGitService(); + const gitService = await this.config.getGitService(); const commandContext = { - agentContext: this.context, + config: this.config, settings: this.settings, git: gitService, sendMessage: async (text: string) => { @@ -905,7 +842,7 @@ export class Session { const errorResponse = (error: Error) => { const durationMs = Date.now() - startTime; logToolCall( - this.context.config, + this.config, new ToolCallEvent( undefined, fc.name ?? '', @@ -935,7 +872,7 @@ export class Session { return errorResponse(new Error('Missing function name')); } - const toolRegistry = this.context.toolRegistry; + const toolRegistry = this.config.getToolRegistry(); const tool = toolRegistry.getTool(fc.name); if (!tool) { @@ -971,10 +908,7 @@ export class Session { const params: acp.RequestPermissionRequest = { sessionId: this.id, - options: toPermissionOptions( - confirmationDetails, - this.context.config, - ), + options: toPermissionOptions(confirmationDetails, this.config), toolCall: { toolCallId: callId, status: 'pending', @@ -1032,15 +966,12 @@ export class Session { sessionUpdate: 'tool_call_update', toolCallId: callId, status: 'completed', - title: invocation.getDescription(), content: content ? [content] : [], - locations: invocation.toolLocations(), - kind: toAcpToolKind(tool.kind), }); const durationMs = Date.now() - startTime; logToolCall( - this.context.config, + this.config, new ToolCallEvent( undefined, fc.name ?? '', @@ -1054,7 +985,7 @@ export class Session { ), ); - this.chat.recordCompletedToolCalls(this.context.config.getActiveModel(), [ + this.chat.recordCompletedToolCalls(this.config.getActiveModel(), [ { status: CoreToolCallStatus.Success, request: { @@ -1072,8 +1003,8 @@ export class Session { fc.name, callId, toolResult.llmContent, - this.context.config.getActiveModel(), - this.context.config, + this.config.getActiveModel(), + this.config, ), resultDisplay: toolResult.returnDisplay, error: undefined, @@ -1086,8 +1017,8 @@ export class Session { fc.name, callId, toolResult.llmContent, - this.context.config.getActiveModel(), - this.context.config, + this.config.getActiveModel(), + this.config, ); } catch (e) { const error = e instanceof Error ? e : new Error(String(e)); @@ -1099,10 +1030,9 @@ export class Session { content: [ { type: 'content', content: { type: 'text', text: error.message } }, ], - kind: toAcpToolKind(tool.kind), }); - this.chat.recordCompletedToolCalls(this.context.config.getActiveModel(), [ + this.chat.recordCompletedToolCalls(this.config.getActiveModel(), [ { status: CoreToolCallStatus.Error, request: { @@ -1188,18 +1118,18 @@ export class Session { const atPathToResolvedSpecMap = new Map(); // Get centralized file discovery service - const fileDiscovery = this.context.config.getFileService(); + const fileDiscovery = this.config.getFileService(); const fileFilteringOptions: FilterFilesOptions = - this.context.config.getFileFilteringOptions(); + this.config.getFileFilteringOptions(); const pathSpecsToRead: string[] = []; const contentLabelsForDisplay: string[] = []; const ignoredPaths: string[] = []; - const toolRegistry = this.context.toolRegistry; + const toolRegistry = this.config.getToolRegistry(); const readManyFilesTool = new ReadManyFilesTool( - this.context.config, - this.context.messageBus, + this.config, + this.config.getMessageBus(), ); const globTool = toolRegistry.getTool('glob'); @@ -1218,11 +1148,8 @@ export class Session { let currentPathSpec = pathName; let resolvedSuccessfully = false; try { - const absolutePath = path.resolve( - this.context.config.getTargetDir(), - pathName, - ); - if (isWithinRoot(absolutePath, this.context.config.getTargetDir())) { + const absolutePath = path.resolve(this.config.getTargetDir(), pathName); + if (isWithinRoot(absolutePath, this.config.getTargetDir())) { const stats = await fs.stat(absolutePath); if (stats.isDirectory()) { currentPathSpec = pathName.endsWith('/') @@ -1242,7 +1169,7 @@ export class Session { } } catch (error) { if (isNodeError(error) && error.code === 'ENOENT') { - if (this.context.config.getEnableRecursiveFileSearch() && globTool) { + if (this.config.getEnableRecursiveFileSearch() && globTool) { this.debug( `Path ${pathName} not found directly, attempting glob search.`, ); @@ -1250,7 +1177,7 @@ export class Session { const globResult = await globTool.buildAndExecute( { pattern: `**/*${pathName}*`, - path: this.context.config.getTargetDir(), + path: this.config.getTargetDir(), }, abortSignal, ); @@ -1264,7 +1191,7 @@ export class Session { if (lines.length > 1 && lines[1]) { const firstMatchAbsolute = lines[1].trim(); currentPathSpec = path.relative( - this.context.config.getTargetDir(), + this.config.getTargetDir(), firstMatchAbsolute, ); this.debug( @@ -1397,10 +1324,7 @@ export class Session { sessionUpdate: 'tool_call_update', toolCallId: callId, status: 'completed', - title: invocation.getDescription(), content: content ? [content] : [], - locations: invocation.toolLocations(), - kind: toAcpToolKind(readManyFilesTool.kind), }); if (Array.isArray(result.llmContent)) { const fileContentRegex = /^--- (.*?) ---\n\n([\s\S]*?)\n\n$/; @@ -1444,7 +1368,6 @@ export class Session { }, }, ], - kind: toAcpToolKind(readManyFilesTool.kind), }); throw error; @@ -1479,7 +1402,7 @@ export class Session { } debug(msg: string) { - if (this.context.config.getDebugMode()) { + if (this.config.getDebugMode()) { debugLogger.warn(msg); } } diff --git a/packages/cli/src/acp/acpResume.test.ts b/packages/cli/src/acp/acpResume.test.ts index 77021004ca..9668ef74f8 100644 --- a/packages/cli/src/acp/acpResume.test.ts +++ b/packages/cli/src/acp/acpResume.test.ts @@ -97,9 +97,6 @@ describe('GeminiAgent Session Resume', () => { getHasAccessToPreviewModel: vi.fn().mockReturnValue(false), getGemini31LaunchedSync: vi.fn().mockReturnValue(false), getCheckpointingEnabled: vi.fn().mockReturnValue(false), - get config() { - return this; - }, } as unknown as Mocked; mockSettings = { merged: { @@ -161,10 +158,9 @@ describe('GeminiAgent Session Resume', () => { ], }; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (mockConfig as any).toolRegistry = { + mockConfig.getToolRegistry = vi.fn().mockReturnValue({ getTool: vi.fn().mockReturnValue({ kind: 'read' }), - }; + }); (SessionSelector as unknown as Mock).mockImplementation(() => ({ resolveSession: vi.fn().mockResolvedValue({ diff --git a/packages/cli/src/acp/commands/extensions.ts b/packages/cli/src/acp/commands/extensions.ts index a6e08f9bbc..c2bd0e7190 100644 --- a/packages/cli/src/acp/commands/extensions.ts +++ b/packages/cli/src/acp/commands/extensions.ts @@ -53,7 +53,7 @@ export class ListExtensionsCommand implements Command { context: CommandContext, _: string[], ): Promise { - const extensions = listExtensions(context.agentContext.config); + const extensions = listExtensions(context.config); const data = extensions.length ? extensions : 'No extensions installed.'; return { name: this.name, data }; @@ -134,7 +134,7 @@ export class EnableExtensionCommand implements Command { args: string[], ): Promise { const enableContext = getEnableDisableContext( - context.agentContext.config, + context.config, args, 'enable', ); @@ -156,8 +156,7 @@ export class EnableExtensionCommand implements Command { if (extension?.mcpServers) { const mcpEnablementManager = McpServerEnablementManager.getInstance(); - const mcpClientManager = - context.agentContext.config.getMcpClientManager(); + const mcpClientManager = context.config.getMcpClientManager(); const enabledServers = await mcpEnablementManager.autoEnableServers( Object.keys(extension.mcpServers), ); @@ -192,7 +191,7 @@ export class DisableExtensionCommand implements Command { args: string[], ): Promise { const enableContext = getEnableDisableContext( - context.agentContext.config, + context.config, args, 'disable', ); @@ -224,7 +223,7 @@ export class InstallExtensionCommand implements Command { context: CommandContext, args: string[], ): Promise { - const extensionLoader = context.agentContext.config.getExtensionLoader(); + const extensionLoader = context.config.getExtensionLoader(); if (!(extensionLoader instanceof ExtensionManager)) { return { name: this.name, @@ -269,7 +268,7 @@ export class LinkExtensionCommand implements Command { context: CommandContext, args: string[], ): Promise { - const extensionLoader = context.agentContext.config.getExtensionLoader(); + const extensionLoader = context.config.getExtensionLoader(); if (!(extensionLoader instanceof ExtensionManager)) { return { name: this.name, @@ -314,7 +313,7 @@ export class UninstallExtensionCommand implements Command { context: CommandContext, args: string[], ): Promise { - const extensionLoader = context.agentContext.config.getExtensionLoader(); + const extensionLoader = context.config.getExtensionLoader(); if (!(extensionLoader instanceof ExtensionManager)) { return { name: this.name, @@ -370,7 +369,7 @@ export class RestartExtensionCommand implements Command { context: CommandContext, args: string[], ): Promise { - const extensionLoader = context.agentContext.config.getExtensionLoader(); + const extensionLoader = context.config.getExtensionLoader(); if (!(extensionLoader instanceof ExtensionManager)) { return { name: this.name, data: 'Cannot restart extensions.' }; } @@ -425,7 +424,7 @@ export class UpdateExtensionCommand implements Command { context: CommandContext, args: string[], ): Promise { - const extensionLoader = context.agentContext.config.getExtensionLoader(); + const extensionLoader = context.config.getExtensionLoader(); if (!(extensionLoader instanceof ExtensionManager)) { return { name: this.name, data: 'Cannot update extensions.' }; } diff --git a/packages/cli/src/acp/commands/init.ts b/packages/cli/src/acp/commands/init.ts index a9104aa84f..5c4197f84c 100644 --- a/packages/cli/src/acp/commands/init.ts +++ b/packages/cli/src/acp/commands/init.ts @@ -22,7 +22,7 @@ export class InitCommand implements Command { context: CommandContext, _args: string[] = [], ): Promise { - const targetDir = context.agentContext.config.getTargetDir(); + const targetDir = context.config.getTargetDir(); if (!targetDir) { throw new Error('Command requires a workspace.'); } diff --git a/packages/cli/src/acp/commands/memory.ts b/packages/cli/src/acp/commands/memory.ts index ac919f2a9b..f88aaac4f2 100644 --- a/packages/cli/src/acp/commands/memory.ts +++ b/packages/cli/src/acp/commands/memory.ts @@ -49,7 +49,7 @@ export class ShowMemoryCommand implements Command { context: CommandContext, _: string[], ): Promise { - const result = showMemory(context.agentContext.config); + const result = showMemory(context.config); return { name: this.name, data: result.content }; } } @@ -63,7 +63,7 @@ export class RefreshMemoryCommand implements Command { context: CommandContext, _: string[], ): Promise { - const result = await refreshMemory(context.agentContext.config); + const result = await refreshMemory(context.config); return { name: this.name, data: result.content }; } } @@ -76,7 +76,7 @@ export class ListMemoryCommand implements Command { context: CommandContext, _: string[], ): Promise { - const result = listMemoryFiles(context.agentContext.config); + const result = listMemoryFiles(context.config); return { name: this.name, data: result.content }; } } @@ -95,7 +95,7 @@ export class AddMemoryCommand implements Command { return { name: this.name, data: result.content }; } - const toolRegistry = context.agentContext.toolRegistry; + const toolRegistry = context.config.getToolRegistry(); const tool = toolRegistry.getTool(result.toolName); if (tool) { const abortController = new AbortController(); @@ -106,10 +106,10 @@ export class AddMemoryCommand implements Command { await tool.buildAndExecute(result.toolArgs, signal, undefined, { shellExecutionConfig: { sanitizationConfig: DEFAULT_SANITIZATION_CONFIG, - sandboxManager: context.agentContext.sandboxManager, + sandboxManager: context.config.sandboxManager, }, }); - await refreshMemory(context.agentContext.config); + await refreshMemory(context.config); return { name: this.name, data: `Added memory: "${textToAdd}"`, diff --git a/packages/cli/src/acp/commands/restore.ts b/packages/cli/src/acp/commands/restore.ts index 6898cff2e1..ec9166ed84 100644 --- a/packages/cli/src/acp/commands/restore.ts +++ b/packages/cli/src/acp/commands/restore.ts @@ -29,8 +29,7 @@ export class RestoreCommand implements Command { context: CommandContext, args: string[], ): Promise { - const { agentContext: agentContext, git: gitService } = context; - const { config } = agentContext; + const { config, git: gitService } = context; const argsStr = args.join(' '); try { @@ -117,7 +116,7 @@ export class ListCheckpointsCommand implements Command { readonly description = 'Lists all available checkpoints.'; async execute(context: CommandContext): Promise { - const { config } = context.agentContext; + const { config } = context; try { if (!config.getCheckpointingEnabled()) { diff --git a/packages/cli/src/acp/commands/types.ts b/packages/cli/src/acp/commands/types.ts index 6f5656bd89..099f0c923f 100644 --- a/packages/cli/src/acp/commands/types.ts +++ b/packages/cli/src/acp/commands/types.ts @@ -4,11 +4,11 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type { AgentLoopContext, GitService } from '@google/gemini-cli-core'; +import type { Config, GitService } from '@google/gemini-cli-core'; import type { LoadedSettings } from '../../config/settings.js'; export interface CommandContext { - agentContext: AgentLoopContext; + config: Config; settings: LoadedSettings; git?: GitService; sendMessage: (text: string) => Promise; diff --git a/packages/cli/src/acp/fileSystemService.ts b/packages/cli/src/acp/fileSystemService.ts index 02b9d68195..1d3c8ad0b8 100644 --- a/packages/cli/src/acp/fileSystemService.ts +++ b/packages/cli/src/acp/fileSystemService.ts @@ -14,7 +14,7 @@ export class AcpFileSystemService implements FileSystemService { constructor( private readonly connection: acp.AgentSideConnection, private readonly sessionId: string, - private readonly capabilities: acp.FileSystemCapabilities, + private readonly capabilities: acp.FileSystemCapability, private readonly fallback: FileSystemService, ) {} diff --git a/packages/cli/src/commands/extensions/install.test.ts b/packages/cli/src/commands/extensions/install.test.ts index 8b3f8c5807..417e750651 100644 --- a/packages/cli/src/commands/extensions/install.test.ts +++ b/packages/cli/src/commands/extensions/install.test.ts @@ -12,46 +12,48 @@ import { beforeEach, afterEach, type MockInstance, + type Mock, } from 'vitest'; import { handleInstall, installCommand } from './install.js'; import yargs from 'yargs'; import * as core from '@google/gemini-cli-core'; +import { + ExtensionManager, + type inferInstallMetadata, +} from '../../config/extension-manager.js'; +import type { + promptForConsentNonInteractive, + requestConsentNonInteractive, +} from '../../config/extensions/consent.js'; +import type { + isWorkspaceTrusted, + loadTrustedFolders, +} from '../../config/trustedFolders.js'; +import type * as fs from 'node:fs/promises'; import type { Stats } from 'node:fs'; import * as path from 'node:path'; -import { promptForSetting } from '../../config/extensions/extensionSettings.js'; -const { - mockInstallOrUpdateExtension, - mockLoadExtensions, - mockExtensionManager, - mockRequestConsentNonInteractive, - mockPromptForConsentNonInteractive, - mockStat, - mockInferInstallMetadata, - mockIsWorkspaceTrusted, - mockLoadTrustedFolders, - mockDiscover, -} = vi.hoisted(() => { - const mockLoadExtensions = vi.fn(); - const mockInstallOrUpdateExtension = vi.fn(); - const mockExtensionManager = vi.fn().mockImplementation(() => ({ - loadExtensions: mockLoadExtensions, - installOrUpdateExtension: mockInstallOrUpdateExtension, - })); - - return { - mockLoadExtensions, - mockInstallOrUpdateExtension, - mockExtensionManager, - mockRequestConsentNonInteractive: vi.fn(), - mockPromptForConsentNonInteractive: vi.fn(), - mockStat: vi.fn(), - mockInferInstallMetadata: vi.fn(), - mockIsWorkspaceTrusted: vi.fn(), - mockLoadTrustedFolders: vi.fn(), - mockDiscover: vi.fn(), - }; -}); +const mockInstallOrUpdateExtension: Mock< + typeof ExtensionManager.prototype.installOrUpdateExtension +> = vi.hoisted(() => vi.fn()); +const mockRequestConsentNonInteractive: Mock< + typeof requestConsentNonInteractive +> = vi.hoisted(() => vi.fn()); +const mockPromptForConsentNonInteractive: Mock< + typeof promptForConsentNonInteractive +> = vi.hoisted(() => vi.fn()); +const mockStat: Mock = vi.hoisted(() => vi.fn()); +const mockInferInstallMetadata: Mock = vi.hoisted( + () => vi.fn(), +); +const mockIsWorkspaceTrusted: Mock = vi.hoisted(() => + vi.fn(), +); +const mockLoadTrustedFolders: Mock = vi.hoisted(() => + vi.fn(), +); +const mockDiscover: Mock = + vi.hoisted(() => vi.fn()); vi.mock('../../config/extensions/consent.js', () => ({ requestConsentNonInteractive: mockRequestConsentNonInteractive, @@ -82,7 +84,6 @@ vi.mock('../../config/extension-manager.js', async (importOriginal) => ({ ...(await importOriginal< typeof import('../../config/extension-manager.js') >()), - ExtensionManager: mockExtensionManager, inferInstallMetadata: mockInferInstallMetadata, })); @@ -116,18 +117,19 @@ describe('handleInstall', () => { let processSpy: MockInstance; beforeEach(() => { - debugLogSpy = vi - .spyOn(core.debugLogger, 'log') - .mockImplementation(() => {}); - debugErrorSpy = vi - .spyOn(core.debugLogger, 'error') - .mockImplementation(() => {}); + debugLogSpy = vi.spyOn(core.debugLogger, 'log'); + debugErrorSpy = vi.spyOn(core.debugLogger, 'error'); processSpy = vi .spyOn(process, 'exit') .mockImplementation(() => undefined as never); - mockLoadExtensions.mockResolvedValue([]); - mockInstallOrUpdateExtension.mockReset(); + vi.spyOn(ExtensionManager.prototype, 'loadExtensions').mockResolvedValue( + [], + ); + vi.spyOn( + ExtensionManager.prototype, + 'installOrUpdateExtension', + ).mockImplementation(mockInstallOrUpdateExtension); mockIsWorkspaceTrusted.mockReturnValue({ isTrusted: true, source: 'file' }); mockDiscover.mockResolvedValue({ @@ -161,7 +163,12 @@ describe('handleInstall', () => { }); afterEach(() => { + mockInstallOrUpdateExtension.mockClear(); + mockRequestConsentNonInteractive.mockClear(); + mockStat.mockClear(); + mockInferInstallMetadata.mockClear(); vi.clearAllMocks(); + vi.restoreAllMocks(); }); function createMockExtension( @@ -281,39 +288,6 @@ describe('handleInstall', () => { expect(processSpy).toHaveBeenCalledWith(1); }); - it('should pass promptForSetting when skipSettings is not provided', async () => { - mockInstallOrUpdateExtension.mockResolvedValue({ - name: 'test-extension', - } as unknown as core.GeminiCLIExtension); - - await handleInstall({ - source: 'http://google.com', - }); - - expect(mockExtensionManager).toHaveBeenCalledWith( - expect.objectContaining({ - requestSetting: promptForSetting, - }), - ); - }); - - it('should pass null for requestSetting when skipSettings is true', async () => { - mockInstallOrUpdateExtension.mockResolvedValue({ - name: 'test-extension', - } as unknown as core.GeminiCLIExtension); - - await handleInstall({ - source: 'http://google.com', - skipSettings: true, - }); - - expect(mockExtensionManager).toHaveBeenCalledWith( - expect.objectContaining({ - requestSetting: null, - }), - ); - }); - it('should proceed if local path is already trusted', async () => { mockInstallOrUpdateExtension.mockResolvedValue( createMockExtension({ diff --git a/packages/cli/src/commands/extensions/install.ts b/packages/cli/src/commands/extensions/install.ts index cf135a9366..542d1240be 100644 --- a/packages/cli/src/commands/extensions/install.ts +++ b/packages/cli/src/commands/extensions/install.ts @@ -37,7 +37,6 @@ interface InstallArgs { autoUpdate?: boolean; allowPreRelease?: boolean; consent?: boolean; - skipSettings?: boolean; } export async function handleInstall(args: InstallArgs) { @@ -154,7 +153,7 @@ export async function handleInstall(args: InstallArgs) { const extensionManager = new ExtensionManager({ workspaceDir, requestConsent, - requestSetting: args.skipSettings ? null : promptForSetting, + requestSetting: promptForSetting, settings, }); await extensionManager.loadExtensions(); @@ -197,11 +196,6 @@ export const installCommand: CommandModule = { type: 'boolean', default: false, }) - .option('skip-settings', { - describe: 'Skip the configuration on install process.', - type: 'boolean', - default: false, - }) .check((argv) => { if (!argv.source) { throw new Error('The source argument must be provided.'); @@ -220,8 +214,6 @@ export const installCommand: CommandModule = { allowPreRelease: argv['pre-release'] as boolean | undefined, // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion consent: argv['consent'] as boolean | undefined, - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - skipSettings: argv['skip-settings'] as boolean | undefined, }); await exitCli(); }, diff --git a/packages/cli/src/commands/hooks/migrate.ts b/packages/cli/src/commands/hooks/migrate.ts index 36bb2cf9aa..5522221b90 100644 --- a/packages/cli/src/commands/hooks/migrate.ts +++ b/packages/cli/src/commands/hooks/migrate.ts @@ -79,7 +79,7 @@ function migrateClaudeHook(claudeHook: unknown): unknown { migrated['command'] = hook['command']; // Replace CLAUDE_PROJECT_DIR with GEMINI_PROJECT_DIR in command - // eslint-disable-next-line no-restricted-syntax + if (typeof migrated['command'] === 'string') { migrated['command'] = migrated['command'].replace( /\$CLAUDE_PROJECT_DIR/g, @@ -94,7 +94,7 @@ function migrateClaudeHook(claudeHook: unknown): unknown { } // Map timeout field (Claude uses seconds, Gemini uses seconds) - // eslint-disable-next-line no-restricted-syntax + if ('timeout' in hook && typeof hook['timeout'] === 'number') { migrated['timeout'] = hook['timeout']; } @@ -142,7 +142,6 @@ function migrateClaudeHooks(claudeConfig: unknown): Record { // Transform matcher if ( 'matcher' in definition && - // eslint-disable-next-line no-restricted-syntax typeof definition['matcher'] === 'string' ) { migratedDef['matcher'] = transformMatcher(definition['matcher']); diff --git a/packages/cli/src/commands/mcp/list.test.ts b/packages/cli/src/commands/mcp/list.test.ts index 578894845e..54534961dd 100644 --- a/packages/cli/src/commands/mcp/list.test.ts +++ b/packages/cli/src/commands/mcp/list.test.ts @@ -264,7 +264,6 @@ describe('mcp list command', () => { config: { 'allowed-server': { url: 'http://allowed' }, }, - requiredConfig: {}, }, }; diff --git a/packages/cli/src/config/config.test.ts b/packages/cli/src/config/config.test.ts index 746fc14475..57d1a150f8 100644 --- a/packages/cli/src/config/config.test.ts +++ b/packages/cli/src/config/config.test.ts @@ -226,51 +226,6 @@ afterEach(() => { }); describe('parseArguments', () => { - describe('worktree', () => { - it('should parse --worktree flag when provided with a name', async () => { - process.argv = ['node', 'script.js', '--worktree', 'my-feature']; - const settings = createTestMergedSettings(); - settings.experimental.worktrees = true; - const argv = await parseArguments(settings); - expect(argv.worktree).toBe('my-feature'); - }); - - it('should generate a random name when --worktree is provided without a name', async () => { - process.argv = ['node', 'script.js', '--worktree']; - const settings = createTestMergedSettings(); - settings.experimental.worktrees = true; - const argv = await parseArguments(settings); - expect(argv.worktree).toBeDefined(); - expect(argv.worktree).not.toBe(''); - expect(typeof argv.worktree).toBe('string'); - }); - - it('should throw an error when --worktree is used but experimental.worktrees is not enabled', async () => { - process.argv = ['node', 'script.js', '--worktree', 'feature']; - const settings = createTestMergedSettings(); - settings.experimental.worktrees = false; - - const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => { - throw new Error('process.exit called'); - }); - const mockConsoleError = vi - .spyOn(console, 'error') - .mockImplementation(() => {}); - - await expect(parseArguments(settings)).rejects.toThrow( - 'process.exit called', - ); - expect(mockConsoleError).toHaveBeenCalledWith( - expect.stringContaining( - 'The --worktree flag is only available when experimental.worktrees is enabled in your settings.', - ), - ); - - mockExit.mockRestore(); - mockConsoleError.mockRestore(); - }); - }); - it.each([ { description: 'long flags', @@ -808,48 +763,6 @@ 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()); @@ -885,7 +798,6 @@ 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 @@ -897,7 +809,6 @@ describe('Hierarchical Memory Loading (config.ts) - Placeholder Suite', () => { }); afterEach(() => { - vi.unstubAllEnvs(); vi.restoreAllMocks(); }); @@ -2270,30 +2181,6 @@ describe('loadCliConfig tool exclusions', () => { expect(config.getExcludeTools()).toContain('ask_user'); }); - it('should exclude ask_user in interactive mode when --acp is provided', async () => { - process.stdin.isTTY = true; - process.argv = ['node', 'script.js', '--acp']; - const argv = await parseArguments(createTestMergedSettings()); - const config = await loadCliConfig( - createTestMergedSettings(), - 'test-session', - argv, - ); - expect(config.getExcludeTools()).toContain('ask_user'); - }); - - it('should exclude ask_user in interactive mode when --experimental-acp is provided', async () => { - process.stdin.isTTY = true; - process.argv = ['node', 'script.js', '--experimental-acp']; - const argv = await parseArguments(createTestMergedSettings()); - const config = await loadCliConfig( - createTestMergedSettings(), - 'test-session', - argv, - ); - expect(config.getExcludeTools()).toContain('ask_user'); - }); - it('should not exclude shell tool in non-interactive mode when --allowed-tools="ShellTool" is set', async () => { process.stdin.isTTY = false; process.argv = [ diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index 227ad4e8ed..b4c8c9ca2e 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -4,11 +4,10 @@ * SPDX-License-Identifier: Apache-2.0 */ -import yargs from 'yargs'; +import yargs from 'yargs/yargs'; import { hideBin } from 'yargs/helpers'; import process from 'node:process'; import * as path from 'node:path'; -import { execa } from 'execa'; import { mcpCommand } from '../commands/mcp.js'; import { extensionsCommand } from '../commands/extensions.js'; import { skillsCommand } from '../commands/skills.js'; @@ -37,11 +36,7 @@ import { Config, resolveToRealPath, applyAdminAllowlist, - applyRequiredServers, getAdminBlockedMcpServersMessage, - getProjectRootForWorktree, - isGeminiWorktree, - type WorktreeSettings, type HookDefinition, type HookEventName, type OutputFormat, @@ -52,8 +47,6 @@ import { type MergedSettings, saveModelChange, loadSettings, - isWorktreeEnabled, - type LoadedSettings, } from './settings.js'; import { loadSandboxConfig } from './sandboxConfig.js'; @@ -80,7 +73,6 @@ export interface CliArgs { debug: boolean | undefined; prompt: string | undefined; promptInteractive: string | undefined; - worktree?: string; yolo: boolean | undefined; approvalMode: string | undefined; @@ -122,36 +114,6 @@ const coerceCommaSeparated = (values: string[]): string[] => { ); }; -/** - * Pre-parses the command line arguments to find the worktree flag. - * Used for early setup before full argument parsing with settings. - */ -export function getWorktreeArg(argv: string[]): string | undefined { - const result = yargs(hideBin(argv)) - .help(false) - .version(false) - .option('worktree', { alias: 'w', type: 'string' }) - .strict(false) - .exitProcess(false) - .parseSync(); - - if (result.worktree === undefined) return undefined; - return typeof result.worktree === 'string' ? result.worktree.trim() : ''; -} - -/** - * Checks if a worktree is requested via CLI and enabled in settings. - * Returns the requested name (can be empty string for auto-generated) or undefined. - */ -export function getRequestedWorktreeName( - settings: LoadedSettings, -): string | undefined { - if (!isWorktreeEnabled(settings)) { - return undefined; - } - return getWorktreeArg(process.argv); -} - export async function parseArguments( settings: MergedSettings, ): Promise { @@ -195,20 +157,6 @@ export async function parseArguments( description: 'Execute the provided prompt and continue in interactive mode', }) - .option('worktree', { - alias: 'w', - type: 'string', - skipValidation: true, - description: - 'Start Gemini in a new git worktree. If no name is provided, one is generated automatically.', - coerce: (value: unknown): string => { - const trimmed = typeof value === 'string' ? value.trim() : ''; - if (trimmed === '') { - return Math.random().toString(36).substring(2, 10); - } - return trimmed; - }, - }) .option('sandbox', { alias: 's', type: 'boolean', @@ -386,9 +334,6 @@ export async function parseArguments( ) { return `Invalid values:\n Argument: output-format, Given: "${argv['outputFormat']}", Choices: "text", "json", "stream-json"`; } - if (argv['worktree'] && !settings.experimental?.worktrees) { - return 'The --worktree flag is only available when experimental.worktrees is enabled in your settings.'; - } return true; }); @@ -474,7 +419,6 @@ export interface LoadCliConfigOptions { projectHooks?: { [K in HookEventName]?: HookDefinition[] } & { disabled?: string[]; }; - worktreeSettings?: WorktreeSettings; } export async function loadCliConfig( @@ -486,8 +430,7 @@ export async function loadCliConfig( const { cwd = process.cwd(), projectHooks } = options; const debugMode = isDebugMode(argv); - const worktreeSettings = - options.worktreeSettings ?? (await resolveWorktreeSettings(cwd)); + const loadedSettings = loadSettings(cwd); if (argv.sandbox) { process.env['GEMINI_SANDBOX'] = 'true'; @@ -532,32 +475,10 @@ 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, @@ -707,16 +628,12 @@ export async function loadCliConfig( const allowedTools = argv.allowedTools || settings.tools?.allowed || []; - const isAcpMode = !!argv.acp || !!argv.experimentalAcp; - // In non-interactive mode, exclude tools that require a prompt. const extraExcludes: string[] = []; - if (!interactive || isAcpMode) { + if (!interactive) { // The Policy Engine natively handles headless safety by translating ASK_USER // decisions to DENY. However, we explicitly block ask_user here to guarantee // it can never be allowed via a high-priority policy rule when no human is present. - // We also exclude it in ACP mode as IDEs intercept tool calls and ask for permission, - // breaking conversational flows. extraExcludes.push(ASK_USER_TOOL_NAME); } @@ -765,19 +682,6 @@ export async function loadCliConfig( ? defaultModel : specifiedModel || defaultModel; const sandboxConfig = await loadSandboxConfig(settings, argv); - if (sandboxConfig) { - const existingPaths = sandboxConfig.allowedPaths || []; - if (settings.tools.sandboxAllowedPaths?.length) { - sandboxConfig.allowedPaths = [ - ...new Set([...existingPaths, ...settings.tools.sandboxAllowedPaths]), - ]; - } - if (settings.tools.sandboxNetworkAccess !== undefined) { - sandboxConfig.networkAccess = - sandboxConfig.networkAccess || settings.tools.sandboxNetworkAccess; - } - } - const screenReader = argv.screenReader !== undefined ? argv.screenReader @@ -813,25 +717,7 @@ export async function loadCliConfig( } } - // Apply admin-required MCP servers (injected regardless of allowlist) - if (mcpEnabled) { - const requiredMcpConfig = settings.admin?.mcp?.requiredConfig; - if (requiredMcpConfig && Object.keys(requiredMcpConfig).length > 0) { - const requiredResult = applyRequiredServers( - mcpServers ?? {}, - requiredMcpConfig, - ); - mcpServers = requiredResult.mcpServers; - - if (requiredResult.requiredServerNames.length > 0) { - coreEvents.emitConsoleLog( - 'info', - `Admin-required MCP servers injected: ${requiredResult.requiredServerNames.join(', ')}`, - ); - } - } - } - + const isAcpMode = !!argv.acp || !!argv.experimentalAcp; let clientName: string | undefined = undefined; if (isAcpMode) { const ide = detectIdeFromEnv(); @@ -860,7 +746,6 @@ export async function loadCliConfig( importFormat: settings.context?.importFormat, debugMode, question, - worktreeSettings, coreTools: settings.tools?.core || undefined, allowedTools: allowedTools.length > 0 ? allowedTools : undefined, @@ -935,7 +820,6 @@ export async function loadCliConfig( skillsSupport: settings.skills?.enabled ?? true, disabledSkills: settings.skills?.disabled, experimentalJitContext: settings.experimental?.jitContext, - experimentalMemoryManager: settings.experimental?.memoryManager, modelSteering: settings.experimental?.modelSteering, topicUpdateNarration: settings.experimental?.topicUpdateNarration, toolOutputMasking: settings.experimental?.toolOutputMasking, @@ -980,7 +864,7 @@ export async function loadCliConfig( hooks: settings.hooks || {}, disabledHooks: settings.hooksConfig?.disabled || [], projectHooks: projectHooks || {}, - onModelChange: (model: string) => saveModelChange(loadSettings(cwd), model), + onModelChange: (model: string) => saveModelChange(loadedSettings, model), onReload: async () => { const refreshedSettings = loadSettings(cwd); return { @@ -1002,48 +886,3 @@ function mergeExcludeTools( ]); return Array.from(allExcludeTools); } - -async function resolveWorktreeSettings( - cwd: string, -): Promise { - let worktreePath: string | undefined; - try { - const { stdout } = await execa('git', ['rev-parse', '--show-toplevel'], { - cwd, - }); - const toplevel = stdout.trim(); - const projectRoot = await getProjectRootForWorktree(toplevel); - - if (isGeminiWorktree(toplevel, projectRoot)) { - worktreePath = toplevel; - } - } catch (_e) { - return undefined; - } - - if (!worktreePath) { - return undefined; - } - - let worktreeBaseSha: string | undefined; - try { - const { stdout } = await execa('git', ['rev-parse', 'HEAD'], { - cwd: worktreePath, - }); - worktreeBaseSha = stdout.trim(); - } catch (e: unknown) { - debugLogger.debug( - `Failed to resolve worktree base SHA at ${worktreePath}: ${e instanceof Error ? e.message : String(e)}`, - ); - } - - if (!worktreeBaseSha) { - return undefined; - } - - return { - name: path.basename(worktreePath), - path: worktreePath, - baseSha: worktreeBaseSha, - }; -} diff --git a/packages/cli/src/config/extension-manager-permissions.test.ts b/packages/cli/src/config/extension-manager-permissions.test.ts deleted file mode 100644 index 662f30d430..0000000000 --- a/packages/cli/src/config/extension-manager-permissions.test.ts +++ /dev/null @@ -1,133 +0,0 @@ -/** - * @license - * Copyright 2026 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import * as fs from 'node:fs'; -import * as os from 'node:os'; -import * as path from 'node:path'; -import { copyExtension } from './extension-manager.js'; - -describe('copyExtension permissions', () => { - let tempDir: string; - let sourceDir: string; - let destDir: string; - - beforeEach(() => { - tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gemini-permission-test-')); - sourceDir = path.join(tempDir, 'source'); - destDir = path.join(tempDir, 'dest'); - fs.mkdirSync(sourceDir); - }); - - afterEach(() => { - // Ensure we can delete the temp directory by making everything writable again - const makeWritableSync = (p: string) => { - try { - const stats = fs.lstatSync(p); - fs.chmodSync(p, stats.mode | 0o700); - if (stats.isDirectory()) { - fs.readdirSync(p).forEach((child) => - makeWritableSync(path.join(p, child)), - ); - } - } catch (_e) { - // Ignore errors during cleanup - } - }; - - if (fs.existsSync(tempDir)) { - makeWritableSync(tempDir); - fs.rmSync(tempDir, { recursive: true, force: true }); - } - }); - - it('should make destination writable even if source is read-only', async () => { - const fileName = 'test.txt'; - const filePath = path.join(sourceDir, fileName); - fs.writeFileSync(filePath, 'hello'); - - // Make source read-only: 0o555 for directory, 0o444 for file - fs.chmodSync(filePath, 0o444); - fs.chmodSync(sourceDir, 0o555); - - // Verify source is read-only - expect(() => fs.writeFileSync(filePath, 'fail')).toThrow(); - - // Perform copy - await copyExtension(sourceDir, destDir); - - // Verify destination is writable - const destFilePath = path.join(destDir, fileName); - const destFileStats = fs.statSync(destFilePath); - const destDirStats = fs.statSync(destDir); - - // Check that owner write bits are set (0o200) - expect(destFileStats.mode & 0o200).toBe(0o200); - expect(destDirStats.mode & 0o200).toBe(0o200); - - // Verify we can actually write to the destination file - fs.writeFileSync(destFilePath, 'writable'); - expect(fs.readFileSync(destFilePath, 'utf-8')).toBe('writable'); - - // Verify we can delete the destination (which requires write bit on destDir) - fs.rmSync(destFilePath); - expect(fs.existsSync(destFilePath)).toBe(false); - }); - - it('should handle nested directories with restrictive permissions', async () => { - const subDir = path.join(sourceDir, 'subdir'); - fs.mkdirSync(subDir); - const fileName = 'nested.txt'; - const filePath = path.join(subDir, fileName); - fs.writeFileSync(filePath, 'nested content'); - - // Make nested structure read-only - fs.chmodSync(filePath, 0o444); - fs.chmodSync(subDir, 0o555); - fs.chmodSync(sourceDir, 0o555); - - // Perform copy - await copyExtension(sourceDir, destDir); - - // Verify nested destination is writable - const destSubDir = path.join(destDir, 'subdir'); - const destFilePath = path.join(destSubDir, fileName); - - expect(fs.statSync(destSubDir).mode & 0o200).toBe(0o200); - expect(fs.statSync(destFilePath).mode & 0o200).toBe(0o200); - - // Verify we can delete the whole destination tree - await fs.promises.rm(destDir, { recursive: true, force: true }); - expect(fs.existsSync(destDir)).toBe(false); - }); - - it('should not follow symlinks or modify symlink targets', async () => { - const symlinkTarget = path.join(tempDir, 'external-target'); - fs.writeFileSync(symlinkTarget, 'external content'); - // Target is read-only - fs.chmodSync(symlinkTarget, 0o444); - - const symlinkPath = path.join(sourceDir, 'symlink-file'); - fs.symlinkSync(symlinkTarget, symlinkPath); - - // Perform copy - await copyExtension(sourceDir, destDir); - - const destSymlinkPath = path.join(destDir, 'symlink-file'); - const destSymlinkStats = fs.lstatSync(destSymlinkPath); - - // Verify it is still a symlink in the destination - expect(destSymlinkStats.isSymbolicLink()).toBe(true); - - // Verify the target (external to the extension) was NOT modified - const targetStats = fs.statSync(symlinkTarget); - // Owner write bit should still NOT be set (0o200) - expect(targetStats.mode & 0o200).toBe(0o000); - - // Clean up - fs.chmodSync(symlinkTarget, 0o644); - }); -}); diff --git a/packages/cli/src/config/extension-manager-skills.test.ts b/packages/cli/src/config/extension-manager-skills.test.ts index 800417de36..a76d88482d 100644 --- a/packages/cli/src/config/extension-manager-skills.test.ts +++ b/packages/cli/src/config/extension-manager-skills.test.ts @@ -15,10 +15,6 @@ import { createExtension } from '../test-utils/createExtension.js'; import { EXTENSIONS_DIRECTORY_NAME } from './extensions/variables.js'; const mockHomedir = vi.hoisted(() => vi.fn(() => '/tmp/mock-home')); -const mockIntegrityManager = vi.hoisted(() => ({ - verify: vi.fn().mockResolvedValue('verified'), - store: vi.fn().mockResolvedValue(undefined), -})); vi.mock('node:os', async (importOriginal) => { const actual = await importOriginal(); @@ -35,9 +31,6 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => { return { ...actual, homedir: mockHomedir, - ExtensionIntegrityManager: vi - .fn() - .mockImplementation(() => mockIntegrityManager), loadAgentsFromDirectory: vi .fn() .mockImplementation(async () => ({ agents: [], errors: [] })), @@ -71,7 +64,6 @@ describe('ExtensionManager skills validation', () => { requestConsent: vi.fn().mockResolvedValue(true), requestSetting: vi.fn(), workspaceDir: tempDir, - integrityManager: mockIntegrityManager, }); }); @@ -147,7 +139,6 @@ describe('ExtensionManager skills validation', () => { requestConsent: vi.fn().mockResolvedValue(true), requestSetting: vi.fn(), workspaceDir: tempDir, - integrityManager: mockIntegrityManager, }); // 4. Load extensions diff --git a/packages/cli/src/config/extension-manager.ts b/packages/cli/src/config/extension-manager.ts index dd37d0ea1b..2c46a845e6 100644 --- a/packages/cli/src/config/extension-manager.ts +++ b/packages/cli/src/config/extension-manager.ts @@ -1248,32 +1248,11 @@ function filterMcpConfig(original: MCPServerConfig): MCPServerConfig { return Object.freeze(rest); } -/** - * Recursively ensures that the owner has write permissions for all files - * and directories within the target path. - */ -async function makeWritableRecursive(targetPath: string): Promise { - const stats = await fs.promises.lstat(targetPath); - - if (stats.isDirectory()) { - // Ensure directory is rwx for the owner (0o700) - await fs.promises.chmod(targetPath, stats.mode | 0o700); - const children = await fs.promises.readdir(targetPath); - for (const child of children) { - await makeWritableRecursive(path.join(targetPath, child)); - } - } else if (stats.isFile()) { - // Ensure file is rw for the owner (0o600) - await fs.promises.chmod(targetPath, stats.mode | 0o600); - } -} - export async function copyExtension( source: string, destination: string, ): Promise { await fs.promises.cp(source, destination, { recursive: true }); - await makeWritableRecursive(destination); } function getContextFileNames(config: ExtensionConfig): string[] { diff --git a/packages/cli/src/config/extensions/consent.test.ts b/packages/cli/src/config/extensions/consent.test.ts index 8de884cdd5..76d7227ab4 100644 --- a/packages/cli/src/config/extensions/consent.test.ts +++ b/packages/cli/src/config/extensions/consent.test.ts @@ -59,9 +59,8 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => { }); async function expectConsentSnapshot(consentString: string) { - const renderResult = await render( - React.createElement(Text, null, consentString), - ); + const renderResult = render(React.createElement(Text, null, consentString)); + await renderResult.waitUntilReady(); await expect(renderResult).toMatchSvgSnapshot(); } diff --git a/packages/cli/src/config/extensions/extensionUpdates.test.ts b/packages/cli/src/config/extensions/extensionUpdates.test.ts index 89282fcd8a..69339b4eeb 100644 --- a/packages/cli/src/config/extensions/extensionUpdates.test.ts +++ b/packages/cli/src/config/extensions/extensionUpdates.test.ts @@ -36,8 +36,6 @@ vi.mock('node:fs', async (importOriginal) => { rm: vi.fn(), cp: vi.fn(), readFile: vi.fn(), - lstat: vi.fn(), - chmod: vi.fn(), }, }; }); @@ -145,11 +143,6 @@ describe('extensionUpdates', () => { vi.mocked(fs.promises.rm).mockResolvedValue(undefined); vi.mocked(fs.promises.cp).mockResolvedValue(undefined); vi.mocked(fs.promises.readdir).mockResolvedValue([]); - vi.mocked(fs.promises.lstat).mockResolvedValue({ - isDirectory: () => true, - mode: 0o755, - } as unknown as fs.Stats); - vi.mocked(fs.promises.chmod).mockResolvedValue(undefined); vi.mocked(isWorkspaceTrusted).mockReturnValue({ isTrusted: true, source: 'file', diff --git a/packages/cli/src/config/policy-engine.integration.test.ts b/packages/cli/src/config/policy-engine.integration.test.ts index 2e74a28201..847b47bbe3 100644 --- a/packages/cli/src/config/policy-engine.integration.test.ts +++ b/packages/cli/src/config/policy-engine.integration.test.ts @@ -516,9 +516,7 @@ describe('Policy Engine Integration Tests', () => { ); expect(mcpServerRule?.priority).toBe(4.1); // MCP allowed server - const readOnlyToolRule = rules.find( - (r) => r.toolName === 'glob' && !r.subagent, - ); + const readOnlyToolRule = rules.find((r) => r.toolName === 'glob'); // Priority 70 in default tier → 1.07 (Overriding Plan Mode Deny) expect(readOnlyToolRule?.priority).toBeCloseTo(1.07, 5); @@ -675,7 +673,7 @@ describe('Policy Engine Integration Tests', () => { const server1Rule = rules.find((r) => r.toolName === 'mcp_server1_*'); expect(server1Rule?.priority).toBe(4.1); // Allowed servers (user tier) - const globRule = rules.find((r) => r.toolName === 'glob' && !r.subagent); + const globRule = rules.find((r) => r.toolName === 'glob'); // Priority 70 in default tier → 1.07 expect(globRule?.priority).toBeCloseTo(1.07, 5); // Auto-accept read-only diff --git a/packages/cli/src/config/sandboxConfig.test.ts b/packages/cli/src/config/sandboxConfig.test.ts index 3ec0e6a5bb..cfe1fed660 100644 --- a/packages/cli/src/config/sandboxConfig.test.ts +++ b/packages/cli/src/config/sandboxConfig.test.ts @@ -338,8 +338,6 @@ describe('loadSandboxConfig', () => { sandbox: { enabled: true, command: 'podman', - allowedPaths: [], - networkAccess: false, }, }, }, @@ -355,8 +353,6 @@ describe('loadSandboxConfig', () => { sandbox: { enabled: true, image: 'custom/image', - allowedPaths: [], - networkAccess: false, }, }, }, @@ -371,8 +367,6 @@ describe('loadSandboxConfig', () => { tools: { sandbox: { enabled: false, - allowedPaths: [], - networkAccess: false, }, }, }, @@ -388,7 +382,6 @@ describe('loadSandboxConfig', () => { sandbox: { enabled: true, allowedPaths: ['/settings-path'], - networkAccess: false, }, }, }, diff --git a/packages/cli/src/config/sandboxConfig.ts b/packages/cli/src/config/sandboxConfig.ts index 1a047760d3..59a9685f70 100644 --- a/packages/cli/src/config/sandboxConfig.ts +++ b/packages/cli/src/config/sandboxConfig.ts @@ -29,7 +29,6 @@ const VALID_SANDBOX_COMMANDS = [ 'sandbox-exec', 'runsc', 'lxc', - 'windows-native', ]; function isSandboxCommand( @@ -76,15 +75,8 @@ function getSandboxCommand( 'gVisor (runsc) sandboxing is only supported on Linux', ); } - // windows-native is only supported on Windows - if (sandbox === 'windows-native' && os.platform() !== 'win32') { - throw new FatalSandboxError( - 'Windows native sandboxing is only supported on Windows', - ); - } - - // confirm that specified command exists (unless it's built-in) - if (sandbox !== 'windows-native' && !commandExists.sync(sandbox)) { + // confirm that specified command exists + if (!commandExists.sync(sandbox)) { throw new FatalSandboxError( `Missing sandbox command '${sandbox}' (from GEMINI_SANDBOX)`, ); @@ -157,12 +149,7 @@ export async function loadSandboxConfig( customImage ?? packageJson?.config?.sandboxImageUri; - const isNative = - command === 'windows-native' || - command === 'sandbox-exec' || - command === 'lxc'; - - return command && (image || isNative) + return command && image ? { enabled: true, allowedPaths, networkAccess, command, image } : undefined; } diff --git a/packages/cli/src/config/settings.test.ts b/packages/cli/src/config/settings.test.ts index a58b9889a2..06129a4760 100644 --- a/packages/cli/src/config/settings.test.ts +++ b/packages/cli/src/config/settings.test.ts @@ -2751,28 +2751,6 @@ describe('Settings Loading and Merging', () => { expect(loadedSettings.merged.admin?.mcp?.config).toEqual(mcpServers); }); - it('should map requiredMcpConfig from remote settings', () => { - const loadedSettings = loadSettings(MOCK_WORKSPACE_DIR); - const requiredMcpConfig = { - 'corp-tool': { - url: 'https://mcp.corp/tool', - type: 'http' as const, - trust: true, - }, - }; - - loadedSettings.setRemoteAdminSettings({ - mcpSetting: { - mcpEnabled: true, - requiredMcpConfig, - }, - }); - - expect(loadedSettings.merged.admin?.mcp?.requiredConfig).toEqual( - requiredMcpConfig, - ); - }); - it('should set skills based on unmanagedCapabilitiesEnabled', () => { const loadedSettings = loadSettings(); loadedSettings.setRemoteAdminSettings({ diff --git a/packages/cli/src/config/settings.ts b/packages/cli/src/config/settings.ts index 984bdb8d60..711ff93271 100644 --- a/packages/cli/src/config/settings.ts +++ b/packages/cli/src/config/settings.ts @@ -480,7 +480,6 @@ export class LoadedSettings { admin.mcp = { enabled: mcpSetting?.mcpEnabled, config: mcpSetting?.mcpConfig?.mcpServers, - requiredConfig: mcpSetting?.requiredMcpConfig, }; admin.extensions = { enabled: cliFeatureSetting?.extensionsSetting?.extensionsEnabled, @@ -632,10 +631,6 @@ export function resetSettingsCacheForTesting() { settingsCache.clear(); } -export function isWorktreeEnabled(settings: LoadedSettings): boolean { - return settings.merged.experimental.worktrees; -} - /** * Loads settings from user and workspace directories. * Project settings override user settings. diff --git a/packages/cli/src/config/settingsSchema.test.ts b/packages/cli/src/config/settingsSchema.test.ts index c358cd65aa..37ddf87642 100644 --- a/packages/cli/src/config/settingsSchema.test.ts +++ b/packages/cli/src/config/settingsSchema.test.ts @@ -538,32 +538,8 @@ describe('SettingsSchema', () => { } }; - const visitJsonSchema = (jsonSchema: Record) => { - const ref = jsonSchema['ref']; - if (typeof ref === 'string') { - referenced.add(ref); - } - const properties = jsonSchema['properties']; - if ( - properties && - typeof properties === 'object' && - !Array.isArray(properties) - ) { - Object.values(properties as Record).forEach((prop) => - visitJsonSchema(prop as Record), - ); - } - const items = jsonSchema['items']; - if (items && typeof items === 'object' && !Array.isArray(items)) { - visitJsonSchema(items as Record); - } - }; - Object.values(schema).forEach(visitDefinition); - // Also visit all definitions to find nested references - Object.values(SETTINGS_SCHEMA_DEFINITIONS).forEach(visitJsonSchema); - // Ensure definitions map doesn't accumulate stale entries. Object.keys(SETTINGS_SCHEMA_DEFINITIONS).forEach((key) => { if (!referenced.has(key)) { diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index 3a622460aa..8a107c4d47 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -12,9 +12,7 @@ import { DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD, DEFAULT_MODEL_CONFIGS, - AuthProviderType, type MCPServerConfig, - type RequiredMcpServerConfig, type BugCommandSettings, type TelemetrySettings, type AuthType, @@ -1083,20 +1081,6 @@ const SETTINGS_SCHEMA = { ref: 'ModelResolution', }, }, - modelChains: { - type: 'object', - label: 'Model Chains', - category: 'Model', - requiresRestart: true, - default: DEFAULT_MODEL_CONFIGS.modelChains, - description: - 'Availability policy chains defining fallback behavior for models.', - showInDialog: false, - additionalProperties: { - type: 'array', - ref: 'ModelPolicyChain', - }, - }, }, }, @@ -1360,30 +1344,10 @@ const SETTINGS_SCHEMA = { description: oneLine` Legacy full-process sandbox execution environment. Set to a boolean to enable or disable the sandbox, provide a string path to a sandbox profile, - or specify an explicit sandbox command (e.g., "docker", "podman", "lxc", "windows-native"). + or specify an explicit sandbox command (e.g., "docker", "podman", "lxc"). `, showInDialog: false, }, - sandboxAllowedPaths: { - type: 'array', - label: 'Sandbox Allowed Paths', - category: 'Tools', - requiresRestart: true, - default: [] as string[], - description: - 'List of additional paths that the sandbox is allowed to access.', - showInDialog: true, - items: { type: 'string' }, - }, - sandboxNetworkAccess: { - type: 'boolean', - label: 'Sandbox Network Access', - category: 'Tools', - requiresRestart: true, - default: false, - description: 'Whether the sandbox is allowed to access the network.', - showInDialog: true, - }, shell: { type: 'object', label: 'Shell', @@ -1906,16 +1870,6 @@ const SETTINGS_SCHEMA = { description: 'Enable local and remote subagents.', showInDialog: false, }, - worktrees: { - type: 'boolean', - label: 'Enable Git Worktrees', - category: 'Experimental', - requiresRestart: true, - default: false, - description: - 'Enable automated Git worktree management for parallel work.', - showInDialog: true, - }, extensionManagement: { type: 'boolean', label: 'Extension Management', @@ -2091,16 +2045,6 @@ const SETTINGS_SCHEMA = { }, }, }, - memoryManager: { - type: 'boolean', - label: 'Memory Manager Agent', - category: 'Experimental', - requiresRestart: true, - default: false, - description: - 'Replace the built-in save_memory tool with a memory manager subagent that supports adding, removing, de-duplicating, and organizing memories.', - showInDialog: true, - }, topicUpdateNarration: { type: 'boolean', label: 'Topic & Update Narration', @@ -2447,7 +2391,7 @@ const SETTINGS_SCHEMA = { category: 'Admin', requiresRestart: false, default: {} as Record, - description: 'Admin-configured MCP servers (allowlist).', + description: 'Admin-configured MCP servers.', showInDialog: false, mergeStrategy: MergeStrategy.REPLACE, additionalProperties: { @@ -2455,20 +2399,6 @@ const SETTINGS_SCHEMA = { ref: 'MCPServerConfig', }, }, - requiredConfig: { - type: 'object', - label: 'Required MCP Config', - category: 'Admin', - requiresRestart: false, - default: {} as Record, - description: 'Admin-required MCP servers that are always injected.', - showInDialog: false, - mergeStrategy: MergeStrategy.REPLACE, - additionalProperties: { - type: 'object', - ref: 'RequiredMcpServerConfig', - }, - }, }, }, skills: { @@ -2593,72 +2523,11 @@ export const SETTINGS_SCHEMA_DEFINITIONS: Record< type: 'string', description: 'Authentication provider used for acquiring credentials (for example `dynamic_discovery`).', - enum: Object.values(AuthProviderType), - }, - targetAudience: { - type: 'string', - description: - 'OAuth target audience (CLIENT_ID.apps.googleusercontent.com).', - }, - targetServiceAccount: { - type: 'string', - description: - 'Service account email to impersonate (name@project.iam.gserviceaccount.com).', - }, - }, - }, - RequiredMcpServerConfig: { - type: 'object', - description: - 'Admin-required MCP server configuration (remote transports only).', - additionalProperties: false, - properties: { - url: { - type: 'string', - description: 'URL for the required MCP server.', - }, - type: { - type: 'string', - description: 'Transport type for the required server.', - enum: ['sse', 'http'], - }, - headers: { - type: 'object', - description: 'Additional HTTP headers sent to the server.', - additionalProperties: { type: 'string' }, - }, - timeout: { - type: 'number', - description: 'Timeout in milliseconds for MCP requests.', - }, - trust: { - type: 'boolean', - description: - 'Marks the server as trusted. Defaults to true for admin-required servers.', - }, - description: { - type: 'string', - description: 'Human-readable description of the server.', - }, - includeTools: { - type: 'array', - description: 'Subset of tools enabled for this server.', - items: { type: 'string' }, - }, - excludeTools: { - type: 'array', - description: 'Tools disabled for this server.', - items: { type: 'string' }, - }, - oauth: { - type: 'object', - description: 'OAuth configuration for authenticating with the server.', - additionalProperties: true, - }, - authProviderType: { - type: 'string', - description: 'Authentication provider used for acquiring credentials.', - enum: Object.values(AuthProviderType), + enum: [ + 'dynamic_discovery', + 'google_credentials', + 'service_account_impersonation', + ], }, targetAudience: { type: 'string', @@ -2998,42 +2867,6 @@ export const SETTINGS_SCHEMA_DEFINITIONS: Record< }, }, }, - ModelPolicyChain: { - type: 'array', - description: 'A chain of model policies for fallback behavior.', - items: { - type: 'object', - ref: 'ModelPolicy', - }, - }, - ModelPolicy: { - type: 'object', - description: - 'Defines the policy for a single model in the availability chain.', - properties: { - model: { type: 'string' }, - isLastResort: { type: 'boolean' }, - actions: { - type: 'object', - properties: { - terminal: { type: 'string', enum: ['silent', 'prompt'] }, - transient: { type: 'string', enum: ['silent', 'prompt'] }, - not_found: { type: 'string', enum: ['silent', 'prompt'] }, - unknown: { type: 'string', enum: ['silent', 'prompt'] }, - }, - }, - stateTransitions: { - type: 'object', - properties: { - terminal: { type: 'string', enum: ['terminal', 'sticky_retry'] }, - transient: { type: 'string', enum: ['terminal', 'sticky_retry'] }, - not_found: { type: 'string', enum: ['terminal', 'sticky_retry'] }, - unknown: { type: 'string', enum: ['terminal', 'sticky_retry'] }, - }, - }, - }, - required: ['model'], - }, }; export function getSettingsSchema(): SettingsSchemaType { diff --git a/packages/cli/src/gemini.test.tsx b/packages/cli/src/gemini.test.tsx index 08c2cbabe8..31fec36db0 100644 --- a/packages/cli/src/gemini.test.tsx +++ b/packages/cli/src/gemini.test.tsx @@ -199,8 +199,6 @@ vi.mock('./config/config.js', () => ({ networkAccess: false, }), isDebugMode: vi.fn(() => false), - getRequestedWorktreeName: vi.fn(() => undefined), - getWorktreeArg: vi.fn(() => undefined), })); vi.mock('read-package-up', () => ({ diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx index c8cd2b3cd8..4722bb73f3 100644 --- a/packages/cli/src/gemini.tsx +++ b/packages/cli/src/gemini.tsx @@ -9,7 +9,6 @@ import { WarningPriority, type Config, type ResumedSessionData, - type WorktreeInfo, type OutputPayload, type ConsoleLogPayload, type UserFeedbackPayload, @@ -64,7 +63,6 @@ import { registerTelemetryConfig, setupSignalHandlers, } from './utils/cleanup.js'; -import { setupWorktree } from './utils/worktreeSetup.js'; import { cleanupToolOutputFiles, cleanupExpiredSessions, @@ -212,13 +210,6 @@ export async function main() { const settings = loadSettings(); loadSettingsHandle?.end(); - // If a worktree is requested and enabled, set it up early. - const requestedWorktree = cliConfig.getRequestedWorktreeName(settings); - let worktreeInfo: WorktreeInfo | undefined; - if (requestedWorktree !== undefined) { - worktreeInfo = await setupWorktree(requestedWorktree || undefined); - } - // Report settings errors once during startup settings.errors.forEach((error) => { coreEvents.emitFeedback('warning', error.message); @@ -435,7 +426,6 @@ export async function main() { const loadConfigHandle = startupProfiler.start('load_cli_config'); const config = await loadCliConfig(settings.merged, sessionId, argv, { projectHooks: settings.workspace.settings.hooks, - worktreeSettings: worktreeInfo, }); loadConfigHandle?.end(); diff --git a/packages/cli/src/gemini_cleanup.test.tsx b/packages/cli/src/gemini_cleanup.test.tsx index 382ad3f81f..9be9fc6194 100644 --- a/packages/cli/src/gemini_cleanup.test.tsx +++ b/packages/cli/src/gemini_cleanup.test.tsx @@ -72,8 +72,6 @@ vi.mock('./config/config.js', () => ({ } as unknown as Config), parseArguments: vi.fn().mockResolvedValue({}), isDebugMode: vi.fn(() => false), - getRequestedWorktreeName: vi.fn(() => undefined), - getWorktreeArg: vi.fn(() => undefined), })); vi.mock('read-package-up', () => ({ diff --git a/packages/cli/src/integration-tests/modelSteering.test.tsx b/packages/cli/src/integration-tests/modelSteering.test.tsx index bada268329..27bcde0dc2 100644 --- a/packages/cli/src/integration-tests/modelSteering.test.tsx +++ b/packages/cli/src/integration-tests/modelSteering.test.tsx @@ -29,7 +29,7 @@ describe('Model Steering Integration', () => { configOverrides: { modelSteering: true }, }); await rig.initialize(); - await rig.render(); + rig.render(); await rig.waitForIdle(); rig.setToolPolicy('list_directory', PolicyDecision.ASK_USER); diff --git a/packages/cli/src/interactiveCli.tsx b/packages/cli/src/interactiveCli.tsx index a6337ef29c..a27cdbbb78 100644 --- a/packages/cli/src/interactiveCli.tsx +++ b/packages/cli/src/interactiveCli.tsx @@ -101,8 +101,18 @@ export async function startInteractiveUI( return ( - - + + diff --git a/packages/cli/src/nonInteractiveCliCommands.ts b/packages/cli/src/nonInteractiveCliCommands.ts index 35cf5105ab..e09db71312 100644 --- a/packages/cli/src/nonInteractiveCliCommands.ts +++ b/packages/cli/src/nonInteractiveCliCommands.ts @@ -65,9 +65,9 @@ export const handleSlashCommand = async ( const logger = new Logger(config?.getSessionId() || '', config?.storage); - const commandContext: CommandContext = { + const context: CommandContext = { services: { - agentContext: config, + config, settings, git: undefined, logger, @@ -84,7 +84,7 @@ export const handleSlashCommand = async ( }, }; - const result = await commandToExecute.action(commandContext, args); + const result = await commandToExecute.action(context, args); if (result) { switch (result.type) { diff --git a/packages/cli/src/services/prompt-processors/atFileProcessor.test.ts b/packages/cli/src/services/prompt-processors/atFileProcessor.test.ts index 3b84baae67..3f49248169 100644 --- a/packages/cli/src/services/prompt-processors/atFileProcessor.test.ts +++ b/packages/cli/src/services/prompt-processors/atFileProcessor.test.ts @@ -31,14 +31,11 @@ describe('AtFileProcessor', () => { mockConfig = { // The processor only passes the config through, so we don't need a full mock. - get config() { - return this; - }, } as unknown as Config; context = createMockCommandContext({ services: { - agentContext: mockConfig, + config: mockConfig, }, }); @@ -63,7 +60,7 @@ describe('AtFileProcessor', () => { const prompt: PartUnion[] = [{ text: 'Analyze @{file.txt}' }]; const contextWithoutConfig = createMockCommandContext({ services: { - agentContext: null, + config: null, }, }); const result = await processor.process(prompt, contextWithoutConfig); diff --git a/packages/cli/src/services/prompt-processors/atFileProcessor.ts b/packages/cli/src/services/prompt-processors/atFileProcessor.ts index 8c1b168584..48e527ed5f 100644 --- a/packages/cli/src/services/prompt-processors/atFileProcessor.ts +++ b/packages/cli/src/services/prompt-processors/atFileProcessor.ts @@ -25,7 +25,7 @@ export class AtFileProcessor implements IPromptProcessor { input: PromptPipelineContent, context: CommandContext, ): Promise { - const config = context.services.agentContext?.config; + const config = context.services.config; if (!config) { return input; } diff --git a/packages/cli/src/services/prompt-processors/shellProcessor.test.ts b/packages/cli/src/services/prompt-processors/shellProcessor.test.ts index 8ab4581228..84010ab625 100644 --- a/packages/cli/src/services/prompt-processors/shellProcessor.test.ts +++ b/packages/cli/src/services/prompt-processors/shellProcessor.test.ts @@ -89,9 +89,6 @@ describe('ShellProcessor', () => { getPolicyEngine: vi.fn().mockReturnValue({ check: mockPolicyEngineCheck, }), - get config() { - return this as unknown as Config; - }, }; context = createMockCommandContext({ @@ -101,7 +98,7 @@ describe('ShellProcessor', () => { args: 'default args', }, services: { - agentContext: mockConfig as Config, + config: mockConfig as Config, }, session: { sessionShellAllowlist: new Set(), @@ -123,7 +120,7 @@ describe('ShellProcessor', () => { const prompt: PromptPipelineContent = createPromptPipelineContent('!{ls}'); const contextWithoutConfig = createMockCommandContext({ services: { - agentContext: null, + config: null, }, }); diff --git a/packages/cli/src/services/prompt-processors/shellProcessor.ts b/packages/cli/src/services/prompt-processors/shellProcessor.ts index 0042dc4f49..4c8369f664 100644 --- a/packages/cli/src/services/prompt-processors/shellProcessor.ts +++ b/packages/cli/src/services/prompt-processors/shellProcessor.ts @@ -74,7 +74,7 @@ export class ShellProcessor implements IPromptProcessor { ]; } - const config = context.services.agentContext?.config; + const config = context.services.config; if (!config) { throw new Error( `Security configuration not loaded. Cannot verify shell command permissions for '${this.commandName}'. Aborting.`, diff --git a/packages/cli/src/test-utils/AppRig.test.tsx b/packages/cli/src/test-utils/AppRig.test.tsx index 6d94342937..76c0ddc522 100644 --- a/packages/cli/src/test-utils/AppRig.test.tsx +++ b/packages/cli/src/test-utils/AppRig.test.tsx @@ -5,6 +5,7 @@ */ import { describe, it, afterEach, expect } from 'vitest'; +import { act } from 'react'; import { AppRig } from './AppRig.js'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -30,7 +31,7 @@ describe('AppRig', () => { configOverrides: { modelSteering: true }, }); await rig.initialize(); - await rig.render(); + rig.render(); await rig.waitForIdle(); // Set breakpoints on the canonical tool names @@ -68,7 +69,12 @@ describe('AppRig', () => { ); rig = new AppRig({ fakeResponsesPath }); await rig.initialize(); - await rig.render(); + await act(async () => { + rig!.render(); + // Allow async initializations (like banners) to settle within the act boundary + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + // Wait for initial render await rig.waitForIdle(); diff --git a/packages/cli/src/test-utils/AppRig.tsx b/packages/cli/src/test-utils/AppRig.tsx index 5ead5d615a..6043c7f8cc 100644 --- a/packages/cli/src/test-utils/AppRig.tsx +++ b/packages/cli/src/test-utils/AppRig.tsx @@ -11,7 +11,7 @@ import os from 'node:os'; import path from 'node:path'; import fs from 'node:fs'; import { AppContainer } from '../ui/AppContainer.js'; -import { renderWithProviders, type RenderInstance } from './render.js'; +import { renderWithProviders } from './render.js'; import { makeFakeConfig, type Config, @@ -155,7 +155,7 @@ export interface PendingConfirmation { } export class AppRig { - private renderResult: RenderInstance | undefined; + private renderResult: ReturnType | undefined; private config: Config | undefined; private settings: LoadedSettings | undefined; private testDir: string; @@ -204,7 +204,6 @@ export class AppRig { enableEventDrivenScheduler: true, extensionLoader: new MockExtensionManager(), excludeTools: this.options.configOverrides?.excludeTools, - useAlternateBuffer: false, ...this.options.configOverrides, }; this.config = makeFakeConfig(configParams); @@ -276,9 +275,6 @@ export class AppRig { enabled: false, hasSeenNudge: true, }, - ui: { - useAlternateBuffer: false, - }, }, }); } @@ -393,12 +389,12 @@ export class AppRig { return isAnyToolActive || isAwaitingConfirmation; } - async render() { + render() { if (!this.config || !this.settings) throw new Error('AppRig not initialized'); - await act(async () => { - this.renderResult = await renderWithProviders( + act(() => { + this.renderResult = renderWithProviders( = []; @@ -108,7 +108,6 @@ function toHaveOnlyValidCharacters(this: Assertion, buffer: TextBuffer) { }; } -// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion expect.extend({ toHaveOnlyValidCharacters, toMatchSvgSnapshot, diff --git a/packages/cli/src/test-utils/mockCommandContext.test.ts b/packages/cli/src/test-utils/mockCommandContext.test.ts index 605718e027..310bf74864 100644 --- a/packages/cli/src/test-utils/mockCommandContext.test.ts +++ b/packages/cli/src/test-utils/mockCommandContext.test.ts @@ -46,19 +46,15 @@ describe('createMockCommandContext', () => { const overrides = { services: { - agentContext: { config: mockConfig }, + config: mockConfig, }, }; const context = createMockCommandContext(overrides); - expect(context.services.agentContext).toBeDefined(); - expect(context.services.agentContext?.config?.getModel()).toBe( - 'gemini-pro', - ); - expect(context.services.agentContext?.config?.getProjectRoot()).toBe( - '/test/project', - ); + expect(context.services.config).toBeDefined(); + expect(context.services.config?.getModel()).toBe('gemini-pro'); + expect(context.services.config?.getProjectRoot()).toBe('/test/project'); // Verify a default property on the same nested object is still there expect(context.services.logger).toBeDefined(); diff --git a/packages/cli/src/test-utils/mockCommandContext.ts b/packages/cli/src/test-utils/mockCommandContext.ts index 15e6422e1a..b153aaf85e 100644 --- a/packages/cli/src/test-utils/mockCommandContext.ts +++ b/packages/cli/src/test-utils/mockCommandContext.ts @@ -36,7 +36,7 @@ export const createMockCommandContext = ( args: '', }, services: { - agentContext: null, + config: null, settings: { merged: defaultMergedSettings, diff --git a/packages/cli/src/test-utils/mockConfig.ts b/packages/cli/src/test-utils/mockConfig.ts index e1505df970..d4f11212e3 100644 --- a/packages/cli/src/test-utils/mockConfig.ts +++ b/packages/cli/src/test-utils/mockConfig.ts @@ -44,7 +44,6 @@ export const createMockConfig = (overrides: Partial = {}): Config => getDeleteSession: vi.fn(() => undefined), setSessionId: vi.fn(), getSessionId: vi.fn().mockReturnValue('mock-session-id'), - getWorktreeSettings: vi.fn(() => undefined), getContentGeneratorConfig: vi.fn(() => ({ authType: 'google' })), getAcpMode: vi.fn(() => false), isBrowserLaunchSuppressed: vi.fn(() => false), diff --git a/packages/cli/src/test-utils/render.test.tsx b/packages/cli/src/test-utils/render.test.tsx index 3c3f4102a4..7172a99119 100644 --- a/packages/cli/src/test-utils/render.test.tsx +++ b/packages/cli/src/test-utils/render.test.tsx @@ -12,18 +12,24 @@ import { waitFor } from './async.js'; describe('render', () => { it('should render a component', async () => { - const { lastFrame, unmount } = await render(Hello World); + const { lastFrame, waitUntilReady, unmount } = render( + Hello World, + ); + await waitUntilReady(); expect(lastFrame()).toBe('Hello World\n'); unmount(); }); it('should support rerender', async () => { - const { lastFrame, rerender, waitUntilReady, unmount } = await render( + const { lastFrame, rerender, waitUntilReady, unmount } = render( Hello, ); + await waitUntilReady(); expect(lastFrame()).toBe('Hello\n'); - await act(async () => rerender(World)); + await act(async () => { + rerender(World); + }); await waitUntilReady(); expect(lastFrame()).toBe('World\n'); unmount(); @@ -36,8 +42,10 @@ describe('render', () => { return Hello; } - const { unmount } = await render(); + const { unmount, waitUntilReady } = render(); + await waitUntilReady(); unmount(); + expect(cleanupMock).toHaveBeenCalled(); }); }); @@ -46,27 +54,36 @@ describe('renderHook', () => { it('should rerender with previous props when called without arguments', async () => { const useTestHook = ({ value }: { value: number }) => { const [count, setCount] = useState(0); - useEffect(() => setCount((c) => c + 1), [value]); + useEffect(() => { + setCount((c) => c + 1); + }, [value]); return { count, value }; }; - const { result, rerender, waitUntilReady, unmount } = await renderHook( + const { result, rerender, waitUntilReady, unmount } = renderHook( useTestHook, - { initialProps: { value: 1 } }, + { + initialProps: { value: 1 }, + }, ); + await waitUntilReady(); expect(result.current.value).toBe(1); await waitFor(() => expect(result.current.count).toBe(1)); // Rerender with new props - await act(async () => rerender({ value: 2 })); + await act(async () => { + rerender({ value: 2 }); + }); await waitUntilReady(); expect(result.current.value).toBe(2); await waitFor(() => expect(result.current.count).toBe(2)); // Rerender without arguments should use previous props (value: 2) // This would previously crash or pass undefined if not fixed - await act(async () => rerender()); + await act(async () => { + rerender(); + }); await waitUntilReady(); expect(result.current.value).toBe(2); // Count should not increase because value didn't change @@ -81,11 +98,14 @@ describe('renderHook', () => { }; const { result, rerender, waitUntilReady, unmount } = - await renderHook(useTestHook); + renderHook(useTestHook); + await waitUntilReady(); expect(result.current.count).toBe(0); - await act(async () => rerender()); + await act(async () => { + rerender(); + }); await waitUntilReady(); expect(result.current.count).toBe(0); unmount(); @@ -93,14 +113,19 @@ describe('renderHook', () => { it('should update props if undefined is passed explicitly', async () => { const useTestHook = (val: string | undefined) => val; - const { result, rerender, waitUntilReady, unmount } = await renderHook( + const { result, rerender, waitUntilReady, unmount } = renderHook( useTestHook, - { initialProps: 'initial' }, + { + initialProps: 'initial' as string | undefined, + }, ); + await waitUntilReady(); expect(result.current).toBe('initial'); - await act(async () => rerender(undefined)); + await act(async () => { + rerender(undefined); + }); await waitUntilReady(); expect(result.current).toBeUndefined(); unmount(); diff --git a/packages/cli/src/test-utils/render.tsx b/packages/cli/src/test-utils/render.tsx index ea889181c6..f3f692ced7 100644 --- a/packages/cli/src/test-utils/render.tsx +++ b/packages/cli/src/test-utils/render.tsx @@ -16,7 +16,9 @@ import { vi } from 'vitest'; import stripAnsi from 'strip-ansi'; import type React from 'react'; import { act, useState } from 'react'; -import type { LoadedSettings } from '../config/settings.js'; +import os from 'node:os'; +import path from 'node:path'; +import { 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'; @@ -42,7 +44,7 @@ import { type OverflowState, } from '../ui/contexts/OverflowContext.js'; -import { type Config } from '@google/gemini-cli-core'; +import { makeFakeConfig, type Config } from '@google/gemini-cli-core'; import { FakePersistentState } from './persistentStateFake.js'; import { AppContext, type AppState } from '../ui/contexts/AppContext.js'; import { createMockSettings } from './settings.js'; @@ -51,7 +53,6 @@ import { themeManager, DEFAULT_THEME } from '../ui/themes/theme-manager.js'; import { DefaultLight } from '../ui/themes/builtin/light/default-light.js'; import { pickDefaultThemeName } from '../ui/themes/theme.js'; import { generateSvgForTerminal } from './svg.js'; -import { loadCliConfig, type CliArgs } from '../config/config.js'; export const persistentStateMock = new FakePersistentState(); @@ -65,9 +66,7 @@ if (process.env['NODE_ENV'] === 'test') { } vi.mock('../utils/persistentState.js', () => ({ - get persistentState() { - return persistentStateMock; - }, + persistentState: persistentStateMock, })); vi.mock('../ui/utils/terminalUtils.js', () => ({ @@ -257,9 +256,13 @@ class XtermStdout extends EventEmitter { return currentFrame !== ''; } - // If Ink expects nothing (no new static content and no dynamic output), - // we consider it a match because the terminal buffer will just hold the historical static content. - if (expectedFrame === '') { + // If both are empty, it's a match. + // We consider undefined lastRenderOutput as effectively empty for this check + // to support hook testing where Ink may skip rendering completely. + if ( + (this.lastRenderOutput === undefined || expectedFrame === '') && + currentFrame === '' + ) { return true; } @@ -267,8 +270,8 @@ class XtermStdout extends EventEmitter { return false; } - // If the terminal is empty but Ink expects something, it's not a match. - if (currentFrame === '') { + // If Ink expects nothing but terminal has content, or vice-versa, it's NOT a match. + if (expectedFrame === '' || currentFrame === '') { return false; } @@ -378,11 +381,13 @@ export type RenderInstance = { const instances: InkInstance[] = []; -export const render = async ( +// Wrapper around ink's render that ensures act() is called and uses Xterm for output +export const render = ( tree: React.ReactElement, terminalWidth?: number, -): Promise< - Omit +): Omit< + RenderInstance, + 'capturedOverflowState' | 'capturedOverflowActions' > => { const cols = terminalWidth ?? 100; // We use 1000 rows to avoid windows with incorrect snapshots if a correct @@ -431,8 +436,6 @@ export const render = async ( instances.push(instance); - await stdout.waitUntilReady(); - return { rerender: (newTree: React.ReactElement) => { act(() => { @@ -483,7 +486,58 @@ export const simulateClick = async ( }); }; -export const mockSettings = createMockSettings(); +let mockConfigInternal: Config | undefined; + +const getMockConfigInternal = (): Config => { + if (!mockConfigInternal) { + mockConfigInternal = makeFakeConfig({ + targetDir: os.tmpdir(), + enableEventDrivenScheduler: true, + }); + } + return mockConfigInternal; +}; + +const configProxy = new Proxy({} as Config, { + get(_target, prop) { + if (prop === 'getTargetDir') { + return () => + path.join( + path.parse(process.cwd()).root, + 'Users', + 'test', + 'project', + 'foo', + 'bar', + 'and', + 'some', + 'more', + 'directories', + 'to', + 'make', + 'it', + 'long', + ); + } + if (prop === 'getUseBackgroundColor') { + return () => true; + } + const internal = getMockConfigInternal(); + if (prop in internal) { + 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, + [], +); // A minimal mock UIState to satisfy the context provider. // Tests that need specific UIState values should provide their own. @@ -592,7 +646,7 @@ const ContextCapture: React.FC<{ children: React.ReactNode }> = ({ return <>{children}; }; -export const renderWithProviders = async ( +export const renderWithProviders = ( component: React.ReactElement, { shellFocus = true, @@ -600,7 +654,9 @@ export const renderWithProviders = async ( uiState: providedUiState, width, mouseEventsEnabled = false, - config, + + config = configProxy as unknown as Config, + useAlternateBuffer = true, uiActions, persistentState, appState = mockAppState, @@ -611,6 +667,7 @@ export const renderWithProviders = async ( width?: number; mouseEventsEnabled?: boolean; config?: Config; + useAlternateBuffer?: boolean; uiActions?: Partial; persistentState?: { get?: typeof persistentStateMock.get; @@ -618,15 +675,13 @@ export const renderWithProviders = async ( }; appState?: AppState; } = {}, -): Promise< - RenderInstance & { - simulateClick: ( - col: number, - row: number, - button?: 0 | 1 | 2, - ) => Promise; - } -> => { +): RenderInstance & { + simulateClick: ( + col: number, + row: number, + button?: 0 | 1 | 2, + ) => Promise; +} => { const baseState: UIState = new Proxy( { ...baseMockUiState, ...providedUiState }, { @@ -655,14 +710,30 @@ export const renderWithProviders = async ( persistentStateMock.mockClear(); const terminalWidth = width ?? baseState.terminalWidth; + let finalSettings = settings; + if (useAlternateBuffer !== undefined) { + finalSettings = createMockSettings({ + ...settings.merged, + ui: { + ...settings.merged.ui, + useAlternateBuffer, + }, + }); + } - if (!config) { - config = await loadCliConfig( - settings.merged, - 'random-session-id', - {} as unknown as CliArgs, - { cwd: '/' }, - ); + // 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; + } + + return Reflect.get(target, prop, receiver); + }, + }); } const mainAreaWidth = terminalWidth; @@ -691,10 +762,10 @@ export const renderWithProviders = async ( capturedOverflowState = undefined; capturedOverflowActions = undefined; - const wrapWithProviders = (comp: React.ReactElement) => ( + const renderResult = render( - - + + @@ -705,7 +776,7 @@ export const renderWithProviders = async ( - {comp} + {component} @@ -744,19 +815,12 @@ export const renderWithProviders = async ( - - ); - - const renderResult = await 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) => @@ -764,19 +828,19 @@ export const renderWithProviders = async ( }; }; -export async function renderHook( +export function renderHook( renderCallback: (props: Props) => Result, options?: { initialProps?: Props; wrapper?: React.ComponentType<{ children: React.ReactNode }>; }, -): Promise<{ +): { result: { current: Result }; rerender: (props?: Props) => void; unmount: () => void; waitUntilReady: () => Promise; generateSvg: () => string; -}> { +} { const result = { current: undefined as unknown as Result }; let currentProps = options?.initialProps as Props; @@ -799,15 +863,17 @@ export async function renderHook( let waitUntilReady: () => Promise = async () => {}; let generateSvg: () => string = () => ''; - const renderResult = await render( - - - , - ); - inkRerender = renderResult.rerender; - unmount = renderResult.unmount; - waitUntilReady = renderResult.waitUntilReady; - generateSvg = renderResult.generateSvg; + act(() => { + const renderResult = render( + + + , + ); + inkRerender = renderResult.rerender; + unmount = renderResult.unmount; + waitUntilReady = renderResult.waitUntilReady; + generateSvg = renderResult.generateSvg; + }); function rerender(props?: Props) { if (arguments.length > 0) { @@ -825,7 +891,7 @@ export async function renderHook( return { result, rerender, unmount, waitUntilReady, generateSvg }; } -export async function renderHookWithProviders( +export function renderHookWithProviders( renderCallback: (props: Props) => Result, options: { initialProps?: Props; @@ -837,14 +903,15 @@ export async function renderHookWithProviders( width?: number; mouseEventsEnabled?: boolean; config?: Config; + useAlternateBuffer?: boolean; } = {}, -): Promise<{ +): { result: { current: Result }; rerender: (props?: Props) => void; unmount: () => void; waitUntilReady: () => Promise; generateSvg: () => string; -}> { +} { const result = { current: undefined as unknown as Result }; let setPropsFn: ((props: Props) => void) | undefined; @@ -861,16 +928,10 @@ export async function renderHookWithProviders( const Wrapper = options.wrapper || (({ children }) => <>{children}); - let renderResult: RenderInstance & { - simulateClick: ( - col: number, - row: number, - button?: 0 | 1 | 2, - ) => Promise; - }; + let renderResult: ReturnType; - await act(async () => { - renderResult = await renderWithProviders( + act(() => { + renderResult = renderWithProviders( {} diff --git a/packages/cli/src/ui/App.test.tsx b/packages/cli/src/ui/App.test.tsx index 950363f6a8..d96bfe3071 100644 --- a/packages/cli/src/ui/App.test.tsx +++ b/packages/cli/src/ui/App.test.tsx @@ -7,7 +7,6 @@ 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'; @@ -94,10 +93,14 @@ describe('App', () => { }; it('should render main content and composer when not quitting', async () => { - const { lastFrame, unmount } = await renderWithProviders(, { - uiState: mockUIState, - settings: createMockSettings({ ui: { useAlternateBuffer: false } }), - }); + const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + , + { + uiState: mockUIState, + useAlternateBuffer: false, + }, + ); + await waitUntilReady(); expect(lastFrame()).toContain('Tips for getting started'); expect(lastFrame()).toContain('Notifications'); @@ -111,10 +114,14 @@ describe('App', () => { quittingMessages: [{ id: 1, type: 'user', text: 'test' }], } as UIState; - const { lastFrame, unmount } = await renderWithProviders(, { - uiState: quittingUIState, - settings: createMockSettings({ ui: { useAlternateBuffer: false } }), - }); + const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + , + { + uiState: quittingUIState, + useAlternateBuffer: false, + }, + ); + await waitUntilReady(); expect(lastFrame()).toContain('Quitting...'); unmount(); @@ -128,10 +135,14 @@ describe('App', () => { pendingHistoryItems: [{ type: 'user', text: 'pending item' }], } as UIState; - const { lastFrame, unmount } = await renderWithProviders(, { - uiState: quittingUIState, - settings: createMockSettings({ ui: { useAlternateBuffer: true } }), - }); + const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + , + { + uiState: quittingUIState, + useAlternateBuffer: true, + }, + ); + await waitUntilReady(); expect(lastFrame()).toContain('HistoryItemDisplay'); expect(lastFrame()).toContain('Quitting...'); @@ -144,10 +155,13 @@ describe('App', () => { dialogsVisible: true, } as UIState; - const { lastFrame, unmount } = await renderWithProviders(, { - uiState: dialogUIState, - settings: createMockSettings({ ui: { useAlternateBuffer: true } }), - }); + const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + , + { + uiState: dialogUIState, + }, + ); + await waitUntilReady(); expect(lastFrame()).toContain('Tips for getting started'); expect(lastFrame()).toContain('Notifications'); @@ -167,10 +181,13 @@ describe('App', () => { [stateKey]: true, } as UIState; - const { lastFrame, unmount } = await renderWithProviders(, { - uiState, - settings: createMockSettings({ ui: { useAlternateBuffer: true } }), - }); + const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + , + { + uiState, + }, + ); + await waitUntilReady(); expect(lastFrame()).toContain(`Press Ctrl+${key} again to exit.`); unmount(); @@ -180,10 +197,13 @@ describe('App', () => { it('should render ScreenReaderAppLayout when screen reader is enabled', async () => { (useIsScreenReaderEnabled as Mock).mockReturnValue(true); - const { lastFrame, unmount } = await renderWithProviders(, { - uiState: mockUIState, - settings: createMockSettings({ ui: { useAlternateBuffer: true } }), - }); + const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + , + { + uiState: mockUIState, + }, + ); + await waitUntilReady(); expect(lastFrame()).toContain('Notifications'); expect(lastFrame()).toContain('Footer'); @@ -195,10 +215,13 @@ describe('App', () => { it('should render DefaultAppLayout when screen reader is not enabled', async () => { (useIsScreenReaderEnabled as Mock).mockReturnValue(false); - const { lastFrame, unmount } = await renderWithProviders(, { - uiState: mockUIState, - settings: createMockSettings({ ui: { useAlternateBuffer: true } }), - }); + const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + , + { + uiState: mockUIState, + }, + ); + await waitUntilReady(); expect(lastFrame()).toContain('Tips for getting started'); expect(lastFrame()).toContain('Notifications'); @@ -242,15 +265,18 @@ describe('App', () => { ], } as UIState; - const configWithExperiment = makeFakeConfig({ useAlternateBuffer: true }); + const configWithExperiment = makeFakeConfig(); vi.spyOn(configWithExperiment, 'isTrustedFolder').mockReturnValue(true); vi.spyOn(configWithExperiment, 'getIdeMode').mockReturnValue(false); - const { lastFrame, unmount } = await renderWithProviders(, { - uiState: stateWithConfirmingTool, - config: configWithExperiment, - settings: createMockSettings({ ui: { useAlternateBuffer: true } }), - }); + const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + , + { + uiState: stateWithConfirmingTool, + config: configWithExperiment, + }, + ); + await waitUntilReady(); expect(lastFrame()).toContain('Tips for getting started'); expect(lastFrame()).toContain('Notifications'); @@ -263,20 +289,26 @@ describe('App', () => { describe('Snapshots', () => { it('renders default layout correctly', async () => { (useIsScreenReaderEnabled as Mock).mockReturnValue(false); - const { lastFrame, unmount } = await renderWithProviders(, { - uiState: mockUIState, - settings: createMockSettings({ ui: { useAlternateBuffer: true } }), - }); + const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + , + { + uiState: mockUIState, + }, + ); + await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); unmount(); }); it('renders screen reader layout correctly', async () => { (useIsScreenReaderEnabled as Mock).mockReturnValue(true); - const { lastFrame, unmount } = await renderWithProviders(, { - uiState: mockUIState, - settings: createMockSettings({ ui: { useAlternateBuffer: true } }), - }); + const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + , + { + uiState: mockUIState, + }, + ); + await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); unmount(); }); @@ -286,10 +318,13 @@ describe('App', () => { ...mockUIState, dialogsVisible: true, } as UIState; - const { lastFrame, unmount } = await renderWithProviders(, { - uiState: dialogUIState, - settings: createMockSettings({ ui: { useAlternateBuffer: true } }), - }); + const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + , + { + uiState: dialogUIState, + }, + ); + await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); unmount(); }); diff --git a/packages/cli/src/ui/AppContainer.test.tsx b/packages/cli/src/ui/AppContainer.test.tsx index 313573a573..13550d3f42 100644 --- a/packages/cli/src/ui/AppContainer.test.tsx +++ b/packages/cli/src/ui/AppContainer.test.tsx @@ -16,7 +16,7 @@ import { } from 'vitest'; import { render, cleanup, persistentStateMock } from '../test-utils/render.js'; import { waitFor } from '../test-utils/async.js'; -import { act, useContext } from 'react'; +import { act, useContext, type ReactElement } from 'react'; import { AppContainer } from './AppContainer.js'; import { SettingsContext } from './contexts/SettingsContext.js'; import { type TrackedToolCall } from './hooks/useToolScheduler.js'; @@ -95,8 +95,7 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => { }; }); import ansiEscapes from 'ansi-escapes'; -import { type LoadedSettings } from '../config/settings.js'; -import { createMockSettings } from '../test-utils/settings.js'; +import { mergeSettings, type LoadedSettings } from '../config/settings.js'; import type { InitializationResult } from '../core/initializer.js'; import { useQuotaAndFallback } from './hooks/useQuotaAndFallback.js'; import { StreamingState } from './types.js'; @@ -212,7 +211,7 @@ import { useEditorSettings } from './hooks/useEditorSettings.js'; import { useSettingsCommand } from './hooks/useSettingsCommand.js'; import { useModelCommand } from './hooks/useModelCommand.js'; import { useSlashCommandProcessor } from './hooks/slashCommandProcessor.js'; -import { useErrorCount } from './hooks/useConsoleMessages.js'; +import { useConsoleMessages } from './hooks/useConsoleMessages.js'; import { useGeminiStream } from './hooks/useGeminiStream.js'; import { useVim } from './hooks/vim.js'; import { useFolderTrust } from './hooks/useFolderTrust.js'; @@ -250,15 +249,6 @@ describe('AppContainer State Management', () => { let mockInitResult: InitializationResult; let mockExtensionManager: MockedObject; - type AppContainerProps = { - settings?: LoadedSettings; - config?: Config; - version?: string; - initResult?: InitializationResult; - startupWarnings?: StartupWarning[]; - resumedSessionData?: ResumedSessionData; - }; - // Helper to generate the AppContainer JSX for render and rerender const getAppContainer = ({ settings = mockSettings, @@ -267,7 +257,14 @@ describe('AppContainer State Management', () => { initResult = mockInitResult, startupWarnings, resumedSessionData, - }: AppContainerProps = {}) => ( + }: { + settings?: LoadedSettings; + config?: Config; + version?: string; + initResult?: InitializationResult; + startupWarnings?: StartupWarning[]; + resumedSessionData?: ResumedSessionData; + } = {}) => ( @@ -284,7 +281,7 @@ describe('AppContainer State Management', () => { ); // Helper to render the AppContainer - const renderAppContainer = async (props?: AppContainerProps) => + const renderAppContainer = (props?: Parameters[0]) => render(getAppContainer(props)); // Create typed mocks for all hooks @@ -296,7 +293,7 @@ describe('AppContainer State Management', () => { const mockedUseSettingsCommand = useSettingsCommand as Mock; const mockedUseModelCommand = useModelCommand as Mock; const mockedUseSlashCommandProcessor = useSlashCommandProcessor as Mock; - const mockedUseConsoleMessages = useErrorCount as Mock; + const mockedUseConsoleMessages = useConsoleMessages as Mock; const mockedUseGeminiStream = useGeminiStream as Mock; const mockedUseVim = useVim as Mock; const mockedUseFolderTrust = useFolderTrust as Mock; @@ -398,9 +395,9 @@ describe('AppContainer State Management', () => { confirmationRequest: null, }); mockedUseConsoleMessages.mockReturnValue({ - errorCount: 0, + consoleMessages: [], handleNewMessage: vi.fn(), - clearErrorCount: vi.fn(), + clearConsoleMessages: vi.fn(), }); mockedUseGeminiStream.mockReturnValue(DEFAULT_GEMINI_STREAM_MOCK); mockedUseVim.mockReturnValue({ handleInput: vi.fn() }); @@ -487,18 +484,23 @@ describe('AppContainer State Management', () => { ); // Mock LoadedSettings - mockSettings = createMockSettings({ - hideBanner: false, - hideFooter: false, - hideTips: false, - showMemoryUsage: false, - theme: 'default', - ui: { - showStatusInTitle: false, - hideWindowTitle: false, - useAlternateBuffer: false, + 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, + }, }, - }); + } as unknown as LoadedSettings; // Mock InitializationResult mockInitResult = { @@ -516,9 +518,13 @@ describe('AppContainer State Management', () => { describe('Basic Rendering', () => { it('renders without crashing with minimal props', async () => { - const { unmount } = await act(async () => renderAppContainer()); - expect(capturedUIState).toBeTruthy(); - unmount(); + let unmount: () => void; + await act(async () => { + const result = renderAppContainer(); + unmount = result.unmount; + }); + await waitFor(() => expect(capturedUIState).toBeTruthy()); + unmount!(); }); it('renders with startup warnings', async () => { @@ -535,32 +541,44 @@ describe('AppContainer State Management', () => { }, ]; - const { unmount } = await act(async () => - renderAppContainer({ startupWarnings }), - ); - expect(capturedUIState).toBeTruthy(); - unmount(); + let unmount: () => void; + await act(async () => { + const result = renderAppContainer({ startupWarnings }); + unmount = result.unmount; + }); + await waitFor(() => expect(capturedUIState).toBeTruthy()); + unmount!(); }); it('shows full UI details by default', async () => { - const { unmount } = await act(async () => renderAppContainer()); + let unmount: () => void; + await act(async () => { + const result = renderAppContainer(); + unmount = result.unmount; + }); - expect(capturedUIState.cleanUiDetailsVisible).toBe(true); - unmount(); + await waitFor(() => { + expect(capturedUIState.cleanUiDetailsVisible).toBe(true); + }); + unmount!(); }); it('starts in minimal UI mode when Focus UI preference is persisted', async () => { persistentStateMock.get.mockReturnValueOnce(true); - const { unmount } = await act(async () => - renderAppContainer({ + let unmount: () => void; + await act(async () => { + const result = renderAppContainer({ settings: mockSettings, - }), - ); + }); + unmount = result.unmount; + }); - expect(capturedUIState.cleanUiDetailsVisible).toBe(false); + await waitFor(() => { + expect(capturedUIState.cleanUiDetailsVisible).toBe(false); + }); expect(persistentStateMock.get).toHaveBeenCalledWith('focusUiEnabled'); - unmount(); + unmount!(); }); }); @@ -595,9 +613,15 @@ describe('AppContainer State Management', () => { ], }); - const { unmount } = await act(async () => renderAppContainer()); + let unmount: (() => void) | undefined; + await act(async () => { + const rendered = renderAppContainer(); + unmount = rendered.unmount; + }); - expect(terminalNotificationsMocks.notifyViaTerminal).toHaveBeenCalled(); + await waitFor(() => + expect(terminalNotificationsMocks.notifyViaTerminal).toHaveBeenCalled(), + ); expect( terminalNotificationsMocks.buildRunEventNotificationContent, ).toHaveBeenCalledWith( @@ -606,7 +630,9 @@ describe('AppContainer State Management', () => { }), ); - unmount(); + await act(async () => { + unmount?.(); + }); }); it('does not send attention notification when terminal is focused', async () => { @@ -639,13 +665,19 @@ describe('AppContainer State Management', () => { ], }); - const { unmount } = await act(async () => renderAppContainer()); + let unmount: (() => void) | undefined; + await act(async () => { + const rendered = renderAppContainer(); + unmount = rendered.unmount; + }); expect( terminalNotificationsMocks.notifyViaTerminal, ).not.toHaveBeenCalled(); - unmount(); + await act(async () => { + unmount?.(); + }); }); it('sends attention notification when focus reporting is unavailable', async () => { @@ -678,11 +710,19 @@ describe('AppContainer State Management', () => { ], }); - const { unmount } = await act(async () => renderAppContainer()); + let unmount: (() => void) | undefined; + await act(async () => { + const rendered = renderAppContainer(); + unmount = rendered.unmount; + }); - expect(terminalNotificationsMocks.notifyViaTerminal).toHaveBeenCalled(); + await waitFor(() => + expect(terminalNotificationsMocks.notifyViaTerminal).toHaveBeenCalled(), + ); - unmount(); + await act(async () => { + unmount?.(); + }); }); it('sends a macOS notification when a response completes while unfocused', async () => { @@ -696,24 +736,35 @@ describe('AppContainer State Management', () => { streamingState: currentStreamingState, })); - const { unmount, rerender } = await act(async () => renderAppContainer()); + let unmount: (() => void) | undefined; + let rerender: ((tree: ReactElement) => void) | undefined; + + await act(async () => { + const rendered = renderAppContainer(); + unmount = rendered.unmount; + rerender = rendered.rerender; + }); currentStreamingState = 'idle'; await act(async () => { - rerender(getAppContainer()); + rerender?.(getAppContainer()); }); - expect( - terminalNotificationsMocks.buildRunEventNotificationContent, - ).toHaveBeenCalledWith( - expect.objectContaining({ - type: 'session_complete', - detail: 'Gemini CLI finished responding.', - }), + await waitFor(() => + expect( + terminalNotificationsMocks.buildRunEventNotificationContent, + ).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'session_complete', + detail: 'Gemini CLI finished responding.', + }), + ), ); expect(terminalNotificationsMocks.notifyViaTerminal).toHaveBeenCalled(); - unmount(); + await act(async () => { + unmount?.(); + }); }); it('sends completion notification when focus reporting is unavailable', async () => { @@ -727,23 +778,34 @@ describe('AppContainer State Management', () => { streamingState: currentStreamingState, })); - const { unmount, rerender } = await act(async () => renderAppContainer()); + let unmount: (() => void) | undefined; + let rerender: ((tree: ReactElement) => void) | undefined; + + await act(async () => { + const rendered = renderAppContainer(); + unmount = rendered.unmount; + rerender = rendered.rerender; + }); currentStreamingState = 'idle'; await act(async () => { - rerender(getAppContainer()); + rerender?.(getAppContainer()); }); - expect( - terminalNotificationsMocks.buildRunEventNotificationContent, - ).toHaveBeenCalledWith( - expect.objectContaining({ - type: 'session_complete', - detail: 'Gemini CLI finished responding.', - }), + await waitFor(() => + expect( + terminalNotificationsMocks.buildRunEventNotificationContent, + ).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'session_complete', + detail: 'Gemini CLI finished responding.', + }), + ), ); - unmount(); + await act(async () => { + unmount?.(); + }); }); it('does not send completion notification when another action-required dialog is pending', async () => { @@ -761,18 +823,27 @@ describe('AppContainer State Management', () => { streamingState: currentStreamingState, })); - const { unmount, rerender } = await act(async () => renderAppContainer()); + let unmount: (() => void) | undefined; + let rerender: ((tree: ReactElement) => void) | undefined; + + await act(async () => { + const rendered = renderAppContainer(); + unmount = rendered.unmount; + rerender = rendered.rerender; + }); currentStreamingState = 'idle'; await act(async () => { - rerender(getAppContainer()); + rerender?.(getAppContainer()); }); expect( terminalNotificationsMocks.notifyViaTerminal, ).not.toHaveBeenCalled(); - unmount(); + await act(async () => { + unmount?.(); + }); }); it('can send repeated attention notifications for the same key after pending state clears', async () => { @@ -808,15 +879,24 @@ describe('AppContainer State Management', () => { pendingHistoryItems, })); - const { unmount, rerender } = await act(async () => renderAppContainer()); + let unmount: (() => void) | undefined; + let rerender: ((tree: ReactElement) => void) | undefined; - expect( - terminalNotificationsMocks.notifyViaTerminal, - ).toHaveBeenCalledTimes(1); + await act(async () => { + const rendered = renderAppContainer(); + unmount = rendered.unmount; + rerender = rendered.rerender; + }); + + await waitFor(() => + expect( + terminalNotificationsMocks.notifyViaTerminal, + ).toHaveBeenCalledTimes(1), + ); pendingHistoryItems = []; await act(async () => { - rerender(getAppContainer()); + rerender?.(getAppContainer()); }); pendingHistoryItems = [ @@ -841,14 +921,18 @@ describe('AppContainer State Management', () => { }, ]; await act(async () => { - rerender(getAppContainer()); + rerender?.(getAppContainer()); }); - expect( - terminalNotificationsMocks.notifyViaTerminal, - ).toHaveBeenCalledTimes(2); + await waitFor(() => + expect( + terminalNotificationsMocks.notifyViaTerminal, + ).toHaveBeenCalledTimes(2), + ); - unmount(); + await act(async () => { + unmount?.(); + }); }); it('initializes with theme error from initialization result', async () => { @@ -857,82 +941,112 @@ describe('AppContainer State Management', () => { themeError: 'Failed to load theme', }; - const { unmount } = await act(async () => - renderAppContainer({ + let unmount: () => void; + await act(async () => { + const result = renderAppContainer({ initResult: initResultWithError, - }), - ); - expect(capturedUIState).toBeTruthy(); - unmount(); + }); + unmount = result.unmount; + }); + await waitFor(() => expect(capturedUIState).toBeTruthy()); + unmount!(); }); - it('handles debug mode state', async () => { + it('handles debug mode state', () => { const debugConfig = makeFakeConfig(); vi.spyOn(debugConfig, 'getDebugMode').mockReturnValue(true); - const { unmount } = await act(async () => - renderAppContainer({ config: debugConfig }), - ); - unmount(); + expect(() => { + renderAppContainer({ config: debugConfig }); + }).not.toThrow(); }); }); describe('Context Providers', () => { it('provides AppContext with correct values', async () => { - const { unmount } = await act(async () => - renderAppContainer({ version: '2.0.0' }), - ); - expect(capturedUIState).toBeTruthy(); + let unmount: () => void; + await act(async () => { + const result = renderAppContainer({ version: '2.0.0' }); + unmount = result.unmount; + }); + await waitFor(() => expect(capturedUIState).toBeTruthy()); // Should render and unmount cleanly - unmount(); + expect(() => unmount!()).not.toThrow(); }); it('provides UIStateContext with state management', async () => { - const { unmount } = await act(async () => renderAppContainer()); - expect(capturedUIState).toBeTruthy(); - unmount(); + let unmount: () => void; + await act(async () => { + const result = renderAppContainer(); + unmount = result.unmount; + }); + await waitFor(() => expect(capturedUIState).toBeTruthy()); + unmount!(); }); it('provides UIActionsContext with action handlers', async () => { - const { unmount } = await act(async () => renderAppContainer()); - expect(capturedUIState).toBeTruthy(); - unmount(); + let unmount: () => void; + await act(async () => { + const result = renderAppContainer(); + unmount = result.unmount; + }); + await waitFor(() => expect(capturedUIState).toBeTruthy()); + unmount!(); }); it('provides ConfigContext with config object', async () => { - const { unmount } = await act(async () => renderAppContainer()); - expect(capturedUIState).toBeTruthy(); - unmount(); + let unmount: () => void; + await act(async () => { + const result = renderAppContainer(); + unmount = result.unmount; + }); + await waitFor(() => expect(capturedUIState).toBeTruthy()); + unmount!(); }); }); describe('Settings Integration', () => { it('handles settings with all display options disabled', async () => { - const settingsAllHidden = createMockSettings({ - hideBanner: true, - hideFooter: true, - hideTips: true, - showMemoryUsage: false, - }); + const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true); + const settingsAllHidden = { + merged: { + ...defaultMergedSettings, + hideBanner: true, + hideFooter: true, + hideTips: true, + showMemoryUsage: false, + }, + } as unknown as LoadedSettings; - const { unmount } = await act(async () => - renderAppContainer({ settings: settingsAllHidden }), - ); - expect(capturedUIState).toBeTruthy(); - unmount(); + let unmount: () => void; + await act(async () => { + const result = renderAppContainer({ settings: settingsAllHidden }); + unmount = result.unmount; + }); + await waitFor(() => expect(capturedUIState).toBeTruthy()); + unmount!(); }); it('handles settings with memory usage enabled', async () => { - const settingsWithMemory = createMockSettings({ - showMemoryUsage: true, - }); + const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true); + const settingsWithMemory = { + merged: { + ...defaultMergedSettings, + hideBanner: false, + hideFooter: false, + hideTips: false, + showMemoryUsage: true, + }, + } as unknown as LoadedSettings; - const { unmount } = await act(async () => - renderAppContainer({ settings: settingsWithMemory }), - ); - expect(capturedUIState).toBeTruthy(); - unmount(); + let unmount: () => void; + await act(async () => { + const result = renderAppContainer({ settings: settingsWithMemory }); + unmount = result.unmount; + }); + await waitFor(() => expect(capturedUIState).toBeTruthy()); + unmount!(); }); }); @@ -940,11 +1054,13 @@ describe('AppContainer State Management', () => { it.each(['1.0.0', '2.1.3-beta', '3.0.0-nightly'])( 'handles version format: %s', async (version) => { - const { unmount } = await act(async () => - renderAppContainer({ version }), - ); - expect(capturedUIState).toBeTruthy(); - unmount(); + let unmount: () => void; + await act(async () => { + const result = renderAppContainer({ version }); + unmount = result.unmount; + }); + await waitFor(() => expect(capturedUIState).toBeTruthy()); + unmount!(); }, ); }); @@ -957,30 +1073,32 @@ describe('AppContainer State Management', () => { }); // Should still render without crashing - errors should be handled internally - const { unmount } = await act(async () => - renderAppContainer({ config: errorConfig }), - ); + const { unmount } = renderAppContainer({ config: errorConfig }); unmount(); }); it('handles undefined settings gracefully', async () => { - const undefinedSettings = createMockSettings(); + const undefinedSettings = { + merged: mergeSettings({}, {}, {}, {}, true), + } as LoadedSettings; - const { unmount } = await act(async () => - renderAppContainer({ settings: undefinedSettings }), - ); - expect(capturedUIState).toBeTruthy(); - unmount(); + let unmount: () => void; + await act(async () => { + const result = renderAppContainer({ settings: undefinedSettings }); + unmount = result.unmount; + }); + await waitFor(() => expect(capturedUIState).toBeTruthy()); + unmount!(); }); }); describe('Provider Hierarchy', () => { - it('establishes correct provider nesting order', async () => { + it('establishes correct provider nesting order', () => { // This tests that all the context providers are properly nested // and that the component tree can be built without circular dependencies - const { unmount } = await act(async () => renderAppContainer()); + const { unmount } = renderAppContainer(); - unmount(); + expect(() => unmount()).not.toThrow(); }); }); @@ -1012,32 +1130,40 @@ describe('AppContainer State Management', () => { filePath: '/tmp/test-session.json', }; - const { unmount } = await act(async () => - renderAppContainer({ + let unmount: () => void; + await act(async () => { + const result = renderAppContainer({ config: mockConfig, settings: mockSettings, version: '1.0.0', initResult: mockInitResult, resumedSessionData: mockResumedSessionData, - }), - ); - unmount(); + }); + unmount = result.unmount; + }); + await act(async () => { + unmount(); + }); }); it('renders without resumed session data', async () => { - const { unmount } = await act(async () => - renderAppContainer({ + let unmount: () => void; + await act(async () => { + const result = renderAppContainer({ config: mockConfig, settings: mockSettings, version: '1.0.0', initResult: mockInitResult, resumedSessionData: undefined, - }), - ); - unmount(); + }); + unmount = result.unmount; + }); + await act(async () => { + unmount(); + }); }); - it('initializes chat recording service when config has it', async () => { + it('initializes chat recording service when config has it', () => { const mockChatRecordingService = { initialize: vi.fn(), recordMessage: vi.fn(), @@ -1057,19 +1183,18 @@ describe('AppContainer State Management', () => { mockGeminiClient as unknown as ReturnType, ); - const { unmount } = await act(async () => + expect(() => { renderAppContainer({ config: configWithRecording, settings: mockSettings, version: '1.0.0', initResult: mockInitResult, - }), - ); - unmount(); + }); + }).not.toThrow(); }); }); describe('Session Recording Integration', () => { - it('provides chat recording service configuration', async () => { + it('provides chat recording service configuration', () => { const mockChatRecordingService = { initialize: vi.fn(), recordMessage: vi.fn(), @@ -1095,24 +1220,23 @@ describe('AppContainer State Management', () => { 'test-session-123', ); - const { unmount } = await act(async () => + expect(() => { renderAppContainer({ config: configWithRecording, settings: mockSettings, version: '1.0.0', initResult: mockInitResult, - }), - ); + }); + }).not.toThrow(); // Verify the recording service structure is correct expect(configWithRecording.getGeminiClient).toBeDefined(); expect(mockGeminiClient.getChatRecordingService).toBeDefined(); expect(mockChatRecordingService.initialize).toBeDefined(); expect(mockChatRecordingService.recordMessage).toBeDefined(); - unmount(); }); - it('handles session recording when messages are added', async () => { + it('handles session recording when messages are added', () => { const mockRecordMessage = vi.fn(); const mockRecordMessageTokens = vi.fn(); @@ -1135,25 +1259,22 @@ describe('AppContainer State Management', () => { mockGeminiClient as unknown as ReturnType, ); - const { unmount } = await act(async () => - renderAppContainer({ - config: configWithRecording, - settings: mockSettings, - version: '1.0.0', - initResult: mockInitResult, - }), - ); + renderAppContainer({ + config: configWithRecording, + settings: mockSettings, + version: '1.0.0', + initResult: mockInitResult, + }); // The actual recording happens through the useHistory hook // which would be triggered by user interactions expect(mockChatRecordingService.initialize).toBeDefined(); expect(mockChatRecordingService.recordMessage).toBeDefined(); - unmount(); }); }); describe('Session Resume Flow', () => { - it('accepts resumed session data', async () => { + it('accepts resumed session data', () => { const mockResumeChat = vi.fn(); const mockGeminiClient = { isInitialized: vi.fn(() => true), @@ -1199,23 +1320,22 @@ describe('AppContainer State Management', () => { filePath: '/tmp/resumed-session.json', }; - const { unmount } = await act(async () => + expect(() => { renderAppContainer({ config: configWithClient, settings: mockSettings, version: '1.0.0', initResult: mockInitResult, resumedSessionData: resumedData, - }), - ); + }); + }).not.toThrow(); // Verify the resume functionality structure is in place expect(mockGeminiClient.resumeChat).toBeDefined(); expect(resumedData.conversation.messages).toHaveLength(2); - unmount(); }); - it('does not attempt resume when client is not initialized', async () => { + it('does not attempt resume when client is not initialized', () => { const mockResumeChat = vi.fn(); const mockGeminiClient = { isInitialized: vi.fn(() => false), // Not initialized @@ -1240,24 +1360,21 @@ describe('AppContainer State Management', () => { filePath: '/tmp/session.json', }; - const { unmount } = await act(async () => - renderAppContainer({ - config: configWithClient, - settings: mockSettings, - version: '1.0.0', - initResult: mockInitResult, - resumedSessionData: resumedData, - }), - ); + renderAppContainer({ + config: configWithClient, + settings: mockSettings, + version: '1.0.0', + initResult: mockInitResult, + resumedSessionData: resumedData, + }); // Should not call resumeChat when client is not initialized expect(mockResumeChat).not.toHaveBeenCalled(); - unmount(); }); }); describe('Token Counting from Session Stats', () => { - it('tracks token counts from session messages', async () => { + it('tracks token counts from session messages', () => { // Session stats are provided through the SessionStatsProvider context // in the real app, not through the config directly const mockChatRecordingService = { @@ -1285,30 +1402,33 @@ describe('AppContainer State Management', () => { mockGeminiClient as unknown as ReturnType, ); - const { unmount } = await act(async () => - renderAppContainer({ - config: configWithRecording, - settings: mockSettings, - version: '1.0.0', - initResult: mockInitResult, - }), - ); + renderAppContainer({ + config: configWithRecording, + settings: mockSettings, + version: '1.0.0', + initResult: mockInitResult, + }); // In the actual app, these stats would be displayed in components // and updated as messages are processed through the recording service expect(mockChatRecordingService.recordMessageTokens).toBeDefined(); expect(mockChatRecordingService.getCurrentConversation).toBeDefined(); - unmount(); }); }); describe('Quota and Fallback Integration', () => { it('passes a null proQuotaRequest to UIStateContext by default', async () => { // The default mock from beforeEach already sets proQuotaRequest to null - const { unmount } = await act(async () => renderAppContainer()); - // Assert that the context value is as expected - expect(capturedUIState.quota.proQuotaRequest).toBeNull(); - unmount(); + let unmount: () => void; + await act(async () => { + const result = renderAppContainer(); + unmount = result.unmount; + }); + await waitFor(() => { + // Assert that the context value is as expected + expect(capturedUIState.quota.proQuotaRequest).toBeNull(); + }); + unmount!(); }); it('passes a valid proQuotaRequest to UIStateContext when provided by the hook', async () => { @@ -1324,10 +1444,16 @@ describe('AppContainer State Management', () => { }); // Act: Render the container - const { unmount } = await act(async () => renderAppContainer()); - // Assert: The mock request is correctly passed through the context - expect(capturedUIState.quota.proQuotaRequest).toEqual(mockRequest); - unmount(); + let unmount: () => void; + await act(async () => { + const result = renderAppContainer(); + unmount = result.unmount; + }); + await waitFor(() => { + // Assert: The mock request is correctly passed through the context + expect(capturedUIState.quota.proQuotaRequest).toEqual(mockRequest); + }); + unmount!(); }); it('passes the handleProQuotaChoice function to UIActionsContext', async () => { @@ -1339,16 +1465,22 @@ describe('AppContainer State Management', () => { }); // Act: Render the container - const { unmount } = await act(async () => renderAppContainer()); - // Assert: The action in the context is the mock handler we provided - expect(capturedUIActions.handleProQuotaChoice).toBe(mockHandler); + let unmount: () => void; + await act(async () => { + const result = renderAppContainer(); + unmount = result.unmount; + }); + await waitFor(() => { + // Assert: The action in the context is the mock handler we provided + expect(capturedUIActions.handleProQuotaChoice).toBe(mockHandler); + }); // You can even verify that the plumbed function is callable act(() => { capturedUIActions.handleProQuotaChoice('retry_later'); }); expect(mockHandler).toHaveBeenCalledWith('retry_later'); - unmount(); + unmount!(); }); }); @@ -1364,14 +1496,20 @@ describe('AppContainer State Management', () => { expect(stdout).toBe(mocks.mockStdout); }); - it('should update terminal title with Working… when showStatusInTitle is false', async () => { + it('should update terminal title with Working… when showStatusInTitle is false', () => { // Arrange: Set up mock settings with showStatusInTitle disabled - const mockSettingsWithShowStatusFalse = createMockSettings({ - ui: { - showStatusInTitle: false, - hideWindowTitle: false, + const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true); + const mockSettingsWithShowStatusFalse = { + ...mockSettings, + merged: { + ...defaultMergedSettings, + ui: { + ...defaultMergedSettings.ui, + showStatusInTitle: false, + hideWindowTitle: false, + }, }, - }); + } as unknown as LoadedSettings; // Mock the streaming state as Active mockedUseGeminiStream.mockReturnValue({ @@ -1381,11 +1519,9 @@ describe('AppContainer State Management', () => { }); // Act: Render the container - const { unmount } = await act(async () => - renderAppContainer({ - settings: mockSettingsWithShowStatusFalse, - }), - ); + const { unmount } = renderAppContainer({ + settings: mockSettingsWithShowStatusFalse, + }); // Assert: Check that title was updated with "Working…" const titleWrites = mocks.mockStdout.write.mock.calls.filter((call) => @@ -1399,14 +1535,19 @@ describe('AppContainer State Management', () => { unmount(); }); - it('should use legacy terminal title when dynamicWindowTitle is false', async () => { + it('should use legacy terminal title when dynamicWindowTitle is false', () => { // Arrange: Set up mock settings with dynamicWindowTitle disabled - const mockSettingsWithDynamicTitleFalse = createMockSettings({ - ui: { - dynamicWindowTitle: false, - hideWindowTitle: false, + const mockSettingsWithDynamicTitleFalse = { + ...mockSettings, + merged: { + ...mockSettings.merged, + ui: { + ...mockSettings.merged.ui, + dynamicWindowTitle: false, + hideWindowTitle: false, + }, }, - }); + } as unknown as LoadedSettings; // Mock the streaming state mockedUseGeminiStream.mockReturnValue({ @@ -1416,11 +1557,9 @@ describe('AppContainer State Management', () => { }); // Act: Render the container - const { unmount } = await act(async () => - renderAppContainer({ - settings: mockSettingsWithDynamicTitleFalse, - }), - ); + const { unmount } = renderAppContainer({ + settings: mockSettingsWithDynamicTitleFalse, + }); // Assert: Check that legacy title was used const titleWrites = mocks.mockStdout.write.mock.calls.filter((call) => @@ -1434,21 +1573,25 @@ describe('AppContainer State Management', () => { unmount(); }); - it('should not update terminal title when hideWindowTitle is true', async () => { + it('should not update terminal title when hideWindowTitle is true', () => { // Arrange: Set up mock settings with hideWindowTitle enabled - const mockSettingsWithHideTitleTrue = createMockSettings({ - ui: { - showStatusInTitle: true, - hideWindowTitle: true, + const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true); + const mockSettingsWithHideTitleTrue = { + ...mockSettings, + merged: { + ...defaultMergedSettings, + ui: { + ...defaultMergedSettings.ui, + showStatusInTitle: true, + hideWindowTitle: true, + }, }, - }); + } as unknown as LoadedSettings; // Act: Render the container - const { unmount } = await act(async () => - renderAppContainer({ - settings: mockSettingsWithHideTitleTrue, - }), - ); + const { unmount } = renderAppContainer({ + settings: mockSettingsWithHideTitleTrue, + }); // Assert: Check that no title-related writes occurred const titleWrites = mocks.mockStdout.write.mock.calls.filter((call) => @@ -1459,14 +1602,20 @@ describe('AppContainer State Management', () => { unmount(); }); - it('should update terminal title with thought subject when in active state', async () => { + it('should update terminal title with thought subject when in active state', () => { // Arrange: Set up mock settings with showStatusInTitle enabled - const mockSettingsWithTitleEnabled = createMockSettings({ - ui: { - showStatusInTitle: true, - hideWindowTitle: false, + const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true); + const mockSettingsWithTitleEnabled = { + ...mockSettings, + merged: { + ...defaultMergedSettings, + ui: { + ...defaultMergedSettings.ui, + showStatusInTitle: true, + hideWindowTitle: false, + }, }, - }); + } as unknown as LoadedSettings; // Mock the streaming state and thought const thoughtSubject = 'Processing request'; @@ -1477,11 +1626,9 @@ describe('AppContainer State Management', () => { }); // Act: Render the container - const { unmount } = await act(async () => - renderAppContainer({ - settings: mockSettingsWithTitleEnabled, - }), - ); + const { unmount } = renderAppContainer({ + settings: mockSettingsWithTitleEnabled, + }); // Assert: Check that title was updated with thought subject and suffix const titleWrites = mocks.mockStdout.write.mock.calls.filter((call) => @@ -1495,24 +1642,28 @@ describe('AppContainer State Management', () => { unmount(); }); - it('should update terminal title with default text when in Idle state and no thought subject', async () => { + 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 mockSettingsWithTitleEnabled = createMockSettings({ - ui: { - showStatusInTitle: true, - hideWindowTitle: false, + const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true); + const mockSettingsWithTitleEnabled = { + ...mockSettings, + merged: { + ...defaultMergedSettings, + ui: { + ...defaultMergedSettings.ui, + showStatusInTitle: true, + hideWindowTitle: false, + }, }, - }); + } as unknown as LoadedSettings; // Mock the streaming state as Idle with no thought mockedUseGeminiStream.mockReturnValue(DEFAULT_GEMINI_STREAM_MOCK); // Act: Render the container - const { unmount } = await act(async () => - renderAppContainer({ - settings: mockSettingsWithTitleEnabled, - }), - ); + const { unmount } = renderAppContainer({ + settings: mockSettingsWithTitleEnabled, + }); // Assert: Check that title was updated with default Idle text const titleWrites = mocks.mockStdout.write.mock.calls.filter((call) => @@ -1528,12 +1679,18 @@ 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 mockSettingsWithTitleEnabled = createMockSettings({ - ui: { - showStatusInTitle: true, - hideWindowTitle: false, + const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true); + const mockSettingsWithTitleEnabled = { + ...mockSettings, + merged: { + ...defaultMergedSettings, + ui: { + ...defaultMergedSettings.ui, + showStatusInTitle: true, + hideWindowTitle: false, + }, }, - }); + } as unknown as LoadedSettings; // Mock the streaming state and thought const thoughtSubject = 'Confirm tool execution'; @@ -1544,11 +1701,13 @@ describe('AppContainer State Management', () => { }); // Act: Render the container - const { unmount } = await act(async () => - renderAppContainer({ + let unmount: () => void; + await act(async () => { + const result = renderAppContainer({ settings: mockSettingsWithTitleEnabled, - }), - ); + }); + unmount = result.unmount; + }); // Assert: Check that title was updated with confirmation text const titleWrites = mocks.mockStdout.write.mock.calls.filter((call) => @@ -1559,7 +1718,7 @@ describe('AppContainer State Management', () => { expect(titleWrites[0][0]).toBe( `\x1b]0;${'✋ Action Required (workspace)'.padEnd(80, ' ')}\x07`, ); - unmount(); + unmount!(); }); describe('Shell Focus Action Required', () => { @@ -1583,12 +1742,17 @@ describe('AppContainer State Management', () => { vi.setSystemTime(startTime); // Arrange: Set up mock settings with showStatusInTitle enabled - const mockSettingsWithTitleEnabled = createMockSettings({ - ui: { - showStatusInTitle: true, - hideWindowTitle: false, + const mockSettingsWithTitleEnabled = { + ...mockSettings, + merged: { + ...mockSettings.merged, + ui: { + ...mockSettings.merged.ui, + showStatusInTitle: true, + hideWindowTitle: false, + }, }, - }); + } as unknown as LoadedSettings; // Mock an active shell pty but not focused mockedUseGeminiStream.mockReturnValue({ @@ -1605,11 +1769,9 @@ describe('AppContainer State Management', () => { vi.spyOn(mockConfig, 'isInteractiveShellEnabled').mockReturnValue(true); // Act: Render the container (embeddedShellFocused is false by default in state) - const { unmount } = await act(async () => - renderAppContainer({ - settings: mockSettingsWithTitleEnabled, - }), - ); + const { unmount } = renderAppContainer({ + settings: mockSettingsWithTitleEnabled, + }); // Initially it should show the working status const titleWrites = mocks.mockStdout.write.mock.calls.filter((call) => @@ -1639,12 +1801,17 @@ describe('AppContainer State Management', () => { vi.setSystemTime(startTime); // Arrange: Set up mock settings with showStatusInTitle enabled - const mockSettingsWithTitleEnabled = createMockSettings({ - ui: { - showStatusInTitle: true, - hideWindowTitle: false, + const mockSettingsWithTitleEnabled = { + ...mockSettings, + merged: { + ...mockSettings.merged, + ui: { + ...mockSettings.merged.ui, + showStatusInTitle: true, + hideWindowTitle: false, + }, }, - }); + } as unknown as LoadedSettings; // Mock an active shell pty with redirection active mockedUseGeminiStream.mockReturnValue({ @@ -1668,11 +1835,9 @@ describe('AppContainer State Management', () => { vi.spyOn(mockConfig, 'isInteractive').mockReturnValue(true); vi.spyOn(mockConfig, 'isInteractiveShellEnabled').mockReturnValue(true); - const { unmount } = await act(async () => - renderAppContainer({ - settings: mockSettingsWithTitleEnabled, - }), - ); + const { unmount } = renderAppContainer({ + settings: mockSettingsWithTitleEnabled, + }); // Fast-forward time by 65 seconds - should still NOT be Action Required await act(async () => { @@ -1706,12 +1871,17 @@ describe('AppContainer State Management', () => { vi.setSystemTime(startTime); // Arrange: Set up mock settings with showStatusInTitle enabled - const mockSettingsWithTitleEnabled = createMockSettings({ - ui: { - showStatusInTitle: true, - hideWindowTitle: false, + const mockSettingsWithTitleEnabled = { + ...mockSettings, + merged: { + ...mockSettings.merged, + ui: { + ...mockSettings.merged.ui, + showStatusInTitle: true, + hideWindowTitle: false, + }, }, - }); + } as unknown as LoadedSettings; // Mock an active shell pty with NO output since operation started (silent) mockedUseGeminiStream.mockReturnValue({ @@ -1727,11 +1897,9 @@ describe('AppContainer State Management', () => { vi.spyOn(mockConfig, 'isInteractive').mockReturnValue(true); vi.spyOn(mockConfig, 'isInteractiveShellEnabled').mockReturnValue(true); - const { unmount } = await act(async () => - renderAppContainer({ - settings: mockSettingsWithTitleEnabled, - }), - ); + const { unmount } = renderAppContainer({ + settings: mockSettingsWithTitleEnabled, + }); // Fast-forward time by 65 seconds await act(async () => { @@ -1753,12 +1921,17 @@ describe('AppContainer State Management', () => { vi.setSystemTime(startTime); // Arrange: Set up mock settings with showStatusInTitle enabled - const mockSettingsWithTitleEnabled = createMockSettings({ - ui: { - showStatusInTitle: true, - hideWindowTitle: false, + const mockSettingsWithTitleEnabled = { + ...mockSettings, + merged: { + ...mockSettings.merged, + ui: { + ...mockSettings.merged.ui, + showStatusInTitle: true, + hideWindowTitle: false, + }, }, - }); + } as unknown as LoadedSettings; // Mock an active shell pty but not focused let lastOutputTime = startTime + 1000; @@ -1774,11 +1947,9 @@ describe('AppContainer State Management', () => { vi.spyOn(mockConfig, 'isInteractiveShellEnabled').mockReturnValue(true); // Act: Render the container - const { unmount, rerender } = await act(async () => - renderAppContainer({ - settings: mockSettingsWithTitleEnabled, - }), - ); + const { unmount, rerender } = renderAppContainer({ + settings: mockSettingsWithTitleEnabled, + }); // Fast-forward time by 20 seconds await act(async () => { @@ -1832,14 +2003,20 @@ describe('AppContainer State Management', () => { }); }); - it('should pad title to exactly 80 characters', async () => { + it('should pad title to exactly 80 characters', () => { // Arrange: Set up mock settings with showStatusInTitle enabled - const mockSettingsWithTitleEnabled = createMockSettings({ - ui: { - showStatusInTitle: true, - hideWindowTitle: false, + const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true); + const mockSettingsWithTitleEnabled = { + ...mockSettings, + merged: { + ...defaultMergedSettings, + ui: { + ...defaultMergedSettings.ui, + showStatusInTitle: true, + hideWindowTitle: false, + }, }, - }); + } as unknown as LoadedSettings; // Mock the streaming state and thought with a short subject const shortTitle = 'Short'; @@ -1850,11 +2027,9 @@ describe('AppContainer State Management', () => { }); // Act: Render the container - const { unmount } = await act(async () => - renderAppContainer({ - settings: mockSettingsWithTitleEnabled, - }), - ); + const { unmount } = renderAppContainer({ + settings: mockSettingsWithTitleEnabled, + }); // Assert: Check that title is padded to exactly 80 characters const titleWrites = mocks.mockStdout.write.mock.calls.filter((call) => @@ -1869,14 +2044,20 @@ describe('AppContainer State Management', () => { unmount(); }); - it('should use correct ANSI escape code format', async () => { + it('should use correct ANSI escape code format', () => { // Arrange: Set up mock settings with showStatusInTitle enabled - const mockSettingsWithTitleEnabled = createMockSettings({ - ui: { - showStatusInTitle: true, - hideWindowTitle: false, + const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true); + const mockSettingsWithTitleEnabled = { + ...mockSettings, + merged: { + ...defaultMergedSettings, + ui: { + ...defaultMergedSettings.ui, + showStatusInTitle: true, + hideWindowTitle: false, + }, }, - }); + } as unknown as LoadedSettings; // Mock the streaming state and thought const title = 'Test Title'; @@ -1887,11 +2068,9 @@ describe('AppContainer State Management', () => { }); // Act: Render the container - const { unmount } = await act(async () => - renderAppContainer({ - settings: mockSettingsWithTitleEnabled, - }), - ); + const { unmount } = renderAppContainer({ + settings: mockSettingsWithTitleEnabled, + }); // Assert: Check that the correct ANSI escape sequence is used const titleWrites = mocks.mockStdout.write.mock.calls.filter((call) => @@ -1904,14 +2083,19 @@ describe('AppContainer State Management', () => { unmount(); }); - it('should use CLI_TITLE environment variable when set', async () => { + it('should use CLI_TITLE environment variable when set', () => { // Arrange: Set up mock settings with showStatusInTitle disabled (so it shows suffix) - const mockSettingsWithTitleDisabled = createMockSettings({ - ui: { - showStatusInTitle: false, - hideWindowTitle: false, + const mockSettingsWithTitleDisabled = { + ...mockSettings, + merged: { + ...mockSettings.merged, + ui: { + ...mockSettings.merged.ui, + showStatusInTitle: false, + hideWindowTitle: false, + }, }, - }); + } as unknown as LoadedSettings; // Mock CLI_TITLE environment variable vi.stubEnv('CLI_TITLE', 'Custom Gemini Title'); @@ -1923,11 +2107,9 @@ describe('AppContainer State Management', () => { }); // Act: Render the container - const { unmount } = await act(async () => - renderAppContainer({ - settings: mockSettingsWithTitleDisabled, - }), - ); + const { unmount } = renderAppContainer({ + settings: mockSettingsWithTitleDisabled, + }); // Assert: Check that title was updated with CLI_TITLE value const titleWrites = mocks.mockStdout.write.mock.calls.filter((call) => @@ -1953,7 +2135,7 @@ describe('AppContainer State Management', () => { }); it('should set and clear the queue error message after a timeout', async () => { - const { rerender, unmount } = await act(async () => renderAppContainer()); + const { rerender, unmount } = renderAppContainer(); await act(async () => { vi.advanceTimersByTime(0); }); @@ -1975,7 +2157,7 @@ describe('AppContainer State Management', () => { }); it('should reset the timer if a new error message is set', async () => { - const { rerender, unmount } = await act(async () => renderAppContainer()); + const { rerender, unmount } = renderAppContainer(); await act(async () => { vi.advanceTimersByTime(0); }); @@ -2017,11 +2199,11 @@ describe('AppContainer State Management', () => { let mockCancelOngoingRequest: Mock; let rerender: () => void; let unmount: () => void; - let stdin: Awaited>['stdin']; + let stdin: ReturnType['stdin']; // Helper function to reduce boilerplate in tests const setupKeypressTest = async () => { - const renderResult = await act(async () => renderAppContainer()); + const renderResult = renderAppContainer(); stdin = renderResult.stdin; await act(async () => { vi.advanceTimersByTime(0); @@ -2235,7 +2417,7 @@ describe('AppContainer State Management', () => { activePtyId: 1, }); - const renderResult = await act(async () => render(getAppContainer())); + const renderResult = render(getAppContainer()); await act(async () => { vi.advanceTimersByTime(0); }); @@ -2353,7 +2535,7 @@ describe('AppContainer State Management', () => { let unmount: () => void; const setupShortcutsVisibilityTest = async () => { - const renderResult = await act(async () => renderAppContainer()); + const renderResult = renderAppContainer(); await act(async () => { vi.advanceTimersByTime(0); }); @@ -2429,7 +2611,9 @@ describe('AppContainer State Management', () => { await act(async () => { rerender(); }); - expect(capturedUIState.shortcutsHelpVisible).toBe(false); + await waitFor(() => { + expect(capturedUIState.shortcutsHelpVisible).toBe(false); + }); unmount(); }); @@ -2458,7 +2642,9 @@ describe('AppContainer State Management', () => { await act(async () => { rerender(); }); - expect(capturedUIState.shortcutsHelpVisible).toBe(false); + await waitFor(() => { + expect(capturedUIState.shortcutsHelpVisible).toBe(false); + }); unmount(); }); @@ -2467,7 +2653,7 @@ describe('AppContainer State Management', () => { describe('Copy Mode (CTRL+S)', () => { let rerender: () => void; let unmount: () => void; - let stdin: Awaited>['stdin']; + let stdin: ReturnType['stdin']; const setupCopyModeTest = async ( isAlternateMode = false, @@ -2478,9 +2664,17 @@ describe('AppContainer State Management', () => { ); // Update settings for this test run - const testSettings = createMockSettings({ - ui: { useAlternateBuffer: isAlternateMode }, - }); + const defaultMergedSettings = mergeSettings({}, {}, {}, {}, true); + const testSettings = { + ...mockSettings, + merged: { + ...defaultMergedSettings, + ui: { + ...defaultMergedSettings.ui, + useAlternateBuffer: isAlternateMode, + }, + }, + } as unknown as LoadedSettings; function TestChild() { useKeypress(childHandler || (() => {}), { @@ -2505,7 +2699,7 @@ describe('AppContainer State Management', () => { ); - const renderResult = await act(async () => render(getTree(testSettings))); + const renderResult = render(getTree(testSettings)); stdin = renderResult.stdin; await act(async () => { vi.advanceTimersByTime(0); @@ -2695,10 +2889,15 @@ describe('AppContainer State Management', () => { closeModelDialog: vi.fn(), }); - const { unmount } = await act(async () => renderAppContainer()); + let unmount: () => void; + await act(async () => { + const result = renderAppContainer(); + unmount = result.unmount; + }); + await waitFor(() => expect(capturedUIState).toBeTruthy()); expect(capturedUIState.isModelDialogOpen).toBe(true); - unmount(); + unmount!(); }); it('should provide model dialog actions in the UIActionsContext', async () => { @@ -2710,29 +2909,45 @@ describe('AppContainer State Management', () => { closeModelDialog: mockCloseModelDialog, }); - const { unmount } = await act(async () => renderAppContainer()); + let unmount: () => void; + await act(async () => { + const result = renderAppContainer(); + unmount = result.unmount; + }); + await waitFor(() => expect(capturedUIState).toBeTruthy()); // Verify that the actions are correctly passed through context act(() => { capturedUIActions.closeModelDialog(); }); expect(mockCloseModelDialog).toHaveBeenCalled(); - unmount(); + unmount!(); }); }); describe('Agent Configuration Dialog Integration', () => { it('should initialize with dialog closed and no agent selected', async () => { - const { unmount } = await act(async () => renderAppContainer()); + let unmount: () => void; + await act(async () => { + const result = renderAppContainer(); + unmount = result.unmount; + }); + await waitFor(() => expect(capturedUIState).toBeTruthy()); + expect(capturedUIState.isAgentConfigDialogOpen).toBe(false); expect(capturedUIState.selectedAgentName).toBeUndefined(); expect(capturedUIState.selectedAgentDisplayName).toBeUndefined(); expect(capturedUIState.selectedAgentDefinition).toBeUndefined(); - unmount(); + unmount!(); }); it('should update state when openAgentConfigDialog is called', async () => { - const { unmount } = await act(async () => renderAppContainer()); + let unmount: () => void; + await act(async () => { + const result = renderAppContainer(); + unmount = result.unmount; + }); + await waitFor(() => expect(capturedUIState).toBeTruthy()); const agentDefinition = { name: 'test-agent' }; act(() => { @@ -2747,11 +2962,16 @@ describe('AppContainer State Management', () => { expect(capturedUIState.selectedAgentName).toBe('test-agent'); expect(capturedUIState.selectedAgentDisplayName).toBe('Test Agent'); expect(capturedUIState.selectedAgentDefinition).toEqual(agentDefinition); - unmount(); + unmount!(); }); it('should clear state when closeAgentConfigDialog is called', async () => { - const { unmount } = await act(async () => renderAppContainer()); + let unmount: () => void; + await act(async () => { + const result = renderAppContainer(); + unmount = result.unmount; + }); + await waitFor(() => expect(capturedUIState).toBeTruthy()); const agentDefinition = { name: 'test-agent' }; act(() => { @@ -2772,26 +2992,31 @@ describe('AppContainer State Management', () => { expect(capturedUIState.selectedAgentName).toBeUndefined(); expect(capturedUIState.selectedAgentDisplayName).toBeUndefined(); expect(capturedUIState.selectedAgentDefinition).toBeUndefined(); - unmount(); + unmount!(); }); }); describe('CoreEvents Integration', () => { it('subscribes to UserFeedback and drains backlog on mount', async () => { - const { unmount } = await act(async () => renderAppContainer()); + let unmount: () => void; + await act(async () => { + const result = renderAppContainer(); + unmount = result.unmount; + }); + await waitFor(() => expect(capturedUIState).toBeTruthy()); expect(mockCoreEvents.on).toHaveBeenCalledWith( CoreEvent.UserFeedback, expect.any(Function), ); expect(mockCoreEvents.drainBacklogs).toHaveBeenCalledTimes(1); - unmount(); + unmount!(); }); it('unsubscribes from UserFeedback on unmount', async () => { let unmount: () => void; await act(async () => { - const result = await renderAppContainer(); + const result = renderAppContainer(); unmount = result.unmount; }); await waitFor(() => expect(capturedUIState).toBeTruthy()); @@ -2807,7 +3032,7 @@ describe('AppContainer State Management', () => { it('adds history item when UserFeedback event is received', async () => { let unmount: () => void; await act(async () => { - const result = await renderAppContainer(); + const result = renderAppContainer(); unmount = result.unmount; }); await waitFor(() => expect(capturedUIState).toBeTruthy()); @@ -2843,7 +3068,7 @@ describe('AppContainer State Management', () => { let unmount: () => void; await act(async () => { - const result = await renderAppContainer(); + const result = renderAppContainer(); unmount = result.unmount; }); await waitFor(() => { @@ -2876,7 +3101,7 @@ describe('AppContainer State Management', () => { let unmount: () => void; await act(async () => { - const result = await renderAppContainer(); + const result = renderAppContainer(); unmount = result.unmount; }); await waitFor(() => expect(capturedUIState).toBeTruthy()); @@ -2888,7 +3113,7 @@ describe('AppContainer State Management', () => { it('handles consent request events', async () => { let unmount: () => void; await act(async () => { - const result = await renderAppContainer(); + const result = renderAppContainer(); unmount = result.unmount; }); await waitFor(() => expect(capturedUIState).toBeTruthy()); @@ -2925,7 +3150,7 @@ describe('AppContainer State Management', () => { it('unsubscribes from ConsentRequest on unmount', async () => { let unmount: () => void; await act(async () => { - const result = await renderAppContainer(); + const result = renderAppContainer(); unmount = result.unmount; }); await waitFor(() => expect(capturedUIState).toBeTruthy()); @@ -2948,7 +3173,7 @@ describe('AppContainer State Management', () => { }); let unmount: () => void; await act(async () => { - const result = await renderAppContainer(); + const result = renderAppContainer(); unmount = result.unmount; }); await waitFor(() => { @@ -2976,7 +3201,12 @@ describe('AppContainer State Management', () => { }); it('preserves buffer when cancelling, even if empty (user is in control)', async () => { - const { unmount } = await act(async () => renderAppContainer()); + let unmount: () => void; + await act(async () => { + const result = renderAppContainer(); + unmount = result.unmount; + }); + await waitFor(() => expect(capturedUIState).toBeTruthy()); const { onCancelSubmit } = extractUseGeminiStreamArgs( mockedUseGeminiStream.mock.lastCall!, @@ -2989,7 +3219,7 @@ describe('AppContainer State Management', () => { // Should NOT modify buffer when cancelling - user is in control expect(mockSetText).not.toHaveBeenCalled(); - unmount(); + unmount!(); }); it('preserves prompt text when cancelling streaming, even if same as last message (regression test for issue #13387)', async () => { @@ -3007,7 +3237,12 @@ describe('AppContainer State Management', () => { initializeFromLogger: vi.fn(), }); - const { unmount } = await act(async () => renderAppContainer()); + let unmount: () => void; + await act(async () => { + const result = renderAppContainer(); + unmount = result.unmount; + }); + await waitFor(() => expect(capturedUIState).toBeTruthy()); const { onCancelSubmit } = extractUseGeminiStreamArgs( mockedUseGeminiStream.mock.lastCall!, @@ -3021,7 +3256,7 @@ describe('AppContainer State Management', () => { // Should NOT call setText - prompt should be preserved regardless of content expect(mockSetText).not.toHaveBeenCalled(); - unmount(); + unmount!(); }); it('restores the prompt when onCancelSubmit is called with shouldRestorePrompt=true (or undefined)', async () => { @@ -3032,8 +3267,14 @@ describe('AppContainer State Management', () => { initializeFromLogger: vi.fn(), }); - const { unmount } = await act(async () => renderAppContainer()); - expect(capturedUIState.userMessages).toContain('previous message'); + let unmount: () => void; + await act(async () => { + const result = renderAppContainer(); + unmount = result.unmount; + }); + await waitFor(() => + expect(capturedUIState.userMessages).toContain('previous message'), + ); const { onCancelSubmit } = extractUseGeminiStreamArgs( mockedUseGeminiStream.mock.lastCall!, @@ -3043,9 +3284,11 @@ describe('AppContainer State Management', () => { onCancelSubmit(true); }); - expect(mockSetText).toHaveBeenCalledWith('previous message'); + await waitFor(() => { + expect(mockSetText).toHaveBeenCalledWith('previous message'); + }); - unmount(); + unmount!(); }); it('input history is independent from conversation history (survives /clear)', async () => { @@ -3058,10 +3301,18 @@ describe('AppContainer State Management', () => { initializeFromLogger: vi.fn(), }); - const { rerender, unmount } = await act(async () => renderAppContainer()); + let rerender: (tree: ReactElement) => void; + let unmount; + await act(async () => { + const result = renderAppContainer(); + rerender = result.rerender; + unmount = result.unmount; + }); // Verify userMessages is populated from inputHistory - expect(capturedUIState.userMessages).toContain('first prompt'); + await waitFor(() => + expect(capturedUIState.userMessages).toContain('first prompt'), + ); expect(capturedUIState.userMessages).toContain('second prompt'); // Clear the conversation history (simulating /clear command) @@ -3084,7 +3335,7 @@ describe('AppContainer State Management', () => { expect(capturedUIState.userMessages).toContain('first prompt'); expect(capturedUIState.userMessages).toContain('second prompt'); - unmount(); + unmount!(); }); }); @@ -3099,10 +3350,14 @@ describe('AppContainer State Management', () => { // Clear previous calls mocks.mockStdout.write.mockClear(); - const { unmount } = await act(async () => renderAppContainer()); + let compUnmount: () => void = () => {}; + await act(async () => { + const { unmount } = renderAppContainer(); + compUnmount = unmount; + }); // Allow async effects to run - expect(capturedUIState).toBeTruthy(); + await waitFor(() => expect(capturedUIState).toBeTruthy()); // Wait for fetchBannerTexts to complete await act(async () => { @@ -3115,7 +3370,7 @@ describe('AppContainer State Management', () => { ); expect(clearTerminalCalls).toHaveLength(0); - unmount(); + compUnmount(); }); }); @@ -3126,13 +3381,20 @@ describe('AppContainer State Management', () => { ); vi.mocked(checkPermissions).mockResolvedValue([]); - const { unmount } = await act(async () => - renderAppContainer({ - settings: createMockSettings({ ui: { useAlternateBuffer: false } }), - }), - ); + let unmount: () => void; + await act(async () => { + unmount = renderAppContainer({ + settings: { + ...mockSettings, + merged: { + ...mockSettings.merged, + ui: { ...mockSettings.merged.ui, useAlternateBuffer: false }, + }, + } as LoadedSettings, + }).unmount; + }); - expect(capturedUIActions).toBeTruthy(); + await waitFor(() => expect(capturedUIActions).toBeTruthy()); // Expand first act(() => capturedUIActions.setConstrainHeight(false)); @@ -3150,7 +3412,7 @@ describe('AppContainer State Management', () => { expect(mocks.mockStdout.write).toHaveBeenCalledWith( ansiEscapes.clearTerminal, ); - unmount(); + unmount!(); }); it('resets expansion state on submission when in alternate buffer without clearing terminal', async () => { @@ -3161,13 +3423,20 @@ describe('AppContainer State Management', () => { vi.spyOn(mockConfig, 'getUseAlternateBuffer').mockReturnValue(true); - const { unmount } = await act(async () => - renderAppContainer({ - settings: createMockSettings({ ui: { useAlternateBuffer: true } }), - }), - ); + let unmount: () => void; + await act(async () => { + unmount = renderAppContainer({ + settings: { + ...mockSettings, + merged: { + ...mockSettings.merged, + ui: { ...mockSettings.merged.ui, useAlternateBuffer: true }, + }, + } as LoadedSettings, + }).unmount; + }); - expect(capturedUIActions).toBeTruthy(); + await waitFor(() => expect(capturedUIActions).toBeTruthy()); // Expand first act(() => capturedUIActions.setConstrainHeight(false)); @@ -3185,7 +3454,7 @@ describe('AppContainer State Management', () => { expect(mocks.mockStdout.write).not.toHaveBeenCalledWith( ansiEscapes.clearTerminal, ); - unmount(); + unmount!(); }); }); @@ -3198,9 +3467,13 @@ describe('AppContainer State Management', () => { vi.useRealTimers(); }); - it('should set showIsExpandableHint when overflow occurs in Standard Mode and hides after 10s', async () => { - const { unmount } = await act(async () => renderAppContainer()); - await waitFor(() => expect(capturedOverflowActions).toBeTruthy()); + it('sets showIsExpandableHint when overflow occurs in Standard Mode and hides after 10s', async () => { + let unmount: () => void; + await act(async () => { + const result = renderAppContainer(); + unmount = result.unmount; + }); + await waitFor(() => expect(capturedUIState).toBeTruthy()); // Trigger overflow act(() => { @@ -3226,12 +3499,16 @@ describe('AppContainer State Management', () => { expect(capturedUIState.showIsExpandableHint).toBe(false); }); - unmount(); + unmount!(); }); it('resets the hint timer when a new component overflows (overflowingIdsSize increases)', async () => { - const { unmount } = await act(async () => renderAppContainer()); - await waitFor(() => expect(capturedOverflowActions).toBeTruthy()); + let unmount: () => void; + await act(async () => { + const result = renderAppContainer(); + unmount = result.unmount; + }); + await waitFor(() => expect(capturedUIState).toBeTruthy()); // 1. Trigger first overflow act(() => { @@ -3279,12 +3556,18 @@ describe('AppContainer State Management', () => { expect(capturedUIState.showIsExpandableHint).toBe(false); }); - unmount(); + unmount!(); }); it('toggles expansion state and resets the hint timer when Ctrl+O is pressed in Standard Mode', async () => { - const { stdin, unmount } = await act(async () => renderAppContainer()); - await waitFor(() => expect(capturedOverflowActions).toBeTruthy()); + let unmount: () => void; + let stdin: ReturnType['stdin']; + await act(async () => { + const result = renderAppContainer(); + unmount = result.unmount; + stdin = result.stdin; + }); + await waitFor(() => expect(capturedUIState).toBeTruthy()); // Initial state is constrainHeight = true expect(capturedUIState.constrainHeight).toBe(true); @@ -3309,8 +3592,10 @@ describe('AppContainer State Management', () => { stdin.write('\x0f'); // \x0f is Ctrl+O }); - // constrainHeight should toggle - expect(capturedUIState.constrainHeight).toBe(false); + await waitFor(() => { + // constrainHeight should toggle + expect(capturedUIState.constrainHeight).toBe(false); + }); // Advance enough that the original timer would have expired if it hadn't reset act(() => { @@ -3329,12 +3614,18 @@ describe('AppContainer State Management', () => { expect(capturedUIState.showIsExpandableHint).toBe(false); }); - unmount(); + unmount!(); }); it('toggles Ctrl+O multiple times and verifies the hint disappears exactly after the last toggle', async () => { - const { stdin, unmount } = await act(async () => renderAppContainer()); - await waitFor(() => expect(capturedOverflowActions).toBeTruthy()); + let unmount: () => void; + let stdin: ReturnType['stdin']; + await act(async () => { + const result = renderAppContainer(); + unmount = result.unmount; + stdin = result.stdin; + }); + await waitFor(() => expect(capturedUIState).toBeTruthy()); // Initial state is constrainHeight = true expect(capturedUIState.constrainHeight).toBe(true); @@ -3358,7 +3649,9 @@ describe('AppContainer State Management', () => { act(() => { stdin.write('\x0f'); // Ctrl+O }); - expect(capturedUIState.constrainHeight).toBe(false); + await waitFor(() => { + expect(capturedUIState.constrainHeight).toBe(false); + }); // Wait 1 second act(() => { @@ -3370,7 +3663,9 @@ describe('AppContainer State Management', () => { act(() => { stdin.write('\x0f'); // Ctrl+O }); - expect(capturedUIState.constrainHeight).toBe(true); + await waitFor(() => { + expect(capturedUIState.constrainHeight).toBe(true); + }); // Wait 1 second act(() => { @@ -3382,7 +3677,9 @@ describe('AppContainer State Management', () => { act(() => { stdin.write('\x0f'); // Ctrl+O }); - expect(capturedUIState.constrainHeight).toBe(false); + await waitFor(() => { + expect(capturedUIState.constrainHeight).toBe(false); + }); // Now we wait just before the timeout from the LAST toggle. // It should still be true. @@ -3400,22 +3697,31 @@ describe('AppContainer State Management', () => { expect(capturedUIState.showIsExpandableHint).toBe(false); }); - unmount(); + unmount!(); }); it('DOES set showIsExpandableHint when overflow occurs in Alternate Buffer Mode', async () => { - const settingsWithAlternateBuffer = createMockSettings({ - ui: { useAlternateBuffer: true }, - }); + const alternateSettings = mergeSettings({}, {}, {}, {}, true); + const settingsWithAlternateBuffer = { + merged: { + ...alternateSettings, + ui: { + ...alternateSettings.ui, + useAlternateBuffer: true, + }, + }, + } as unknown as LoadedSettings; vi.spyOn(mockConfig, 'getUseAlternateBuffer').mockReturnValue(true); - const { unmount } = await act(async () => - renderAppContainer({ + let unmount: () => void; + await act(async () => { + const result = renderAppContainer({ settings: settingsWithAlternateBuffer, - }), - ); - await waitFor(() => expect(capturedOverflowActions).toBeTruthy()); + }); + unmount = result.unmount; + }); + await waitFor(() => expect(capturedUIState).toBeTruthy()); // Trigger overflow act(() => { @@ -3427,7 +3733,7 @@ describe('AppContainer State Management', () => { expect(capturedUIState.showIsExpandableHint).toBe(true); }); - unmount(); + unmount!(); }); }); @@ -3438,9 +3744,10 @@ describe('AppContainer State Management', () => { ); vi.mocked(checkPermissions).mockResolvedValue(['/test/file.txt']); - const { unmount } = await act(async () => renderAppContainer()); + let unmount: () => void; + await act(async () => (unmount = renderAppContainer().unmount)); - expect(capturedUIActions).toBeTruthy(); + await waitFor(() => expect(capturedUIActions).toBeTruthy()); await act(async () => capturedUIActions.handleFinalSubmit('read @file.txt'), @@ -3450,7 +3757,7 @@ describe('AppContainer State Management', () => { expect(capturedUIState.permissionConfirmationRequest?.files).toEqual([ '/test/file.txt', ]); - unmount(); + await act(async () => unmount!()); }); it.each([true, false])( @@ -3466,9 +3773,10 @@ describe('AppContainer State Management', () => { ); const { submitQuery } = mockedUseGeminiStream(); - const { unmount } = await act(async () => renderAppContainer()); + let unmount: () => void; + await act(async () => (unmount = renderAppContainer().unmount)); - expect(capturedUIActions).toBeTruthy(); + await waitFor(() => expect(capturedUIActions).toBeTruthy()); await act(async () => capturedUIActions.handleFinalSubmit('read @file.txt'), @@ -3487,7 +3795,7 @@ describe('AppContainer State Management', () => { } expect(submitQuery).toHaveBeenCalledWith('read @file.txt'); expect(capturedUIState.permissionConfirmationRequest).toBeNull(); - unmount(); + await act(async () => unmount!()); }, ); }); @@ -3500,11 +3808,17 @@ describe('AppContainer State Management', () => { pendingHistoryItems: [], }); - const { unmount } = await act(async () => renderAppContainer()); + let unmount: () => void; + await act(async () => { + const result = renderAppContainer(); + unmount = result.unmount; + }); - expect(capturedUIState).toBeTruthy(); - expect(capturedUIState.allowPlanMode).toBe(true); - unmount(); + await waitFor(() => { + expect(capturedUIState).toBeTruthy(); + expect(capturedUIState.allowPlanMode).toBe(true); + }); + unmount!(); }); it('should NOT allow plan mode when disabled in config', async () => { @@ -3514,11 +3828,17 @@ describe('AppContainer State Management', () => { pendingHistoryItems: [], }); - const { unmount } = await act(async () => renderAppContainer()); + let unmount: () => void; + await act(async () => { + const result = renderAppContainer(); + unmount = result.unmount; + }); - expect(capturedUIState).toBeTruthy(); - expect(capturedUIState.allowPlanMode).toBe(false); - unmount(); + await waitFor(() => { + expect(capturedUIState).toBeTruthy(); + expect(capturedUIState.allowPlanMode).toBe(false); + }); + unmount!(); }); it('should NOT allow plan mode when streaming', async () => { @@ -3529,11 +3849,17 @@ describe('AppContainer State Management', () => { pendingHistoryItems: [], }); - const { unmount } = await act(async () => renderAppContainer()); + let unmount: () => void; + await act(async () => { + const result = renderAppContainer(); + unmount = result.unmount; + }); - expect(capturedUIState).toBeTruthy(); - expect(capturedUIState.allowPlanMode).toBe(false); - unmount(); + await waitFor(() => { + expect(capturedUIState).toBeTruthy(); + expect(capturedUIState.allowPlanMode).toBe(false); + }); + unmount!(); }); it('should NOT allow plan mode when a tool is awaiting confirmation', async () => { @@ -3554,11 +3880,17 @@ describe('AppContainer State Management', () => { ], }); - const { unmount } = await act(async () => renderAppContainer()); + let unmount: () => void; + await act(async () => { + const result = renderAppContainer(); + unmount = result.unmount; + }); - expect(capturedUIState).toBeTruthy(); - expect(capturedUIState.allowPlanMode).toBe(false); - unmount(); + await waitFor(() => { + expect(capturedUIState).toBeTruthy(); + expect(capturedUIState.allowPlanMode).toBe(false); + }); + unmount!(); }); }); }); diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 9d05f54347..b0a936a81b 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -103,7 +103,7 @@ import { useOverflowActions, useOverflowState, } from './contexts/OverflowContext.js'; -import { useErrorCount } from './hooks/useConsoleMessages.js'; +import { useConsoleMessages } from './hooks/useConsoleMessages.js'; import { useTerminalSize } from './hooks/useTerminalSize.js'; import { calculatePromptWidths } from './components/InputPrompt.js'; import { calculateMainAreaWidth } from './utils/ui-sizing.js'; @@ -552,7 +552,8 @@ export const AppContainer = (props: AppContainerProps) => { }; }, [settings]); - const { errorCount, clearErrorCount } = useErrorCount(); + const { consoleMessages, clearConsoleMessages: clearConsoleMessagesState } = + useConsoleMessages(); const mainAreaWidth = calculateMainAreaWidth(terminalWidth, config); // Derive widths for InputPrompt using shared helper @@ -1007,18 +1008,10 @@ Logging in with Google... Restarting Gemini CLI to continue. Date.now(), ); try { - let flattenedMemory: string; - let fileCount: number; + const { memoryContent, fileCount } = + await refreshServerHierarchicalMemory(config); - if (config.isJitContextEnabled()) { - await config.getContextManager()?.refresh(); - flattenedMemory = flattenMemory(config.getUserMemory()); - fileCount = config.getGeminiMdFileCount(); - } else { - const result = await refreshServerHierarchicalMemory(config); - flattenedMemory = flattenMemory(result.memoryContent); - fileCount = result.fileCount; - } + const flattenedMemory = flattenMemory(memoryContent); historyManager.addItem( { @@ -1379,11 +1372,11 @@ Logging in with Google... Restarting Gemini CLI to continue. // Explicitly hide the expansion hint and clear its x-second timer when clearing the screen. triggerExpandHint(null); historyManager.clearItems(); - clearErrorCount(); + clearConsoleMessagesState(); refreshStatic(); }, [ historyManager, - clearErrorCount, + clearConsoleMessagesState, refreshStatic, reset, triggerExpandHint, @@ -1684,6 +1677,11 @@ 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); } @@ -1862,6 +1860,7 @@ Logging in with Google... Restarting Gemini CLI to continue. activePtyId, handleSuspend, embeddedShellFocused, + settings.merged.general.debugKeystrokeLogging, refreshStatic, setCopyModeEnabled, tabFocusTimeoutRef, @@ -1990,6 +1989,22 @@ Logging in with Google... Restarting Gemini CLI to continue. }; }, [historyManager]); + const filteredConsoleMessages = useMemo(() => { + if (config.getDebugMode()) { + return consoleMessages; + } + return consoleMessages.filter((msg) => msg.type !== 'debug'); + }, [consoleMessages, config]); + + // Computed values + const errorCount = useMemo( + () => + filteredConsoleMessages + .filter((msg) => msg.type === 'error') + .reduce((total, msg) => total + msg.count, 0), + [filteredConsoleMessages], + ); + const nightly = props.version.includes('nightly'); const dialogsVisible = @@ -2224,6 +2239,7 @@ Logging in with Google... Restarting Gemini CLI to continue. constrainHeight, showErrorDetails, showFullTodos, + filteredConsoleMessages, ideContextState, renderMarkdown, ctrlCPressedOnce: ctrlCPressCount >= 1, @@ -2351,6 +2367,7 @@ Logging in with Google... Restarting Gemini CLI to continue. constrainHeight, showErrorDetails, showFullTodos, + filteredConsoleMessages, ideContextState, renderMarkdown, ctrlCPressCount, diff --git a/packages/cli/src/ui/IdeIntegrationNudge.test.tsx b/packages/cli/src/ui/IdeIntegrationNudge.test.tsx index eb3e6a3e4c..52d00550ea 100644 --- a/packages/cli/src/ui/IdeIntegrationNudge.test.tsx +++ b/packages/cli/src/ui/IdeIntegrationNudge.test.tsx @@ -5,9 +5,10 @@ */ import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest'; -import { renderWithProviders } from '../test-utils/render.js'; +import { render } 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 @@ -53,9 +54,12 @@ describe('IdeIntegrationNudge', () => { }); it('renders correctly with default options', async () => { - const { lastFrame, unmount } = await renderWithProviders( - , + const { lastFrame, waitUntilReady, unmount } = render( + + + , ); + await waitUntilReady(); const frame = lastFrame(); expect(frame).toContain('Do you want to connect VS Code to Gemini CLI?'); @@ -67,10 +71,14 @@ describe('IdeIntegrationNudge', () => { it('handles "Yes" selection', async () => { const onComplete = vi.fn(); - const { stdin, waitUntilReady, unmount } = await renderWithProviders( - , + const { stdin, waitUntilReady, unmount } = render( + + + , ); + await waitUntilReady(); + // "Yes" is the first option and selected by default usually. await act(async () => { stdin.write('\r'); @@ -86,10 +94,14 @@ describe('IdeIntegrationNudge', () => { it('handles "No" selection', async () => { const onComplete = vi.fn(); - const { stdin, waitUntilReady, unmount } = await renderWithProviders( - , + const { stdin, waitUntilReady, unmount } = render( + + + , ); + await waitUntilReady(); + // Navigate down to "No (esc)" await act(async () => { stdin.write('\u001B[B'); // Down arrow @@ -110,10 +122,14 @@ describe('IdeIntegrationNudge', () => { it('handles "Dismiss" selection', async () => { const onComplete = vi.fn(); - const { stdin, waitUntilReady, unmount } = await renderWithProviders( - , + const { stdin, waitUntilReady, unmount } = render( + + + , ); + await waitUntilReady(); + // Navigate down to "No, don't ask again" await act(async () => { stdin.write('\u001B[B'); // Down arrow @@ -139,10 +155,14 @@ describe('IdeIntegrationNudge', () => { it('handles Escape key press', async () => { const onComplete = vi.fn(); - const { stdin, waitUntilReady, unmount } = await renderWithProviders( - , + const { stdin, waitUntilReady, unmount } = render( + + + , ); + await waitUntilReady(); + // Press Escape await act(async () => { stdin.write('\u001B'); @@ -164,10 +184,13 @@ describe('IdeIntegrationNudge', () => { vi.stubEnv('GEMINI_CLI_IDE_WORKSPACE_PATH', '/tmp'); const onComplete = vi.fn(); - const { lastFrame, stdin, waitUntilReady, unmount } = - await renderWithProviders( - , - ); + const { lastFrame, stdin, waitUntilReady, unmount } = render( + + + , + ); + + await waitUntilReady(); const frame = lastFrame(); diff --git a/packages/cli/src/ui/auth/ApiAuthDialog.test.tsx b/packages/cli/src/ui/auth/ApiAuthDialog.test.tsx index d46e0295a1..b8de6adb0b 100644 --- a/packages/cli/src/ui/auth/ApiAuthDialog.test.tsx +++ b/packages/cli/src/ui/auth/ApiAuthDialog.test.tsx @@ -73,21 +73,23 @@ describe('ApiAuthDialog', () => { }); it('renders correctly', async () => { - const { lastFrame, unmount } = await render( + const { lastFrame, waitUntilReady, unmount } = render( , ); + await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); unmount(); }); it('renders with a defaultValue', async () => { - const { unmount } = await render( + const { waitUntilReady, unmount } = render( , ); + await waitUntilReady(); expect(mockedUseTextBuffer).toHaveBeenCalledWith( expect.objectContaining({ initialText: 'test-key', @@ -111,9 +113,10 @@ describe('ApiAuthDialog', () => { 'calls $expectedCall.name when $keyName is pressed', async ({ keyName, sequence, expectedCall, args }) => { mockBuffer.text = 'submitted-key'; // Set for the onSubmit case - const { unmount } = await render( + const { waitUntilReady, unmount } = render( , ); + await waitUntilReady(); // calls[0] is the ApiAuthDialog's useKeypress (Ctrl+C handler) // calls[1] is the TextInput's useKeypress (typing handler) const keypressHandler = mockedUseKeypress.mock.calls[1][0]; @@ -133,22 +136,24 @@ describe('ApiAuthDialog', () => { ); it('displays an error message', async () => { - const { lastFrame, unmount } = await render( + const { lastFrame, waitUntilReady, unmount } = render( , ); + await waitUntilReady(); expect(lastFrame()).toContain('Invalid API Key'); unmount(); }); it('calls clearApiKey and clears buffer when Ctrl+C is pressed', async () => { - const { unmount } = await render( + const { waitUntilReady, unmount } = render( , ); + await waitUntilReady(); // Call 0 is ApiAuthDialog (isActive: true) // Call 1 is TextInput (isActive: true, priority: true) const keypressHandler = mockedUseKeypress.mock.calls[0][0]; diff --git a/packages/cli/src/ui/auth/AuthDialog.test.tsx b/packages/cli/src/ui/auth/AuthDialog.test.tsx index 4837a71490..7ab5fc0be2 100644 --- a/packages/cli/src/ui/auth/AuthDialog.test.tsx +++ b/packages/cli/src/ui/auth/AuthDialog.test.tsx @@ -143,9 +143,10 @@ describe('AuthDialog', () => { for (const [key, value] of Object.entries(env)) { vi.stubEnv(key, value as string); } - const { unmount } = await renderWithProviders( + const { waitUntilReady, unmount } = renderWithProviders( , ); + await waitUntilReady(); const items = mockedRadioButtonSelect.mock.calls[0][0].items; for (const item of shouldContain) { expect(items).toContainEqual(item); @@ -160,7 +161,10 @@ describe('AuthDialog', () => { it('filters auth types when enforcedType is set', async () => { props.settings.merged.security.auth.enforcedType = AuthType.USE_GEMINI; - const { unmount } = await renderWithProviders(); + const { waitUntilReady, unmount } = renderWithProviders( + , + ); + await waitUntilReady(); const items = mockedRadioButtonSelect.mock.calls[0][0].items; expect(items).toHaveLength(1); expect(items[0].value).toBe(AuthType.USE_GEMINI); @@ -169,7 +173,10 @@ describe('AuthDialog', () => { it('sets initial index to 0 when enforcedType is set', async () => { props.settings.merged.security.auth.enforcedType = AuthType.USE_GEMINI; - const { unmount } = await renderWithProviders(); + const { waitUntilReady, unmount } = renderWithProviders( + , + ); + await waitUntilReady(); const { initialIndex } = mockedRadioButtonSelect.mock.calls[0][0]; expect(initialIndex).toBe(0); unmount(); @@ -206,7 +213,10 @@ describe('AuthDialog', () => { }, ])('selects initial auth type $desc', async ({ setup, expected }) => { setup(); - const { unmount } = await renderWithProviders(); + const { waitUntilReady, unmount } = renderWithProviders( + , + ); + await waitUntilReady(); const { items, initialIndex } = mockedRadioButtonSelect.mock.calls[0][0]; expect(items[initialIndex].value).toBe(expected); unmount(); @@ -216,7 +226,10 @@ describe('AuthDialog', () => { describe('handleAuthSelect', () => { it('calls onAuthError if validation fails', async () => { mockedValidateAuthMethod.mockReturnValue('Invalid method'); - const { unmount } = await renderWithProviders(); + const { waitUntilReady, unmount } = renderWithProviders( + , + ); + await waitUntilReady(); const { onSelect: handleAuthSelect } = mockedRadioButtonSelect.mock.calls[0][0]; handleAuthSelect(AuthType.USE_GEMINI); @@ -232,7 +245,10 @@ describe('AuthDialog', () => { it('sets auth context with requiresRestart: true for LOGIN_WITH_GOOGLE', async () => { mockedValidateAuthMethod.mockReturnValue(null); - const { unmount } = await renderWithProviders(); + const { waitUntilReady, unmount } = renderWithProviders( + , + ); + await waitUntilReady(); const { onSelect: handleAuthSelect } = mockedRadioButtonSelect.mock.calls[0][0]; await handleAuthSelect(AuthType.LOGIN_WITH_GOOGLE); @@ -245,7 +261,10 @@ describe('AuthDialog', () => { it('sets auth context with empty object for other auth types', async () => { mockedValidateAuthMethod.mockReturnValue(null); - const { unmount } = await renderWithProviders(); + const { waitUntilReady, unmount } = renderWithProviders( + , + ); + await waitUntilReady(); const { onSelect: handleAuthSelect } = mockedRadioButtonSelect.mock.calls[0][0]; await handleAuthSelect(AuthType.USE_GEMINI); @@ -259,7 +278,10 @@ describe('AuthDialog', () => { vi.stubEnv('GEMINI_API_KEY', 'test-key-from-env'); // props.settings.merged.security.auth.selectedType is undefined here, simulating initial setup - const { unmount } = await renderWithProviders(); + const { waitUntilReady, unmount } = renderWithProviders( + , + ); + await waitUntilReady(); const { onSelect: handleAuthSelect } = mockedRadioButtonSelect.mock.calls[0][0]; await handleAuthSelect(AuthType.USE_GEMINI); @@ -275,7 +297,10 @@ describe('AuthDialog', () => { vi.stubEnv('GEMINI_API_KEY', ''); // Empty string // props.settings.merged.security.auth.selectedType is undefined here - const { unmount } = await renderWithProviders(); + const { waitUntilReady, unmount } = renderWithProviders( + , + ); + await waitUntilReady(); const { onSelect: handleAuthSelect } = mockedRadioButtonSelect.mock.calls[0][0]; await handleAuthSelect(AuthType.USE_GEMINI); @@ -291,7 +316,10 @@ describe('AuthDialog', () => { // process.env['GEMINI_API_KEY'] is not set // props.settings.merged.security.auth.selectedType is undefined here, simulating initial setup - const { unmount } = await renderWithProviders(); + const { waitUntilReady, unmount } = renderWithProviders( + , + ); + await waitUntilReady(); const { onSelect: handleAuthSelect } = mockedRadioButtonSelect.mock.calls[0][0]; await handleAuthSelect(AuthType.USE_GEMINI); @@ -309,7 +337,10 @@ describe('AuthDialog', () => { props.settings.merged.security.auth.selectedType = AuthType.LOGIN_WITH_GOOGLE; - const { unmount } = await renderWithProviders(); + const { waitUntilReady, unmount } = renderWithProviders( + , + ); + await waitUntilReady(); const { onSelect: handleAuthSelect } = mockedRadioButtonSelect.mock.calls[0][0]; await handleAuthSelect(AuthType.USE_GEMINI); @@ -329,7 +360,10 @@ describe('AuthDialog', () => { vi.mocked(props.config.isBrowserLaunchSuppressed).mockReturnValue(true); mockedValidateAuthMethod.mockReturnValue(null); - const { unmount } = await renderWithProviders(); + const { waitUntilReady, unmount } = renderWithProviders( + , + ); + await waitUntilReady(); const { onSelect: handleAuthSelect } = mockedRadioButtonSelect.mock.calls[0][0]; await act(async () => { @@ -349,9 +383,10 @@ describe('AuthDialog', () => { it('displays authError when provided', async () => { props.authError = 'Something went wrong'; - const { lastFrame, unmount } = await renderWithProviders( + const { lastFrame, waitUntilReady, unmount } = renderWithProviders( , ); + await waitUntilReady(); expect(lastFrame()).toContain('Something went wrong'); unmount(); }); @@ -394,7 +429,10 @@ describe('AuthDialog', () => { }, ])('$desc', async ({ setup, expectations }) => { setup(); - const { unmount } = await renderWithProviders(); + const { waitUntilReady, unmount } = renderWithProviders( + , + ); + await waitUntilReady(); const keypressHandler = mockedUseKeypress.mock.calls[0][0]; keypressHandler({ name: 'escape' }); expectations(props); @@ -404,27 +442,30 @@ describe('AuthDialog', () => { describe('Snapshots', () => { it('renders correctly with default props', async () => { - const { lastFrame, unmount } = await renderWithProviders( + const { lastFrame, waitUntilReady, unmount } = renderWithProviders( , ); + await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); unmount(); }); it('renders correctly with auth error', async () => { props.authError = 'Something went wrong'; - const { lastFrame, unmount } = await renderWithProviders( + const { lastFrame, waitUntilReady, unmount } = renderWithProviders( , ); + await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); unmount(); }); it('renders correctly with enforced auth type', async () => { props.settings.merged.security.auth.enforcedType = AuthType.USE_GEMINI; - const { lastFrame, unmount } = await renderWithProviders( + const { lastFrame, waitUntilReady, unmount } = renderWithProviders( , ); + await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); unmount(); }); diff --git a/packages/cli/src/ui/auth/AuthInProgress.test.tsx b/packages/cli/src/ui/auth/AuthInProgress.test.tsx index 1c392be28d..bd6a3cb126 100644 --- a/packages/cli/src/ui/auth/AuthInProgress.test.tsx +++ b/packages/cli/src/ui/auth/AuthInProgress.test.tsx @@ -55,18 +55,20 @@ describe('AuthInProgress', () => { }); it('renders initial state with spinner', async () => { - const { lastFrame, unmount } = await render( + const { lastFrame, waitUntilReady, unmount } = render( , ); + await waitUntilReady(); expect(lastFrame()).toContain('[Spinner] Waiting for authentication...'); expect(lastFrame()).toContain('Press Esc or Ctrl+C to cancel'); unmount(); }); it('calls onTimeout when ESC is pressed', async () => { - const { waitUntilReady, unmount } = await render( + const { waitUntilReady, unmount } = render( , ); + await waitUntilReady(); const keypressHandler = vi.mocked(useKeypress).mock.calls[0][0]; await act(async () => { @@ -82,9 +84,10 @@ describe('AuthInProgress', () => { }); it('calls onTimeout when Ctrl+C is pressed', async () => { - const { waitUntilReady, unmount } = await render( + const { waitUntilReady, unmount } = render( , ); + await waitUntilReady(); const keypressHandler = vi.mocked(useKeypress).mock.calls[0][0]; await act(async () => { @@ -97,9 +100,10 @@ describe('AuthInProgress', () => { }); it('calls onTimeout and shows timeout message after 3 minutes', async () => { - const { lastFrame, waitUntilReady, unmount } = await render( + const { lastFrame, waitUntilReady, unmount } = render( , ); + await waitUntilReady(); await act(async () => { vi.advanceTimersByTime(180000); @@ -112,7 +116,10 @@ describe('AuthInProgress', () => { }); it('clears timer on unmount', async () => { - const { unmount } = await render(); + const { waitUntilReady, unmount } = render( + , + ); + await waitUntilReady(); await act(async () => { unmount(); diff --git a/packages/cli/src/ui/auth/BannedAccountDialog.test.tsx b/packages/cli/src/ui/auth/BannedAccountDialog.test.tsx index 4b5d44e6d5..692b249415 100644 --- a/packages/cli/src/ui/auth/BannedAccountDialog.test.tsx +++ b/packages/cli/src/ui/auth/BannedAccountDialog.test.tsx @@ -73,13 +73,14 @@ describe('BannedAccountDialog', () => { }); it('renders the suspension message from accountSuspensionInfo', async () => { - const { lastFrame, unmount } = await renderWithProviders( + const { lastFrame, waitUntilReady, unmount } = renderWithProviders( , ); + await waitUntilReady(); const frame = lastFrame(); expect(frame).toContain('Account Suspended'); expect(frame).toContain('violation of Terms of Service'); @@ -88,13 +89,14 @@ describe('BannedAccountDialog', () => { }); it('renders menu options with appeal link text from response', async () => { - const { unmount } = await renderWithProviders( + const { waitUntilReady, unmount } = renderWithProviders( , ); + await waitUntilReady(); const items = mockedRadioButtonSelect.mock.calls[0][0].items; expect(items).toHaveLength(3); expect(items[0].label).toBe('Appeal Here'); @@ -107,13 +109,14 @@ describe('BannedAccountDialog', () => { const infoWithoutUrl: AccountSuspensionInfo = { message: 'Account suspended.', }; - const { unmount } = await renderWithProviders( + const { waitUntilReady, unmount } = renderWithProviders( , ); + await waitUntilReady(); const items = mockedRadioButtonSelect.mock.calls[0][0].items; expect(items).toHaveLength(2); expect(items[0].label).toBe('Change authentication'); @@ -126,26 +129,28 @@ describe('BannedAccountDialog', () => { message: 'Account suspended.', appealUrl: 'https://example.com/appeal', }; - const { unmount } = await renderWithProviders( + const { waitUntilReady, unmount } = renderWithProviders( , ); + await waitUntilReady(); const items = mockedRadioButtonSelect.mock.calls[0][0].items; expect(items[0].label).toBe('Open the Google Form'); unmount(); }); it('opens browser when appeal option is selected', async () => { - const { unmount } = await renderWithProviders( + const { waitUntilReady, unmount } = renderWithProviders( , ); + await waitUntilReady(); const { onSelect } = mockedRadioButtonSelect.mock.calls[0][0]; await onSelect('open_form'); expect(mockedOpenBrowser).toHaveBeenCalledWith( @@ -157,13 +162,14 @@ describe('BannedAccountDialog', () => { it('shows URL when browser cannot be launched', async () => { mockedShouldLaunchBrowser.mockReturnValue(false); - const { lastFrame, unmount } = await renderWithProviders( + const { lastFrame, waitUntilReady, unmount } = renderWithProviders( , ); + await waitUntilReady(); const { onSelect } = mockedRadioButtonSelect.mock.calls[0][0]; onSelect('open_form'); await waitFor(() => { @@ -174,13 +180,14 @@ describe('BannedAccountDialog', () => { }); it('calls onExit when "Exit" is selected', async () => { - const { unmount } = await renderWithProviders( + const { waitUntilReady, unmount } = renderWithProviders( , ); + await waitUntilReady(); const { onSelect } = mockedRadioButtonSelect.mock.calls[0][0]; await onSelect('exit'); expect(mockedRunExitCleanup).toHaveBeenCalled(); @@ -189,13 +196,14 @@ describe('BannedAccountDialog', () => { }); it('calls onChangeAuth when "Change authentication" is selected', async () => { - const { unmount } = await renderWithProviders( + const { waitUntilReady, unmount } = renderWithProviders( , ); + await waitUntilReady(); const { onSelect } = mockedRadioButtonSelect.mock.calls[0][0]; onSelect('change_auth'); expect(onChangeAuth).toHaveBeenCalled(); @@ -204,13 +212,14 @@ describe('BannedAccountDialog', () => { }); it('exits on escape key', async () => { - const { unmount } = await renderWithProviders( + const { waitUntilReady, unmount } = renderWithProviders( , ); + await waitUntilReady(); const keypressHandler = mockedUseKeypress.mock.calls[0][0]; const result = keypressHandler({ name: 'escape' }); expect(result).toBe(true); @@ -218,13 +227,14 @@ describe('BannedAccountDialog', () => { }); it('renders snapshot correctly', async () => { - const { lastFrame, unmount } = await renderWithProviders( + const { lastFrame, waitUntilReady, unmount } = renderWithProviders( , ); + await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); unmount(); }); diff --git a/packages/cli/src/ui/auth/LoginWithGoogleRestartDialog.test.tsx b/packages/cli/src/ui/auth/LoginWithGoogleRestartDialog.test.tsx index 4dd13a3334..77310e3069 100644 --- a/packages/cli/src/ui/auth/LoginWithGoogleRestartDialog.test.tsx +++ b/packages/cli/src/ui/auth/LoginWithGoogleRestartDialog.test.tsx @@ -45,23 +45,25 @@ describe('LoginWithGoogleRestartDialog', () => { }); it('renders correctly', async () => { - const { lastFrame, unmount } = await render( + const { lastFrame, waitUntilReady, unmount } = render( , ); + await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); unmount(); }); it('calls onDismiss when escape is pressed', async () => { - const { unmount } = await render( + const { waitUntilReady, unmount } = render( , ); + await waitUntilReady(); const keypressHandler = mockedUseKeypress.mock.calls[0][0]; keypressHandler({ @@ -81,12 +83,13 @@ describe('LoginWithGoogleRestartDialog', () => { async (keyName) => { vi.useFakeTimers(); - const { unmount } = await render( + const { waitUntilReady, unmount } = render( , ); + await waitUntilReady(); const keypressHandler = mockedUseKeypress.mock.calls[0][0]; keypressHandler({ diff --git a/packages/cli/src/ui/auth/useAuth.test.tsx b/packages/cli/src/ui/auth/useAuth.test.tsx index 8d51e46a64..f236428ff1 100644 --- a/packages/cli/src/ui/auth/useAuth.test.tsx +++ b/packages/cli/src/ui/auth/useAuth.test.tsx @@ -4,8 +4,15 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { act } from 'react'; +import { + describe, + it, + expect, + vi, + beforeEach, + afterEach, + type Mock, +} from 'vitest'; import { renderHook } from '../../test-utils/render.js'; import { useAuthCommand, validateAuthMethodWithSettings } from './useAuth.js'; import { @@ -15,6 +22,7 @@ import { } from '@google/gemini-cli-core'; import { AuthState } from '../types.js'; import type { LoadedSettings } from '../../config/settings.js'; +import { waitFor } from '../../test-utils/async.js'; // Mock dependencies const mockLoadApiKey = vi.fn(); @@ -134,202 +142,171 @@ describe('useAuth', () => { }, }) as LoadedSettings; - let deferredRefreshAuth: { - resolve: () => void; - reject: (e: Error) => void; - }; - - beforeEach(() => { - vi.mocked(mockConfig.refreshAuth).mockImplementation( - () => - new Promise((resolve, reject) => { - deferredRefreshAuth = { resolve, reject }; - }), - ); - }); - it('should initialize with Unauthenticated state', async () => { - const { result } = await renderHook(() => + const { result } = renderHook(() => useAuthCommand(createSettings(AuthType.LOGIN_WITH_GOOGLE), mockConfig), ); - // Because we defer refreshAuth, the initial state is safely caught here expect(result.current.authState).toBe(AuthState.Unauthenticated); - await act(async () => { - deferredRefreshAuth.resolve(); + await waitFor(() => { + expect(result.current.authState).toBe(AuthState.Authenticated); }); - - expect(result.current.authState).toBe(AuthState.Authenticated); }); it('should set error if no auth type is selected and no env key', async () => { - const { result } = await renderHook(() => + const { result } = renderHook(() => useAuthCommand(createSettings(undefined), mockConfig), ); - // This happens synchronously, no deferred promise - expect(result.current.authError).toBe( - 'No authentication method selected.', - ); - expect(result.current.authState).toBe(AuthState.Updating); + await waitFor(() => { + expect(result.current.authError).toBe( + 'No authentication method selected.', + ); + expect(result.current.authState).toBe(AuthState.Updating); + }); }); it('should set error if no auth type is selected but env key exists', async () => { process.env['GEMINI_API_KEY'] = 'env-key'; - const { result } = await renderHook(() => + const { result } = renderHook(() => useAuthCommand(createSettings(undefined), mockConfig), ); - expect(result.current.authError).toContain( - 'Existing API key detected (GEMINI_API_KEY)', - ); - expect(result.current.authState).toBe(AuthState.Updating); + await waitFor(() => { + expect(result.current.authError).toContain( + 'Existing API key detected (GEMINI_API_KEY)', + ); + expect(result.current.authState).toBe(AuthState.Updating); + }); }); it('should transition to AwaitingApiKeyInput if USE_GEMINI and no key found', async () => { - let deferredLoadKey: { resolve: (k: string | null) => void }; - mockLoadApiKey.mockImplementation( - () => - new Promise((resolve) => { - deferredLoadKey = { resolve }; - }), - ); - - const { result } = await renderHook(() => + mockLoadApiKey.mockResolvedValue(null); + const { result } = renderHook(() => useAuthCommand(createSettings(AuthType.USE_GEMINI), mockConfig), ); - await act(async () => { - deferredLoadKey.resolve(null); + await waitFor(() => { + expect(result.current.authState).toBe(AuthState.AwaitingApiKeyInput); }); - - expect(result.current.authState).toBe(AuthState.AwaitingApiKeyInput); }); it('should authenticate if USE_GEMINI and key is found', async () => { - let deferredLoadKey: { resolve: (k: string | null) => void }; - mockLoadApiKey.mockImplementation( - () => - new Promise((resolve) => { - deferredLoadKey = { resolve }; - }), - ); - - const { result } = await renderHook(() => + mockLoadApiKey.mockResolvedValue('stored-key'); + const { result } = renderHook(() => useAuthCommand(createSettings(AuthType.USE_GEMINI), mockConfig), ); - await act(async () => { - deferredLoadKey.resolve('stored-key'); + await waitFor(() => { + expect(mockConfig.refreshAuth).toHaveBeenCalledWith( + AuthType.USE_GEMINI, + ); + expect(result.current.authState).toBe(AuthState.Authenticated); + expect(result.current.apiKeyDefaultValue).toBe('stored-key'); }); - - await act(async () => { - deferredRefreshAuth.resolve(); - }); - - expect(mockConfig.refreshAuth).toHaveBeenCalledWith(AuthType.USE_GEMINI); - expect(result.current.authState).toBe(AuthState.Authenticated); - expect(result.current.apiKeyDefaultValue).toBe('stored-key'); }); it('should authenticate if USE_GEMINI and env key is found', async () => { + mockLoadApiKey.mockResolvedValue(null); process.env['GEMINI_API_KEY'] = 'env-key'; - - const { result } = await renderHook(() => + const { result } = renderHook(() => useAuthCommand(createSettings(AuthType.USE_GEMINI), mockConfig), ); - await act(async () => { - deferredRefreshAuth.resolve(); + await waitFor(() => { + expect(mockConfig.refreshAuth).toHaveBeenCalledWith( + AuthType.USE_GEMINI, + ); + expect(result.current.authState).toBe(AuthState.Authenticated); + expect(result.current.apiKeyDefaultValue).toBe('env-key'); }); - - expect(mockConfig.refreshAuth).toHaveBeenCalledWith(AuthType.USE_GEMINI); - expect(result.current.authState).toBe(AuthState.Authenticated); - expect(result.current.apiKeyDefaultValue).toBe('env-key'); }); it('should prioritize env key over stored key when both are present', async () => { + mockLoadApiKey.mockResolvedValue('stored-key'); process.env['GEMINI_API_KEY'] = 'env-key'; - - const { result } = await renderHook(() => + const { result } = renderHook(() => useAuthCommand(createSettings(AuthType.USE_GEMINI), mockConfig), ); - await act(async () => { - deferredRefreshAuth.resolve(); + await waitFor(() => { + expect(mockConfig.refreshAuth).toHaveBeenCalledWith( + AuthType.USE_GEMINI, + ); + expect(result.current.authState).toBe(AuthState.Authenticated); + // The environment key should take precedence + expect(result.current.apiKeyDefaultValue).toBe('env-key'); }); - - expect(mockConfig.refreshAuth).toHaveBeenCalledWith(AuthType.USE_GEMINI); - expect(result.current.authState).toBe(AuthState.Authenticated); - expect(result.current.apiKeyDefaultValue).toBe('env-key'); }); it('should set error if validation fails', async () => { mockValidateAuthMethod.mockReturnValue('Validation Failed'); - const { result } = await renderHook(() => + const { result } = renderHook(() => useAuthCommand(createSettings(AuthType.LOGIN_WITH_GOOGLE), mockConfig), ); - expect(result.current.authError).toBe('Validation Failed'); - expect(result.current.authState).toBe(AuthState.Updating); + await waitFor(() => { + expect(result.current.authError).toBe('Validation Failed'); + expect(result.current.authState).toBe(AuthState.Updating); + }); }); it('should set error if GEMINI_DEFAULT_AUTH_TYPE is invalid', async () => { process.env['GEMINI_DEFAULT_AUTH_TYPE'] = 'INVALID_TYPE'; - const { result } = await renderHook(() => + const { result } = renderHook(() => useAuthCommand(createSettings(AuthType.LOGIN_WITH_GOOGLE), mockConfig), ); - expect(result.current.authError).toContain( - 'Invalid value for GEMINI_DEFAULT_AUTH_TYPE', - ); - expect(result.current.authState).toBe(AuthState.Updating); + await waitFor(() => { + expect(result.current.authError).toContain( + 'Invalid value for GEMINI_DEFAULT_AUTH_TYPE', + ); + expect(result.current.authState).toBe(AuthState.Updating); + }); }); it('should authenticate successfully for valid auth type', async () => { - const { result } = await renderHook(() => + const { result } = renderHook(() => useAuthCommand(createSettings(AuthType.LOGIN_WITH_GOOGLE), mockConfig), ); - await act(async () => { - deferredRefreshAuth.resolve(); + await waitFor(() => { + expect(mockConfig.refreshAuth).toHaveBeenCalledWith( + AuthType.LOGIN_WITH_GOOGLE, + ); + expect(result.current.authState).toBe(AuthState.Authenticated); + expect(result.current.authError).toBeNull(); }); - - expect(mockConfig.refreshAuth).toHaveBeenCalledWith( - AuthType.LOGIN_WITH_GOOGLE, - ); - expect(result.current.authState).toBe(AuthState.Authenticated); - expect(result.current.authError).toBeNull(); }); it('should handle refreshAuth failure', async () => { - const { result } = await renderHook(() => + (mockConfig.refreshAuth as Mock).mockRejectedValue( + new Error('Auth Failed'), + ); + const { result } = renderHook(() => useAuthCommand(createSettings(AuthType.LOGIN_WITH_GOOGLE), mockConfig), ); - await act(async () => { - deferredRefreshAuth.reject(new Error('Auth Failed')); + await waitFor(() => { + expect(result.current.authError).toContain('Failed to sign in'); + expect(result.current.authState).toBe(AuthState.Updating); }); - - expect(result.current.authError).toContain('Failed to sign in'); - expect(result.current.authState).toBe(AuthState.Updating); }); it('should handle ProjectIdRequiredError without "Failed to login" prefix', async () => { const projectIdError = new ProjectIdRequiredError(); - const { result } = await renderHook(() => + (mockConfig.refreshAuth as Mock).mockRejectedValue(projectIdError); + const { result } = renderHook(() => useAuthCommand(createSettings(AuthType.LOGIN_WITH_GOOGLE), mockConfig), ); - await act(async () => { - deferredRefreshAuth.reject(projectIdError); + await waitFor(() => { + expect(result.current.authError).toBe( + 'This account requires setting the GOOGLE_CLOUD_PROJECT or GOOGLE_CLOUD_PROJECT_ID env var. See https://goo.gle/gemini-cli-auth-docs#workspace-gca', + ); + expect(result.current.authError).not.toContain('Failed to login'); + expect(result.current.authState).toBe(AuthState.Updating); }); - - expect(result.current.authError).toBe( - 'This account requires setting the GOOGLE_CLOUD_PROJECT or GOOGLE_CLOUD_PROJECT_ID env var. See https://goo.gle/gemini-cli-auth-docs#workspace-gca', - ); - expect(result.current.authError).not.toContain('Failed to login'); - expect(result.current.authState).toBe(AuthState.Updating); }); }); }); diff --git a/packages/cli/src/ui/commands/aboutCommand.test.ts b/packages/cli/src/ui/commands/aboutCommand.test.ts index 0fa1f709ba..f1c010678e 100644 --- a/packages/cli/src/ui/commands/aboutCommand.test.ts +++ b/packages/cli/src/ui/commands/aboutCommand.test.ts @@ -36,12 +36,10 @@ describe('aboutCommand', () => { beforeEach(() => { mockContext = createMockCommandContext({ services: { - agentContext: { - config: { - getModel: vi.fn(), - getIdeMode: vi.fn().mockReturnValue(true), - getUserTierName: vi.fn().mockReturnValue(undefined), - }, + config: { + getModel: vi.fn(), + getIdeMode: vi.fn().mockReturnValue(true), + getUserTierName: vi.fn().mockReturnValue(undefined), }, settings: { merged: { @@ -59,10 +57,9 @@ describe('aboutCommand', () => { } as unknown as CommandContext); vi.mocked(getVersion).mockResolvedValue('test-version'); - vi.spyOn( - mockContext.services.agentContext!.config, - 'getModel', - ).mockReturnValue('test-model'); + vi.spyOn(mockContext.services.config!, 'getModel').mockReturnValue( + 'test-model', + ); process.env['GOOGLE_CLOUD_PROJECT'] = 'test-gcp-project'; Object.defineProperty(process, 'platform', { value: 'test-os', @@ -163,9 +160,9 @@ describe('aboutCommand', () => { }); it('should display the tier when getUserTierName returns a value', async () => { - vi.mocked( - mockContext.services.agentContext!.config.getUserTierName, - ).mockReturnValue('Enterprise Tier'); + vi.mocked(mockContext.services.config!.getUserTierName).mockReturnValue( + 'Enterprise Tier', + ); if (!aboutCommand.action) { throw new Error('The about command must have an action.'); } diff --git a/packages/cli/src/ui/commands/aboutCommand.ts b/packages/cli/src/ui/commands/aboutCommand.ts index 8b436d69b8..afd1ada9cd 100644 --- a/packages/cli/src/ui/commands/aboutCommand.ts +++ b/packages/cli/src/ui/commands/aboutCommand.ts @@ -34,8 +34,7 @@ export const aboutCommand: SlashCommand = { process.env['SEATBELT_PROFILE'] || 'unknown' })`; } - const modelVersion = - context.services.agentContext?.config.getModel() || 'Unknown'; + const modelVersion = context.services.config?.getModel() || 'Unknown'; const cliVersion = await getVersion(); const selectedAuthType = context.services.settings.merged.security.auth.selectedType || ''; @@ -49,7 +48,7 @@ export const aboutCommand: SlashCommand = { }); const userEmail = cachedAccount ?? undefined; - const tier = context.services.agentContext?.config.getUserTierName(); + const tier = context.services.config?.getUserTierName(); const aboutItem: Omit = { type: MessageType.ABOUT, @@ -69,7 +68,7 @@ export const aboutCommand: SlashCommand = { }; async function getIdeClientName(context: CommandContext) { - if (!context.services.agentContext?.config.getIdeMode()) { + if (!context.services.config?.getIdeMode()) { return ''; } const ideClient = await IdeClient.getInstance(); diff --git a/packages/cli/src/ui/commands/agentsCommand.test.ts b/packages/cli/src/ui/commands/agentsCommand.test.ts index 1a5de99122..5e6cc36efa 100644 --- a/packages/cli/src/ui/commands/agentsCommand.test.ts +++ b/packages/cli/src/ui/commands/agentsCommand.test.ts @@ -26,7 +26,6 @@ describe('agentsCommand', () => { let mockContext: ReturnType; let mockConfig: { getAgentRegistry: ReturnType; - config: Config; }; beforeEach(() => { @@ -38,14 +37,11 @@ describe('agentsCommand', () => { getAllAgentNames: vi.fn().mockReturnValue([]), reload: vi.fn(), }), - get config() { - return this as unknown as Config; - }, }; mockContext = createMockCommandContext({ services: { - agentContext: mockConfig as unknown as Config, + config: mockConfig as unknown as Config, settings: { workspace: { path: '/mock/path' }, merged: { agents: { overrides: {} } }, @@ -57,7 +53,7 @@ describe('agentsCommand', () => { it('should show an error if config is not available', async () => { const contextWithoutConfig = createMockCommandContext({ services: { - agentContext: null, + config: null, }, }); @@ -230,7 +226,7 @@ describe('agentsCommand', () => { it('should show an error if config is not available for enable', async () => { const contextWithoutConfig = createMockCommandContext({ - services: { agentContext: null }, + services: { config: null }, }); const enableCommand = agentsCommand.subCommands?.find( (cmd) => cmd.name === 'enable', @@ -336,7 +332,7 @@ describe('agentsCommand', () => { it('should show an error if config is not available for disable', async () => { const contextWithoutConfig = createMockCommandContext({ - services: { agentContext: null }, + services: { config: null }, }); const disableCommand = agentsCommand.subCommands?.find( (cmd) => cmd.name === 'disable', @@ -437,7 +433,7 @@ describe('agentsCommand', () => { it('should show an error if config is not available', async () => { const contextWithoutConfig = createMockCommandContext({ - services: { agentContext: null }, + services: { config: null }, }); const configCommand = agentsCommand.subCommands?.find( (cmd) => cmd.name === 'config', diff --git a/packages/cli/src/ui/commands/agentsCommand.ts b/packages/cli/src/ui/commands/agentsCommand.ts index d1b582d673..3658c741ff 100644 --- a/packages/cli/src/ui/commands/agentsCommand.ts +++ b/packages/cli/src/ui/commands/agentsCommand.ts @@ -21,7 +21,7 @@ const agentsListCommand: SlashCommand = { kind: CommandKind.BUILT_IN, autoExecute: true, action: async (context: CommandContext) => { - const config = context.services.agentContext?.config; + const { config } = context.services; if (!config) { return { type: 'message', @@ -61,8 +61,7 @@ async function enableAction( context: CommandContext, args: string, ): Promise { - const config = context.services.agentContext?.config; - const { settings } = context.services; + const { config, settings } = context.services; if (!config) { return { type: 'message', @@ -138,8 +137,7 @@ async function disableAction( context: CommandContext, args: string, ): Promise { - const config = context.services.agentContext?.config; - const { settings } = context.services; + const { config, settings } = context.services; if (!config) { return { type: 'message', @@ -218,7 +216,7 @@ async function configAction( context: CommandContext, args: string, ): Promise { - const config = context.services.agentContext?.config; + const { config } = context.services; if (!config) { return { type: 'message', @@ -268,8 +266,7 @@ async function configAction( } function completeAgentsToEnable(context: CommandContext, partialArg: string) { - const config = context.services.agentContext?.config; - const { settings } = context.services; + const { config, settings } = context.services; if (!config) return []; const overrides = settings.merged.agents.overrides; @@ -281,7 +278,7 @@ function completeAgentsToEnable(context: CommandContext, partialArg: string) { } function completeAgentsToDisable(context: CommandContext, partialArg: string) { - const config = context.services.agentContext?.config; + const { config } = context.services; if (!config) return []; const agentRegistry = config.getAgentRegistry(); @@ -290,7 +287,7 @@ function completeAgentsToDisable(context: CommandContext, partialArg: string) { } function completeAllAgents(context: CommandContext, partialArg: string) { - const config = context.services.agentContext?.config; + const { config } = context.services; if (!config) return []; const agentRegistry = config.getAgentRegistry(); @@ -331,7 +328,7 @@ const agentsReloadCommand: SlashCommand = { description: 'Reload the agent registry', kind: CommandKind.BUILT_IN, action: async (context: CommandContext) => { - const config = context.services.agentContext?.config; + const { config } = context.services; const agentRegistry = config?.getAgentRegistry(); if (!agentRegistry) { return { diff --git a/packages/cli/src/ui/commands/authCommand.test.ts b/packages/cli/src/ui/commands/authCommand.test.ts index ff4f2ba614..88e3273c8d 100644 --- a/packages/cli/src/ui/commands/authCommand.test.ts +++ b/packages/cli/src/ui/commands/authCommand.test.ts @@ -9,7 +9,6 @@ import { authCommand } from './authCommand.js'; import { type CommandContext } from './types.js'; import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; import { SettingScope } from '../../config/settings.js'; -import type { GeminiClient } from '@google/gemini-cli-core'; vi.mock('@google/gemini-cli-core', async () => { const actual = await vi.importActual('@google/gemini-cli-core'); @@ -25,10 +24,8 @@ describe('authCommand', () => { beforeEach(() => { mockContext = createMockCommandContext({ services: { - agentContext: { - geminiClient: { - stripThoughtsFromHistory: vi.fn(), - }, + config: { + getGeminiClient: vi.fn(), }, }, }); @@ -104,19 +101,17 @@ describe('authCommand', () => { const mockStripThoughts = vi.fn(); const mockClient = { stripThoughtsFromHistory: mockStripThoughts, - } as unknown as GeminiClient; - if (mockContext.services.agentContext?.config) { - mockContext.services.agentContext.config.getGeminiClient = vi.fn( - () => mockClient, - ); + } as unknown as ReturnType< + NonNullable['getGeminiClient'] + >; + + if (mockContext.services.config) { + mockContext.services.config.getGeminiClient = vi.fn(() => mockClient); } await logoutCommand!.action!(mockContext, ''); - expect( - mockContext.services.agentContext?.geminiClient - .stripThoughtsFromHistory, - ).toHaveBeenCalled(); + expect(mockStripThoughts).toHaveBeenCalled(); }); it('should return logout action to signal explicit state change', async () => { @@ -128,7 +123,7 @@ describe('authCommand', () => { it('should handle missing config gracefully', async () => { const logoutCommand = authCommand.subCommands?.[1]; - mockContext.services.agentContext = null; + mockContext.services.config = null; const result = await logoutCommand!.action!(mockContext, ''); diff --git a/packages/cli/src/ui/commands/authCommand.ts b/packages/cli/src/ui/commands/authCommand.ts index 084763058c..80c432894c 100644 --- a/packages/cli/src/ui/commands/authCommand.ts +++ b/packages/cli/src/ui/commands/authCommand.ts @@ -39,7 +39,7 @@ const authLogoutCommand: SlashCommand = { undefined, ); // Strip thoughts from history instead of clearing completely - context.services.agentContext?.geminiClient.stripThoughtsFromHistory(); + context.services.config?.getGeminiClient()?.stripThoughtsFromHistory(); // Return logout action to signal explicit state change return { type: 'logout', diff --git a/packages/cli/src/ui/commands/bugCommand.test.ts b/packages/cli/src/ui/commands/bugCommand.test.ts index c2c1a9a1d6..88db905e77 100644 --- a/packages/cli/src/ui/commands/bugCommand.test.ts +++ b/packages/cli/src/ui/commands/bugCommand.test.ts @@ -83,18 +83,16 @@ describe('bugCommand', () => { it('should generate the default GitHub issue URL', async () => { const mockContext = createMockCommandContext({ services: { - agentContext: { - config: { - getModel: () => 'gemini-pro', - getBugCommand: () => undefined, - getIdeMode: () => true, - getContentGeneratorConfig: () => ({ authType: 'oauth-personal' }), - }, - geminiClient: { + config: { + getModel: () => 'gemini-pro', + getBugCommand: () => undefined, + getIdeMode: () => true, + getGeminiClient: () => ({ getChat: () => ({ getHistory: () => [], }), - }, + }), + getContentGeneratorConfig: () => ({ authType: 'oauth-personal' }), }, }, }); @@ -128,20 +126,18 @@ describe('bugCommand', () => { ]; const mockContext = createMockCommandContext({ services: { - agentContext: { - config: { - getModel: () => 'gemini-pro', - getBugCommand: () => undefined, - getIdeMode: () => true, - getContentGeneratorConfig: () => ({ authType: 'vertex-ai' }), - storage: { - getProjectTempDir: () => '/tmp/gemini', - }, - }, - geminiClient: { + config: { + getModel: () => 'gemini-pro', + getBugCommand: () => undefined, + getIdeMode: () => true, + getGeminiClient: () => ({ getChat: () => ({ getHistory: () => history, }), + }), + getContentGeneratorConfig: () => ({ authType: 'vertex-ai' }), + storage: { + getProjectTempDir: () => '/tmp/gemini', }, }, }, @@ -176,18 +172,16 @@ describe('bugCommand', () => { 'https://internal.bug-tracker.com/new?desc={title}&details={info}'; const mockContext = createMockCommandContext({ services: { - agentContext: { - config: { - getModel: () => 'gemini-pro', - getBugCommand: () => ({ urlTemplate: customTemplate }), - getIdeMode: () => true, - getContentGeneratorConfig: () => ({ authType: 'vertex-ai' }), - }, - geminiClient: { + config: { + getModel: () => 'gemini-pro', + getBugCommand: () => ({ urlTemplate: customTemplate }), + getIdeMode: () => true, + getGeminiClient: () => ({ getChat: () => ({ getHistory: () => [], }), - }, + }), + getContentGeneratorConfig: () => ({ authType: 'vertex-ai' }), }, }, }); diff --git a/packages/cli/src/ui/commands/bugCommand.ts b/packages/cli/src/ui/commands/bugCommand.ts index 134bccc9f0..26ddb7e850 100644 --- a/packages/cli/src/ui/commands/bugCommand.ts +++ b/packages/cli/src/ui/commands/bugCommand.ts @@ -32,8 +32,8 @@ export const bugCommand: SlashCommand = { autoExecute: false, action: async (context: CommandContext, args?: string): Promise => { const bugDescription = (args || '').trim(); - const agentContext = context.services.agentContext; - const config = agentContext?.config; + const { config } = context.services; + const osVersion = `${process.platform} ${process.version}`; let sandboxEnv = 'no sandbox'; if (process.env['SANDBOX'] && process.env['SANDBOX'] !== 'sandbox-exec') { @@ -73,7 +73,7 @@ export const bugCommand: SlashCommand = { info += `* **IDE Client:** ${ideClient}\n`; } - const chat = agentContext?.geminiClient?.getChat(); + const chat = config?.getGeminiClient()?.getChat(); const history = chat?.getHistory() || []; let historyFileMessage = ''; let problemValue = bugDescription; @@ -134,7 +134,7 @@ export const bugCommand: SlashCommand = { }; async function getIdeClientName(context: CommandContext) { - if (!context.services.agentContext?.config.getIdeMode()) { + if (!context.services.config?.getIdeMode()) { return ''; } const ideClient = await IdeClient.getInstance(); diff --git a/packages/cli/src/ui/commands/chatCommand.test.ts b/packages/cli/src/ui/commands/chatCommand.test.ts index 04d0753ee8..c0288fbef2 100644 --- a/packages/cli/src/ui/commands/chatCommand.test.ts +++ b/packages/cli/src/ui/commands/chatCommand.test.ts @@ -70,19 +70,18 @@ describe('chatCommand', () => { mockContext = createMockCommandContext({ services: { - agentContext: { - config: { - getProjectRoot: () => '/project/root', - getContentGeneratorConfig: () => ({ - authType: AuthType.LOGIN_WITH_GOOGLE, - }), - storage: { - getProjectTempDir: () => '/project/root/.gemini/tmp/mockhash', - }, + config: { + getProjectRoot: () => '/project/root', + getGeminiClient: () => + ({ + getChat: mockGetChat, + }) as unknown as GeminiClient, + storage: { + getProjectTempDir: () => '/project/root/.gemini/tmp/mockhash', }, - geminiClient: { - getChat: mockGetChat, - } as unknown as GeminiClient, + getContentGeneratorConfig: () => ({ + authType: AuthType.LOGIN_WITH_GOOGLE, + }), }, logger: { saveCheckpoint: mockSaveCheckpoint, @@ -699,11 +698,7 @@ Hi there!`; beforeEach(() => { mockGetLatestApiRequest = vi.fn(); - if (!mockContext.services.agentContext!.config) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (mockContext.services.agentContext!.config as any) = {}; - } - mockContext.services.agentContext!.config.getLatestApiRequest = + mockContext.services.config!.getLatestApiRequest = mockGetLatestApiRequest; vi.spyOn(process, 'cwd').mockReturnValue('/project/root'); vi.spyOn(Date, 'now').mockReturnValue(1234567890); diff --git a/packages/cli/src/ui/commands/chatCommand.ts b/packages/cli/src/ui/commands/chatCommand.ts index 87aacb056b..8b38204aa2 100644 --- a/packages/cli/src/ui/commands/chatCommand.ts +++ b/packages/cli/src/ui/commands/chatCommand.ts @@ -35,7 +35,7 @@ const getSavedChatTags = async ( context: CommandContext, mtSortDesc: boolean, ): Promise => { - const cfg = context.services.agentContext?.config; + const cfg = context.services.config; const geminiDir = cfg?.storage?.getProjectTempDir(); if (!geminiDir) { return []; @@ -103,8 +103,7 @@ const saveCommand: SlashCommand = { }; } - const { logger } = context.services; - const config = context.services.agentContext?.config; + const { logger, config } = context.services; await logger.initialize(); if (!context.overwriteConfirmed) { @@ -126,7 +125,7 @@ const saveCommand: SlashCommand = { } } - const chat = context.services.agentContext?.geminiClient?.getChat(); + const chat = config?.getGeminiClient()?.getChat(); if (!chat) { return { type: 'message', @@ -173,8 +172,7 @@ const resumeCheckpointCommand: SlashCommand = { }; } - const { logger } = context.services; - const config = context.services.agentContext?.config; + const { logger, config } = context.services; await logger.initialize(); const checkpoint = await logger.loadCheckpoint(tag); const conversation = checkpoint.history; @@ -300,7 +298,7 @@ const shareCommand: SlashCommand = { }; } - const chat = context.services.agentContext?.geminiClient?.getChat(); + const chat = context.services.config?.getGeminiClient()?.getChat(); if (!chat) { return { type: 'message', @@ -346,7 +344,7 @@ export const debugCommand: SlashCommand = { kind: CommandKind.BUILT_IN, autoExecute: true, action: async (context): Promise => { - const req = context.services.agentContext?.config.getLatestApiRequest(); + const req = context.services.config?.getLatestApiRequest(); if (!req) { return { type: 'message', diff --git a/packages/cli/src/ui/commands/clearCommand.test.ts b/packages/cli/src/ui/commands/clearCommand.test.ts index 77f6e4854d..0072bebf27 100644 --- a/packages/cli/src/ui/commands/clearCommand.test.ts +++ b/packages/cli/src/ui/commands/clearCommand.test.ts @@ -36,25 +36,24 @@ describe('clearCommand', () => { mockContext = createMockCommandContext({ services: { - agentContext: { - config: { - getEnableHooks: vi.fn().mockReturnValue(false), - setSessionId: vi.fn(), - getMessageBus: vi.fn().mockReturnValue(undefined), - getHookSystem: vi.fn().mockReturnValue({ - fireSessionEndEvent: vi.fn().mockResolvedValue(undefined), - fireSessionStartEvent: vi.fn().mockResolvedValue(undefined), - }), - injectionService: { - clear: mockHintClear, - }, + config: { + getGeminiClient: () => + ({ + resetChat: mockResetChat, + getChat: () => ({ + getChatRecordingService: mockGetChatRecordingService, + }), + }) as unknown as GeminiClient, + setSessionId: vi.fn(), + getEnableHooks: vi.fn().mockReturnValue(false), + getMessageBus: vi.fn().mockReturnValue(undefined), + getHookSystem: vi.fn().mockReturnValue({ + fireSessionEndEvent: vi.fn().mockResolvedValue(undefined), + fireSessionStartEvent: vi.fn().mockResolvedValue(undefined), + }), + injectionService: { + clear: mockHintClear, }, - geminiClient: { - resetChat: mockResetChat, - getChat: () => ({ - getChatRecordingService: mockGetChatRecordingService, - }), - } as unknown as GeminiClient, }, }, }); @@ -99,7 +98,7 @@ describe('clearCommand', () => { const nullConfigContext = createMockCommandContext({ services: { - agentContext: null, + config: null, }, }); diff --git a/packages/cli/src/ui/commands/clearCommand.ts b/packages/cli/src/ui/commands/clearCommand.ts index 061c4f9085..05eb96193f 100644 --- a/packages/cli/src/ui/commands/clearCommand.ts +++ b/packages/cli/src/ui/commands/clearCommand.ts @@ -20,8 +20,8 @@ export const clearCommand: SlashCommand = { kind: CommandKind.BUILT_IN, autoExecute: true, action: async (context, _args) => { - const geminiClient = context.services.agentContext?.geminiClient; - const config = context.services.agentContext?.config; + const geminiClient = context.services.config?.getGeminiClient(); + const config = context.services.config; // Fire SessionEnd hook before clearing const hookSystem = config?.getHookSystem(); diff --git a/packages/cli/src/ui/commands/compressCommand.test.ts b/packages/cli/src/ui/commands/compressCommand.test.ts index fd60b54354..5fd6f8dc6a 100644 --- a/packages/cli/src/ui/commands/compressCommand.test.ts +++ b/packages/cli/src/ui/commands/compressCommand.test.ts @@ -22,10 +22,11 @@ describe('compressCommand', () => { mockTryCompressChat = vi.fn(); context = createMockCommandContext({ services: { - agentContext: { - geminiClient: { - tryCompressChat: mockTryCompressChat, - } as unknown as GeminiClient, + config: { + getGeminiClient: () => + ({ + tryCompressChat: mockTryCompressChat, + }) as unknown as GeminiClient, }, }, }); diff --git a/packages/cli/src/ui/commands/compressCommand.ts b/packages/cli/src/ui/commands/compressCommand.ts index 6d53667010..a52e75ab32 100644 --- a/packages/cli/src/ui/commands/compressCommand.ts +++ b/packages/cli/src/ui/commands/compressCommand.ts @@ -39,11 +39,9 @@ export const compressCommand: SlashCommand = { try { ui.setPendingItem(pendingMessage); const promptId = `compress-${Date.now()}`; - const compressed = - await context.services.agentContext?.geminiClient?.tryCompressChat( - promptId, - true, - ); + const compressed = await context.services.config + ?.getGeminiClient() + ?.tryCompressChat(promptId, true); if (compressed) { ui.addItem( { diff --git a/packages/cli/src/ui/commands/copyCommand.test.ts b/packages/cli/src/ui/commands/copyCommand.test.ts index 6a1d36ca21..611162fe20 100644 --- a/packages/cli/src/ui/commands/copyCommand.test.ts +++ b/packages/cli/src/ui/commands/copyCommand.test.ts @@ -29,10 +29,10 @@ describe('copyCommand', () => { mockContext = createMockCommandContext({ services: { - agentContext: { - geminiClient: { + config: { + getGeminiClient: () => ({ getChat: mockGetChat, - }, + }), }, }, }); @@ -301,7 +301,7 @@ describe('copyCommand', () => { if (!copyCommand.action) throw new Error('Command has no action'); const nullConfigContext = createMockCommandContext({ - services: { agentContext: null }, + services: { config: null }, }); const result = await copyCommand.action(nullConfigContext, ''); diff --git a/packages/cli/src/ui/commands/copyCommand.ts b/packages/cli/src/ui/commands/copyCommand.ts index 746d6899a6..0c01b252ec 100644 --- a/packages/cli/src/ui/commands/copyCommand.ts +++ b/packages/cli/src/ui/commands/copyCommand.ts @@ -18,7 +18,7 @@ export const copyCommand: SlashCommand = { kind: CommandKind.BUILT_IN, autoExecute: true, action: async (context, _args): Promise => { - const chat = context.services.agentContext?.geminiClient?.getChat(); + const chat = context.services.config?.getGeminiClient()?.getChat(); const history = chat?.getHistory(); // Get the last message from the AI (model role) diff --git a/packages/cli/src/ui/commands/directoryCommand.test.tsx b/packages/cli/src/ui/commands/directoryCommand.test.tsx index 837bc696b7..bdfa6ac3a0 100644 --- a/packages/cli/src/ui/commands/directoryCommand.test.tsx +++ b/packages/cli/src/ui/commands/directoryCommand.test.tsx @@ -85,14 +85,11 @@ describe('directoryCommand', () => { getFileFilteringOptions: () => ({ ignore: [], include: [] }), setUserMemory: vi.fn(), setGeminiMdFileCount: vi.fn(), - get config() { - return this; - }, } as unknown as Config; mockContext = { services: { - agentContext: mockConfig, + config: mockConfig, settings: { merged: { memoryDiscoveryMaxDirs: 1000, diff --git a/packages/cli/src/ui/commands/directoryCommand.tsx b/packages/cli/src/ui/commands/directoryCommand.tsx index 4106efa97b..70206410de 100644 --- a/packages/cli/src/ui/commands/directoryCommand.tsx +++ b/packages/cli/src/ui/commands/directoryCommand.tsx @@ -60,7 +60,7 @@ async function finishAddingDirectories( } if (added.length > 0) { - const gemini = config.geminiClient; + const gemini = config.getGeminiClient(); if (gemini) { await gemini.addDirectoryContext(); @@ -110,9 +110,9 @@ export const directoryCommand: SlashCommand = { // Filter out existing directories let filteredSuggestions = suggestions; - if (context.services.agentContext?.config) { + if (context.services.config) { const workspaceContext = - context.services.agentContext.config.getWorkspaceContext(); + context.services.config.getWorkspaceContext(); const existingDirs = new Set( workspaceContext.getDirectories().map((dir) => path.resolve(dir)), ); @@ -144,11 +144,11 @@ export const directoryCommand: SlashCommand = { action: async (context: CommandContext, args: string) => { const { ui: { addItem }, - services: { agentContext, settings }, + services: { config, settings }, } = context; const [...rest] = args.split(' '); - if (!agentContext) { + if (!config) { addItem({ type: MessageType.ERROR, text: 'Configuration is not available.', @@ -156,7 +156,7 @@ export const directoryCommand: SlashCommand = { return; } - if (agentContext.config.isRestrictiveSandbox()) { + if (config.isRestrictiveSandbox()) { return { type: 'message' as const, messageType: 'error' as const, @@ -181,7 +181,7 @@ export const directoryCommand: SlashCommand = { const errors: string[] = []; const alreadyAdded: string[] = []; - const workspaceContext = agentContext.config.getWorkspaceContext(); + const workspaceContext = config.getWorkspaceContext(); const currentWorkspaceDirs = workspaceContext.getDirectories(); const pathsToProcess: string[] = []; @@ -252,7 +252,7 @@ export const directoryCommand: SlashCommand = { trustedDirs={added} errors={errors} finishAddingDirectories={finishAddingDirectories} - config={agentContext.config} + config={config} addItem={addItem} /> ), @@ -264,12 +264,7 @@ export const directoryCommand: SlashCommand = { errors.push(...result.errors); } - await finishAddingDirectories( - agentContext.config, - addItem, - added, - errors, - ); + await finishAddingDirectories(config, addItem, added, errors); return; }, }, @@ -280,16 +275,16 @@ export const directoryCommand: SlashCommand = { action: async (context: CommandContext) => { const { ui: { addItem }, - services: { agentContext }, + services: { config }, } = context; - if (!agentContext) { + if (!config) { addItem({ type: MessageType.ERROR, text: 'Configuration is not available.', }); return; } - const workspaceContext = agentContext.config.getWorkspaceContext(); + const workspaceContext = config.getWorkspaceContext(); const directories = workspaceContext.getDirectories(); const directoryList = directories.map((dir) => `- ${dir}`).join('\n'); addItem({ diff --git a/packages/cli/src/ui/commands/extensionsCommand.test.ts b/packages/cli/src/ui/commands/extensionsCommand.test.ts index 8f065438e2..d1c2ede5e8 100644 --- a/packages/cli/src/ui/commands/extensionsCommand.test.ts +++ b/packages/cli/src/ui/commands/extensionsCommand.test.ts @@ -161,16 +161,14 @@ describe('extensionsCommand', () => { mockContext = createMockCommandContext({ services: { - agentContext: { - config: { - getExtensions: mockGetExtensions, - getExtensionLoader: vi.fn().mockReturnValue(mockExtensionLoader), - getWorkingDir: () => '/test/dir', - reloadSkills: mockReloadSkills, - getAgentRegistry: vi.fn().mockReturnValue({ - reload: mockReloadAgents, - }), - }, + config: { + getExtensions: mockGetExtensions, + getExtensionLoader: vi.fn().mockReturnValue(mockExtensionLoader), + getWorkingDir: () => '/test/dir', + reloadSkills: mockReloadSkills, + getAgentRegistry: vi.fn().mockReturnValue({ + reload: mockReloadAgents, + }), }, }, ui: { @@ -710,14 +708,10 @@ describe('extensionsCommand', () => { size: 100, } as Stats); await linkAction!(mockContext, packageName); - expect(mockInstallExtension).toHaveBeenCalledWith( - { - source: packageName, - type: 'link', - }, - undefined, - undefined, - ); + expect(mockInstallExtension).toHaveBeenCalledWith({ + source: packageName, + type: 'link', + }); expect(mockContext.ui.addItem).toHaveBeenCalledWith({ type: MessageType.INFO, text: `Linking extension from "${packageName}"...`, @@ -737,14 +731,10 @@ describe('extensionsCommand', () => { } as Stats); await linkAction!(mockContext, packageName); - expect(mockInstallExtension).toHaveBeenCalledWith( - { - source: packageName, - type: 'link', - }, - undefined, - undefined, - ); + expect(mockInstallExtension).toHaveBeenCalledWith({ + source: packageName, + type: 'link', + }); expect(mockContext.ui.addItem).toHaveBeenCalledWith({ type: MessageType.ERROR, text: `Failed to link extension from "${packageName}": ${errorMessage}`, @@ -927,7 +917,7 @@ describe('extensionsCommand', () => { expect(restartAction).not.toBeNull(); mockRestartExtension = vi.fn(); - mockContext.services.agentContext!.config.getExtensionLoader = vi + mockContext.services.config!.getExtensionLoader = vi .fn() .mockImplementation(() => ({ getExtensions: mockGetExtensions, @@ -937,7 +927,7 @@ describe('extensionsCommand', () => { }); it('should show a message if no extensions are installed', async () => { - mockContext.services.agentContext!.config.getExtensionLoader = vi + mockContext.services.config!.getExtensionLoader = vi .fn() .mockImplementation(() => ({ getExtensions: () => [], @@ -1027,7 +1017,7 @@ describe('extensionsCommand', () => { }); it('shows an error if no extension loader is available', async () => { - mockContext.services.agentContext!.config.getExtensionLoader = vi.fn(); + mockContext.services.config!.getExtensionLoader = vi.fn(); await restartAction!(mockContext, '--all'); diff --git a/packages/cli/src/ui/commands/extensionsCommand.ts b/packages/cli/src/ui/commands/extensionsCommand.ts index aed7595389..8fe206bfc4 100644 --- a/packages/cli/src/ui/commands/extensionsCommand.ts +++ b/packages/cli/src/ui/commands/extensionsCommand.ts @@ -54,8 +54,8 @@ function showMessageIfNoExtensions( } async function listAction(context: CommandContext) { - const extensions = context.services.agentContext?.config - ? listExtensions(context.services.agentContext.config) + const extensions = context.services.config + ? listExtensions(context.services.config) : []; if (showMessageIfNoExtensions(context, extensions)) { @@ -88,8 +88,8 @@ function updateAction(context: CommandContext, args: string): Promise { (resolve) => (resolveUpdateComplete = resolve), ); - const extensions = context.services.agentContext?.config - ? listExtensions(context.services.agentContext.config) + const extensions = context.services.config + ? listExtensions(context.services.config) : []; if (showMessageIfNoExtensions(context, extensions)) { @@ -128,7 +128,7 @@ function updateAction(context: CommandContext, args: string): Promise { }, }); if (names?.length) { - const extensions = listExtensions(context.services.agentContext!.config); + const extensions = listExtensions(context.services.config!); for (const name of names) { const extension = extensions.find( (extension) => extension.name === name, @@ -156,8 +156,7 @@ async function restartAction( context: CommandContext, args: string, ): Promise { - const extensionLoader = - context.services.agentContext?.config.getExtensionLoader(); + const extensionLoader = context.services.config?.getExtensionLoader(); if (!extensionLoader) { context.ui.addItem({ type: MessageType.ERROR, @@ -236,8 +235,8 @@ async function restartAction( if (failures.length < extensionsToRestart.length) { try { - await context.services.agentContext?.config.reloadSkills(); - await context.services.agentContext?.config.getAgentRegistry()?.reload(); + await context.services.config?.reloadSkills(); + await context.services.config?.getAgentRegistry()?.reload(); } catch (error) { context.ui.addItem({ type: MessageType.ERROR, @@ -275,8 +274,7 @@ async function exploreAction( const useRegistryUI = settings.experimental?.extensionRegistry; if (useRegistryUI) { - const extensionManager = - context.services.agentContext?.config.getExtensionLoader(); + const extensionManager = context.services.config?.getExtensionLoader(); if (extensionManager instanceof ExtensionManager) { return { type: 'custom_dialog' as const, @@ -286,11 +284,6 @@ async function exploreAction( await installAction(context, extension.url, requestConsentOverride); context.ui.removeComponent(); }, - onLink: async (extension, requestConsentOverride) => { - debugLogger.log(`Linking extension: ${extension.extensionName}`); - await linkAction(context, extension.url, requestConsentOverride); - context.ui.removeComponent(); - }, onClose: () => context.ui.removeComponent(), extensionManager, }), @@ -338,8 +331,7 @@ function getEnableDisableContext( names: string[]; scope: SettingScope; } | null { - const extensionLoader = - context.services.agentContext?.config.getExtensionLoader(); + const extensionLoader = context.services.config?.getExtensionLoader(); if (!(extensionLoader instanceof ExtensionManager)) { debugLogger.error( `Cannot ${context.invocation?.name} extensions in this environment`, @@ -439,8 +431,7 @@ async function enableAction(context: CommandContext, args: string) { if (extension?.mcpServers) { const mcpEnablementManager = McpServerEnablementManager.getInstance(); - const mcpClientManager = - context.services.agentContext?.config.getMcpClientManager(); + const mcpClientManager = context.services.config?.getMcpClientManager(); const enabledServers = await mcpEnablementManager.autoEnableServers( Object.keys(extension.mcpServers ?? {}), ); @@ -472,8 +463,7 @@ async function installAction( args: string, requestConsentOverride?: (consent: string) => Promise, ) { - const extensionLoader = - context.services.agentContext?.config.getExtensionLoader(); + const extensionLoader = context.services.config?.getExtensionLoader(); if (!(extensionLoader instanceof ExtensionManager)) { debugLogger.error( `Cannot ${context.invocation?.name} extensions in this environment`, @@ -538,13 +528,8 @@ async function installAction( } } -async function linkAction( - context: CommandContext, - args: string, - requestConsentOverride?: (consent: string) => Promise, -) { - const extensionLoader = - context.services.agentContext?.config.getExtensionLoader(); +async function linkAction(context: CommandContext, args: string) { + const extensionLoader = context.services.config?.getExtensionLoader(); if (!(extensionLoader instanceof ExtensionManager)) { debugLogger.error( `Cannot ${context.invocation?.name} extensions in this environment`, @@ -591,11 +576,8 @@ async function linkAction( source: sourceFilepath, type: 'link', }; - const extension = await extensionLoader.installOrUpdateExtension( - installMetadata, - undefined, - requestConsentOverride, - ); + const extension = + await extensionLoader.installOrUpdateExtension(installMetadata); context.ui.addItem({ type: MessageType.INFO, text: `Extension "${extension.name}" linked successfully.`, @@ -611,8 +593,7 @@ async function linkAction( } async function uninstallAction(context: CommandContext, args: string) { - const extensionLoader = - context.services.agentContext?.config.getExtensionLoader(); + const extensionLoader = context.services.config?.getExtensionLoader(); if (!(extensionLoader instanceof ExtensionManager)) { debugLogger.error( `Cannot ${context.invocation?.name} extensions in this environment`, @@ -711,8 +692,7 @@ async function configAction(context: CommandContext, args: string) { } } - const extensionManager = - context.services.agentContext?.config.getExtensionLoader(); + const extensionManager = context.services.config?.getExtensionLoader(); if (!(extensionManager instanceof ExtensionManager)) { debugLogger.error( `Cannot ${context.invocation?.name} extensions in this environment`, @@ -749,7 +729,7 @@ export function completeExtensions( context: CommandContext, partialArg: string, ) { - let extensions = context.services.agentContext?.config.getExtensions() ?? []; + let extensions = context.services.config?.getExtensions() ?? []; if (context.invocation?.name === 'enable') { extensions = extensions.filter((ext) => !ext.isActive); diff --git a/packages/cli/src/ui/commands/hooksCommand.test.ts b/packages/cli/src/ui/commands/hooksCommand.test.ts index 0059f86105..930658e1ab 100644 --- a/packages/cli/src/ui/commands/hooksCommand.test.ts +++ b/packages/cli/src/ui/commands/hooksCommand.test.ts @@ -93,7 +93,7 @@ describe('hooksCommand', () => { // Create mock context with config and settings mockContext = createMockCommandContext({ services: { - agentContext: { config: mockConfig }, + config: mockConfig, settings: mockSettings, }, }); @@ -141,7 +141,7 @@ describe('hooksCommand', () => { it('should return error when config is not loaded', async () => { const contextWithoutConfig = createMockCommandContext({ services: { - agentContext: null, + config: null, }, }); @@ -225,7 +225,7 @@ describe('hooksCommand', () => { it('should return error when config is not loaded', async () => { const contextWithoutConfig = createMockCommandContext({ services: { - agentContext: null, + config: null, }, }); @@ -338,7 +338,7 @@ describe('hooksCommand', () => { it('should return error when config is not loaded', async () => { const contextWithoutConfig = createMockCommandContext({ services: { - agentContext: null, + config: null, }, }); @@ -470,7 +470,7 @@ describe('hooksCommand', () => { it('should return empty array when config is not available', () => { const contextWithoutConfig = createMockCommandContext({ services: { - agentContext: null, + config: null, }, }); @@ -567,7 +567,7 @@ describe('hooksCommand', () => { it('should return error when config is not loaded', async () => { const contextWithoutConfig = createMockCommandContext({ services: { - agentContext: null, + config: null, }, }); @@ -691,7 +691,7 @@ describe('hooksCommand', () => { it('should return error when config is not loaded', async () => { const contextWithoutConfig = createMockCommandContext({ services: { - agentContext: null, + config: null, }, }); diff --git a/packages/cli/src/ui/commands/hooksCommand.ts b/packages/cli/src/ui/commands/hooksCommand.ts index 4bdc9ead54..bc51f42037 100644 --- a/packages/cli/src/ui/commands/hooksCommand.ts +++ b/packages/cli/src/ui/commands/hooksCommand.ts @@ -27,8 +27,7 @@ import { HooksDialog } from '../components/HooksDialog.js'; function panelAction( context: CommandContext, ): MessageActionReturn | OpenCustomDialogActionReturn { - const agentContext = context.services.agentContext; - const config = agentContext?.config; + const { config } = context.services; if (!config) { return { type: 'message', @@ -56,8 +55,7 @@ async function enableAction( context: CommandContext, args: string, ): Promise { - const agentContext = context.services.agentContext; - const config = agentContext?.config; + const { config } = context.services; if (!config) { return { type: 'message', @@ -110,8 +108,7 @@ async function disableAction( context: CommandContext, args: string, ): Promise { - const agentContext = context.services.agentContext; - const config = agentContext?.config; + const { config } = context.services; if (!config) { return { type: 'message', @@ -166,8 +163,7 @@ function completeEnabledHookNames( context: CommandContext, partialArg: string, ): string[] { - const agentContext = context.services.agentContext; - const config = agentContext?.config; + const { config } = context.services; if (!config) return []; const hookSystem = config.getHookSystem(); @@ -187,8 +183,7 @@ function completeDisabledHookNames( context: CommandContext, partialArg: string, ): string[] { - const agentContext = context.services.agentContext; - const config = agentContext?.config; + const { config } = context.services; if (!config) return []; const hookSystem = config.getHookSystem(); @@ -214,8 +209,7 @@ function getHookDisplayName(hook: HookRegistryEntry): string { async function enableAllAction( context: CommandContext, ): Promise { - const agentContext = context.services.agentContext; - const config = agentContext?.config; + const { config } = context.services; if (!config) { return { type: 'message', @@ -286,8 +280,7 @@ async function enableAllAction( async function disableAllAction( context: CommandContext, ): Promise { - const agentContext = context.services.agentContext; - const config = agentContext?.config; + const { config } = context.services; if (!config) { return { type: 'message', diff --git a/packages/cli/src/ui/commands/ideCommand.test.ts b/packages/cli/src/ui/commands/ideCommand.test.ts index 2cb880feaa..1ddb55dc89 100644 --- a/packages/cli/src/ui/commands/ideCommand.test.ts +++ b/packages/cli/src/ui/commands/ideCommand.test.ts @@ -60,12 +60,10 @@ describe('ideCommand', () => { settings: { setValue: vi.fn(), }, - agentContext: { - config: { - getIdeMode: vi.fn(), - setIdeMode: vi.fn(), - getUsageStatisticsEnabled: vi.fn().mockReturnValue(false), - }, + config: { + getIdeMode: vi.fn(), + setIdeMode: vi.fn(), + getUsageStatisticsEnabled: vi.fn().mockReturnValue(false), }, }, } as unknown as CommandContext; diff --git a/packages/cli/src/ui/commands/ideCommand.ts b/packages/cli/src/ui/commands/ideCommand.ts index df26fdf471..1f726f90e5 100644 --- a/packages/cli/src/ui/commands/ideCommand.ts +++ b/packages/cli/src/ui/commands/ideCommand.ts @@ -217,13 +217,9 @@ export const ideCommand = async (): Promise => { ); // Poll for up to 5 seconds for the extension to activate. for (let i = 0; i < 10; i++) { - await setIdeModeAndSyncConnection( - context.services.agentContext!.config, - true, - { - logToConsole: false, - }, - ); + await setIdeModeAndSyncConnection(context.services.config!, true, { + logToConsole: false, + }); if ( ideClient.getConnectionStatus().status === IDEConnectionStatus.Connected @@ -266,10 +262,7 @@ export const ideCommand = async (): Promise => { 'ide.enabled', true, ); - await setIdeModeAndSyncConnection( - context.services.agentContext!.config, - true, - ); + await setIdeModeAndSyncConnection(context.services.config!, true); const { messageType, content } = getIdeStatusMessage(ideClient); context.ui.addItem( { @@ -292,10 +285,7 @@ export const ideCommand = async (): Promise => { 'ide.enabled', false, ); - await setIdeModeAndSyncConnection( - context.services.agentContext!.config, - false, - ); + await setIdeModeAndSyncConnection(context.services.config!, false); const { messageType, content } = getIdeStatusMessage(ideClient); context.ui.addItem( { diff --git a/packages/cli/src/ui/commands/initCommand.test.ts b/packages/cli/src/ui/commands/initCommand.test.ts index 0e4f24a1fe..62991c7610 100644 --- a/packages/cli/src/ui/commands/initCommand.test.ts +++ b/packages/cli/src/ui/commands/initCommand.test.ts @@ -31,10 +31,8 @@ describe('initCommand', () => { // Create a fresh mock context for each test mockContext = createMockCommandContext({ services: { - agentContext: { - config: { - getTargetDir: () => targetDir, - }, + config: { + getTargetDir: () => targetDir, }, }, }); @@ -96,7 +94,7 @@ describe('initCommand', () => { // Arrange: Create a context without config const noConfigContext = createMockCommandContext(); if (noConfigContext.services) { - noConfigContext.services.agentContext = null; + noConfigContext.services.config = null; } // Act: Run the command's action diff --git a/packages/cli/src/ui/commands/initCommand.ts b/packages/cli/src/ui/commands/initCommand.ts index d4d8040622..ea0d1ea0c6 100644 --- a/packages/cli/src/ui/commands/initCommand.ts +++ b/packages/cli/src/ui/commands/initCommand.ts @@ -23,14 +23,14 @@ export const initCommand: SlashCommand = { context: CommandContext, _args: string, ): Promise => { - if (!context.services.agentContext?.config) { + if (!context.services.config) { return { type: 'message', messageType: 'error', content: 'Configuration not available.', }; } - const targetDir = context.services.agentContext.config.getTargetDir(); + const targetDir = context.services.config.getTargetDir(); const geminiMdPath = path.join(targetDir, 'GEMINI.md'); const result = performInit(fs.existsSync(geminiMdPath)); diff --git a/packages/cli/src/ui/commands/mcpCommand.test.ts b/packages/cli/src/ui/commands/mcpCommand.test.ts index 9a3254fbae..3acace0774 100644 --- a/packages/cli/src/ui/commands/mcpCommand.test.ts +++ b/packages/cli/src/ui/commands/mcpCommand.test.ts @@ -119,10 +119,7 @@ describe('mcpCommand', () => { mockContext = createMockCommandContext({ services: { - agentContext: { - config: mockConfig, - toolRegistry: mockConfig.getToolRegistry(), - }, + config: mockConfig, }, }); }); @@ -135,7 +132,7 @@ describe('mcpCommand', () => { it('should show an error if config is not available', async () => { const contextWithoutConfig = createMockCommandContext({ services: { - agentContext: null, + config: null, }, }); @@ -149,8 +146,7 @@ describe('mcpCommand', () => { }); it('should show an error if tool registry is not available', async () => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (mockContext.services.agentContext as any).toolRegistry = undefined; + mockConfig.getToolRegistry = vi.fn().mockReturnValue(undefined); const result = await mcpCommand.action!(mockContext, ''); @@ -200,13 +196,9 @@ describe('mcpCommand', () => { ...mockServer3Tools, ]; - const mockToolRegistry = { + mockConfig.getToolRegistry = vi.fn().mockReturnValue({ getAllTools: vi.fn().mockReturnValue(allTools), - }; - mockConfig.getToolRegistry = vi.fn().mockReturnValue(mockToolRegistry); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (mockContext.services.agentContext as any).toolRegistry = - mockToolRegistry; + }); const resourcesByServer: Record< string, diff --git a/packages/cli/src/ui/commands/mcpCommand.ts b/packages/cli/src/ui/commands/mcpCommand.ts index 0fb6b5a1dd..9ccaaf4273 100644 --- a/packages/cli/src/ui/commands/mcpCommand.ts +++ b/packages/cli/src/ui/commands/mcpCommand.ts @@ -42,8 +42,8 @@ const authCommand: SlashCommand = { args: string, ): Promise => { const serverName = args.trim(); - const agentContext = context.services.agentContext; - const config = agentContext?.config; + const { config } = context.services; + if (!config) { return { type: 'message', @@ -138,7 +138,7 @@ const authCommand: SlashCommand = { await mcpClientManager.restartServer(serverName); } // Update the client with the new tools - const geminiClient = context.services.agentContext?.geminiClient; + const geminiClient = config.getGeminiClient(); if (geminiClient?.isInitialized()) { await geminiClient.setTools(); } @@ -162,8 +162,7 @@ const authCommand: SlashCommand = { } }, completion: async (context: CommandContext, partialArg: string) => { - const agentContext = context.services.agentContext; - const config = agentContext?.config; + const { config } = context.services; if (!config) return []; const mcpServers = config.getMcpClientManager()?.getMcpServers() || {}; @@ -178,8 +177,7 @@ const listAction = async ( showDescriptions = false, showSchema = false, ): Promise => { - const agentContext = context.services.agentContext; - const config = agentContext?.config; + const { config } = context.services; if (!config) { return { type: 'message', @@ -190,7 +188,7 @@ const listAction = async ( config.setUserInteractedWithMcp(); - const toolRegistry = agentContext.toolRegistry; + const toolRegistry = config.getToolRegistry(); if (!toolRegistry) { return { type: 'message', @@ -336,8 +334,7 @@ const reloadCommand: SlashCommand = { action: async ( context: CommandContext, ): Promise => { - const agentContext = context.services.agentContext; - const config = agentContext?.config; + const { config } = context.services; if (!config) { return { type: 'message', @@ -363,7 +360,7 @@ const reloadCommand: SlashCommand = { await mcpClientManager.restart(); // Update the client with the new tools - const geminiClient = agentContext.geminiClient; + const geminiClient = config.getGeminiClient(); if (geminiClient?.isInitialized()) { await geminiClient.setTools(); } @@ -380,8 +377,7 @@ async function handleEnableDisable( args: string, enable: boolean, ): Promise { - const agentContext = context.services.agentContext; - const config = agentContext?.config; + const { config } = context.services; if (!config) { return { type: 'message', @@ -469,8 +465,8 @@ async function handleEnableDisable( ); await mcpClientManager.restart(); } - if (agentContext.geminiClient?.isInitialized()) - await agentContext.geminiClient.setTools(); + if (config.getGeminiClient()?.isInitialized()) + await config.getGeminiClient().setTools(); context.ui.reloadCommands(); return { type: 'message', messageType: 'info', content: msg }; @@ -481,8 +477,7 @@ async function getEnablementCompletion( partialArg: string, showEnabled: boolean, ): Promise { - const agentContext = context.services.agentContext; - const config = agentContext?.config; + const { config } = context.services; if (!config) return []; const servers = Object.keys( config.getMcpClientManager()?.getMcpServers() || {}, diff --git a/packages/cli/src/ui/commands/memoryCommand.test.ts b/packages/cli/src/ui/commands/memoryCommand.test.ts index f02393bef2..4e70054fac 100644 --- a/packages/cli/src/ui/commands/memoryCommand.test.ts +++ b/packages/cli/src/ui/commands/memoryCommand.test.ts @@ -102,12 +102,10 @@ describe('memoryCommand', () => { mockContext = createMockCommandContext({ services: { - agentContext: { - config: { - getUserMemory: mockGetUserMemory, - getGeminiMdFileCount: mockGetGeminiMdFileCount, - getExtensionLoader: () => new SimpleExtensionLoader([]), - }, + config: { + getUserMemory: mockGetUserMemory, + getGeminiMdFileCount: mockGetGeminiMdFileCount, + getExtensionLoader: () => new SimpleExtensionLoader([]), }, }, }); @@ -252,7 +250,7 @@ describe('memoryCommand', () => { mockContext = createMockCommandContext({ services: { - agentContext: { config: mockConfig }, + config: mockConfig, settings: { merged: { memoryDiscoveryMaxDirs: 1000, @@ -270,7 +268,7 @@ describe('memoryCommand', () => { if (!reloadCommand.action) throw new Error('Command has no action'); // Enable JIT in mock config - const config = mockContext.services.agentContext?.config; + const config = mockContext.services.config; if (!config) throw new Error('Config is undefined'); vi.mocked(config.isJitContextEnabled).mockReturnValue(true); @@ -372,7 +370,7 @@ describe('memoryCommand', () => { if (!reloadCommand.action) throw new Error('Command has no action'); const nullConfigContext = createMockCommandContext({ - services: { agentContext: null }, + services: { config: null }, }); await expect( @@ -415,10 +413,8 @@ describe('memoryCommand', () => { }); mockContext = createMockCommandContext({ services: { - agentContext: { - config: { - getGeminiMdFilePaths: mockGetGeminiMdfilePaths, - }, + config: { + getGeminiMdFilePaths: mockGetGeminiMdfilePaths, }, }, }); diff --git a/packages/cli/src/ui/commands/memoryCommand.ts b/packages/cli/src/ui/commands/memoryCommand.ts index 145fbae9c3..44c632c67a 100644 --- a/packages/cli/src/ui/commands/memoryCommand.ts +++ b/packages/cli/src/ui/commands/memoryCommand.ts @@ -29,7 +29,7 @@ export const memoryCommand: SlashCommand = { kind: CommandKind.BUILT_IN, autoExecute: true, action: async (context) => { - const config = context.services.agentContext?.config; + const config = context.services.config; if (!config) return; const result = showMemory(config); @@ -81,7 +81,7 @@ export const memoryCommand: SlashCommand = { ); try { - const config = context.services.agentContext?.config; + const config = context.services.config; if (config) { const result = await refreshMemory(config); @@ -111,7 +111,7 @@ export const memoryCommand: SlashCommand = { kind: CommandKind.BUILT_IN, autoExecute: true, action: async (context) => { - const config = context.services.agentContext?.config; + const config = context.services.config; if (!config) return; const result = listMemoryFiles(config); diff --git a/packages/cli/src/ui/commands/modelCommand.test.ts b/packages/cli/src/ui/commands/modelCommand.test.ts index aa2359d8fa..89938eb037 100644 --- a/packages/cli/src/ui/commands/modelCommand.test.ts +++ b/packages/cli/src/ui/commands/modelCommand.test.ts @@ -37,11 +37,8 @@ describe('modelCommand', () => { } const mockRefreshUserQuota = vi.fn(); - mockContext.services.agentContext = { + mockContext.services.config = { refreshUserQuota: mockRefreshUserQuota, - get config() { - return this; - }, } as unknown as Config; await modelCommand.action(mockContext, ''); @@ -69,11 +66,8 @@ describe('modelCommand', () => { (c) => c.name === 'manage', ); const mockRefreshUserQuota = vi.fn(); - mockContext.services.agentContext = { + mockContext.services.config = { refreshUserQuota: mockRefreshUserQuota, - get config() { - return this; - }, } as unknown as Config; await manageCommand!.action!(mockContext, ''); @@ -90,7 +84,7 @@ describe('modelCommand', () => { expect(setCommand).toBeDefined(); const mockSetModel = vi.fn(); - mockContext.services.agentContext = { + mockContext.services.config = { setModel: mockSetModel, getHasAccessToPreviewModel: vi.fn().mockReturnValue(true), getUserId: vi.fn().mockReturnValue('test-user'), @@ -104,9 +98,6 @@ describe('modelCommand', () => { getPolicyEngine: vi.fn().mockReturnValue({ getApprovalMode: vi.fn().mockReturnValue('auto'), }), - get config() { - return this; - }, } as unknown as Config; await setCommand!.action!(mockContext, 'gemini-pro'); @@ -125,7 +116,7 @@ describe('modelCommand', () => { (c) => c.name === 'set', ); const mockSetModel = vi.fn(); - mockContext.services.agentContext = { + mockContext.services.config = { setModel: mockSetModel, getHasAccessToPreviewModel: vi.fn().mockReturnValue(true), getUserId: vi.fn().mockReturnValue('test-user'), @@ -139,9 +130,6 @@ describe('modelCommand', () => { getPolicyEngine: vi.fn().mockReturnValue({ getApprovalMode: vi.fn().mockReturnValue('auto'), }), - get config() { - return this; - }, } as unknown as Config; await setCommand!.action!(mockContext, 'gemini-pro --persist'); diff --git a/packages/cli/src/ui/commands/modelCommand.ts b/packages/cli/src/ui/commands/modelCommand.ts index facaba81ba..ead7e521c5 100644 --- a/packages/cli/src/ui/commands/modelCommand.ts +++ b/packages/cli/src/ui/commands/modelCommand.ts @@ -34,10 +34,10 @@ const setModelCommand: SlashCommand = { const modelName = parts[0]; const persist = parts.includes('--persist'); - if (context.services.agentContext?.config) { - context.services.agentContext.config.setModel(modelName, !persist); + if (context.services.config) { + context.services.config.setModel(modelName, !persist); const event = new ModelSlashCommandEvent(modelName); - logModelSlashCommand(context.services.agentContext.config, event); + logModelSlashCommand(context.services.config, event); context.ui.addItem({ type: MessageType.INFO, @@ -53,8 +53,8 @@ const manageModelCommand: SlashCommand = { kind: CommandKind.BUILT_IN, autoExecute: true, action: async (context: CommandContext) => { - if (context.services.agentContext?.config) { - await context.services.agentContext.config.refreshUserQuota(); + if (context.services.config) { + await context.services.config.refreshUserQuota(); } return { type: 'dialog', diff --git a/packages/cli/src/ui/commands/oncallCommand.tsx b/packages/cli/src/ui/commands/oncallCommand.tsx index 23236ea49c..ba4cbe4835 100644 --- a/packages/cli/src/ui/commands/oncallCommand.tsx +++ b/packages/cli/src/ui/commands/oncallCommand.tsx @@ -24,8 +24,7 @@ export const oncallCommand: SlashCommand = { kind: CommandKind.BUILT_IN, autoExecute: true, action: async (context, args): Promise => { - const agentContext = context.services.agentContext; - const config = agentContext?.config; + const { config } = context.services; if (!config) { throw new Error('Config not available'); } @@ -57,8 +56,7 @@ export const oncallCommand: SlashCommand = { kind: CommandKind.BUILT_IN, autoExecute: true, action: async (context, args): Promise => { - const agentContext = context.services.agentContext; - const config = agentContext?.config; + const { config } = context.services; if (!config) { throw new Error('Config not available'); } diff --git a/packages/cli/src/ui/commands/planCommand.test.ts b/packages/cli/src/ui/commands/planCommand.test.ts index 49c00ce8bd..fab1267b17 100644 --- a/packages/cli/src/ui/commands/planCommand.test.ts +++ b/packages/cli/src/ui/commands/planCommand.test.ts @@ -52,16 +52,14 @@ describe('planCommand', () => { beforeEach(() => { mockContext = createMockCommandContext({ services: { - agentContext: { - config: { - isPlanEnabled: vi.fn(), - setApprovalMode: vi.fn(), - getApprovedPlanPath: vi.fn(), - getApprovalMode: vi.fn(), - getFileSystemService: vi.fn(), - storage: { - getPlansDir: vi.fn().mockReturnValue('/mock/plans/dir'), - }, + config: { + isPlanEnabled: vi.fn(), + setApprovalMode: vi.fn(), + getApprovedPlanPath: vi.fn(), + getApprovalMode: vi.fn(), + getFileSystemService: vi.fn(), + storage: { + getPlansDir: vi.fn().mockReturnValue('/mock/plans/dir'), }, }, }, @@ -85,19 +83,17 @@ describe('planCommand', () => { }); it('should switch to plan mode if enabled', async () => { - vi.mocked( - mockContext.services.agentContext!.config.isPlanEnabled, - ).mockReturnValue(true); - vi.mocked( - mockContext.services.agentContext!.config.getApprovedPlanPath, - ).mockReturnValue(undefined); + vi.mocked(mockContext.services.config!.isPlanEnabled).mockReturnValue(true); + vi.mocked(mockContext.services.config!.getApprovedPlanPath).mockReturnValue( + undefined, + ); if (!planCommand.action) throw new Error('Action missing'); await planCommand.action(mockContext, ''); - expect( - mockContext.services.agentContext!.config.setApprovalMode, - ).toHaveBeenCalledWith(ApprovalMode.PLAN); + expect(mockContext.services.config!.setApprovalMode).toHaveBeenCalledWith( + ApprovalMode.PLAN, + ); expect(coreEvents.emitFeedback).toHaveBeenCalledWith( 'info', 'Switched to Plan Mode.', @@ -106,12 +102,10 @@ describe('planCommand', () => { it('should display the approved plan from config', async () => { const mockPlanPath = '/mock/plans/dir/approved-plan.md'; - vi.mocked( - mockContext.services.agentContext!.config.isPlanEnabled, - ).mockReturnValue(true); - vi.mocked( - mockContext.services.agentContext!.config.getApprovedPlanPath, - ).mockReturnValue(mockPlanPath); + vi.mocked(mockContext.services.config!.isPlanEnabled).mockReturnValue(true); + vi.mocked(mockContext.services.config!.getApprovedPlanPath).mockReturnValue( + mockPlanPath, + ); vi.mocked(processSingleFileContent).mockResolvedValue({ llmContent: '# Approved Plan Content', returnDisplay: '# Approved Plan Content', @@ -134,7 +128,7 @@ describe('planCommand', () => { it('should copy the approved plan to clipboard', async () => { const mockPlanPath = '/mock/plans/dir/approved-plan.md'; vi.mocked( - mockContext.services.agentContext!.config.getApprovedPlanPath, + mockContext.services.config!.getApprovedPlanPath, ).mockReturnValue(mockPlanPath); vi.mocked(readFileWithEncoding).mockResolvedValue('# Plan Content'); @@ -155,7 +149,7 @@ describe('planCommand', () => { it('should warn if no approved plan is found', async () => { vi.mocked( - mockContext.services.agentContext!.config.getApprovedPlanPath, + mockContext.services.config!.getApprovedPlanPath, ).mockReturnValue(undefined); const copySubCommand = planCommand.subCommands?.find( diff --git a/packages/cli/src/ui/commands/planCommand.ts b/packages/cli/src/ui/commands/planCommand.ts index c38d021d90..cfa3f9433e 100644 --- a/packages/cli/src/ui/commands/planCommand.ts +++ b/packages/cli/src/ui/commands/planCommand.ts @@ -22,7 +22,7 @@ import * as path from 'node:path'; import { copyToClipboard } from '../utils/commandUtils.js'; async function copyAction(context: CommandContext) { - const config = context.services.agentContext?.config; + const config = context.services.config; if (!config) { debugLogger.debug('Plan copy command: config is not available in context'); return; @@ -53,7 +53,7 @@ export const planCommand: SlashCommand = { kind: CommandKind.BUILT_IN, autoExecute: false, action: async (context) => { - const config = context.services.agentContext?.config; + const config = context.services.config; if (!config) { debugLogger.debug('Plan command: config is not available in context'); return; diff --git a/packages/cli/src/ui/commands/policiesCommand.test.ts b/packages/cli/src/ui/commands/policiesCommand.test.ts index 929b528290..554d5cd53d 100644 --- a/packages/cli/src/ui/commands/policiesCommand.test.ts +++ b/packages/cli/src/ui/commands/policiesCommand.test.ts @@ -32,7 +32,7 @@ describe('policiesCommand', () => { describe('list subcommand', () => { it('should show error if config is missing', async () => { - mockContext.services.agentContext = null; + mockContext.services.config = null; const listCommand = policiesCommand.subCommands![0]; await listCommand.action!(mockContext, ''); @@ -50,11 +50,8 @@ describe('policiesCommand', () => { const mockPolicyEngine = { getRules: vi.fn().mockReturnValue([]), }; - mockContext.services.agentContext = { + mockContext.services.config = { getPolicyEngine: vi.fn().mockReturnValue(mockPolicyEngine), - get config() { - return this; - }, } as unknown as Config; const listCommand = policiesCommand.subCommands![0]; @@ -88,11 +85,8 @@ describe('policiesCommand', () => { const mockPolicyEngine = { getRules: vi.fn().mockReturnValue(mockRules), }; - mockContext.services.agentContext = { + mockContext.services.config = { getPolicyEngine: vi.fn().mockReturnValue(mockPolicyEngine), - get config() { - return this; - }, } as unknown as Config; const listCommand = policiesCommand.subCommands![0]; @@ -116,9 +110,7 @@ describe('policiesCommand', () => { expect(content).toContain( '### Yolo Mode Policies (combined with normal mode policies)', ); - expect(content).toContain( - '### Plan Mode Policies (combined with normal mode policies)', - ); + expect(content).toContain('### Plan Mode Policies'); expect(content).toContain( '**DENY** tool: `dangerousTool` [Priority: 10]', ); @@ -150,11 +142,8 @@ describe('policiesCommand', () => { const mockPolicyEngine = { getRules: vi.fn().mockReturnValue(mockRules), }; - mockContext.services.agentContext = { + mockContext.services.config = { getPolicyEngine: vi.fn().mockReturnValue(mockPolicyEngine), - get config() { - return this; - }, } as unknown as Config; const listCommand = policiesCommand.subCommands![0]; @@ -164,9 +153,7 @@ describe('policiesCommand', () => { const content = (call[0] as { text: string }).text; // Plan-only rules appear under Plan Mode section - expect(content).toContain( - '### Plan Mode Policies (combined with normal mode policies)', - ); + expect(content).toContain('### Plan Mode Policies'); // glob ALLOW is plan-only, should appear in plan section expect(content).toContain('**ALLOW** tool: `glob` [Priority: 70]'); // shell ALLOW has no modes (applies to all), appears in normal section diff --git a/packages/cli/src/ui/commands/policiesCommand.ts b/packages/cli/src/ui/commands/policiesCommand.ts index c6f3b1e1e1..f4bd13de28 100644 --- a/packages/cli/src/ui/commands/policiesCommand.ts +++ b/packages/cli/src/ui/commands/policiesCommand.ts @@ -51,8 +51,7 @@ const listPoliciesCommand: SlashCommand = { kind: CommandKind.BUILT_IN, autoExecute: true, action: async (context) => { - const agentContext = context.services.agentContext; - const config = agentContext?.config; + const { config } = context.services; if (!config) { context.ui.addItem( { @@ -100,10 +99,7 @@ const listPoliciesCommand: SlashCommand = { 'Yolo Mode Policies (combined with normal mode policies)', uniqueYolo, ); - content += formatSection( - 'Plan Mode Policies (combined with normal mode policies)', - uniquePlan, - ); + content += formatSection('Plan Mode Policies', uniquePlan); context.ui.addItem( { diff --git a/packages/cli/src/ui/commands/restoreCommand.test.ts b/packages/cli/src/ui/commands/restoreCommand.test.ts index a2f29ca5b9..2a5def5c42 100644 --- a/packages/cli/src/ui/commands/restoreCommand.test.ts +++ b/packages/cli/src/ui/commands/restoreCommand.test.ts @@ -47,17 +47,14 @@ describe('restoreCommand', () => { getProjectTempCheckpointsDir: vi.fn().mockReturnValue(checkpointsDir), getProjectTempDir: vi.fn().mockReturnValue(geminiTempDir), }, - geminiClient: { + getGeminiClient: vi.fn().mockReturnValue({ setHistory: mockSetHistory, - }, - get config() { - return this; - }, + }), } as unknown as Config; mockContext = createMockCommandContext({ services: { - agentContext: mockConfig, + config: mockConfig, git: mockGitService, }, }); diff --git a/packages/cli/src/ui/commands/restoreCommand.ts b/packages/cli/src/ui/commands/restoreCommand.ts index cf18836c20..3051588e7c 100644 --- a/packages/cli/src/ui/commands/restoreCommand.ts +++ b/packages/cli/src/ui/commands/restoreCommand.ts @@ -37,11 +37,10 @@ async function restoreAction( args: string, ): Promise { const { services, ui } = context; - const { agentContext, git: gitService } = services; + const { config, git: gitService } = services; const { addItem, loadHistory } = ui; - const checkpointDir = - agentContext?.config.storage.getProjectTempCheckpointsDir(); + const checkpointDir = config?.storage.getProjectTempCheckpointsDir(); if (!checkpointDir) { return { @@ -117,7 +116,7 @@ async function restoreAction( } else if (action.type === 'load_history' && loadHistory) { loadHistory(action.history); if (action.clientHistory) { - agentContext!.geminiClient?.setHistory(action.clientHistory); + config?.getGeminiClient()?.setHistory(action.clientHistory); } } } @@ -141,9 +140,8 @@ async function completion( _partialArg: string, ): Promise { const { services } = context; - const { agentContext } = services; - const checkpointDir = - agentContext?.config.storage.getProjectTempCheckpointsDir(); + const { config } = services; + const checkpointDir = config?.storage.getProjectTempCheckpointsDir(); if (!checkpointDir) { return []; } diff --git a/packages/cli/src/ui/commands/rewindCommand.test.tsx b/packages/cli/src/ui/commands/rewindCommand.test.tsx index d93d365a3e..529991b07f 100644 --- a/packages/cli/src/ui/commands/rewindCommand.test.tsx +++ b/packages/cli/src/ui/commands/rewindCommand.test.tsx @@ -97,17 +97,15 @@ describe('rewindCommand', () => { mockContext = createMockCommandContext({ services: { - agentContext: { - geminiClient: { + config: { + getGeminiClient: () => ({ getChatRecordingService: mockGetChatRecordingService, setHistory: mockSetHistory, sendMessageStream: mockSendMessageStream, - }, - config: { - getSessionId: () => 'test-session-id', - getContextManager: () => ({ refresh: mockResetContext }), - getProjectRoot: mockGetProjectRoot, - }, + }), + getSessionId: () => 'test-session-id', + getContextManager: () => ({ refresh: mockResetContext }), + getProjectRoot: mockGetProjectRoot, }, }, ui: { @@ -295,12 +293,7 @@ describe('rewindCommand', () => { it('should fail if client is not initialized', () => { const context = createMockCommandContext({ services: { - agentContext: { - geminiClient: undefined, - get config() { - return this; - }, - }, + config: { getGeminiClient: () => undefined }, }, }) as unknown as CommandContext; @@ -316,11 +309,8 @@ describe('rewindCommand', () => { it('should fail if recording service is unavailable', () => { const context = createMockCommandContext({ services: { - agentContext: { - geminiClient: { getChatRecordingService: () => undefined }, - get config() { - return this; - }, + config: { + getGeminiClient: () => ({ getChatRecordingService: () => undefined }), }, }, }) as unknown as CommandContext; diff --git a/packages/cli/src/ui/commands/rewindCommand.tsx b/packages/cli/src/ui/commands/rewindCommand.tsx index c4e0284d0f..c4af3e845d 100644 --- a/packages/cli/src/ui/commands/rewindCommand.tsx +++ b/packages/cli/src/ui/commands/rewindCommand.tsx @@ -61,7 +61,7 @@ async function rewindConversation( client.setHistory(clientHistory as Content[]); // Reset context manager as we are rewinding history - await context.services.agentContext?.config.getContextManager()?.refresh(); + await context.services.config?.getContextManager()?.refresh(); // Update UI History // We generate IDs based on index for the rewind history @@ -94,8 +94,7 @@ export const rewindCommand: SlashCommand = { description: 'Jump back to a specific message and restart the conversation', kind: CommandKind.BUILT_IN, action: (context) => { - const agentContext = context.services.agentContext; - const config = agentContext?.config; + const config = context.services.config; if (!config) return { type: 'message', @@ -103,7 +102,7 @@ export const rewindCommand: SlashCommand = { content: 'Config not found', }; - const client = agentContext.geminiClient; + const client = config.getGeminiClient(); if (!client) return { type: 'message', diff --git a/packages/cli/src/ui/commands/setupGithubCommand.ts b/packages/cli/src/ui/commands/setupGithubCommand.ts index afc9b7210e..c68dd5cb88 100644 --- a/packages/cli/src/ui/commands/setupGithubCommand.ts +++ b/packages/cli/src/ui/commands/setupGithubCommand.ts @@ -230,7 +230,7 @@ export const setupGithubCommand: SlashCommand = { } // Get the latest release tag from GitHub - const proxy = context?.services?.agentContext?.config.getProxy(); + const proxy = context?.services?.config?.getProxy(); const releaseTag = await getLatestGitHubRelease(proxy); const readmeUrl = `https://github.com/google-github-actions/run-gemini-cli/blob/${releaseTag}/README.md#quick-start`; diff --git a/packages/cli/src/ui/commands/skillsCommand.test.ts b/packages/cli/src/ui/commands/skillsCommand.test.ts index 120ba01ed7..89f690e143 100644 --- a/packages/cli/src/ui/commands/skillsCommand.test.ts +++ b/packages/cli/src/ui/commands/skillsCommand.test.ts @@ -68,7 +68,7 @@ describe('skillsCommand', () => { ]; context = createMockCommandContext({ services: { - agentContext: { + config: { getSkillManager: vi.fn().mockReturnValue({ getAllSkills: vi.fn().mockReturnValue(skills), getSkills: vi.fn().mockReturnValue(skills), @@ -80,9 +80,6 @@ describe('skillsCommand', () => { ), }), getContentGenerator: vi.fn(), - get config() { - return this; - }, } as unknown as Config, settings: { merged: createTestMergedSettings({ skills: { disabled: [] } }), @@ -165,8 +162,7 @@ describe('skillsCommand', () => { }); it('should filter built-in skills by default and show them with "all"', async () => { - const skillManager = - context.services.agentContext!.config.getSkillManager(); + const skillManager = context.services.config!.getSkillManager(); const mockSkills = [ { name: 'regular', @@ -456,8 +452,7 @@ describe('skillsCommand', () => { }); it('should show error if skills are disabled by admin during disable', async () => { - const skillManager = - context.services.agentContext!.config.getSkillManager(); + const skillManager = context.services.config!.getSkillManager(); vi.mocked(skillManager.isAdminEnabled).mockReturnValue(false); const disableCmd = skillsCommand.subCommands!.find( @@ -475,8 +470,7 @@ describe('skillsCommand', () => { }); it('should show error if skills are disabled by admin during enable', async () => { - const skillManager = - context.services.agentContext!.config.getSkillManager(); + const skillManager = context.services.config!.getSkillManager(); vi.mocked(skillManager.isAdminEnabled).mockReturnValue(false); const enableCmd = skillsCommand.subCommands!.find( @@ -503,7 +497,8 @@ describe('skillsCommand', () => { const reloadSkillsMock = vi.fn().mockImplementation(async () => { await new Promise((resolve) => setTimeout(resolve, 200)); }); - context.services.agentContext!.config.reloadSkills = reloadSkillsMock; + // @ts-expect-error Mocking reloadSkills + context.services.config.reloadSkills = reloadSkillsMock; const actionPromise = reloadCmd.action!(context, ''); @@ -542,15 +537,15 @@ describe('skillsCommand', () => { (s) => s.name === 'reload', )!; const reloadSkillsMock = vi.fn().mockImplementation(async () => { - const skillManager = - context.services.agentContext!.config.getSkillManager(); + const skillManager = context.services.config!.getSkillManager(); vi.mocked(skillManager.getSkills).mockReturnValue([ { name: 'skill1' }, { name: 'skill2' }, { name: 'skill3' }, ] as SkillDefinition[]); }); - context.services.agentContext!.config.reloadSkills = reloadSkillsMock; + // @ts-expect-error Mocking reloadSkills + context.services.config.reloadSkills = reloadSkillsMock; await reloadCmd.action!(context, ''); @@ -567,13 +562,13 @@ describe('skillsCommand', () => { (s) => s.name === 'reload', )!; const reloadSkillsMock = vi.fn().mockImplementation(async () => { - const skillManager = - context.services.agentContext!.config.getSkillManager(); + const skillManager = context.services.config!.getSkillManager(); vi.mocked(skillManager.getSkills).mockReturnValue([ { name: 'skill1' }, ] as SkillDefinition[]); }); - context.services.agentContext!.config.reloadSkills = reloadSkillsMock; + // @ts-expect-error Mocking reloadSkills + context.services.config.reloadSkills = reloadSkillsMock; await reloadCmd.action!(context, ''); @@ -590,14 +585,14 @@ describe('skillsCommand', () => { (s) => s.name === 'reload', )!; const reloadSkillsMock = vi.fn().mockImplementation(async () => { - const skillManager = - context.services.agentContext!.config.getSkillManager(); + const skillManager = context.services.config!.getSkillManager(); vi.mocked(skillManager.getSkills).mockReturnValue([ { name: 'skill2' }, // skill1 removed, skill3 added { name: 'skill3' }, ] as SkillDefinition[]); }); - context.services.agentContext!.config.reloadSkills = reloadSkillsMock; + // @ts-expect-error Mocking reloadSkills + context.services.config.reloadSkills = reloadSkillsMock; await reloadCmd.action!(context, ''); @@ -613,7 +608,7 @@ describe('skillsCommand', () => { const reloadCmd = skillsCommand.subCommands!.find( (s) => s.name === 'reload', )!; - context.services.agentContext = null; + context.services.config = null; await reloadCmd.action!(context, ''); @@ -633,7 +628,8 @@ describe('skillsCommand', () => { const reloadSkillsMock = vi.fn().mockImplementation(async () => { await new Promise((_, reject) => setTimeout(() => reject(error), 200)); }); - context.services.agentContext!.config.reloadSkills = reloadSkillsMock; + // @ts-expect-error Mocking reloadSkills + context.services.config.reloadSkills = reloadSkillsMock; const actionPromise = reloadCmd.action!(context, ''); await vi.advanceTimersByTimeAsync(100); @@ -655,8 +651,7 @@ describe('skillsCommand', () => { const disableCmd = skillsCommand.subCommands!.find( (s) => s.name === 'disable', )!; - const skillManager = - context.services.agentContext!.config.getSkillManager(); + const skillManager = context.services.config!.getSkillManager(); const mockSkills = [ { name: 'skill1', @@ -686,8 +681,7 @@ describe('skillsCommand', () => { const enableCmd = skillsCommand.subCommands!.find( (s) => s.name === 'enable', )!; - const skillManager = - context.services.agentContext!.config.getSkillManager(); + const skillManager = context.services.config!.getSkillManager(); const mockSkills = [ { name: 'skill1', diff --git a/packages/cli/src/ui/commands/skillsCommand.ts b/packages/cli/src/ui/commands/skillsCommand.ts index a1f9c82445..6f1672208d 100644 --- a/packages/cli/src/ui/commands/skillsCommand.ts +++ b/packages/cli/src/ui/commands/skillsCommand.ts @@ -46,7 +46,7 @@ async function listAction( } } - const skillManager = context.services.agentContext?.config.getSkillManager(); + const skillManager = context.services.config?.getSkillManager(); if (!skillManager) { context.ui.addItem({ type: MessageType.ERROR, @@ -127,8 +127,8 @@ async function linkAction( text: `Successfully linked skills from "${sourcePath}" (${scope}).`, }); - if (context.services.agentContext?.config) { - await context.services.agentContext.config.reloadSkills(); + if (context.services.config) { + await context.services.config.reloadSkills(); } } catch (error) { context.ui.addItem({ @@ -150,14 +150,14 @@ async function disableAction( }); return; } - const skillManager = context.services.agentContext?.config.getSkillManager(); + const skillManager = context.services.config?.getSkillManager(); if (skillManager?.isAdminEnabled() === false) { context.ui.addItem( { type: MessageType.ERROR, text: getAdminErrorMessage( 'Agent skills', - context.services.agentContext?.config ?? undefined, + context.services.config ?? undefined, ), }, Date.now(), @@ -211,14 +211,14 @@ async function enableAction( return; } - const skillManager = context.services.agentContext?.config.getSkillManager(); + const skillManager = context.services.config?.getSkillManager(); if (skillManager?.isAdminEnabled() === false) { context.ui.addItem( { type: MessageType.ERROR, text: getAdminErrorMessage( 'Agent skills', - context.services.agentContext?.config ?? undefined, + context.services.config ?? undefined, ), }, Date.now(), @@ -246,7 +246,7 @@ async function enableAction( async function reloadAction( context: CommandContext, ): Promise { - const config = context.services.agentContext?.config; + const config = context.services.config; if (!config) { context.ui.addItem({ type: MessageType.ERROR, @@ -333,7 +333,7 @@ function disableCompletion( context: CommandContext, partialArg: string, ): string[] { - const skillManager = context.services.agentContext?.config.getSkillManager(); + const skillManager = context.services.config?.getSkillManager(); if (!skillManager) { return []; } @@ -347,7 +347,7 @@ function enableCompletion( context: CommandContext, partialArg: string, ): string[] { - const skillManager = context.services.agentContext?.config.getSkillManager(); + const skillManager = context.services.config?.getSkillManager(); if (!skillManager) { return []; } diff --git a/packages/cli/src/ui/commands/statsCommand.test.ts b/packages/cli/src/ui/commands/statsCommand.test.ts index 86ecf68654..57fff84b6b 100644 --- a/packages/cli/src/ui/commands/statsCommand.test.ts +++ b/packages/cli/src/ui/commands/statsCommand.test.ts @@ -43,15 +43,12 @@ describe('statsCommand', () => { it('should display general session stats when run with no subcommand', async () => { if (!statsCommand.action) throw new Error('Command has no action'); - mockContext.services.agentContext = { + mockContext.services.config = { refreshUserQuota: vi.fn(), refreshAvailableCredits: vi.fn(), getUserTierName: vi.fn(), getUserPaidTier: vi.fn(), getModel: vi.fn(), - get config() { - return this; - }, } as unknown as Config; await statsCommand.action(mockContext, ''); @@ -83,7 +80,7 @@ describe('statsCommand', () => { .fn() .mockReturnValue('2025-01-01T12:00:00Z'); - mockContext.services.agentContext = { + mockContext.services.config = { refreshUserQuota: mockRefreshUserQuota, getUserTierName: mockGetUserTierName, getModel: mockGetModel, @@ -92,9 +89,6 @@ describe('statsCommand', () => { getQuotaResetTime: mockGetQuotaResetTime, getUserPaidTier: vi.fn(), refreshAvailableCredits: vi.fn(), - get config() { - return this; - }, } as unknown as Config; await statsCommand.action(mockContext, ''); diff --git a/packages/cli/src/ui/commands/statsCommand.ts b/packages/cli/src/ui/commands/statsCommand.ts index 2ca4596337..fe991e97ed 100644 --- a/packages/cli/src/ui/commands/statsCommand.ts +++ b/packages/cli/src/ui/commands/statsCommand.ts @@ -29,8 +29,8 @@ function getUserIdentity(context: CommandContext) { const cachedAccount = userAccountManager.getCachedGoogleAccount(); const userEmail = cachedAccount ?? undefined; - const tier = context.services.agentContext?.config.getUserTierName(); - const paidTier = context.services.agentContext?.config.getUserPaidTier(); + const tier = context.services.config?.getUserTierName(); + const paidTier = context.services.config?.getUserPaidTier(); const creditBalance = getG1CreditBalance(paidTier) ?? undefined; return { selectedAuthType, userEmail, tier, creditBalance }; @@ -50,7 +50,7 @@ async function defaultSessionView(context: CommandContext) { const { selectedAuthType, userEmail, tier, creditBalance } = getUserIdentity(context); - const currentModel = context.services.agentContext?.config.getModel(); + const currentModel = context.services.config?.getModel(); const statsItem: HistoryItemStats = { type: MessageType.STATS, @@ -62,19 +62,16 @@ async function defaultSessionView(context: CommandContext) { creditBalance, }; - if (context.services.agentContext?.config) { + if (context.services.config) { const [quota] = await Promise.all([ - context.services.agentContext.config.refreshUserQuota(), - context.services.agentContext.config.refreshAvailableCredits(), + context.services.config.refreshUserQuota(), + context.services.config.refreshAvailableCredits(), ]); if (quota) { statsItem.quotas = quota; - statsItem.pooledRemaining = - context.services.agentContext.config.getQuotaRemaining(); - statsItem.pooledLimit = - context.services.agentContext.config.getQuotaLimit(); - statsItem.pooledResetTime = - context.services.agentContext.config.getQuotaResetTime(); + statsItem.pooledRemaining = context.services.config.getQuotaRemaining(); + statsItem.pooledLimit = context.services.config.getQuotaLimit(); + statsItem.pooledResetTime = context.services.config.getQuotaResetTime(); } } @@ -110,13 +107,10 @@ export const statsCommand: SlashCommand = { isSafeConcurrent: true, action: (context: CommandContext) => { const { selectedAuthType, userEmail, tier } = getUserIdentity(context); - const currentModel = context.services.agentContext?.config.getModel(); - const pooledRemaining = - context.services.agentContext?.config.getQuotaRemaining(); - const pooledLimit = - context.services.agentContext?.config.getQuotaLimit(); - const pooledResetTime = - context.services.agentContext?.config.getQuotaResetTime(); + const currentModel = context.services.config?.getModel(); + const pooledRemaining = context.services.config?.getQuotaRemaining(); + const pooledLimit = context.services.config?.getQuotaLimit(); + const pooledResetTime = context.services.config?.getQuotaResetTime(); context.ui.addItem({ type: MessageType.MODEL_STATS, selectedAuthType, diff --git a/packages/cli/src/ui/commands/toolsCommand.test.ts b/packages/cli/src/ui/commands/toolsCommand.test.ts index 02d9ddb5bc..f5ff86f259 100644 --- a/packages/cli/src/ui/commands/toolsCommand.test.ts +++ b/packages/cli/src/ui/commands/toolsCommand.test.ts @@ -30,8 +30,8 @@ describe('toolsCommand', () => { it('should display an error if the tool registry is unavailable', async () => { const mockContext = createMockCommandContext({ services: { - agentContext: { - toolRegistry: undefined, + config: { + getToolRegistry: () => undefined, }, }, }); @@ -48,10 +48,10 @@ describe('toolsCommand', () => { it('should display "No tools available" when none are found', async () => { const mockContext = createMockCommandContext({ services: { - agentContext: { - toolRegistry: { + config: { + getToolRegistry: () => ({ getAllTools: () => [] as Array>, - }, + }), }, }, }); @@ -69,8 +69,8 @@ describe('toolsCommand', () => { it('should list tools without descriptions by default (no args)', async () => { const mockContext = createMockCommandContext({ services: { - agentContext: { - toolRegistry: { getAllTools: () => mockTools }, + config: { + getToolRegistry: () => ({ getAllTools: () => mockTools }), }, }, }); @@ -90,8 +90,8 @@ describe('toolsCommand', () => { it('should list tools without descriptions when "list" arg is passed', async () => { const mockContext = createMockCommandContext({ services: { - agentContext: { - toolRegistry: { getAllTools: () => mockTools }, + config: { + getToolRegistry: () => ({ getAllTools: () => mockTools }), }, }, }); @@ -111,8 +111,8 @@ describe('toolsCommand', () => { it('should list tools with descriptions when "desc" arg is passed', async () => { const mockContext = createMockCommandContext({ services: { - agentContext: { - toolRegistry: { getAllTools: () => mockTools }, + config: { + getToolRegistry: () => ({ getAllTools: () => mockTools }), }, }, }); @@ -144,8 +144,8 @@ describe('toolsCommand', () => { it('subcommand "list" should display tools without descriptions', async () => { const mockContext = createMockCommandContext({ services: { - agentContext: { - toolRegistry: { getAllTools: () => mockTools }, + config: { + getToolRegistry: () => ({ getAllTools: () => mockTools }), }, }, }); @@ -165,8 +165,8 @@ describe('toolsCommand', () => { it('subcommand "desc" should display tools with descriptions', async () => { const mockContext = createMockCommandContext({ services: { - agentContext: { - toolRegistry: { getAllTools: () => mockTools }, + config: { + getToolRegistry: () => ({ getAllTools: () => mockTools }), }, }, }); @@ -196,8 +196,8 @@ describe('toolsCommand', () => { const mockContext = createMockCommandContext({ services: { - agentContext: { - toolRegistry: { getAllTools: () => mockTools }, + config: { + getToolRegistry: () => ({ getAllTools: () => mockTools }), }, }, }); diff --git a/packages/cli/src/ui/commands/toolsCommand.ts b/packages/cli/src/ui/commands/toolsCommand.ts index d3e5aef74b..082da26fab 100644 --- a/packages/cli/src/ui/commands/toolsCommand.ts +++ b/packages/cli/src/ui/commands/toolsCommand.ts @@ -15,7 +15,7 @@ async function listTools( context: CommandContext, showDescriptions: boolean, ): Promise { - const toolRegistry = context.services.agentContext?.toolRegistry; + const toolRegistry = context.services.config?.getToolRegistry(); if (!toolRegistry) { context.ui.addItem({ type: MessageType.ERROR, diff --git a/packages/cli/src/ui/commands/types.ts b/packages/cli/src/ui/commands/types.ts index 4065e075bf..7bd640090f 100644 --- a/packages/cli/src/ui/commands/types.ts +++ b/packages/cli/src/ui/commands/types.ts @@ -11,11 +11,11 @@ import type { ConfirmationRequest, } from '../types.js'; import type { + Config, GitService, Logger, CommandActionReturn, AgentDefinition, - AgentLoopContext, } from '@google/gemini-cli-core'; import type { LoadedSettings } from '../../config/settings.js'; import type { UseHistoryManagerReturn } from '../hooks/useHistoryManager.js'; @@ -39,7 +39,7 @@ export interface CommandContext { // Core services and configuration services: { // TODO(abhipatel12): Ensure that config is never null. - agentContext: AgentLoopContext | null; + config: Config | null; settings: LoadedSettings; git: GitService | undefined; logger: Logger; diff --git a/packages/cli/src/ui/commands/upgradeCommand.test.ts b/packages/cli/src/ui/commands/upgradeCommand.test.ts index bb07c1bd44..9c54eb0191 100644 --- a/packages/cli/src/ui/commands/upgradeCommand.test.ts +++ b/packages/cli/src/ui/commands/upgradeCommand.test.ts @@ -33,13 +33,11 @@ describe('upgradeCommand', () => { vi.clearAllMocks(); mockContext = createMockCommandContext({ services: { - agentContext: { - config: { - getContentGeneratorConfig: vi.fn().mockReturnValue({ - authType: AuthType.LOGIN_WITH_GOOGLE, - }), - getUserTierName: vi.fn().mockReturnValue(undefined), - }, + config: { + getContentGeneratorConfig: vi.fn().mockReturnValue({ + authType: AuthType.LOGIN_WITH_GOOGLE, + }), + getUserTierName: vi.fn().mockReturnValue(undefined), }, }, } as unknown as CommandContext); @@ -64,7 +62,7 @@ describe('upgradeCommand', () => { it('should return an error message when NOT logged in with Google', async () => { vi.mocked( - mockContext.services.agentContext!.config.getContentGeneratorConfig, + mockContext.services.config!.getContentGeneratorConfig, ).mockReturnValue({ authType: AuthType.USE_GEMINI, }); @@ -120,9 +118,9 @@ describe('upgradeCommand', () => { }); it('should return info message for ultra tiers', async () => { - vi.mocked( - mockContext.services.agentContext!.config.getUserTierName, - ).mockReturnValue('Advanced Ultra'); + vi.mocked(mockContext.services.config!.getUserTierName).mockReturnValue( + 'Advanced Ultra', + ); if (!upgradeCommand.action) { throw new Error('The upgrade command must have an action.'); diff --git a/packages/cli/src/ui/commands/upgradeCommand.ts b/packages/cli/src/ui/commands/upgradeCommand.ts index f7c09a42f0..9bbea156ce 100644 --- a/packages/cli/src/ui/commands/upgradeCommand.ts +++ b/packages/cli/src/ui/commands/upgradeCommand.ts @@ -23,8 +23,8 @@ export const upgradeCommand: SlashCommand = { description: 'Upgrade your Gemini Code Assist tier for higher limits', autoExecute: true, action: async (context) => { - const config = context.services.agentContext?.config; - const authType = config?.getContentGeneratorConfig()?.authType; + const authType = + context.services.config?.getContentGeneratorConfig()?.authType; if (authType !== AuthType.LOGIN_WITH_GOOGLE) { // This command should ideally be hidden if not logged in with Google, // but we add a safety check here just in case. @@ -36,7 +36,7 @@ export const upgradeCommand: SlashCommand = { }; } - const tierName = config?.getUserTierName(); + const tierName = context.services.config?.getUserTierName(); if (isUltraTier(tierName)) { return { type: 'message', diff --git a/packages/cli/src/ui/components/AboutBox.test.tsx b/packages/cli/src/ui/components/AboutBox.test.tsx index 9115ca31c1..3f1226b651 100644 --- a/packages/cli/src/ui/components/AboutBox.test.tsx +++ b/packages/cli/src/ui/components/AboutBox.test.tsx @@ -25,9 +25,10 @@ describe('AboutBox', () => { }; it('renders with required props', async () => { - const { lastFrame, unmount } = await renderWithProviders( + const { lastFrame, waitUntilReady, unmount } = renderWithProviders( , ); + await waitUntilReady(); const output = lastFrame(); expect(output).toContain('About Gemini CLI'); expect(output).toContain('1.0.0'); @@ -45,9 +46,10 @@ describe('AboutBox', () => { ['tier', 'Enterprise', 'Tier'], ])('renders optional prop %s', async (prop, value, label) => { const props = { ...defaultProps, [prop]: value }; - const { lastFrame, unmount } = await renderWithProviders( + const { lastFrame, waitUntilReady, unmount } = renderWithProviders( , ); + await waitUntilReady(); const output = lastFrame(); expect(output).toContain(label); expect(output).toContain(value); @@ -56,9 +58,10 @@ describe('AboutBox', () => { it('renders Auth Method with email when userEmail is provided', async () => { const props = { ...defaultProps, userEmail: 'test@example.com' }; - const { lastFrame, unmount } = await renderWithProviders( + const { lastFrame, waitUntilReady, unmount } = renderWithProviders( , ); + await waitUntilReady(); const output = lastFrame(); expect(output).toContain('Signed in with Google (test@example.com)'); unmount(); @@ -66,9 +69,10 @@ describe('AboutBox', () => { it('renders Auth Method correctly when not oauth', async () => { const props = { ...defaultProps, selectedAuthType: 'api-key' }; - const { lastFrame, unmount } = await renderWithProviders( + const { lastFrame, waitUntilReady, unmount } = renderWithProviders( , ); + await waitUntilReady(); const output = lastFrame(); expect(output).toContain('api-key'); unmount(); diff --git a/packages/cli/src/ui/components/AdminSettingsChangedDialog.test.tsx b/packages/cli/src/ui/components/AdminSettingsChangedDialog.test.tsx index 76a36fe4dc..0cfe00c764 100644 --- a/packages/cli/src/ui/components/AdminSettingsChangedDialog.test.tsx +++ b/packages/cli/src/ui/components/AdminSettingsChangedDialog.test.tsx @@ -17,14 +17,15 @@ describe('AdminSettingsChangedDialog', () => { }); it('renders correctly', async () => { - const { lastFrame } = await renderWithProviders( + const { lastFrame, waitUntilReady } = renderWithProviders( , ); + await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); }); it('restarts on "r" key press', async () => { - const { stdin } = await renderWithProviders( + const { stdin, waitUntilReady } = renderWithProviders( , { uiActions: { @@ -32,6 +33,7 @@ describe('AdminSettingsChangedDialog', () => { }, }, ); + await waitUntilReady(); act(() => { stdin.write('r'); @@ -41,7 +43,7 @@ describe('AdminSettingsChangedDialog', () => { }); it.each(['r', 'R'])('restarts on "%s" key press', async (key) => { - const { stdin } = await renderWithProviders( + const { stdin, waitUntilReady } = renderWithProviders( , { uiActions: { @@ -49,6 +51,7 @@ describe('AdminSettingsChangedDialog', () => { }, }, ); + await waitUntilReady(); act(() => { stdin.write(key); diff --git a/packages/cli/src/ui/components/AgentConfigDialog.test.tsx b/packages/cli/src/ui/components/AgentConfigDialog.test.tsx index 2c6ea454db..52cda094e0 100644 --- a/packages/cli/src/ui/components/AgentConfigDialog.test.tsx +++ b/packages/cli/src/ui/components/AgentConfigDialog.test.tsx @@ -4,14 +4,21 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { renderWithProviders } from '../../test-utils/render.js'; +import { render } 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', @@ -115,17 +122,19 @@ describe('AgentConfigDialog', () => { settings: LoadedSettings, definition: AgentDefinition = createMockAgentDefinition(), ) => { - const result = await renderWithProviders( - , - { settings, uiState: { mainAreaWidth: 100 } }, + const result = render( + + + , ); + await result.waitUntilReady(); return result; }; @@ -322,17 +331,18 @@ describe('AgentConfigDialog', () => { const settings = createMockSettings(); // Agent config has about 6 base items + 2 per tool // Render with very small height (20) - const { lastFrame, unmount } = await renderWithProviders( - , - { settings, uiState: { mainAreaWidth: 100 } }, + const { lastFrame, unmount } = render( + + + , ); await waitFor(() => expect(lastFrame()).toContain('Configure: Test Agent'), diff --git a/packages/cli/src/ui/components/AlternateBufferQuittingDisplay.test.tsx b/packages/cli/src/ui/components/AlternateBufferQuittingDisplay.test.tsx index 571e0d36d3..6e9623a8ff 100644 --- a/packages/cli/src/ui/components/AlternateBufferQuittingDisplay.test.tsx +++ b/packages/cli/src/ui/components/AlternateBufferQuittingDisplay.test.tsx @@ -108,7 +108,7 @@ describe('AlternateBufferQuittingDisplay', () => { it('renders with active and pending tool messages', async () => { persistentStateMock.setData({ tipsShown: 0 }); - const { lastFrame, unmount } = await renderWithProviders( + const { lastFrame, waitUntilReady, unmount } = renderWithProviders( , { uiState: { @@ -118,13 +118,14 @@ describe('AlternateBufferQuittingDisplay', () => { }, }, ); + await waitUntilReady(); expect(lastFrame()).toMatchSnapshot('with_history_and_pending'); unmount(); }); it('renders with empty history and no pending items', async () => { persistentStateMock.setData({ tipsShown: 0 }); - const { lastFrame, unmount } = await renderWithProviders( + const { lastFrame, waitUntilReady, unmount } = renderWithProviders( , { uiState: { @@ -134,13 +135,14 @@ describe('AlternateBufferQuittingDisplay', () => { }, }, ); + await waitUntilReady(); expect(lastFrame()).toMatchSnapshot('empty'); unmount(); }); it('renders with history but no pending items', async () => { persistentStateMock.setData({ tipsShown: 0 }); - const { lastFrame, unmount } = await renderWithProviders( + const { lastFrame, waitUntilReady, unmount } = renderWithProviders( , { uiState: { @@ -150,13 +152,14 @@ describe('AlternateBufferQuittingDisplay', () => { }, }, ); + await waitUntilReady(); expect(lastFrame()).toMatchSnapshot('with_history_no_pending'); unmount(); }); it('renders with pending items but no history', async () => { persistentStateMock.setData({ tipsShown: 0 }); - const { lastFrame, unmount } = await renderWithProviders( + const { lastFrame, waitUntilReady, unmount } = renderWithProviders( , { uiState: { @@ -166,6 +169,7 @@ describe('AlternateBufferQuittingDisplay', () => { }, }, ); + await waitUntilReady(); expect(lastFrame()).toMatchSnapshot('with_pending_no_history'); unmount(); }); @@ -191,7 +195,7 @@ describe('AlternateBufferQuittingDisplay', () => { ], }, ]; - const { lastFrame, unmount } = await renderWithProviders( + const { lastFrame, waitUntilReady, unmount } = renderWithProviders( , { uiState: { @@ -201,6 +205,7 @@ describe('AlternateBufferQuittingDisplay', () => { }, }, ); + await waitUntilReady(); const output = lastFrame(); expect(output).toContain('Action Required (was prompted):'); expect(output).toContain('confirming_tool'); @@ -215,7 +220,7 @@ describe('AlternateBufferQuittingDisplay', () => { { id: 1, type: 'user', text: 'Hello Gemini' }, { id: 2, type: 'gemini', text: 'Hello User!' }, ]; - const { lastFrame, unmount } = await renderWithProviders( + const { lastFrame, waitUntilReady, unmount } = renderWithProviders( , { uiState: { @@ -225,6 +230,7 @@ describe('AlternateBufferQuittingDisplay', () => { }, }, ); + await waitUntilReady(); expect(lastFrame()).toMatchSnapshot('with_user_gemini_messages'); unmount(); }); diff --git a/packages/cli/src/ui/components/AnsiOutput.test.tsx b/packages/cli/src/ui/components/AnsiOutput.test.tsx index 758361be0a..ac824fefe6 100644 --- a/packages/cli/src/ui/components/AnsiOutput.test.tsx +++ b/packages/cli/src/ui/components/AnsiOutput.test.tsx @@ -29,9 +29,10 @@ describe('', () => { createAnsiToken({ text: 'world!' }), ], ]; - const { lastFrame, unmount } = await render( + const { lastFrame, waitUntilReady, unmount } = render( , ); + await waitUntilReady(); expect(lastFrame().trim()).toBe('Hello, world!'); unmount(); }); @@ -46,9 +47,10 @@ describe('', () => { { style: { inverse: true }, text: 'Inverse' }, ])('correctly applies style $text', async ({ style, text }) => { const data: AnsiOutput = [[createAnsiToken({ text, ...style })]]; - const { lastFrame, unmount } = await render( + const { lastFrame, waitUntilReady, unmount } = render( , ); + await waitUntilReady(); expect(lastFrame().trim()).toBe(text); unmount(); }); @@ -59,9 +61,10 @@ describe('', () => { { color: { fg: '#00ff00', bg: '#ff00ff' }, text: 'Green FG Magenta BG' }, ])('correctly applies color $text', async ({ color, text }) => { const data: AnsiOutput = [[createAnsiToken({ text, ...color })]]; - const { lastFrame, unmount } = await render( + const { lastFrame, waitUntilReady, unmount } = render( , ); + await waitUntilReady(); expect(lastFrame().trim()).toBe(text); unmount(); }); @@ -73,9 +76,10 @@ describe('', () => { [createAnsiToken({ text: 'Third line' })], [createAnsiToken({ text: '' })], ]; - const { lastFrame, unmount } = await render( + const { lastFrame, waitUntilReady, unmount } = render( , ); + await waitUntilReady(); const output = lastFrame(); expect(output).toBeDefined(); const lines = output.split('\n'); @@ -92,9 +96,10 @@ describe('', () => { [createAnsiToken({ text: 'Line 3' })], [createAnsiToken({ text: 'Line 4' })], ]; - const { lastFrame, unmount } = await render( + const { lastFrame, waitUntilReady, unmount } = render( , ); + await waitUntilReady(); const output = lastFrame(); expect(output).not.toContain('Line 1'); expect(output).not.toContain('Line 2'); @@ -110,9 +115,10 @@ describe('', () => { [createAnsiToken({ text: 'Line 3' })], [createAnsiToken({ text: 'Line 4' })], ]; - const { lastFrame, unmount } = await render( + const { lastFrame, waitUntilReady, unmount } = render( , ); + await waitUntilReady(); const output = lastFrame(); expect(output).not.toContain('Line 1'); expect(output).not.toContain('Line 2'); @@ -129,7 +135,7 @@ describe('', () => { [createAnsiToken({ text: 'Line 4' })], ]; // availableTerminalHeight=3, maxLines=2 => show 2 lines - const { lastFrame, unmount } = await render( + const { lastFrame, waitUntilReady, unmount } = render( ', () => { width={80} />, ); + await waitUntilReady(); const output = lastFrame(); expect(output).not.toContain('Line 2'); expect(output).toContain('Line 3'); @@ -149,9 +156,10 @@ describe('', () => { for (let i = 0; i < 1000; i++) { largeData.push([createAnsiToken({ text: `Line ${i}` })]); } - const { lastFrame, unmount } = await render( + const { lastFrame, waitUntilReady, unmount } = render( , ); + await waitUntilReady(); // We are just checking that it renders something without crashing. expect(lastFrame()).toBeDefined(); unmount(); diff --git a/packages/cli/src/ui/components/AnsiOutput.tsx b/packages/cli/src/ui/components/AnsiOutput.tsx index a1b30b0856..cc17b6b6b0 100644 --- a/packages/cli/src/ui/components/AnsiOutput.tsx +++ b/packages/cli/src/ui/components/AnsiOutput.tsx @@ -35,11 +35,7 @@ export const AnsiOutputText: React.FC = ({ ? Math.min(availableHeightLimit, maxLines) : (availableHeightLimit ?? maxLines ?? DEFAULT_HEIGHT); - const lastLines = disableTruncation - ? data - : numLinesRetained === 0 - ? [] - : data.slice(-numLinesRetained); + const lastLines = disableTruncation ? data : data.slice(-numLinesRetained); return ( {lastLines.map((line: AnsiLine, lineIndex: number) => ( diff --git a/packages/cli/src/ui/components/AppHeader.test.tsx b/packages/cli/src/ui/components/AppHeader.test.tsx index 8ff4caaacf..ebcd4de973 100644 --- a/packages/cli/src/ui/components/AppHeader.test.tsx +++ b/packages/cli/src/ui/components/AppHeader.test.tsx @@ -10,6 +10,7 @@ import { } from '../../test-utils/render.js'; import { AppHeader } from './AppHeader.js'; import { describe, it, expect, vi } from 'vitest'; +import { makeFakeConfig } from '@google/gemini-cli-core'; import crypto from 'node:crypto'; vi.mock('../utils/terminalSetup.js', () => ({ @@ -18,6 +19,7 @@ vi.mock('../utils/terminalSetup.js', () => ({ describe('', () => { it('should render the banner with default text', async () => { + const mockConfig = makeFakeConfig(); const uiState = { history: [], bannerData: { @@ -27,12 +29,14 @@ describe('', () => { bannerVisible: true, }; - const { lastFrame, unmount } = await renderWithProviders( + const { lastFrame, waitUntilReady, unmount } = renderWithProviders( , { + config: mockConfig, uiState, }, ); + await waitUntilReady(); expect(lastFrame()).toContain('This is the default banner'); expect(lastFrame()).toMatchSnapshot(); @@ -40,6 +44,7 @@ describe('', () => { }); it('should render the banner with warning text', async () => { + const mockConfig = makeFakeConfig(); const uiState = { history: [], bannerData: { @@ -49,12 +54,14 @@ describe('', () => { bannerVisible: true, }; - const { lastFrame, unmount } = await renderWithProviders( + const { lastFrame, waitUntilReady, unmount } = renderWithProviders( , { + config: mockConfig, uiState, }, ); + await waitUntilReady(); expect(lastFrame()).toContain('There are capacity issues'); expect(lastFrame()).toMatchSnapshot(); @@ -62,6 +69,7 @@ describe('', () => { }); it('should not render the banner when no flags are set', async () => { + const mockConfig = makeFakeConfig(); const uiState = { history: [], bannerData: { @@ -70,12 +78,14 @@ describe('', () => { }, }; - const { lastFrame, unmount } = await renderWithProviders( + const { lastFrame, waitUntilReady, unmount } = renderWithProviders( , { + config: mockConfig, uiState, }, ); + await waitUntilReady(); expect(lastFrame()).not.toContain('Banner'); expect(lastFrame()).toMatchSnapshot(); @@ -83,6 +93,7 @@ describe('', () => { }); it('should not render the default banner if shown count is 5 or more', async () => { + const mockConfig = makeFakeConfig(); const uiState = { history: [], bannerData: { @@ -100,12 +111,14 @@ describe('', () => { }, }); - const { lastFrame, unmount } = await renderWithProviders( + const { lastFrame, waitUntilReady, unmount } = renderWithProviders( , { + config: mockConfig, uiState, }, ); + await waitUntilReady(); expect(lastFrame()).not.toContain('This is the default banner'); expect(lastFrame()).toMatchSnapshot(); @@ -113,6 +126,7 @@ describe('', () => { }); it('should increment the version count when default banner is displayed', async () => { + const mockConfig = makeFakeConfig(); const uiState = { history: [], bannerData: { @@ -125,12 +139,14 @@ describe('', () => { // and interfering with the expected persistentState.set call. persistentStateMock.setData({ tipsShown: 10 }); - const { unmount } = await renderWithProviders( + const { waitUntilReady, unmount } = renderWithProviders( , { + config: mockConfig, uiState, }, ); + await waitUntilReady(); expect(persistentStateMock.set).toHaveBeenCalledWith( 'defaultBannerShownCount', @@ -145,6 +161,7 @@ describe('', () => { }); it('should render banner text with unescaped newlines', async () => { + const mockConfig = makeFakeConfig(); const uiState = { history: [], bannerData: { @@ -154,18 +171,21 @@ describe('', () => { bannerVisible: true, }; - const { lastFrame, unmount } = await renderWithProviders( + const { lastFrame, waitUntilReady, unmount } = renderWithProviders( , { + config: mockConfig, uiState, }, ); + await waitUntilReady(); expect(lastFrame()).not.toContain('First line\\nSecond line'); unmount(); }); it('should render Tips when tipsShown is less than 10', async () => { + const mockConfig = makeFakeConfig(); const uiState = { history: [], bannerData: { @@ -177,12 +197,14 @@ describe('', () => { persistentStateMock.setData({ tipsShown: 5 }); - const { lastFrame, unmount } = await renderWithProviders( + const { lastFrame, waitUntilReady, unmount } = renderWithProviders( , { + config: mockConfig, uiState, }, ); + await waitUntilReady(); expect(lastFrame()).toContain('Tips'); expect(persistentStateMock.set).toHaveBeenCalledWith('tipsShown', 6); @@ -190,6 +212,7 @@ describe('', () => { }); it('should NOT render Tips when tipsShown is 10 or more', async () => { + const mockConfig = makeFakeConfig(); const uiState = { bannerData: { defaultText: '', @@ -199,12 +222,14 @@ describe('', () => { persistentStateMock.setData({ tipsShown: 10 }); - const { lastFrame, unmount } = await renderWithProviders( + const { lastFrame, waitUntilReady, unmount } = renderWithProviders( , { + config: mockConfig, uiState, }, ); + await waitUntilReady(); expect(lastFrame()).not.toContain('Tips'); unmount(); @@ -213,6 +238,7 @@ describe('', () => { it('should show tips until they have been shown 10 times (persistence flow)', async () => { persistentStateMock.setData({ tipsShown: 9 }); + const mockConfig = makeFakeConfig(); const uiState = { history: [], bannerData: { @@ -223,19 +249,21 @@ describe('', () => { }; // First session - const session1 = await renderWithProviders(, { + const session1 = renderWithProviders(, { + config: mockConfig, uiState, }); + await session1.waitUntilReady(); expect(session1.lastFrame()).toContain('Tips'); expect(persistentStateMock.get('tipsShown')).toBe(10); session1.unmount(); // Second session - state is persisted in the fake - const session2 = await renderWithProviders( - , - {}, - ); + const session2 = renderWithProviders(, { + config: mockConfig, + }); + await session2.waitUntilReady(); expect(session2.lastFrame()).not.toContain('Tips'); session2.unmount(); diff --git a/packages/cli/src/ui/components/AppHeaderIcon.test.tsx b/packages/cli/src/ui/components/AppHeaderIcon.test.tsx index 6b6f0e6210..c16febea66 100644 --- a/packages/cli/src/ui/components/AppHeaderIcon.test.tsx +++ b/packages/cli/src/ui/components/AppHeaderIcon.test.tsx @@ -32,7 +32,7 @@ describe('AppHeader Icon Rendering', () => { it('renders the default icon in standard terminals', async () => { vi.mocked(isAppleTerminal).mockReturnValue(false); - const result = await renderWithProviders(); + const result = renderWithProviders(); await result.waitUntilReady(); await expect(result).toMatchSvgSnapshot(); @@ -41,7 +41,7 @@ describe('AppHeader Icon Rendering', () => { it('renders the symmetric icon in Apple Terminal', async () => { vi.mocked(isAppleTerminal).mockReturnValue(true); - const result = await renderWithProviders(); + const result = renderWithProviders(); await result.waitUntilReady(); await expect(result).toMatchSvgSnapshot(); diff --git a/packages/cli/src/ui/components/ApprovalModeIndicator.test.tsx b/packages/cli/src/ui/components/ApprovalModeIndicator.test.tsx index 1b2decbe16..4386891c7a 100644 --- a/packages/cli/src/ui/components/ApprovalModeIndicator.test.tsx +++ b/packages/cli/src/ui/components/ApprovalModeIndicator.test.tsx @@ -11,50 +11,56 @@ import { ApprovalMode } from '@google/gemini-cli-core'; describe('ApprovalModeIndicator', () => { it('renders correctly for AUTO_EDIT mode', async () => { - const { lastFrame } = await render( + const { lastFrame, waitUntilReady } = render( , ); + await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); }); it('renders correctly for AUTO_EDIT mode with plan enabled', async () => { - const { lastFrame } = await render( + const { lastFrame, waitUntilReady } = render( , ); + await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); }); it('renders correctly for PLAN mode', async () => { - const { lastFrame } = await render( + const { lastFrame, waitUntilReady } = render( , ); + await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); }); it('renders correctly for YOLO mode', async () => { - const { lastFrame } = await render( + const { lastFrame, waitUntilReady } = render( , ); + await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); }); it('renders correctly for DEFAULT mode', async () => { - const { lastFrame } = await render( + const { lastFrame, waitUntilReady } = render( , ); + await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); }); it('renders correctly for DEFAULT mode with plan enabled', async () => { - const { lastFrame } = await render( + const { lastFrame, waitUntilReady } = render( , ); + await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); }); }); diff --git a/packages/cli/src/ui/components/AskUserDialog.test.tsx b/packages/cli/src/ui/components/AskUserDialog.test.tsx index 864800a061..0469bec373 100644 --- a/packages/cli/src/ui/components/AskUserDialog.test.tsx +++ b/packages/cli/src/ui/components/AskUserDialog.test.tsx @@ -7,8 +7,6 @@ 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'; @@ -48,7 +46,7 @@ describe('AskUserDialog', () => { ]; it('renders question and options', async () => { - const { lastFrame } = await renderWithProviders( + const { lastFrame, waitUntilReady } = renderWithProviders( { { width: 120 }, ); + await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); }); @@ -139,7 +138,7 @@ describe('AskUserDialog', () => { ])('Submission: $name', ({ name, questions, actions, expectedSubmit }) => { it(`submits correct values for ${name}`, async () => { const onSubmit = vi.fn(); - const { stdin } = await renderWithProviders( + const { stdin } = renderWithProviders( { }, ] as Question[]; - const { stdin, lastFrame, waitUntilReady } = await renderWithProviders( + const { stdin, lastFrame, waitUntilReady } = renderWithProviders( { it('handles custom option in single select with inline typing', async () => { const onSubmit = vi.fn(); - const { stdin, lastFrame, waitUntilReady } = await renderWithProviders( + const { stdin, lastFrame, waitUntilReady } = renderWithProviders( { ]; const onSubmit = vi.fn(); - const { stdin, lastFrame, waitUntilReady } = await renderWithProviders( + const { stdin, lastFrame, waitUntilReady } = renderWithProviders( { }, ]; - const { lastFrame, waitUntilReady } = await renderWithProviders( + const { lastFrame, waitUntilReady } = renderWithProviders( { width={80} availableHeight={10} // Small height to force scrolling />, - { - config: makeFakeConfig({ useAlternateBuffer }), - settings: createMockSettings({ ui: { useAlternateBuffer } }), - }, + { useAlternateBuffer }, ); await waitFor(async () => { @@ -340,7 +336,7 @@ describe('AskUserDialog', () => { ); it('navigates to custom option when typing unbound characters (Type-to-Jump)', async () => { - const { stdin, lastFrame, waitUntilReady } = await renderWithProviders( + const { stdin, lastFrame, waitUntilReady } = renderWithProviders( { }, ]; - const { lastFrame } = await renderWithProviders( + const { lastFrame, waitUntilReady } = renderWithProviders( { { width: 120 }, ); + await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); }); it('hides progress header for single question', async () => { - const { lastFrame } = await renderWithProviders( + const { lastFrame, waitUntilReady } = renderWithProviders( { { width: 120 }, ); + await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); }); it('shows keyboard hints', async () => { - const { lastFrame } = await renderWithProviders( + const { lastFrame, waitUntilReady } = renderWithProviders( { { width: 120 }, ); + await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); }); @@ -457,7 +456,7 @@ describe('AskUserDialog', () => { }, ]; - const { stdin, lastFrame, waitUntilReady } = await renderWithProviders( + const { stdin, lastFrame, waitUntilReady } = renderWithProviders( { { width: 120 }, ); + await waitUntilReady(); expect(lastFrame()).toContain('Which testing framework?'); writeKey(stdin, '\x1b[C'); // Right arrow @@ -503,7 +503,7 @@ describe('AskUserDialog', () => { ]; const onSubmit = vi.fn(); - const { stdin, lastFrame, waitUntilReady } = await renderWithProviders( + const { stdin, lastFrame, waitUntilReady } = renderWithProviders( { }, ]; - const { lastFrame } = await renderWithProviders( + const { lastFrame, waitUntilReady } = renderWithProviders( { { width: 120 }, ); + await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); }); @@ -608,7 +609,7 @@ describe('AskUserDialog', () => { }, ]; - const { stdin, lastFrame, waitUntilReady } = await renderWithProviders( + const { stdin, lastFrame, waitUntilReady } = renderWithProviders( { }, ]; - const { stdin, lastFrame, waitUntilReady } = await renderWithProviders( + const { stdin, lastFrame, waitUntilReady } = renderWithProviders( { ]; const onSubmit = vi.fn(); - const { stdin } = await renderWithProviders( + const { stdin } = renderWithProviders( { }, ]; - const { lastFrame } = await renderWithProviders( + const { lastFrame, waitUntilReady } = renderWithProviders( { { width: 120 }, ); + await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); }); @@ -752,7 +754,7 @@ describe('AskUserDialog', () => { }, ]; - const { lastFrame } = await renderWithProviders( + const { lastFrame, waitUntilReady } = renderWithProviders( { { width: 120 }, ); + await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); }); @@ -774,7 +777,7 @@ describe('AskUserDialog', () => { }, ]; - const { stdin, lastFrame, waitUntilReady } = await renderWithProviders( + const { stdin, lastFrame, waitUntilReady } = renderWithProviders( { }, ]; - const { lastFrame } = await renderWithProviders( + const { lastFrame, waitUntilReady } = renderWithProviders( { { width: 120 }, ); + await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); }); @@ -844,7 +848,7 @@ describe('AskUserDialog', () => { }, ]; - const { stdin, lastFrame, waitUntilReady } = await renderWithProviders( + const { stdin, lastFrame, waitUntilReady } = renderWithProviders( { ]; const onSubmit = vi.fn(); - const { stdin, lastFrame, waitUntilReady } = await renderWithProviders( + const { stdin, lastFrame, waitUntilReady } = renderWithProviders( { ]; const onSubmit = vi.fn(); - const { stdin } = await renderWithProviders( + const { stdin } = renderWithProviders( { ]; const onCancel = vi.fn(); - const { stdin, lastFrame, waitUntilReady } = await renderWithProviders( + const { stdin, lastFrame, waitUntilReady } = renderWithProviders( { }, ]; - const { stdin, lastFrame, waitUntilReady } = await renderWithProviders( + const { stdin, lastFrame, waitUntilReady } = renderWithProviders( { ]; const onSubmit = vi.fn(); - const { stdin, lastFrame, waitUntilReady } = await renderWithProviders( + const { stdin, lastFrame, waitUntilReady } = renderWithProviders( { }, ]; - const { lastFrame, waitUntilReady } = await renderWithProviders( + const { lastFrame, waitUntilReady } = renderWithProviders( { }, ]; - const { lastFrame, waitUntilReady } = await renderWithProviders( + const { lastFrame, waitUntilReady } = renderWithProviders( { }, ]; - const { lastFrame, waitUntilReady } = await renderWithProviders( + const { lastFrame, waitUntilReady } = renderWithProviders( { }, ]; - const { lastFrame, waitUntilReady } = await renderWithProviders( + const { lastFrame, waitUntilReady } = renderWithProviders( { availableTerminalHeight: 5, // Small height to force scroll arrows } as UIState; - const { lastFrame, waitUntilReady } = await renderWithProviders( + const { lastFrame, waitUntilReady } = renderWithProviders( { width={80} /> , - { - config: makeFakeConfig({ useAlternateBuffer: false }), - settings: createMockSettings({ ui: { useAlternateBuffer: false } }), - }, + { useAlternateBuffer: false }, ); // With height 5 and alternate buffer disabled, it should show scroll arrows (▲) @@ -1317,7 +1318,7 @@ describe('AskUserDialog', () => { availableTerminalHeight: 5, } as UIState; - const { lastFrame, waitUntilReady } = await renderWithProviders( + const { lastFrame, waitUntilReady } = renderWithProviders( { width={40} // Small width to force wrapping /> , - { - config: makeFakeConfig({ useAlternateBuffer: true }), - settings: createMockSettings({ ui: { useAlternateBuffer: true } }), - }, + { useAlternateBuffer: true }, ); // Should NOT contain the truncation message @@ -1356,7 +1354,7 @@ describe('AskUserDialog', () => { }, ]; - const { stdin, lastFrame, waitUntilReady } = await renderWithProviders( + const { stdin, lastFrame, waitUntilReady } = renderWithProviders( { }, ]; - const { stdin, lastFrame, waitUntilReady } = await renderWithProviders( + const { stdin, lastFrame, waitUntilReady } = renderWithProviders( { ]; const onSubmit = vi.fn(); - const { stdin } = await renderWithProviders( + const { stdin } = renderWithProviders( ', () => { it('renders the output of the active shell', async () => { const width = 80; - const { lastFrame, unmount } = await render( + const { lastFrame, waitUntilReady, unmount } = render( ', () => { , width, ); + await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); unmount(); @@ -165,7 +166,7 @@ describe('', () => { it('renders tabs for multiple shells', async () => { const width = 100; - const { lastFrame, unmount } = await render( + const { lastFrame, waitUntilReady, unmount } = render( ', () => { , width, ); + await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); unmount(); @@ -185,7 +187,7 @@ describe('', () => { it('highlights the focused state', async () => { const width = 80; - const { lastFrame, unmount } = await render( + const { lastFrame, waitUntilReady, unmount } = render( ', () => { , width, ); + await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); unmount(); @@ -205,7 +208,7 @@ describe('', () => { it('resizes the PTY on mount and when dimensions change', async () => { const width = 80; - const { rerender, unmount } = await render( + const { rerender, waitUntilReady, unmount } = render( ', () => { , width, ); + await waitUntilReady(); expect(ShellExecutionService.resizePty).toHaveBeenCalledWith( shell1.pid, @@ -237,6 +241,7 @@ describe('', () => { /> , ); + await waitUntilReady(); expect(ShellExecutionService.resizePty).toHaveBeenCalledWith( shell1.pid, @@ -248,7 +253,7 @@ describe('', () => { it('renders the process list when isListOpenProp is true', async () => { const width = 80; - const { lastFrame, unmount } = await render( + const { lastFrame, waitUntilReady, unmount } = render( ', () => { , width, ); + await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); unmount(); @@ -268,7 +274,7 @@ describe('', () => { it('selects the current process and closes the list when Ctrl+L is pressed in list view', async () => { const width = 80; - const { unmount } = await render( + const { waitUntilReady, unmount } = render( ', () => { , width, ); + await waitUntilReady(); // Simulate down arrow to select the second process (handled by RadioButtonSelect) await act(async () => { simulateKey({ name: 'down' }); }); + await waitUntilReady(); // Simulate Ctrl+L (handled by BackgroundShellDisplay) await act(async () => { simulateKey({ name: 'l', ctrl: true }); }); + await waitUntilReady(); expect(mockSetActiveBackgroundShellPid).toHaveBeenCalledWith(shell2.pid); expect(mockSetIsBackgroundShellListOpen).toHaveBeenCalledWith(false); @@ -299,7 +308,7 @@ describe('', () => { it('kills the highlighted process when Ctrl+K is pressed in list view', async () => { const width = 80; - const { unmount } = await render( + const { waitUntilReady, unmount } = render( ', () => { , width, ); + await waitUntilReady(); // Initial state: shell1 (active) is highlighted @@ -319,11 +329,13 @@ describe('', () => { await act(async () => { simulateKey({ name: 'down' }); }); + await waitUntilReady(); // Press Ctrl+K await act(async () => { simulateKey({ name: 'k', ctrl: true }); }); + await waitUntilReady(); expect(mockDismissBackgroundShell).toHaveBeenCalledWith(shell2.pid); unmount(); @@ -331,7 +343,7 @@ describe('', () => { it('kills the active process when Ctrl+K is pressed in output view', async () => { const width = 80; - const { unmount } = await render( + const { waitUntilReady, unmount } = render( ', () => { , width, ); + await waitUntilReady(); await act(async () => { simulateKey({ name: 'k', ctrl: true }); }); + await waitUntilReady(); expect(mockDismissBackgroundShell).toHaveBeenCalledWith(shell1.pid); unmount(); @@ -356,7 +370,7 @@ describe('', () => { it('scrolls to active shell when list opens', async () => { // shell2 is active const width = 80; - const { lastFrame, unmount } = await render( + const { lastFrame, waitUntilReady, unmount } = render( ', () => { , width, ); + await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); unmount(); @@ -387,7 +402,7 @@ describe('', () => { mockShells.set(exitedShell.pid, exitedShell); const width = 80; - const { lastFrame, unmount } = await render( + const { lastFrame, waitUntilReady, unmount } = render( ', () => { , width, ); + await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); unmount(); diff --git a/packages/cli/src/ui/components/Banner.test.tsx b/packages/cli/src/ui/components/Banner.test.tsx index 7219cf4861..46c47b8a71 100644 --- a/packages/cli/src/ui/components/Banner.test.tsx +++ b/packages/cli/src/ui/components/Banner.test.tsx @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { renderWithProviders } from '../../test-utils/render.js'; +import { render } from '../../test-utils/render.js'; import { Banner } from './Banner.js'; import { describe, it, expect } from 'vitest'; @@ -12,23 +12,22 @@ describe('Banner', () => { it.each([ ['warning mode', true, 'Warning Message'], ['info mode', false, 'Info Message'], - ['multi-line warning', true, 'Title Line\\nBody Line 1\\nBody Line 2'], ])('renders in %s', async (_, isWarning, text) => { - const renderResult = await renderWithProviders( + const { lastFrame, waitUntilReady, unmount } = render( , ); - await renderResult.waitUntilReady(); - await expect(renderResult).toMatchSvgSnapshot(); - renderResult.unmount(); + await waitUntilReady(); + expect(lastFrame()).toMatchSnapshot(); + unmount(); }); it('handles newlines in text', async () => { const text = 'Line 1\\nLine 2'; - const renderResult = await renderWithProviders( + const { lastFrame, waitUntilReady, unmount } = render( , ); - await renderResult.waitUntilReady(); - await expect(renderResult).toMatchSvgSnapshot(); - renderResult.unmount(); + await waitUntilReady(); + expect(lastFrame()).toMatchSnapshot(); + unmount(); }); }); diff --git a/packages/cli/src/ui/components/Banner.tsx b/packages/cli/src/ui/components/Banner.tsx index 3f9777aa45..99f573a68e 100644 --- a/packages/cli/src/ui/components/Banner.tsx +++ b/packages/cli/src/ui/components/Banner.tsx @@ -14,21 +14,20 @@ export function getFormattedBannerContent( isWarning: boolean, subsequentLineColor: string, ): ReactNode { + if (isWarning) { + return ( + {rawText.replace(/\\n/g, '\n')} + ); + } + const text = rawText.replace(/\\n/g, '\n'); const lines = text.split('\n'); return lines.map((line, index) => { if (index === 0) { - if (isWarning) { - return ( - - {line} - - ); - } return ( - {line} + {line} ); } diff --git a/packages/cli/src/ui/components/BubblingRegression.test.tsx b/packages/cli/src/ui/components/BubblingRegression.test.tsx index 5e83a6b9eb..b91943b019 100644 --- a/packages/cli/src/ui/components/BubblingRegression.test.tsx +++ b/packages/cli/src/ui/components/BubblingRegression.test.tsx @@ -30,7 +30,7 @@ describe('Key Bubbling Regression', () => { ]; it('does not navigate when pressing "j" or "k" in a focused text input', async () => { - const { stdin, lastFrame } = await renderWithProviders( + const { stdin, lastFrame } = renderWithProviders( ', () => { ]; it('renders nothing when list is empty', async () => { - const { lastFrame } = await render( + const { lastFrame, waitUntilReady } = render( , ); + await waitUntilReady(); expect(lastFrame({ allowEmpty: true })).toBe(''); }); @@ -29,14 +30,15 @@ describe('', () => { { status: 'completed', label: 'Task 1' }, { status: 'cancelled', label: 'Task 2' }, ]; - const { lastFrame } = await render( + const { lastFrame, waitUntilReady } = render( , ); + await waitUntilReady(); expect(lastFrame({ allowEmpty: true })).toBe(''); }); it('renders summary view correctly (collapsed)', async () => { - const { lastFrame } = await render( + const { lastFrame, waitUntilReady } = render( ', () => { toggleHint="toggle me" />, ); + await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); }); it('renders expanded view correctly', async () => { - const { lastFrame } = await render( + const { lastFrame, waitUntilReady } = render( ', () => { toggleHint="toggle me" />, ); + await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); }); @@ -64,9 +68,10 @@ describe('', () => { { status: 'completed', label: 'Task 1' }, { status: 'pending', label: 'Task 2' }, ]; - const { lastFrame } = await render( + const { lastFrame, waitUntilReady } = render( , ); + await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); }); }); diff --git a/packages/cli/src/ui/components/ChecklistItem.test.tsx b/packages/cli/src/ui/components/ChecklistItem.test.tsx index c71af523e1..0f6c0eb0b0 100644 --- a/packages/cli/src/ui/components/ChecklistItem.test.tsx +++ b/packages/cli/src/ui/components/ChecklistItem.test.tsx @@ -15,9 +15,9 @@ describe('', () => { { 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 } = await render(); + const { lastFrame, waitUntilReady } = render(); + await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); }); @@ -27,11 +27,12 @@ describe('', () => { label: 'This is a very long text that should be truncated because the wrap prop is set to truncate', }; - const { lastFrame } = await render( + const { lastFrame, waitUntilReady } = render( , ); + await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); }); @@ -41,11 +42,12 @@ describe('', () => { label: 'This is a very long text that should wrap because the default behavior is wrapping', }; - const { lastFrame } = await render( + const { lastFrame, waitUntilReady } = render( , ); + await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); }); }); diff --git a/packages/cli/src/ui/components/ChecklistItem.tsx b/packages/cli/src/ui/components/ChecklistItem.tsx index 065c79d516..6e08e0af6b 100644 --- a/packages/cli/src/ui/components/ChecklistItem.tsx +++ b/packages/cli/src/ui/components/ChecklistItem.tsx @@ -13,8 +13,7 @@ export type ChecklistStatus = | 'pending' | 'in_progress' | 'completed' - | 'cancelled' - | 'blocked'; + | 'cancelled'; export interface ChecklistItemData { status: ChecklistStatus; @@ -49,12 +48,6 @@ const ChecklistStatusDisplay: React.FC<{ status: ChecklistStatus }> = ({ ✗ ); - case 'blocked': - return ( - - ⛔ - - ); default: checkExhaustive(status); } @@ -77,7 +70,6 @@ export const ChecklistItem: React.FC = ({ return theme.text.accent; case 'completed': case 'cancelled': - case 'blocked': return theme.text.secondary; case 'pending': return theme.text.primary; diff --git a/packages/cli/src/ui/components/CliSpinner.test.tsx b/packages/cli/src/ui/components/CliSpinner.test.tsx index 4da6abb199..738c487698 100644 --- a/packages/cli/src/ui/components/CliSpinner.test.tsx +++ b/packages/cli/src/ui/components/CliSpinner.test.tsx @@ -17,7 +17,8 @@ describe('', () => { it('should increment debugNumAnimatedComponents on mount and decrement on unmount', async () => { expect(debugState.debugNumAnimatedComponents).toBe(0); - const { unmount } = await renderWithProviders(); + const { waitUntilReady, unmount } = renderWithProviders(); + await waitUntilReady(); expect(debugState.debugNumAnimatedComponents).toBe(1); unmount(); expect(debugState.debugNumAnimatedComponents).toBe(0); @@ -25,9 +26,11 @@ describe('', () => { it('should not render when showSpinner is false', async () => { const settings = createMockSettings({ ui: { showSpinner: false } }); - const { lastFrame, unmount } = await renderWithProviders(, { - settings, - }); + const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + , + { settings }, + ); + await waitUntilReady(); expect(lastFrame({ allowEmpty: true })).toBe(''); unmount(); }); diff --git a/packages/cli/src/ui/components/ColorsDisplay.test.tsx b/packages/cli/src/ui/components/ColorsDisplay.test.tsx index d934831c0e..ec44bd6406 100644 --- a/packages/cli/src/ui/components/ColorsDisplay.test.tsx +++ b/packages/cli/src/ui/components/ColorsDisplay.test.tsx @@ -96,9 +96,10 @@ describe('ColorsDisplay', () => { it('renders correctly', async () => { const mockTheme = themeManager.getActiveTheme(); - const { lastFrame, unmount } = await renderWithProviders( + const { lastFrame, waitUntilReady, unmount } = renderWithProviders( , ); + await waitUntilReady(); const output = lastFrame(); // Check for title and description diff --git a/packages/cli/src/ui/components/Composer.test.tsx b/packages/cli/src/ui/components/Composer.test.tsx index 8df5f690e7..84f8d15a06 100644 --- a/packages/cli/src/ui/components/Composer.test.tsx +++ b/packages/cli/src/ui/components/Composer.test.tsx @@ -183,6 +183,7 @@ const createMockUIState = (overrides: Partial = {}): UIState => ideContextState: null, geminiMdFileCount: 0, renderMarkdown: true, + filteredConsoleMessages: [], history: [], sessionStats: { sessionId: 'test-session', @@ -251,7 +252,7 @@ const renderComposer = async ( config = createMockConfig(), uiActions = createMockUIActions(), ) => { - const result = await render( + const result = render( @@ -262,6 +263,7 @@ const renderComposer = async ( , ); + await result.waitUntilReady(); // Wait for shortcuts hint debounce if using fake timers if (vi.isFakeTimers()) { @@ -406,7 +408,7 @@ describe('Composer', () => { thought: { subject: 'Hidden', description: 'Should not show' }, }); const settings = createMockSettings({ - ui: { loadingPhrases: 'off' }, + merged: { ui: { loadingPhrases: 'off' } }, }); const { lastFrame } = await renderComposer(uiState, settings); @@ -755,6 +757,13 @@ describe('Composer', () => { it('shows DetailedMessagesDisplay when showErrorDetails is true', async () => { const uiState = createMockUIState({ showErrorDetails: true, + filteredConsoleMessages: [ + { + type: 'error', + content: 'Test error', + count: 1, + }, + ], }); const { lastFrame } = await renderComposer(uiState); diff --git a/packages/cli/src/ui/components/Composer.tsx b/packages/cli/src/ui/components/Composer.tsx index 89c9c9d3d6..0864b8f02b 100644 --- a/packages/cli/src/ui/components/Composer.tsx +++ b/packages/cli/src/ui/components/Composer.tsx @@ -422,6 +422,7 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => { { }); it('renders initial state', async () => { - const { lastFrame } = await renderWithProviders(); + const { lastFrame, waitUntilReady } = renderWithProviders( + , + ); + await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); }); @@ -56,7 +59,7 @@ describe('ConfigInitDisplay', () => { return coreEvents; }); - const { lastFrame } = await renderWithProviders(); + const { lastFrame } = renderWithProviders(); // Wait for listener to be registered await waitFor(() => { @@ -94,7 +97,7 @@ describe('ConfigInitDisplay', () => { return coreEvents; }); - const { lastFrame } = await renderWithProviders(); + const { lastFrame } = renderWithProviders(); await waitFor(() => { if (!listener) throw new Error('Listener not registered yet'); @@ -130,7 +133,7 @@ describe('ConfigInitDisplay', () => { return coreEvents; }); - const { lastFrame } = await renderWithProviders(); + const { lastFrame } = renderWithProviders(); await waitFor(() => { if (!listener) throw new Error('Listener not registered yet'); diff --git a/packages/cli/src/ui/components/ConsentPrompt.test.tsx b/packages/cli/src/ui/components/ConsentPrompt.test.tsx index 09a2dde16e..dd69c44dd5 100644 --- a/packages/cli/src/ui/components/ConsentPrompt.test.tsx +++ b/packages/cli/src/ui/components/ConsentPrompt.test.tsx @@ -33,13 +33,14 @@ describe('ConsentPrompt', () => { it('renders a string prompt with MarkdownDisplay', async () => { const prompt = 'Are you sure?'; - const { unmount } = await render( + const { waitUntilReady, unmount } = render( , ); + await waitUntilReady(); expect(MockedMarkdownDisplay).toHaveBeenCalledWith( { @@ -54,13 +55,14 @@ describe('ConsentPrompt', () => { it('renders a ReactNode prompt directly', async () => { const prompt = Are you sure?; - const { lastFrame, unmount } = await render( + const { lastFrame, waitUntilReady, unmount } = render( , ); + await waitUntilReady(); expect(MockedMarkdownDisplay).not.toHaveBeenCalled(); expect(lastFrame()).toContain('Are you sure?'); @@ -69,13 +71,14 @@ describe('ConsentPrompt', () => { it('calls onConfirm with true when "Yes" is selected', async () => { const prompt = 'Are you sure?'; - const { waitUntilReady, unmount } = await render( + const { waitUntilReady, unmount } = render( , ); + await waitUntilReady(); const onSelect = MockedRadioButtonSelect.mock.calls[0][0].onSelect; await act(async () => { @@ -89,13 +92,14 @@ describe('ConsentPrompt', () => { it('calls onConfirm with false when "No" is selected', async () => { const prompt = 'Are you sure?'; - const { waitUntilReady, unmount } = await render( + const { waitUntilReady, unmount } = render( , ); + await waitUntilReady(); const onSelect = MockedRadioButtonSelect.mock.calls[0][0].onSelect; await act(async () => { @@ -109,13 +113,14 @@ describe('ConsentPrompt', () => { it('passes correct items to RadioButtonSelect', async () => { const prompt = 'Are you sure?'; - const { unmount } = await render( + const { waitUntilReady, unmount } = render( , ); + await waitUntilReady(); expect(MockedRadioButtonSelect).toHaveBeenCalledWith( expect.objectContaining({ diff --git a/packages/cli/src/ui/components/ConsoleSummaryDisplay.test.tsx b/packages/cli/src/ui/components/ConsoleSummaryDisplay.test.tsx index b7662c3a26..cb8db1a895 100644 --- a/packages/cli/src/ui/components/ConsoleSummaryDisplay.test.tsx +++ b/packages/cli/src/ui/components/ConsoleSummaryDisplay.test.tsx @@ -10,9 +10,10 @@ import { describe, it, expect } from 'vitest'; describe('ConsoleSummaryDisplay', () => { it('renders nothing when errorCount is 0', async () => { - const { lastFrame, unmount } = await render( + const { lastFrame, waitUntilReady, unmount } = render( , ); + await waitUntilReady(); expect(lastFrame({ allowEmpty: true })).toBe(''); unmount(); }); @@ -21,9 +22,10 @@ describe('ConsoleSummaryDisplay', () => { [1, '1 error'], [5, '5 errors'], ])('renders correct message for %i errors', async (count, expectedText) => { - const { lastFrame, unmount } = await render( + const { lastFrame, waitUntilReady, unmount } = render( , ); + await waitUntilReady(); const output = lastFrame(); expect(output).toContain(expectedText); expect(output).toContain('✖'); diff --git a/packages/cli/src/ui/components/ContextSummaryDisplay.test.tsx b/packages/cli/src/ui/components/ContextSummaryDisplay.test.tsx index 1049e97912..f48cfb2a31 100644 --- a/packages/cli/src/ui/components/ContextSummaryDisplay.test.tsx +++ b/packages/cli/src/ui/components/ContextSummaryDisplay.test.tsx @@ -26,7 +26,8 @@ const renderWithWidth = async ( props: React.ComponentProps, ) => { useTerminalSizeMock.mockReturnValue({ columns: width, rows: 24 }); - const result = await render(); + const result = render(); + await result.waitUntilReady(); return result; }; diff --git a/packages/cli/src/ui/components/ContextUsageDisplay.test.tsx b/packages/cli/src/ui/components/ContextUsageDisplay.test.tsx index d8ec1650ee..dcb2a3eae7 100644 --- a/packages/cli/src/ui/components/ContextUsageDisplay.test.tsx +++ b/packages/cli/src/ui/components/ContextUsageDisplay.test.tsx @@ -19,33 +19,35 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => { describe('ContextUsageDisplay', () => { it('renders correct percentage used', async () => { - const { lastFrame, unmount } = await renderWithProviders( + const { lastFrame, waitUntilReady, unmount } = renderWithProviders( , ); + await waitUntilReady(); const output = lastFrame(); expect(output).toContain('50% used'); unmount(); }); it('renders correctly when usage is 0%', async () => { - const { lastFrame, unmount } = await renderWithProviders( + const { lastFrame, waitUntilReady, unmount } = renderWithProviders( , ); + await waitUntilReady(); const output = lastFrame(); expect(output).toContain('0% used'); unmount(); }); it('renders abbreviated label when terminal width is small', async () => { - const { lastFrame, unmount } = await renderWithProviders( + const { lastFrame, waitUntilReady, unmount } = renderWithProviders( { />, { width: 80 }, ); + await waitUntilReady(); const output = lastFrame(); expect(output).toContain('20%'); expect(output).not.toContain('context used'); @@ -60,26 +63,28 @@ describe('ContextUsageDisplay', () => { }); it('renders 80% correctly', async () => { - const { lastFrame, unmount } = await renderWithProviders( + const { lastFrame, waitUntilReady, unmount } = renderWithProviders( , ); + await waitUntilReady(); const output = lastFrame(); expect(output).toContain('80% used'); unmount(); }); it('renders 100% when full', async () => { - const { lastFrame, unmount } = await renderWithProviders( + const { lastFrame, waitUntilReady, unmount } = renderWithProviders( , ); + await waitUntilReady(); const output = lastFrame(); expect(output).toContain('100% used'); unmount(); diff --git a/packages/cli/src/ui/components/CopyModeWarning.test.tsx b/packages/cli/src/ui/components/CopyModeWarning.test.tsx index cc20a142dd..6f202ced4a 100644 --- a/packages/cli/src/ui/components/CopyModeWarning.test.tsx +++ b/packages/cli/src/ui/components/CopyModeWarning.test.tsx @@ -22,7 +22,8 @@ describe('CopyModeWarning', () => { mockUseUIState.mockReturnValue({ copyModeEnabled: false, } as unknown as UIState); - const { lastFrame, unmount } = await render(); + const { lastFrame, waitUntilReady, unmount } = render(); + await waitUntilReady(); expect(lastFrame({ allowEmpty: true })).toBe(''); unmount(); }); @@ -31,7 +32,8 @@ describe('CopyModeWarning', () => { mockUseUIState.mockReturnValue({ copyModeEnabled: true, } as unknown as UIState); - const { lastFrame, unmount } = await render(); + const { lastFrame, waitUntilReady, unmount } = render(); + await waitUntilReady(); expect(lastFrame()).toContain('In Copy Mode'); expect(lastFrame()).toContain('Use Page Up/Down to scroll'); expect(lastFrame()).toContain('Press Ctrl+S or any other key to exit'); diff --git a/packages/cli/src/ui/components/DebugProfiler.test.tsx b/packages/cli/src/ui/components/DebugProfiler.test.tsx index a014c740f0..d4c0e28902 100644 --- a/packages/cli/src/ui/components/DebugProfiler.test.tsx +++ b/packages/cli/src/ui/components/DebugProfiler.test.tsx @@ -242,7 +242,8 @@ describe('DebugProfiler Component', () => { showDebugProfiler: false, constrainHeight: false, } as unknown as UIState); - const { lastFrame, unmount } = await render(); + const { lastFrame, waitUntilReady, unmount } = render(); + await waitUntilReady(); expect(lastFrame({ allowEmpty: true })).toBe(''); unmount(); }); @@ -256,7 +257,8 @@ describe('DebugProfiler Component', () => { profiler.totalIdleFrames = 5; profiler.totalFlickerFrames = 2; - const { lastFrame, unmount } = await render(); + const { lastFrame, waitUntilReady, unmount } = render(); + await waitUntilReady(); const output = lastFrame(); expect(output).toContain('Renders: 10 (total)'); @@ -273,7 +275,8 @@ describe('DebugProfiler Component', () => { const reportActionSpy = vi.spyOn(profiler, 'reportAction'); - const { waitUntilReady, unmount } = await render(); + const { waitUntilReady, unmount } = render(); + await waitUntilReady(); await act(async () => { coreEvents.emitModelChanged('new-model'); @@ -292,7 +295,8 @@ describe('DebugProfiler Component', () => { const reportActionSpy = vi.spyOn(profiler, 'reportAction'); - const { waitUntilReady, unmount } = await render(); + const { waitUntilReady, unmount } = render(); + await waitUntilReady(); await act(async () => { appEvents.emit(AppEvent.SelectionWarning); diff --git a/packages/cli/src/ui/components/DetailedMessagesDisplay.test.tsx b/packages/cli/src/ui/components/DetailedMessagesDisplay.test.tsx index 30f98a6eda..65d54e50d6 100644 --- a/packages/cli/src/ui/components/DetailedMessagesDisplay.test.tsx +++ b/packages/cli/src/ui/components/DetailedMessagesDisplay.test.tsx @@ -6,16 +6,11 @@ import { renderWithProviders } from '../../test-utils/render.js'; import { DetailedMessagesDisplay } from './DetailedMessagesDisplay.js'; -import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { describe, it, expect, vi } from 'vitest'; import type { ConsoleMessageItem } from '../types.js'; import { Box } from 'ink'; import type React from 'react'; import { createMockSettings } from '../../test-utils/settings.js'; -import { useConsoleMessages } from '../hooks/useConsoleMessages.js'; - -vi.mock('../hooks/useConsoleMessages.js', () => ({ - useConsoleMessages: vi.fn(), -})); vi.mock('./shared/ScrollableList.js', () => ({ ScrollableList: ({ @@ -34,19 +29,21 @@ vi.mock('./shared/ScrollableList.js', () => ({ })); describe('DetailedMessagesDisplay', () => { - beforeEach(() => { - vi.mocked(useConsoleMessages).mockReturnValue({ - consoleMessages: [], - clearConsoleMessages: vi.fn(), - }); - }); it('renders nothing when messages are empty', async () => { - const { lastFrame, unmount } = await renderWithProviders( - , + const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + , { - settings: createMockSettings({ ui: { errorVerbosity: 'full' } }), + settings: createMockSettings({ + merged: { ui: { errorVerbosity: 'full' } }, + }), }, ); + await waitUntilReady(); expect(lastFrame({ allowEmpty: true })).toBe(''); unmount(); }); @@ -58,17 +55,21 @@ describe('DetailedMessagesDisplay', () => { { type: 'error', content: 'Error message', count: 1 }, { type: 'debug', content: 'Debug message', count: 1 }, ]; - vi.mocked(useConsoleMessages).mockReturnValue({ - consoleMessages: messages, - clearConsoleMessages: vi.fn(), - }); - const { lastFrame, unmount } = await renderWithProviders( - , + const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + , { - settings: createMockSettings({ ui: { errorVerbosity: 'full' } }), + settings: createMockSettings({ + merged: { ui: { errorVerbosity: 'full' } }, + }), }, ); + await waitUntilReady(); const output = lastFrame(); expect(output).toMatchSnapshot(); @@ -79,17 +80,21 @@ describe('DetailedMessagesDisplay', () => { const messages: ConsoleMessageItem[] = [ { type: 'error', content: 'Error message', count: 1 }, ]; - vi.mocked(useConsoleMessages).mockReturnValue({ - consoleMessages: messages, - clearConsoleMessages: vi.fn(), - }); - const { lastFrame, unmount } = await renderWithProviders( - , + const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + , { - settings: createMockSettings({ ui: { errorVerbosity: 'low' } }), + settings: createMockSettings({ + merged: { ui: { errorVerbosity: 'low' } }, + }), }, ); + await waitUntilReady(); expect(lastFrame()).toContain('(F12 to close)'); unmount(); }); @@ -98,17 +103,21 @@ describe('DetailedMessagesDisplay', () => { const messages: ConsoleMessageItem[] = [ { type: 'error', content: 'Error message', count: 1 }, ]; - vi.mocked(useConsoleMessages).mockReturnValue({ - consoleMessages: messages, - clearConsoleMessages: vi.fn(), - }); - const { lastFrame, unmount } = await renderWithProviders( - , + const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + , { - settings: createMockSettings({ ui: { errorVerbosity: 'full' } }), + settings: createMockSettings({ + merged: { ui: { errorVerbosity: 'full' } }, + }), }, ); + await waitUntilReady(); expect(lastFrame()).toContain('(F12 to close)'); unmount(); }); @@ -117,17 +126,21 @@ describe('DetailedMessagesDisplay', () => { const messages: ConsoleMessageItem[] = [ { type: 'log', content: 'Repeated message', count: 5 }, ]; - vi.mocked(useConsoleMessages).mockReturnValue({ - consoleMessages: messages, - clearConsoleMessages: vi.fn(), - }); - const { lastFrame, unmount } = await renderWithProviders( - , + const { lastFrame, waitUntilReady, unmount } = renderWithProviders( + , { - settings: createMockSettings({ ui: { errorVerbosity: 'full' } }), + settings: createMockSettings({ + merged: { ui: { errorVerbosity: 'full' } }, + }), }, ); + await waitUntilReady(); const output = lastFrame(); expect(output).toMatchSnapshot(); diff --git a/packages/cli/src/ui/components/DetailedMessagesDisplay.tsx b/packages/cli/src/ui/components/DetailedMessagesDisplay.tsx index 2daa1c39e3..13f3872e5d 100644 --- a/packages/cli/src/ui/components/DetailedMessagesDisplay.tsx +++ b/packages/cli/src/ui/components/DetailedMessagesDisplay.tsx @@ -5,7 +5,7 @@ */ import type React from 'react'; -import { useRef, useCallback, useMemo } from 'react'; +import { useRef, useCallback } from 'react'; import { Box, Text } from 'ink'; import { theme } from '../semantic-colors.js'; import type { ConsoleMessageItem } from '../types.js'; @@ -13,10 +13,9 @@ import { ScrollableList, type ScrollableListRef, } from './shared/ScrollableList.js'; -import { useConsoleMessages } from '../hooks/useConsoleMessages.js'; -import { useConfig } from '../contexts/ConfigContext.js'; interface DetailedMessagesDisplayProps { + messages: ConsoleMessageItem[]; maxHeight: number | undefined; width: number; hasFocus: boolean; @@ -26,19 +25,9 @@ const iconBoxWidth = 3; export const DetailedMessagesDisplay: React.FC< DetailedMessagesDisplayProps -> = ({ maxHeight, width, hasFocus }) => { +> = ({ messages, maxHeight, width, hasFocus }) => { const scrollableListRef = useRef>(null); - const { consoleMessages } = useConsoleMessages(); - const config = useConfig(); - - const messages = useMemo(() => { - if (config.getDebugMode()) { - return consoleMessages; - } - return consoleMessages.filter((msg) => msg.type !== 'debug'); - }, [consoleMessages, config]); - const borderAndPadding = 3; const estimatedItemHeight = useCallback( diff --git a/packages/cli/src/ui/components/DialogManager.test.tsx b/packages/cli/src/ui/components/DialogManager.test.tsx index 31b28f5223..6329ca89a1 100644 --- a/packages/cli/src/ui/components/DialogManager.test.tsx +++ b/packages/cli/src/ui/components/DialogManager.test.tsx @@ -104,10 +104,11 @@ describe('DialogManager', () => { }; it('renders nothing by default', async () => { - const { lastFrame, unmount } = await renderWithProviders( + const { lastFrame, waitUntilReady, unmount } = renderWithProviders( , { uiState: baseUiState as Partial as UIState }, ); + await waitUntilReady(); expect(lastFrame({ allowEmpty: true })).toBe(''); unmount(); }); @@ -196,7 +197,7 @@ describe('DialogManager', () => { it.each(testCases)( 'renders %s when state is %o', async (uiStateOverride, expectedComponent) => { - const { lastFrame, unmount } = await renderWithProviders( + const { lastFrame, waitUntilReady, unmount } = renderWithProviders( , { uiState: { @@ -205,6 +206,7 @@ describe('DialogManager', () => { } as Partial as UIState, }, ); + await waitUntilReady(); expect(lastFrame()).toContain(expectedComponent); unmount(); }, diff --git a/packages/cli/src/ui/components/EditorSettingsDialog.test.tsx b/packages/cli/src/ui/components/EditorSettingsDialog.test.tsx index 18b47def7b..6ebe22d982 100644 --- a/packages/cli/src/ui/components/EditorSettingsDialog.test.tsx +++ b/packages/cli/src/ui/components/EditorSettingsDialog.test.tsx @@ -4,10 +4,11 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { renderWithProviders } from '../../test-utils/render.js'; +import { render } 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'; @@ -51,41 +52,44 @@ describe('EditorSettingsDialog', () => { vi.clearAllMocks(); }); - const renderWithProvider = async (ui: React.ReactElement) => - renderWithProviders(ui); + const renderWithProvider = (ui: React.ReactNode) => + render({ui}); it('renders correctly', async () => { - const { lastFrame } = await renderWithProvider( + const { lastFrame, waitUntilReady } = renderWithProvider( , ); + await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); }); it('calls onSelect when an editor is selected', async () => { const onSelect = vi.fn(); - const { lastFrame } = await renderWithProvider( + const { lastFrame, waitUntilReady } = renderWithProvider( , ); + await waitUntilReady(); expect(lastFrame()).toContain('VS Code'); }); it('switches focus between editor and scope sections on Tab', async () => { - const { lastFrame, stdin, waitUntilReady } = await renderWithProvider( + const { lastFrame, stdin, waitUntilReady } = renderWithProvider( , ); + await waitUntilReady(); // Initial focus on editor expect(lastFrame()).toContain('> Select Editor'); @@ -124,13 +128,14 @@ describe('EditorSettingsDialog', () => { it('calls onExit when Escape is pressed', async () => { const onExit = vi.fn(); - const { stdin, waitUntilReady } = await renderWithProvider( + const { stdin, waitUntilReady } = renderWithProvider( , ); + await waitUntilReady(); await act(async () => { stdin.write('\u001B'); // Escape @@ -158,13 +163,14 @@ describe('EditorSettingsDialog', () => { }, } as unknown as LoadedSettings; - const { lastFrame } = await renderWithProvider( + const { lastFrame, waitUntilReady } = renderWithProvider( , ); + await waitUntilReady(); const frame = lastFrame() || ''; if (!frame.includes('(Also modified')) { diff --git a/packages/cli/src/ui/components/EmptyWalletDialog.test.tsx b/packages/cli/src/ui/components/EmptyWalletDialog.test.tsx index 74de1a8a41..6f8f063c43 100644 --- a/packages/cli/src/ui/components/EmptyWalletDialog.test.tsx +++ b/packages/cli/src/ui/components/EmptyWalletDialog.test.tsx @@ -30,7 +30,7 @@ describe('EmptyWalletDialog', () => { describe('rendering', () => { it('should match snapshot with fallback available', async () => { - const { lastFrame, unmount } = await renderWithProviders( + const { lastFrame, unmount, waitUntilReady } = renderWithProviders( { onChoice={mockOnChoice} />, ); + await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); unmount(); }); it('should match snapshot without fallback', async () => { - const { lastFrame, unmount } = await renderWithProviders( + const { lastFrame, unmount, waitUntilReady } = renderWithProviders( , ); + await waitUntilReady(); expect(lastFrame()).toMatchSnapshot(); unmount(); }); it('should display the model name and usage limit message', async () => { - const { lastFrame, unmount } = await renderWithProviders( + const { lastFrame, unmount, waitUntilReady } = renderWithProviders( , ); + await waitUntilReady(); const output = lastFrame() ?? ''; expect(output).toContain('gemini-2.5-pro'); @@ -70,12 +73,13 @@ describe('EmptyWalletDialog', () => { }); it('should display purchase prompt and credits update notice', async () => { - const { lastFrame, unmount } = await renderWithProviders( + const { lastFrame, unmount, waitUntilReady } = renderWithProviders( , ); + await waitUntilReady(); const output = lastFrame() ?? ''; expect(output).toContain('purchase more AI Credits'); @@ -86,13 +90,14 @@ describe('EmptyWalletDialog', () => { }); it('should display reset time when provided', async () => { - const { lastFrame, unmount } = await renderWithProviders( + const { lastFrame, unmount, waitUntilReady } = renderWithProviders( , ); + await waitUntilReady(); const output = lastFrame() ?? ''; expect(output).toContain('3:45 PM'); @@ -101,12 +106,13 @@ describe('EmptyWalletDialog', () => { }); it('should not display reset time when not provided', async () => { - const { lastFrame, unmount } = await renderWithProviders( + const { lastFrame, unmount, waitUntilReady } = renderWithProviders( , ); + await waitUntilReady(); const output = lastFrame() ?? ''; expect(output).not.toContain('Access resets at'); @@ -114,12 +120,13 @@ describe('EmptyWalletDialog', () => { }); it('should display slash command hints', async () => { - const { lastFrame, unmount } = await renderWithProviders( + const { lastFrame, unmount, waitUntilReady } = renderWithProviders( , ); + await waitUntilReady(); const output = lastFrame() ?? ''; expect(output).toContain('/stats'); @@ -132,13 +139,14 @@ describe('EmptyWalletDialog', () => { describe('onChoice handling', () => { it('should call onGetCredits and onChoice when get_credits is selected', async () => { // get_credits is the first item, so just press Enter - const { unmount, stdin } = await renderWithProviders( + const { unmount, stdin, waitUntilReady } = renderWithProviders( , ); + await waitUntilReady(); writeKey(stdin, '\r'); @@ -150,12 +158,13 @@ describe('EmptyWalletDialog', () => { }); it('should call onChoice without onGetCredits when onGetCredits is not provided', async () => { - const { unmount, stdin } = await renderWithProviders( + const { unmount, stdin, waitUntilReady } = renderWithProviders( , ); + await waitUntilReady(); writeKey(stdin, '\r'); @@ -168,13 +177,14 @@ describe('EmptyWalletDialog', () => { it('should call onChoice with use_fallback when selected', async () => { // With fallback: items are [get_credits, use_fallback, stop] // use_fallback is the second item: Down + Enter - const { unmount, stdin } = await renderWithProviders( + const { unmount, stdin, waitUntilReady } = renderWithProviders( , ); + await waitUntilReady(); writeKey(stdin, '\x1b[B'); // Down arrow writeKey(stdin, '\r'); @@ -188,12 +198,13 @@ describe('EmptyWalletDialog', () => { it('should call onChoice with stop when selected', async () => { // Without fallback: items are [get_credits, stop] // stop is the second item: Down + Enter - const { unmount, stdin } = await renderWithProviders( + const { unmount, stdin, waitUntilReady } = renderWithProviders( , ); + await waitUntilReady(); writeKey(stdin, '\x1b[B'); // Down arrow writeKey(stdin, '\r'); diff --git a/packages/cli/src/ui/components/ExitPlanModeDialog.test.tsx b/packages/cli/src/ui/components/ExitPlanModeDialog.test.tsx index d6fc23dd70..33daca1e33 100644 --- a/packages/cli/src/ui/components/ExitPlanModeDialog.test.tsx +++ b/packages/cli/src/ui/components/ExitPlanModeDialog.test.tsx @@ -7,7 +7,6 @@ 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'; @@ -139,9 +138,8 @@ Implement a comprehensive authentication system with multiple providers. vi.restoreAllMocks(); }); - const renderDialog = async (options?: { useAlternateBuffer?: boolean }) => { - const useAlternateBuffer = options?.useAlternateBuffer ?? true; - return renderWithProviders( + const renderDialog = (options?: { useAlternateBuffer?: boolean }) => + renderWithProviders( useAlternateBuffer, + getUseAlternateBuffer: () => options?.useAlternateBuffer ?? true, } as unknown as import('@google/gemini-cli-core').Config, - settings: createMockSettings({ ui: { useAlternateBuffer } }), }, ); - }; describe.each([{ useAlternateBuffer: true }, { useAlternateBuffer: false }])( 'useAlternateBuffer: $useAlternateBuffer', ({ useAlternateBuffer }) => { it('renders correctly with plan content', async () => { - const { lastFrame } = await act(async () => - renderDialog({ useAlternateBuffer }), - ); + const { lastFrame } = renderDialog({ useAlternateBuffer }); // Advance timers to pass the debounce period await act(async () => { @@ -201,9 +195,7 @@ Implement a comprehensive authentication system with multiple providers. }); it('calls onApprove with AUTO_EDIT when first option is selected', async () => { - const { stdin, lastFrame } = await act(async () => - renderDialog({ useAlternateBuffer }), - ); + const { stdin, lastFrame } = renderDialog({ useAlternateBuffer }); await act(async () => { vi.runAllTimers(); @@ -221,9 +213,7 @@ Implement a comprehensive authentication system with multiple providers. }); it('calls onApprove with DEFAULT when second option is selected', async () => { - const { stdin, lastFrame } = await act(async () => - renderDialog({ useAlternateBuffer }), - ); + const { stdin, lastFrame } = renderDialog({ useAlternateBuffer }); await act(async () => { vi.runAllTimers(); @@ -242,9 +232,7 @@ Implement a comprehensive authentication system with multiple providers. }); it('calls onFeedback when feedback is typed and submitted', async () => { - const { stdin, lastFrame } = await act(async () => - renderDialog({ useAlternateBuffer }), - ); + const { stdin, lastFrame } = renderDialog({ useAlternateBuffer }); await act(async () => { vi.runAllTimers(); @@ -275,9 +263,7 @@ Implement a comprehensive authentication system with multiple providers. }); it('calls onCancel when Esc is pressed', async () => { - const { stdin, lastFrame } = await act(async () => - renderDialog({ useAlternateBuffer }), - ); + const { stdin, lastFrame } = renderDialog({ useAlternateBuffer }); await act(async () => { vi.runAllTimers(); @@ -303,9 +289,7 @@ Implement a comprehensive authentication system with multiple providers. error: 'File not found', }); - const { lastFrame } = await act(async () => - renderDialog({ useAlternateBuffer }), - ); + const { lastFrame } = renderDialog({ useAlternateBuffer }); await act(async () => { vi.runAllTimers(); @@ -321,9 +305,7 @@ Implement a comprehensive authentication system with multiple providers. it('displays error state when plan file is empty', async () => { vi.mocked(validatePlanContent).mockResolvedValue('Plan file is empty.'); - const { lastFrame } = await act(async () => - renderDialog({ useAlternateBuffer }), - ); + const { lastFrame } = renderDialog({ useAlternateBuffer }); await act(async () => { vi.runAllTimers(); @@ -342,9 +324,7 @@ Implement a comprehensive authentication system with multiple providers. returnDisplay: 'Read file', }); - const { lastFrame } = await act(async () => - renderDialog({ useAlternateBuffer }), - ); + const { lastFrame } = renderDialog({ useAlternateBuffer }); await act(async () => { vi.runAllTimers(); @@ -360,9 +340,7 @@ Implement a comprehensive authentication system with multiple providers. }); it('allows number key quick selection', async () => { - const { stdin, lastFrame } = await act(async () => - renderDialog({ useAlternateBuffer }), - ); + const { stdin, lastFrame } = renderDialog({ useAlternateBuffer }); await act(async () => { vi.runAllTimers(); @@ -381,9 +359,7 @@ Implement a comprehensive authentication system with multiple providers. }); it('clears feedback text when Ctrl+C is pressed while editing', async () => { - const { stdin, lastFrame } = await act(async () => - renderDialog({ useAlternateBuffer }), - ); + const { stdin, lastFrame } = renderDialog({ useAlternateBuffer }); await act(async () => { vi.runAllTimers(); @@ -440,38 +416,34 @@ Implement a comprehensive authentication system with multiple providers. return <>{children}; }; - const { stdin, lastFrame } = await act(async () => - renderWithProviders( - - - , - { - config: { - getTargetDir: () => mockTargetDir, - getIdeMode: () => false, - isTrustedFolder: () => true, - storage: { - getPlansDir: () => mockPlansDir, - }, - getFileSystemService: (): FileSystemService => ({ - readTextFile: vi.fn(), - writeTextFile: vi.fn(), - }), - getUseAlternateBuffer: () => useAlternateBuffer ?? true, - } as unknown as import('@google/gemini-cli-core').Config, - settings: createMockSettings({ - ui: { useAlternateBuffer: useAlternateBuffer ?? true }, + const { stdin, lastFrame } = renderWithProviders( + + + , + { + useAlternateBuffer, + config: { + getTargetDir: () => mockTargetDir, + getIdeMode: () => false, + isTrustedFolder: () => true, + storage: { + getPlansDir: () => mockPlansDir, + }, + getFileSystemService: (): FileSystemService => ({ + readTextFile: vi.fn(), + writeTextFile: vi.fn(), }), - }, - ), + getUseAlternateBuffer: () => useAlternateBuffer ?? true, + } as unknown as import('@google/gemini-cli-core').Config, + }, ); await act(async () => { @@ -513,9 +485,7 @@ Implement a comprehensive authentication system with multiple providers. }); it('does not submit empty feedback when Enter is pressed', async () => { - const { stdin, lastFrame } = await act(async () => - renderDialog({ useAlternateBuffer }), - ); + const { stdin, lastFrame } = renderDialog({ useAlternateBuffer }); await act(async () => { vi.runAllTimers(); @@ -542,9 +512,7 @@ Implement a comprehensive authentication system with multiple providers. }); it('allows arrow navigation while typing feedback to change selection', async () => { - const { stdin, lastFrame } = await act(async () => - renderDialog({ useAlternateBuffer }), - ); + const { stdin, lastFrame } = renderDialog({ useAlternateBuffer }); await act(async () => { vi.runAllTimers(); @@ -576,9 +544,7 @@ Implement a comprehensive authentication system with multiple providers. }); it('automatically submits feedback when Ctrl+X is used to edit the plan', async () => { - const { stdin, lastFrame } = await act(async () => - renderDialog({ useAlternateBuffer }), - ); + const { stdin, lastFrame } = renderDialog({ useAlternateBuffer }); await act(async () => { vi.runAllTimers(); diff --git a/packages/cli/src/ui/components/ExitWarning.test.tsx b/packages/cli/src/ui/components/ExitWarning.test.tsx index a504670d03..6d495a5e21 100644 --- a/packages/cli/src/ui/components/ExitWarning.test.tsx +++ b/packages/cli/src/ui/components/ExitWarning.test.tsx @@ -24,7 +24,8 @@ describe('ExitWarning', () => { ctrlCPressedOnce: false, ctrlDPressedOnce: false, } as unknown as UIState); - const { lastFrame, unmount } = await render(); + const { lastFrame, waitUntilReady, unmount } = render(); + await waitUntilReady(); expect(lastFrame({ allowEmpty: true })).toBe(''); unmount(); }); @@ -35,7 +36,8 @@ describe('ExitWarning', () => { ctrlCPressedOnce: true, ctrlDPressedOnce: false, } as unknown as UIState); - const { lastFrame, unmount } = await render(); + const { lastFrame, waitUntilReady, unmount } = render(); + await waitUntilReady(); expect(lastFrame()).toContain('Press Ctrl+C again to exit'); unmount(); }); @@ -46,7 +48,8 @@ describe('ExitWarning', () => { ctrlCPressedOnce: false, ctrlDPressedOnce: true, } as unknown as UIState); - const { lastFrame, unmount } = await render(); + const { lastFrame, waitUntilReady, unmount } = render(); + await waitUntilReady(); expect(lastFrame()).toContain('Press Ctrl+D again to exit'); unmount(); }); @@ -57,7 +60,8 @@ describe('ExitWarning', () => { ctrlCPressedOnce: true, ctrlDPressedOnce: true, } as unknown as UIState); - const { lastFrame, unmount } = await render(); + const { lastFrame, waitUntilReady, unmount } = render(); + await waitUntilReady(); expect(lastFrame({ allowEmpty: true })).toBe(''); unmount(); }); diff --git a/packages/cli/src/ui/components/FolderTrustDialog.test.tsx b/packages/cli/src/ui/components/FolderTrustDialog.test.tsx index de6e8096ec..e68417fc55 100644 --- a/packages/cli/src/ui/components/FolderTrustDialog.test.tsx +++ b/packages/cli/src/ui/components/FolderTrustDialog.test.tsx @@ -5,12 +5,11 @@ */ 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', () => ({ @@ -48,9 +47,10 @@ describe('FolderTrustDialog', () => { }); it('should render the dialog with title and description', async () => { - const { lastFrame, unmount } = await renderWithProviders( + const { lastFrame, waitUntilReady, unmount } = renderWithProviders( , ); + await waitUntilReady(); expect(lastFrame()).toContain('Do you trust the files in this folder?'); expect(lastFrame()).toContain( @@ -71,19 +71,19 @@ describe('FolderTrustDialog', () => { discoveryErrors: [], securityWarnings: [], }; - const { lastFrame, unmount } = await renderWithProviders( + const { lastFrame, unmount, waitUntilReady } = renderWithProviders( , { width: 80, - config: makeFakeConfig({ useAlternateBuffer: false }), - settings: createMockSettings({ ui: { useAlternateBuffer: false } }), + useAlternateBuffer: false, uiState: { constrainHeight: true, terminalHeight: 24 }, }, ); + await waitUntilReady(); expect(lastFrame()).toContain('This folder contains:'); expect(lastFrame()).toContain('hidden'); unmount(); @@ -101,19 +101,19 @@ describe('FolderTrustDialog', () => { discoveryErrors: [], securityWarnings: [], }; - const { lastFrame, unmount } = await renderWithProviders( + const { lastFrame, unmount, waitUntilReady } = renderWithProviders( , { width: 80, - config: makeFakeConfig({ useAlternateBuffer: false }), - settings: createMockSettings({ ui: { useAlternateBuffer: false } }), + useAlternateBuffer: false, uiState: { constrainHeight: true, terminalHeight: 14 }, }, ); + await waitUntilReady(); // With maxHeight=4, the intro text (4 lines) will take most of the space. // The discovery results will likely be hidden. expect(lastFrame()).toContain('hidden'); @@ -132,19 +132,19 @@ describe('FolderTrustDialog', () => { discoveryErrors: [], securityWarnings: [], }; - const { lastFrame, unmount } = await renderWithProviders( + const { lastFrame, unmount, waitUntilReady } = renderWithProviders( , { width: 80, - config: makeFakeConfig({ useAlternateBuffer: false }), - settings: createMockSettings({ ui: { useAlternateBuffer: false } }), + useAlternateBuffer: false, uiState: { constrainHeight: true, terminalHeight: 10 }, }, ); + await waitUntilReady(); expect(lastFrame()).toContain('hidden'); unmount(); }); @@ -161,15 +161,14 @@ describe('FolderTrustDialog', () => { securityWarnings: [], }; - const { lastFrame, unmount } = await renderWithProviders( + const { lastFrame, unmount } = renderWithProviders( , { width: 80, - config: makeFakeConfig({ useAlternateBuffer: false }), - settings: createMockSettings({ ui: { useAlternateBuffer: false } }), + useAlternateBuffer: false, // Initially constrained uiState: { constrainHeight: true, terminalHeight: 24 }, }, @@ -178,7 +177,9 @@ describe('FolderTrustDialog', () => { // Initial state: truncated await waitFor(() => { expect(lastFrame()).toContain('Do you trust the files in this folder?'); - expect(lastFrame()).toContain('Press Ctrl+O'); + // In standard terminal mode, the expansion hint is handled globally by ToastDisplay + // via AppContainer, so it should not be present in the dialog's local frame. + expect(lastFrame()).not.toContain('Press Ctrl+O'); expect(lastFrame()).toContain('hidden'); }); @@ -186,15 +187,14 @@ describe('FolderTrustDialog', () => { // because it's handled in AppContainer. // But we can re-render with constrainHeight: false. const { lastFrame: lastFrameExpanded, unmount: unmountExpanded } = - await renderWithProviders( + renderWithProviders( , { width: 80, - config: makeFakeConfig({ useAlternateBuffer: false }), - settings: createMockSettings({ ui: { useAlternateBuffer: false } }), + useAlternateBuffer: false, uiState: { constrainHeight: false, terminalHeight: 24 }, }, ); @@ -211,10 +211,10 @@ describe('FolderTrustDialog', () => { it('should display exit message and call process.exit and not call onSelect when escape is pressed', async () => { const onSelect = vi.fn(); - const { lastFrame, stdin, waitUntilReady, unmount } = - await renderWithProviders( - , - ); + const { lastFrame, stdin, waitUntilReady, unmount } = renderWithProviders( + , + ); + await waitUntilReady(); await act(async () => { stdin.write('\u001b[27u'); // Press kitty escape key @@ -239,9 +239,10 @@ describe('FolderTrustDialog', () => { }); it('should display restart message when isRestarting is true', async () => { - const { lastFrame, unmount } = await renderWithProviders( + const { lastFrame, waitUntilReady, unmount } = renderWithProviders( , ); + await waitUntilReady(); expect(lastFrame()).toContain('Gemini CLI is restarting'); unmount(); @@ -252,9 +253,10 @@ describe('FolderTrustDialog', () => { const relaunchApp = vi .spyOn(processUtils, 'relaunchApp') .mockResolvedValue(undefined); - const { unmount } = await renderWithProviders( + const { waitUntilReady, unmount } = renderWithProviders( , ); + await waitUntilReady(); await vi.advanceTimersByTimeAsync(250); expect(relaunchApp).toHaveBeenCalled(); unmount(); @@ -266,9 +268,10 @@ describe('FolderTrustDialog', () => { const relaunchApp = vi .spyOn(processUtils, 'relaunchApp') .mockResolvedValue(undefined); - const { unmount } = await renderWithProviders( + const { waitUntilReady, unmount } = renderWithProviders( , ); + await waitUntilReady(); // Unmount immediately (before 250ms) unmount(); @@ -279,9 +282,10 @@ describe('FolderTrustDialog', () => { }); it('should not call process.exit when "r" is pressed and isRestarting is false', async () => { - const { stdin, waitUntilReady, unmount } = await renderWithProviders( + const { stdin, waitUntilReady, unmount } = renderWithProviders( , ); + await waitUntilReady(); await act(async () => { stdin.write('r'); @@ -297,27 +301,30 @@ describe('FolderTrustDialog', () => { describe('directory display', () => { it('should correctly display the folder name for a nested directory', async () => { mockedCwd.mockReturnValue('/home/user/project'); - const { lastFrame, unmount } = await renderWithProviders( + const { lastFrame, waitUntilReady, unmount } = renderWithProviders( , ); + await waitUntilReady(); expect(lastFrame()).toContain('Trust folder (project)'); unmount(); }); it('should correctly display the parent folder name for a nested directory', async () => { mockedCwd.mockReturnValue('/home/user/project'); - const { lastFrame, unmount } = await renderWithProviders( + const { lastFrame, waitUntilReady, unmount } = renderWithProviders( , ); + await waitUntilReady(); expect(lastFrame()).toContain('Trust parent folder (user)'); unmount(); }); it('should correctly display an empty parent folder name for a directory directly under root', async () => { mockedCwd.mockReturnValue('/project'); - const { lastFrame, unmount } = await renderWithProviders( + const { lastFrame, waitUntilReady, unmount } = renderWithProviders( , ); + await waitUntilReady(); expect(lastFrame()).toContain('Trust parent folder ()'); unmount(); }); @@ -334,7 +341,7 @@ describe('FolderTrustDialog', () => { discoveryErrors: [], securityWarnings: [], }; - const { lastFrame, unmount } = await renderWithProviders( + const { lastFrame, unmount, waitUntilReady } = renderWithProviders( { { width: 80 }, ); + await waitUntilReady(); expect(lastFrame()).toContain('This folder contains:'); expect(lastFrame()).toContain('• Commands (2):'); expect(lastFrame()).toContain('- cmd1'); @@ -371,13 +379,14 @@ describe('FolderTrustDialog', () => { discoveryErrors: [], securityWarnings: ['Dangerous setting detected!'], }; - const { lastFrame, unmount } = await renderWithProviders( + const { lastFrame, unmount, waitUntilReady } = renderWithProviders( , ); + await waitUntilReady(); expect(lastFrame()).toContain('Security Warnings:'); expect(lastFrame()).toContain('Dangerous setting detected!'); unmount(); @@ -394,13 +403,14 @@ describe('FolderTrustDialog', () => { discoveryErrors: ['Failed to load custom commands'], securityWarnings: [], }; - const { lastFrame, unmount } = await renderWithProviders( + const { lastFrame, unmount, waitUntilReady } = renderWithProviders( , ); + await waitUntilReady(); expect(lastFrame()).toContain('Discovery Errors:'); expect(lastFrame()).toContain('Failed to load custom commands'); unmount(); @@ -417,19 +427,19 @@ describe('FolderTrustDialog', () => { discoveryErrors: [], securityWarnings: [], }; - const { lastFrame, unmount } = await renderWithProviders( + const { lastFrame, unmount, waitUntilReady } = renderWithProviders( , { width: 80, - config: makeFakeConfig({ useAlternateBuffer: true }), - settings: createMockSettings({ ui: { useAlternateBuffer: true } }), + useAlternateBuffer: true, uiState: { constrainHeight: false, terminalHeight: 15 }, }, ); + await waitUntilReady(); // In alternate buffer + expanded, the title should be visible (StickyHeader) expect(lastFrame()).toContain('Do you trust the files in this folder?'); // And it should NOT use MaxSizedBox truncation @@ -452,7 +462,7 @@ describe('FolderTrustDialog', () => { securityWarnings: [`${ansiRed}warning-with-ansi${ansiReset}`], }; - const { lastFrame, unmount } = await renderWithProviders( + const { lastFrame, unmount, waitUntilReady } = renderWithProviders( { { width: 100, uiState: { terminalHeight: 40 } }, ); + await waitUntilReady(); const output = lastFrame(); expect(output).toContain('cmd-with-ansi'); diff --git a/packages/cli/src/ui/components/Footer.test.tsx b/packages/cli/src/ui/components/Footer.test.tsx index c0a52af868..ab487a440f 100644 --- a/packages/cli/src/ui/components/Footer.test.tsx +++ b/packages/cli/src/ui/components/Footer.test.tsx @@ -8,7 +8,6 @@ import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest'; import { renderWithProviders } from '../../test-utils/render.js'; import { Footer } from './Footer.js'; import { createMockSettings } from '../../test-utils/settings.js'; -import { type Config } from '@google/gemini-cli-core'; import path from 'node:path'; // Normalize paths to POSIX slashes for stable cross-platform snapshots. @@ -17,11 +16,7 @@ const normalizeFrame = (frame: string | undefined) => { return frame.replace(/\\/g, '/'); }; -const { mocks } = vi.hoisted(() => ({ - mocks: { - isDevelopment: false, - }, -})); +let mockIsDevelopment = false; vi.mock('../../utils/installationInfo.js', async (importOriginal) => { const original = @@ -29,7 +24,7 @@ vi.mock('../../utils/installationInfo.js', async (importOriginal) => { return { ...original, get isDevelopment() { - return mocks.isDevelopment; + return mockIsDevelopment; }, }; }); @@ -50,34 +45,11 @@ vi.mock('@google/gemini-cli-core', async (importOriginal) => { const defaultProps = { model: 'gemini-pro', - targetDir: path.join( - path.parse(process.cwd()).root, - 'Users', - 'test', - 'project', - 'foo', - 'bar', - 'and', - 'some', - 'more', - 'directories', - 'to', - 'make', - 'it', - 'long', - ), + targetDir: + '/Users/test/project/foo/bar/and/some/more/directories/to/make/it/long', branchName: 'main', }; -const mockConfig = { - getTargetDir: () => defaultProps.targetDir, - getDebugMode: () => false, - getModel: () => defaultProps.model, - getIdeMode: () => false, - isTrustedFolder: () => true, - getExtensionRegistryURI: () => undefined, -} as unknown as Config; - const mockSessionStats = { sessionId: 'test-session-id', sessionStartTime: new Date(), @@ -138,25 +110,31 @@ describe('