diff --git a/docs/cli/settings.md b/docs/cli/settings.md index 174c8d22994..baec68a27ad 100644 --- a/docs/cli/settings.md +++ b/docs/cli/settings.md @@ -27,6 +27,7 @@ they appear in the UI. | Vim Mode | `general.vimMode` | Enable Vim keybindings | `false` | | Default Approval Mode | `general.defaultApprovalMode` | The default approval mode for tool execution. 'default' prompts for approval, 'auto_edit' auto-approves edit tools, and 'plan' is read-only mode. 'yolo' is not supported yet. | `"default"` | | Enable Auto Update | `general.enableAutoUpdate` | Enable automatic updates. | `true` | +| Enable Notifications | `general.enableNotifications` | Enable run-event notifications for action-required prompts and session completion. Currently macOS only. | `false` | | Enable Prompt Completion | `general.enablePromptCompletion` | Enable AI-powered prompt completion suggestions while typing. | `false` | | Debug Keystroke Logging | `general.debugKeystrokeLogging` | Enable debug logging of keystrokes to the console. | `false` | | Enable Session Cleanup | `general.sessionRetention.enabled` | Enable automatic session cleanup | `false` | diff --git a/docs/get-started/configuration.md b/docs/get-started/configuration.md index 5f6b89b9a2d..07062da9622 100644 --- a/docs/get-started/configuration.md +++ b/docs/get-started/configuration.md @@ -132,6 +132,11 @@ their corresponding top-level category object in your `settings.json` file. - **Description:** Enable update notification prompts. - **Default:** `true` +- **`general.enableNotifications`** (boolean): + - **Description:** Enable run-event notifications for action-required prompts + and session completion. Currently macOS only. + - **Default:** `false` + - **`general.checkpointing.enabled`** (boolean): - **Description:** Enable session checkpointing for recovery - **Default:** `false` diff --git a/packages/cli/src/config/settingsSchema.test.ts b/packages/cli/src/config/settingsSchema.test.ts index bc558e77b89..2a2b535eea0 100644 --- a/packages/cli/src/config/settingsSchema.test.ts +++ b/packages/cli/src/config/settingsSchema.test.ts @@ -353,6 +353,17 @@ describe('SettingsSchema', () => { ).toBe('Show the "? for shortcuts" hint above the input.'); }); + it('should have enableNotifications setting in schema', () => { + const setting = + getSettingsSchema().general.properties.enableNotifications; + expect(setting).toBeDefined(); + expect(setting.type).toBe('boolean'); + expect(setting.category).toBe('General'); + expect(setting.default).toBe(false); + expect(setting.requiresRestart).toBe(false); + expect(setting.showInDialog).toBe(true); + }); + it('should have enableAgents setting in schema', () => { const setting = getSettingsSchema().experimental.properties.enableAgents; expect(setting).toBeDefined(); diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index b6b764808f9..8f1497575cf 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -236,6 +236,16 @@ const SETTINGS_SCHEMA = { description: 'Enable update notification prompts.', showInDialog: false, }, + enableNotifications: { + type: 'boolean', + label: 'Enable Notifications', + category: 'General', + requiresRestart: false, + default: false, + description: + 'Enable run-event notifications for action-required prompts and session completion. Currently macOS only.', + showInDialog: true, + }, checkpointing: { type: 'object', label: 'Checkpointing', diff --git a/packages/cli/src/gemini.test.tsx b/packages/cli/src/gemini.test.tsx index 9dac908a977..976d832abd6 100644 --- a/packages/cli/src/gemini.test.tsx +++ b/packages/cli/src/gemini.test.tsx @@ -56,6 +56,20 @@ vi.mock('./nonInteractiveCli.js', () => ({ runNonInteractive: runNonInteractiveSpy, })); +const terminalNotificationMocks = vi.hoisted(() => ({ + notifyViaTerminal: vi.fn().mockResolvedValue(true), + buildRunEventNotificationContent: vi.fn(() => ({ + title: 'Session complete', + body: 'done', + subtitle: 'Run finished', + })), +})); +vi.mock('./utils/terminalNotifications.js', () => ({ + notifyViaTerminal: terminalNotificationMocks.notifyViaTerminal, + buildRunEventNotificationContent: + terminalNotificationMocks.buildRunEventNotificationContent, +})); + vi.mock('@google/gemini-cli-core', async (importOriginal) => { const actual = await importOriginal(); @@ -837,6 +851,10 @@ describe('gemini.tsx main function kitty protocol', () => { expect(runNonInteractive).toHaveBeenCalled(); const callArgs = vi.mocked(runNonInteractive).mock.calls[0][0]; expect(callArgs.input).toBe('stdin-data\n\ntest-question'); + expect( + terminalNotificationMocks.buildRunEventNotificationContent, + ).not.toHaveBeenCalled(); + expect(terminalNotificationMocks.notifyViaTerminal).not.toHaveBeenCalled(); expect(processExitSpy).toHaveBeenCalledWith(0); processExitSpy.mockRestore(); }); diff --git a/packages/cli/src/gemini_cleanup.test.tsx b/packages/cli/src/gemini_cleanup.test.tsx index 17e3380f2c4..fb37bb94ece 100644 --- a/packages/cli/src/gemini_cleanup.test.tsx +++ b/packages/cli/src/gemini_cleanup.test.tsx @@ -9,14 +9,6 @@ import { main } from './gemini.js'; import { debugLogger } from '@google/gemini-cli-core'; import { type Config } from '@google/gemini-cli-core'; -// Custom error to identify mock process.exit calls -class MockProcessExitError extends Error { - constructor(readonly code?: string | number | null | undefined) { - super('PROCESS_EXIT_MOCKED'); - this.name = 'MockProcessExitError'; - } -} - vi.mock('@google/gemini-cli-core', async (importOriginal) => { const actual = await importOriginal(); @@ -124,10 +116,39 @@ vi.mock('./validateNonInterActiveAuth.js', () => ({ validateNonInteractiveAuth: vi.fn().mockResolvedValue({}), })); +vi.mock('./core/initializer.js', () => ({ + initializeApp: vi.fn().mockResolvedValue({ + authError: null, + themeError: null, + shouldOpenAuthDialog: false, + geminiMdFileCount: 0, + }), +})); + vi.mock('./nonInteractiveCli.js', () => ({ runNonInteractive: vi.fn().mockResolvedValue(undefined), })); +vi.mock('./utils/cleanup.js', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + cleanupCheckpoints: vi.fn().mockResolvedValue(undefined), + registerCleanup: vi.fn(), + registerSyncCleanup: vi.fn(), + registerTelemetryConfig: vi.fn(), + runExitCleanup: vi.fn().mockResolvedValue(undefined), + }; +}); + +vi.mock('./zed-integration/zedIntegration.js', () => ({ + runZedIntegration: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock('./utils/readStdin.js', () => ({ + readStdin: vi.fn().mockResolvedValue(''), +})); + const { cleanupMockState } = vi.hoisted(() => ({ cleanupMockState: { shouldThrow: false, called: false }, })); @@ -169,12 +190,6 @@ describe('gemini.tsx main function cleanup', () => { const debugLoggerErrorSpy = vi .spyOn(debugLogger, 'error') .mockImplementation(() => {}); - const processExitSpy = vi - .spyOn(process, 'exit') - .mockImplementation((code) => { - throw new MockProcessExitError(code); - }); - vi.mocked(loadSettings).mockReturnValue({ merged: { advanced: {}, security: { auth: {} }, ui: {} }, workspace: { settings: {} }, @@ -201,7 +216,7 @@ describe('gemini.tsx main function cleanup', () => { getMcpServers: () => ({}), getMcpClientManager: vi.fn(), getIdeMode: vi.fn(() => false), - getExperimentalZedIntegration: vi.fn(() => false), + getExperimentalZedIntegration: vi.fn(() => true), getScreenReader: vi.fn(() => false), getGeminiMdFileCount: vi.fn(() => 0), getProjectRoot: vi.fn(() => '/'), @@ -224,18 +239,12 @@ describe('gemini.tsx main function cleanup', () => { getRemoteAdminSettings: vi.fn(() => undefined), } as any); // eslint-disable-line @typescript-eslint/no-explicit-any - try { - await main(); - } catch (e) { - if (!(e instanceof MockProcessExitError)) throw e; - } + await main(); expect(cleanupMockState.called).toBe(true); expect(debugLoggerErrorSpy).toHaveBeenCalledWith( 'Failed to cleanup expired sessions:', expect.objectContaining({ message: 'Cleanup failed' }), ); - expect(processExitSpy).toHaveBeenCalledWith(0); // Should not exit on cleanup failure - processExitSpy.mockRestore(); }); }); diff --git a/packages/cli/src/ui/AppContainer.test.tsx b/packages/cli/src/ui/AppContainer.test.tsx index 065195a14a2..d137ed7cf0e 100644 --- a/packages/cli/src/ui/AppContainer.test.tsx +++ b/packages/cli/src/ui/AppContainer.test.tsx @@ -49,6 +49,15 @@ const mockIdeClient = vi.hoisted(() => ({ const mocks = vi.hoisted(() => ({ mockStdout: { write: vi.fn() }, })); +const terminalNotificationsMocks = vi.hoisted(() => ({ + notifyViaTerminal: vi.fn().mockResolvedValue(true), + isNotificationsEnabled: vi.fn(() => true), + buildRunEventNotificationContent: vi.fn((event) => ({ + title: 'Mock Notification', + subtitle: 'Mock Subtitle', + body: JSON.stringify(event), + })), +})); vi.mock('@google/gemini-cli-core', async (importOriginal) => { const actual = @@ -164,6 +173,12 @@ vi.mock('./hooks/useShellInactivityStatus.js', () => ({ inactivityStatus: 'none', })), })); +vi.mock('../utils/terminalNotifications.js', () => ({ + notifyViaTerminal: terminalNotificationsMocks.notifyViaTerminal, + isNotificationsEnabled: terminalNotificationsMocks.isNotificationsEnabled, + buildRunEventNotificationContent: + terminalNotificationsMocks.buildRunEventNotificationContent, +})); vi.mock('./hooks/useTerminalTheme.js', () => ({ useTerminalTheme: vi.fn(), })); @@ -171,6 +186,7 @@ vi.mock('./hooks/useTerminalTheme.js', () => ({ import { useHookDisplayState } from './hooks/useHookDisplayState.js'; import { useTerminalTheme } from './hooks/useTerminalTheme.js'; import { useShellInactivityStatus } from './hooks/useShellInactivityStatus.js'; +import { useFocusState } from './hooks/useFocus.js'; // Mock external utilities vi.mock('../utils/events.js'); @@ -279,6 +295,7 @@ describe('AppContainer State Management', () => { const mockedUseHookDisplayState = useHookDisplayState as Mock; const mockedUseTerminalTheme = useTerminalTheme as Mock; const mockedUseShellInactivityStatus = useShellInactivityStatus as Mock; + const mockedUseFocusState = useFocusState as Mock; const DEFAULT_GEMINI_STREAM_MOCK = { streamingState: 'idle', @@ -416,6 +433,10 @@ describe('AppContainer State Management', () => { shouldShowFocusHint: false, inactivityStatus: 'none', }); + mockedUseFocusState.mockReturnValue({ + isFocused: true, + hasReceivedFocusEvent: true, + }); // Mock Config mockConfig = makeFakeConfig(); @@ -524,6 +545,358 @@ describe('AppContainer State Management', () => { }); describe('State Initialization', () => { + it('sends a macOS notification when confirmation is pending and terminal is unfocused', async () => { + mockedUseFocusState.mockReturnValue({ + isFocused: false, + hasReceivedFocusEvent: true, + }); + mockedUseGeminiStream.mockReturnValue({ + ...DEFAULT_GEMINI_STREAM_MOCK, + pendingHistoryItems: [ + { + type: 'tool_group', + tools: [ + { + callId: 'call-1', + name: 'run_shell_command', + description: 'Run command', + resultDisplay: undefined, + status: CoreToolCallStatus.AwaitingApproval, + confirmationDetails: { + type: 'exec', + title: 'Run shell command', + command: 'ls', + rootCommand: 'ls', + rootCommands: ['ls'], + }, + }, + ], + }, + ], + }); + + let unmount: (() => void) | undefined; + await act(async () => { + const rendered = renderAppContainer(); + unmount = rendered.unmount; + }); + + await waitFor(() => + expect(terminalNotificationsMocks.notifyViaTerminal).toHaveBeenCalled(), + ); + expect( + terminalNotificationsMocks.buildRunEventNotificationContent, + ).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'attention', + }), + ); + + await act(async () => { + unmount?.(); + }); + }); + + it('does not send attention notification when terminal is focused', async () => { + mockedUseFocusState.mockReturnValue({ + isFocused: true, + hasReceivedFocusEvent: true, + }); + mockedUseGeminiStream.mockReturnValue({ + ...DEFAULT_GEMINI_STREAM_MOCK, + pendingHistoryItems: [ + { + type: 'tool_group', + tools: [ + { + callId: 'call-2', + name: 'run_shell_command', + description: 'Run command', + resultDisplay: undefined, + status: CoreToolCallStatus.AwaitingApproval, + confirmationDetails: { + type: 'exec', + title: 'Run shell command', + command: 'ls', + rootCommand: 'ls', + rootCommands: ['ls'], + }, + }, + ], + }, + ], + }); + + let unmount: (() => void) | undefined; + await act(async () => { + const rendered = renderAppContainer(); + unmount = rendered.unmount; + }); + + expect( + terminalNotificationsMocks.notifyViaTerminal, + ).not.toHaveBeenCalled(); + + await act(async () => { + unmount?.(); + }); + }); + + it('sends attention notification when focus reporting is unavailable', async () => { + mockedUseFocusState.mockReturnValue({ + isFocused: true, + hasReceivedFocusEvent: false, + }); + mockedUseGeminiStream.mockReturnValue({ + ...DEFAULT_GEMINI_STREAM_MOCK, + pendingHistoryItems: [ + { + type: 'tool_group', + tools: [ + { + callId: 'call-focus-unknown', + name: 'run_shell_command', + description: 'Run command', + resultDisplay: undefined, + status: CoreToolCallStatus.AwaitingApproval, + confirmationDetails: { + type: 'exec', + title: 'Run shell command', + command: 'ls', + rootCommand: 'ls', + rootCommands: ['ls'], + }, + }, + ], + }, + ], + }); + + let unmount: (() => void) | undefined; + await act(async () => { + const rendered = renderAppContainer(); + unmount = rendered.unmount; + }); + + await waitFor(() => + expect(terminalNotificationsMocks.notifyViaTerminal).toHaveBeenCalled(), + ); + + await act(async () => { + unmount?.(); + }); + }); + + it('sends a macOS notification when a response completes while unfocused', async () => { + mockedUseFocusState.mockReturnValue({ + isFocused: false, + hasReceivedFocusEvent: true, + }); + let currentStreamingState: 'idle' | 'responding' = 'responding'; + mockedUseGeminiStream.mockImplementation(() => ({ + ...DEFAULT_GEMINI_STREAM_MOCK, + streamingState: currentStreamingState, + })); + + 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()); + }); + + await waitFor(() => + expect( + terminalNotificationsMocks.buildRunEventNotificationContent, + ).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'session_complete', + detail: 'Gemini CLI finished responding.', + }), + ), + ); + expect(terminalNotificationsMocks.notifyViaTerminal).toHaveBeenCalled(); + + await act(async () => { + unmount?.(); + }); + }); + + it('sends completion notification when focus reporting is unavailable', async () => { + mockedUseFocusState.mockReturnValue({ + isFocused: true, + hasReceivedFocusEvent: false, + }); + let currentStreamingState: 'idle' | 'responding' = 'responding'; + mockedUseGeminiStream.mockImplementation(() => ({ + ...DEFAULT_GEMINI_STREAM_MOCK, + streamingState: currentStreamingState, + })); + + 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()); + }); + + await waitFor(() => + expect( + terminalNotificationsMocks.buildRunEventNotificationContent, + ).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'session_complete', + detail: 'Gemini CLI finished responding.', + }), + ), + ); + + await act(async () => { + unmount?.(); + }); + }); + + it('does not send completion notification when another action-required dialog is pending', async () => { + mockedUseFocusState.mockReturnValue({ + isFocused: false, + hasReceivedFocusEvent: true, + }); + mockedUseQuotaAndFallback.mockReturnValue({ + proQuotaRequest: { kind: 'upgrade' }, + handleProQuotaChoice: vi.fn(), + }); + let currentStreamingState: 'idle' | 'responding' = 'responding'; + mockedUseGeminiStream.mockImplementation(() => ({ + ...DEFAULT_GEMINI_STREAM_MOCK, + streamingState: currentStreamingState, + })); + + 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()); + }); + + expect( + terminalNotificationsMocks.notifyViaTerminal, + ).not.toHaveBeenCalled(); + + await act(async () => { + unmount?.(); + }); + }); + + it('can send repeated attention notifications for the same key after pending state clears', async () => { + mockedUseFocusState.mockReturnValue({ + isFocused: false, + hasReceivedFocusEvent: true, + }); + + let pendingHistoryItems = [ + { + type: 'tool_group', + tools: [ + { + callId: 'repeat-key-call', + name: 'run_shell_command', + description: 'Run command', + resultDisplay: undefined, + status: CoreToolCallStatus.AwaitingApproval, + confirmationDetails: { + type: 'exec', + title: 'Run shell command', + command: 'ls', + rootCommand: 'ls', + rootCommands: ['ls'], + }, + }, + ], + }, + ]; + + mockedUseGeminiStream.mockImplementation(() => ({ + ...DEFAULT_GEMINI_STREAM_MOCK, + pendingHistoryItems, + })); + + let unmount: (() => void) | undefined; + let rerender: ((tree: ReactElement) => void) | undefined; + + 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()); + }); + + pendingHistoryItems = [ + { + type: 'tool_group', + tools: [ + { + callId: 'repeat-key-call', + name: 'run_shell_command', + description: 'Run command', + resultDisplay: undefined, + status: CoreToolCallStatus.AwaitingApproval, + confirmationDetails: { + type: 'exec', + title: 'Run shell command', + command: 'ls', + rootCommand: 'ls', + rootCommands: ['ls'], + }, + }, + ], + }, + ]; + await act(async () => { + rerender?.(getAppContainer()); + }); + + await waitFor(() => + expect( + terminalNotificationsMocks.notifyViaTerminal, + ).toHaveBeenCalledTimes(2), + ); + + await act(async () => { + unmount?.(); + }); + }); + it('initializes with theme error from initialization result', async () => { const initResultWithError = { ...mockInitResult, diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 5b330138464..81acd0b7545 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -106,7 +106,7 @@ import { type BackgroundShell } from './hooks/shellCommandProcessor.js'; import { useVim } from './hooks/vim.js'; import { type LoadableSettingScope, SettingScope } from '../config/settings.js'; import { type InitializationResult } from '../core/initializer.js'; -import { useFocus } from './hooks/useFocus.js'; +import { useFocusState } from './hooks/useFocus.js'; import { useKeypress, type Key } from './hooks/useKeypress.js'; import { KeypressPriority } from './contexts/KeypressContext.js'; import { keyMatchers, Command } from './keyMatchers.js'; @@ -157,6 +157,8 @@ import { useTerminalTheme } from './hooks/useTerminalTheme.js'; import { useTimedMessage } from './hooks/useTimedMessage.js'; import { shouldDismissShortcutsHelpOnHotkey } from './utils/shortcutsHelp.js'; import { useSuspend } from './hooks/useSuspend.js'; +import { useRunEventNotifications } from './hooks/useRunEventNotifications.js'; +import { isNotificationsEnabled } from '../utils/terminalNotifications.js'; function isToolExecuting(pendingHistoryItems: HistoryItemWithoutId[]) { return pendingHistoryItems.some((item) => { @@ -207,6 +209,7 @@ const SHELL_HEIGHT_PADDING = 10; export const AppContainer = (props: AppContainerProps) => { const { config, initializationResult, resumedSessionData } = props; const settings = useSettings(); + const notificationsEnabled = isNotificationsEnabled(settings); const historyManager = useHistory({ chatRecordingService: config.getGeminiClient()?.getChatRecordingService(), @@ -1304,7 +1307,7 @@ Logging in with Google... Restarting Gemini CLI to continue. sanitizationConfig: config.sanitizationConfig, }); - const isFocused = useFocus(); + const { isFocused, hasReceivedFocusEvent } = useFocusState(); // Context file names computation const contextFileNames = useMemo(() => { @@ -1958,16 +1961,36 @@ Logging in with Google... Restarting Gemini CLI to continue. [pendingHistoryItems], ); + const hasConfirmUpdateExtensionRequests = + confirmUpdateExtensionRequests.length > 0; + const hasLoopDetectionConfirmationRequest = + !!loopDetectionConfirmationRequest; + const hasPendingActionRequired = hasPendingToolConfirmation || !!commandConfirmationRequest || !!authConsentRequest || - confirmUpdateExtensionRequests.length > 0 || - !!loopDetectionConfirmationRequest || + hasConfirmUpdateExtensionRequests || + hasLoopDetectionConfirmationRequest || !!proQuotaRequest || !!validationRequest || !!customDialog; + useRunEventNotifications({ + notificationsEnabled, + isFocused, + hasReceivedFocusEvent, + streamingState, + hasPendingActionRequired, + pendingHistoryItems, + commandConfirmationRequest, + authConsentRequest, + permissionConfirmationRequest, + hasConfirmUpdateExtensionRequests, + hasLoopDetectionConfirmationRequest, + terminalName: terminalCapabilityManager.getTerminalName(), + }); + const isPassiveShortcutsHelpState = isInputActive && streamingState === StreamingState.Idle && diff --git a/packages/cli/src/ui/components/__snapshots__/InputPrompt.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/InputPrompt.test.tsx.snap index 05d128f738c..ff3818d6f8b 100644 --- a/packages/cli/src/ui/components/__snapshots__/InputPrompt.test.tsx.snap +++ b/packages/cli/src/ui/components/__snapshots__/InputPrompt.test.tsx.snap @@ -77,39 +77,6 @@ exports[`InputPrompt > mouse interaction > should toggle paste expansion on doub ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄" `; -exports[`InputPrompt > mouse interaction > should toggle paste expansion on double-click 4`] = ` -"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ - > [Pasted Text: 10 lines] -▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄" -`; - -exports[`InputPrompt > mouse interaction > should toggle paste expansion on double-click 5`] = ` -"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ - > [Pasted Text: 10 lines]  -▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄" -`; - -exports[`InputPrompt > mouse interaction > should toggle paste expansion on double-click 6`] = ` -"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ - > line1  - line2  - line3  - line4  - line5  - line6  - line7  - line8  - line9  - line10  -▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄" -`; - -exports[`InputPrompt > mouse interaction > should toggle paste expansion on double-click 7`] = ` -"▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ - > [Pasted Text: 10 lines]  -▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄" -`; - exports[`InputPrompt > snapshots > should not show inverted cursor when shell is focused 1`] = ` "▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ > Type your message or @path/to/file diff --git a/packages/cli/src/ui/components/__snapshots__/SettingsDialog.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/SettingsDialog.test.tsx.snap index 2252594d4be..8cf87c2f888 100644 --- a/packages/cli/src/ui/components/__snapshots__/SettingsDialog.test.tsx.snap +++ b/packages/cli/src/ui/components/__snapshots__/SettingsDialog.test.tsx.snap @@ -19,6 +19,9 @@ exports[`SettingsDialog > Initial Rendering > should render settings list with v │ Enable Auto Update true │ │ Enable automatic updates. │ │ │ +│ Enable Notifications false │ +│ Enable run-event notifications for action-required prompts and session completion. … │ +│ │ │ Enable Prompt Completion false │ │ Enable AI-powered prompt completion suggestions while typing. │ │ │ @@ -31,9 +34,6 @@ exports[`SettingsDialog > Initial Rendering > should render settings list with v │ Keep chat history undefined │ │ Automatically delete chats older than this time period (e.g., "30d", "7d", "24h… │ │ │ -│ Output Format Text │ -│ The format of the CLI output. Can be \`text\` or \`json\`. │ -│ │ │ ▼ │ │ │ │ Apply To │ @@ -65,6 +65,9 @@ exports[`SettingsDialog > Snapshot Tests > should render 'accessibility settings │ Enable Auto Update true │ │ Enable automatic updates. │ │ │ +│ Enable Notifications false │ +│ Enable run-event notifications for action-required prompts and session completion. … │ +│ │ │ Enable Prompt Completion false │ │ Enable AI-powered prompt completion suggestions while typing. │ │ │ @@ -77,9 +80,6 @@ exports[`SettingsDialog > Snapshot Tests > should render 'accessibility settings │ Keep chat history undefined │ │ Automatically delete chats older than this time period (e.g., "30d", "7d", "24h… │ │ │ -│ Output Format Text │ -│ The format of the CLI output. Can be \`text\` or \`json\`. │ -│ │ │ ▼ │ │ │ │ Apply To │ @@ -111,6 +111,9 @@ exports[`SettingsDialog > Snapshot Tests > should render 'all boolean settings d │ Enable Auto Update true* │ │ Enable automatic updates. │ │ │ +│ Enable Notifications false │ +│ Enable run-event notifications for action-required prompts and session completion. … │ +│ │ │ Enable Prompt Completion false* │ │ Enable AI-powered prompt completion suggestions while typing. │ │ │ @@ -123,9 +126,6 @@ exports[`SettingsDialog > Snapshot Tests > should render 'all boolean settings d │ Keep chat history undefined │ │ Automatically delete chats older than this time period (e.g., "30d", "7d", "24h… │ │ │ -│ Output Format Text │ -│ The format of the CLI output. Can be \`text\` or \`json\`. │ -│ │ │ ▼ │ │ │ │ Apply To │ @@ -157,6 +157,9 @@ exports[`SettingsDialog > Snapshot Tests > should render 'default state' correct │ Enable Auto Update true │ │ Enable automatic updates. │ │ │ +│ Enable Notifications false │ +│ Enable run-event notifications for action-required prompts and session completion. … │ +│ │ │ Enable Prompt Completion false │ │ Enable AI-powered prompt completion suggestions while typing. │ │ │ @@ -169,9 +172,6 @@ exports[`SettingsDialog > Snapshot Tests > should render 'default state' correct │ Keep chat history undefined │ │ Automatically delete chats older than this time period (e.g., "30d", "7d", "24h… │ │ │ -│ Output Format Text │ -│ The format of the CLI output. Can be \`text\` or \`json\`. │ -│ │ │ ▼ │ │ │ │ Apply To │ @@ -203,6 +203,9 @@ exports[`SettingsDialog > Snapshot Tests > should render 'file filtering setting │ Enable Auto Update true │ │ Enable automatic updates. │ │ │ +│ Enable Notifications false │ +│ Enable run-event notifications for action-required prompts and session completion. … │ +│ │ │ Enable Prompt Completion false │ │ Enable AI-powered prompt completion suggestions while typing. │ │ │ @@ -215,9 +218,6 @@ exports[`SettingsDialog > Snapshot Tests > should render 'file filtering setting │ Keep chat history undefined │ │ Automatically delete chats older than this time period (e.g., "30d", "7d", "24h… │ │ │ -│ Output Format Text │ -│ The format of the CLI output. Can be \`text\` or \`json\`. │ -│ │ │ ▼ │ │ │ │ Apply To │ @@ -249,6 +249,9 @@ exports[`SettingsDialog > Snapshot Tests > should render 'focused on scope selec │ Enable Auto Update true │ │ Enable automatic updates. │ │ │ +│ Enable Notifications false │ +│ Enable run-event notifications for action-required prompts and session completion. … │ +│ │ │ Enable Prompt Completion false │ │ Enable AI-powered prompt completion suggestions while typing. │ │ │ @@ -261,9 +264,6 @@ exports[`SettingsDialog > Snapshot Tests > should render 'focused on scope selec │ Keep chat history undefined │ │ Automatically delete chats older than this time period (e.g., "30d", "7d", "24h… │ │ │ -│ Output Format Text │ -│ The format of the CLI output. Can be \`text\` or \`json\`. │ -│ │ │ ▼ │ │ │ │ > Apply To │ @@ -295,6 +295,9 @@ exports[`SettingsDialog > Snapshot Tests > should render 'mixed boolean and numb │ Enable Auto Update false* │ │ Enable automatic updates. │ │ │ +│ Enable Notifications false │ +│ Enable run-event notifications for action-required prompts and session completion. … │ +│ │ │ Enable Prompt Completion false │ │ Enable AI-powered prompt completion suggestions while typing. │ │ │ @@ -307,9 +310,6 @@ exports[`SettingsDialog > Snapshot Tests > should render 'mixed boolean and numb │ Keep chat history undefined │ │ Automatically delete chats older than this time period (e.g., "30d", "7d", "24h… │ │ │ -│ Output Format Text │ -│ The format of the CLI output. Can be \`text\` or \`json\`. │ -│ │ │ ▼ │ │ │ │ Apply To │ @@ -341,6 +341,9 @@ exports[`SettingsDialog > Snapshot Tests > should render 'tools and security set │ Enable Auto Update true │ │ Enable automatic updates. │ │ │ +│ Enable Notifications false │ +│ Enable run-event notifications for action-required prompts and session completion. … │ +│ │ │ Enable Prompt Completion false │ │ Enable AI-powered prompt completion suggestions while typing. │ │ │ @@ -353,9 +356,6 @@ exports[`SettingsDialog > Snapshot Tests > should render 'tools and security set │ Keep chat history undefined │ │ Automatically delete chats older than this time period (e.g., "30d", "7d", "24h… │ │ │ -│ Output Format Text │ -│ The format of the CLI output. Can be \`text\` or \`json\`. │ -│ │ │ ▼ │ │ │ │ Apply To │ @@ -387,6 +387,9 @@ exports[`SettingsDialog > Snapshot Tests > should render 'various boolean settin │ Enable Auto Update false* │ │ Enable automatic updates. │ │ │ +│ Enable Notifications false │ +│ Enable run-event notifications for action-required prompts and session completion. … │ +│ │ │ Enable Prompt Completion true* │ │ Enable AI-powered prompt completion suggestions while typing. │ │ │ @@ -399,9 +402,6 @@ exports[`SettingsDialog > Snapshot Tests > should render 'various boolean settin │ Keep chat history undefined │ │ Automatically delete chats older than this time period (e.g., "30d", "7d", "24h… │ │ │ -│ Output Format Text │ -│ The format of the CLI output. Can be \`text\` or \`json\`. │ -│ │ │ ▼ │ │ │ │ Apply To │ diff --git a/packages/cli/src/ui/hooks/useConfirmingTool.ts b/packages/cli/src/ui/hooks/useConfirmingTool.ts index a7cd2939f1c..210238cafe5 100644 --- a/packages/cli/src/ui/hooks/useConfirmingTool.ts +++ b/packages/cli/src/ui/hooks/useConfirmingTool.ts @@ -6,17 +6,10 @@ import { useMemo } from 'react'; import { useUIState } from '../contexts/UIStateContext.js'; -import { - type IndividualToolCallDisplay, - type HistoryItemToolGroup, -} from '../types.js'; -import { CoreToolCallStatus } from '@google/gemini-cli-core'; +import { getConfirmingToolState } from '../utils/confirmingTool.js'; +import type { ConfirmingToolState } from '../utils/confirmingTool.js'; -export interface ConfirmingToolState { - tool: IndividualToolCallDisplay; - index: number; - total: number; -} +export type { ConfirmingToolState } from '../utils/confirmingTool.js'; /** * Selects the "Head" of the confirmation queue. @@ -27,36 +20,8 @@ export function useConfirmingTool(): ConfirmingToolState | null { // Gemini responses and Slash commands. const { pendingHistoryItems } = useUIState(); - return useMemo(() => { - // 1. Flatten all pending tools from all pending history groups - const allPendingTools = pendingHistoryItems - .filter( - (item): item is HistoryItemToolGroup => item.type === 'tool_group', - ) - .flatMap((group) => group.tools); - - // 2. Filter for those requiring confirmation - const confirmingTools = allPendingTools.filter( - (t) => t.status === CoreToolCallStatus.AwaitingApproval, - ); - - if (confirmingTools.length === 0) { - return null; - } - - // 3. Select Head (FIFO) - const head = confirmingTools[0]; - - // 4. Calculate progress based on the full tool list - // This gives the user context of where they are in the current batch. - const headIndexInFullList = allPendingTools.findIndex( - (t) => t.callId === head.callId, - ); - - return { - tool: head, - index: headIndexInFullList + 1, - total: allPendingTools.length, - }; - }, [pendingHistoryItems]); + return useMemo( + () => getConfirmingToolState(pendingHistoryItems), + [pendingHistoryItems], + ); } diff --git a/packages/cli/src/ui/hooks/useFocus.test.tsx b/packages/cli/src/ui/hooks/useFocus.test.tsx index 070156b1848..f80a829501d 100644 --- a/packages/cli/src/ui/hooks/useFocus.test.tsx +++ b/packages/cli/src/ui/hooks/useFocus.test.tsx @@ -6,7 +6,7 @@ import { render } from '../../test-utils/render.js'; import { EventEmitter } from 'node:events'; -import { useFocus } from './useFocus.js'; +import { useFocus, useFocusState } from './useFocus.js'; import { vi, type Mock } from 'vitest'; import { useStdin, useStdout } from 'ink'; import { KeypressProvider } from '../contexts/KeypressContext.js'; @@ -69,6 +69,27 @@ describe('useFocus', () => { }; }; + const renderFocusStateHook = () => { + let hookResult: ReturnType; + function TestComponent() { + hookResult = useFocusState(); + return null; + } + const { unmount } = render( + + + , + ); + return { + result: { + get current() { + return hookResult; + }, + }, + unmount, + }; + }; + it('should initialize with focus and enable focus reporting', () => { const { result } = renderFocusHook(); @@ -163,4 +184,17 @@ describe('useFocus', () => { }); expect(result.current).toBe(true); }); + + it('tracks whether any focus event has been received', () => { + const { result } = renderFocusStateHook(); + + expect(result.current.hasReceivedFocusEvent).toBe(false); + + act(() => { + stdin.emit('data', '\x1b[O'); + }); + + expect(result.current.hasReceivedFocusEvent).toBe(true); + expect(result.current.isFocused).toBe(false); + }); }); diff --git a/packages/cli/src/ui/hooks/useFocus.ts b/packages/cli/src/ui/hooks/useFocus.ts index 65288cb0da7..65547cf776d 100644 --- a/packages/cli/src/ui/hooks/useFocus.ts +++ b/packages/cli/src/ui/hooks/useFocus.ts @@ -16,10 +16,16 @@ export const DISABLE_FOCUS_REPORTING = '\x1b[?1004l'; export const FOCUS_IN = '\x1b[I'; export const FOCUS_OUT = '\x1b[O'; -export const useFocus = () => { +export interface FocusState { + isFocused: boolean; + hasReceivedFocusEvent: boolean; +} + +export const useFocusState = (): FocusState => { const { stdin } = useStdin(); const { stdout } = useStdout(); const [isFocused, setIsFocused] = useState(true); + const [hasReceivedFocusEvent, setHasReceivedFocusEvent] = useState(false); useEffect(() => { const handleData = (data: Buffer) => { @@ -28,8 +34,10 @@ export const useFocus = () => { const lastFocusOut = sequence.lastIndexOf(FOCUS_OUT); if (lastFocusIn > lastFocusOut) { + setHasReceivedFocusEvent(true); setIsFocused(true); } else if (lastFocusOut > lastFocusIn) { + setHasReceivedFocusEvent(true); setIsFocused(false); } }; @@ -58,5 +66,10 @@ export const useFocus = () => { { isActive: true }, ); - return isFocused; + return { + isFocused, + hasReceivedFocusEvent, + }; }; + +export const useFocus = () => useFocusState().isFocused; diff --git a/packages/cli/src/ui/hooks/useRunEventNotifications.ts b/packages/cli/src/ui/hooks/useRunEventNotifications.ts new file mode 100644 index 00000000000..8b7b7be5987 --- /dev/null +++ b/packages/cli/src/ui/hooks/useRunEventNotifications.ts @@ -0,0 +1,175 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useEffect, useMemo, useRef } from 'react'; +import { + StreamingState, + type ConfirmationRequest, + type HistoryItemWithoutId, + type PermissionConfirmationRequest, +} from '../types.js'; +import { getPendingAttentionNotification } from '../utils/pendingAttentionNotification.js'; +import { + buildRunEventNotificationContent, + notifyViaTerminal, +} from '../../utils/terminalNotifications.js'; + +const ATTENTION_NOTIFICATION_COOLDOWN_MS = 20_000; + +interface RunEventNotificationParams { + notificationsEnabled: boolean; + isFocused: boolean; + hasReceivedFocusEvent: boolean; + streamingState: StreamingState; + hasPendingActionRequired: boolean; + pendingHistoryItems: HistoryItemWithoutId[]; + commandConfirmationRequest: ConfirmationRequest | null; + authConsentRequest: ConfirmationRequest | null; + permissionConfirmationRequest: PermissionConfirmationRequest | null; + hasConfirmUpdateExtensionRequests: boolean; + hasLoopDetectionConfirmationRequest: boolean; + terminalName?: string; +} + +export function useRunEventNotifications({ + notificationsEnabled, + isFocused, + hasReceivedFocusEvent, + streamingState, + hasPendingActionRequired, + pendingHistoryItems, + commandConfirmationRequest, + authConsentRequest, + permissionConfirmationRequest, + hasConfirmUpdateExtensionRequests, + hasLoopDetectionConfirmationRequest, + terminalName, +}: RunEventNotificationParams): void { + const pendingAttentionNotification = useMemo( + () => + getPendingAttentionNotification( + pendingHistoryItems, + commandConfirmationRequest, + authConsentRequest, + permissionConfirmationRequest, + hasConfirmUpdateExtensionRequests, + hasLoopDetectionConfirmationRequest, + ), + [ + pendingHistoryItems, + commandConfirmationRequest, + authConsentRequest, + permissionConfirmationRequest, + hasConfirmUpdateExtensionRequests, + hasLoopDetectionConfirmationRequest, + ], + ); + + const hadPendingAttentionRef = useRef(false); + const previousFocusedRef = useRef(isFocused); + const previousStreamingStateRef = useRef(streamingState); + const lastSentAttentionNotificationRef = useRef<{ + key: string; + sentAt: number; + } | null>(null); + + useEffect(() => { + if (!notificationsEnabled) { + return; + } + + const wasFocused = previousFocusedRef.current; + previousFocusedRef.current = isFocused; + + const hasPendingAttention = pendingAttentionNotification !== null; + const hadPendingAttention = hadPendingAttentionRef.current; + hadPendingAttentionRef.current = hasPendingAttention; + + if (!hasPendingAttention) { + lastSentAttentionNotificationRef.current = null; + return; + } + + const shouldSuppressForFocus = hasReceivedFocusEvent && isFocused; + if (shouldSuppressForFocus) { + return; + } + + const justEnteredAttentionState = !hadPendingAttention; + const justLostFocus = wasFocused && !isFocused; + const now = Date.now(); + const currentKey = pendingAttentionNotification.key; + const lastSent = lastSentAttentionNotificationRef.current; + const keyChanged = !lastSent || lastSent.key !== currentKey; + const onCooldown = + !!lastSent && + lastSent.key === currentKey && + now - lastSent.sentAt < ATTENTION_NOTIFICATION_COOLDOWN_MS; + + const shouldNotifyByStateChange = hasReceivedFocusEvent + ? justEnteredAttentionState || justLostFocus || keyChanged + : justEnteredAttentionState || keyChanged; + + if (!shouldNotifyByStateChange || onCooldown) { + return; + } + + lastSentAttentionNotificationRef.current = { + key: currentKey, + sentAt: now, + }; + + void notifyViaTerminal( + notificationsEnabled, + buildRunEventNotificationContent(pendingAttentionNotification.event), + terminalName, + ); + }, [ + isFocused, + hasReceivedFocusEvent, + notificationsEnabled, + pendingAttentionNotification, + terminalName, + ]); + + useEffect(() => { + if (!notificationsEnabled) { + return; + } + + const previousStreamingState = previousStreamingStateRef.current; + previousStreamingStateRef.current = streamingState; + + const justCompletedTurn = + previousStreamingState === StreamingState.Responding && + streamingState === StreamingState.Idle; + const shouldSuppressForFocus = hasReceivedFocusEvent && isFocused; + + if ( + !justCompletedTurn || + shouldSuppressForFocus || + hasPendingActionRequired + ) { + return; + } + + void notifyViaTerminal( + notificationsEnabled, + buildRunEventNotificationContent({ + type: 'session_complete', + detail: 'Gemini CLI finished responding.', + }), + terminalName, + ); + }, [ + streamingState, + isFocused, + hasReceivedFocusEvent, + notificationsEnabled, + hasPendingActionRequired, + terminalName, + ]); +} diff --git a/packages/cli/src/ui/utils/confirmingTool.ts b/packages/cli/src/ui/utils/confirmingTool.ts new file mode 100644 index 00000000000..86579f1d1fc --- /dev/null +++ b/packages/cli/src/ui/utils/confirmingTool.ts @@ -0,0 +1,48 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { CoreToolCallStatus } from '@google/gemini-cli-core'; +import { + type HistoryItemToolGroup, + type HistoryItemWithoutId, + type IndividualToolCallDisplay, +} from '../types.js'; + +export interface ConfirmingToolState { + tool: IndividualToolCallDisplay; + index: number; + total: number; +} + +/** + * Selects the "head" of the confirmation queue. + */ +export function getConfirmingToolState( + pendingHistoryItems: HistoryItemWithoutId[], +): ConfirmingToolState | null { + const allPendingTools = pendingHistoryItems + .filter((item): item is HistoryItemToolGroup => item.type === 'tool_group') + .flatMap((group) => group.tools); + + const confirmingTools = allPendingTools.filter( + (tool) => tool.status === CoreToolCallStatus.AwaitingApproval, + ); + + if (confirmingTools.length === 0) { + return null; + } + + const head = confirmingTools[0]; + const headIndexInFullList = allPendingTools.findIndex( + (tool) => tool.callId === head.callId, + ); + + return { + tool: head, + index: headIndexInFullList + 1, + total: allPendingTools.length, + }; +} diff --git a/packages/cli/src/ui/utils/pendingAttentionNotification.test.ts b/packages/cli/src/ui/utils/pendingAttentionNotification.test.ts new file mode 100644 index 00000000000..34c59dd2316 --- /dev/null +++ b/packages/cli/src/ui/utils/pendingAttentionNotification.test.ts @@ -0,0 +1,114 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it } from 'vitest'; +import { CoreToolCallStatus } from '@google/gemini-cli-core'; +import { getPendingAttentionNotification } from './pendingAttentionNotification.js'; + +describe('getPendingAttentionNotification', () => { + it('returns tool confirmation notification for awaiting tool approvals', () => { + const notification = getPendingAttentionNotification( + [ + { + type: 'tool_group', + tools: [ + { + callId: 'tool-1', + status: CoreToolCallStatus.AwaitingApproval, + description: 'Run command', + confirmationDetails: { + type: 'exec', + title: 'Run shell command', + command: 'ls', + rootCommand: 'ls', + rootCommands: ['ls'], + }, + }, + ], + } as never, + ], + null, + null, + null, + false, + false, + ); + + expect(notification?.key).toBe('tool_confirmation:tool-1'); + expect(notification?.event.type).toBe('attention'); + }); + + it('returns ask-user notification for ask_user confirmations', () => { + const notification = getPendingAttentionNotification( + [ + { + type: 'tool_group', + tools: [ + { + callId: 'ask-user-1', + status: CoreToolCallStatus.AwaitingApproval, + description: 'Ask user', + confirmationDetails: { + type: 'ask_user', + questions: [ + { + header: 'Need approval?', + question: 'Proceed?', + options: [], + id: 'q1', + }, + ], + }, + }, + ], + } as never, + ], + null, + null, + null, + false, + false, + ); + + expect(notification?.key).toBe('ask_user:ask-user-1'); + expect(notification?.event).toEqual({ + type: 'attention', + heading: 'Answer requested by agent', + detail: 'Need approval?', + }); + }); + + it('uses request content in command/auth keys', () => { + const commandNotification = getPendingAttentionNotification( + [], + { + prompt: 'Approve command?', + onConfirm: () => {}, + }, + null, + null, + false, + false, + ); + + const authNotification = getPendingAttentionNotification( + [], + null, + { + prompt: 'Authorize sign-in?', + onConfirm: () => {}, + }, + null, + false, + false, + ); + + expect(commandNotification?.key).toContain('command_confirmation:'); + expect(commandNotification?.key).toContain('Approve command?'); + expect(authNotification?.key).toContain('auth_consent:'); + expect(authNotification?.key).toContain('Authorize sign-in?'); + }); +}); diff --git a/packages/cli/src/ui/utils/pendingAttentionNotification.ts b/packages/cli/src/ui/utils/pendingAttentionNotification.ts new file mode 100644 index 00000000000..5a92dde38cf --- /dev/null +++ b/packages/cli/src/ui/utils/pendingAttentionNotification.ts @@ -0,0 +1,126 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + type ConfirmationRequest, + type HistoryItemWithoutId, + type PermissionConfirmationRequest, +} from '../types.js'; +import { type ReactNode } from 'react'; +import { type RunEventNotificationEvent } from '../../utils/terminalNotifications.js'; +import { getConfirmingToolState } from './confirmingTool.js'; + +export interface PendingAttentionNotification { + key: string; + event: RunEventNotificationEvent; +} + +function keyFromReactNode(node: ReactNode): string { + if (typeof node === 'string' || typeof node === 'number') { + return String(node); + } + if (Array.isArray(node)) { + return node.map((item) => keyFromReactNode(item)).join('|'); + } + return 'react-node'; +} + +export function getPendingAttentionNotification( + pendingHistoryItems: HistoryItemWithoutId[], + commandConfirmationRequest: ConfirmationRequest | null, + authConsentRequest: ConfirmationRequest | null, + permissionConfirmationRequest: PermissionConfirmationRequest | null, + hasConfirmUpdateExtensionRequests: boolean, + hasLoopDetectionConfirmationRequest: boolean, +): PendingAttentionNotification | null { + const confirmingToolState = getConfirmingToolState(pendingHistoryItems); + if (confirmingToolState) { + const details = confirmingToolState.tool.confirmationDetails; + if (details?.type === 'ask_user') { + const firstQuestion = details.questions.at(0)?.header; + return { + key: `ask_user:${confirmingToolState.tool.callId}`, + event: { + type: 'attention', + heading: 'Answer requested by agent', + detail: firstQuestion || 'The agent needs your response to continue.', + }, + }; + } + + const toolTitle = details?.title || confirmingToolState.tool.description; + return { + key: `tool_confirmation:${confirmingToolState.tool.callId}`, + event: { + type: 'attention', + heading: 'Approval required', + detail: toolTitle + ? `Approve tool action: ${toolTitle}` + : 'Approve a pending tool action to continue.', + }, + }; + } + + if (commandConfirmationRequest) { + const promptKey = keyFromReactNode(commandConfirmationRequest.prompt); + return { + key: `command_confirmation:${promptKey}`, + event: { + type: 'attention', + heading: 'Confirmation required', + detail: 'A command is waiting for your confirmation.', + }, + }; + } + + if (authConsentRequest) { + const promptKey = keyFromReactNode(authConsentRequest.prompt); + return { + key: `auth_consent:${promptKey}`, + event: { + type: 'attention', + heading: 'Authentication confirmation required', + detail: 'Authentication is waiting for your confirmation.', + }, + }; + } + + if (permissionConfirmationRequest) { + const filesKey = permissionConfirmationRequest.files.join('|'); + return { + key: `filesystem_permission_confirmation:${filesKey}`, + event: { + type: 'attention', + heading: 'Filesystem permission required', + detail: 'Read-only path access is waiting for your confirmation.', + }, + }; + } + + if (hasConfirmUpdateExtensionRequests) { + return { + key: 'extension_update_confirmation', + event: { + type: 'attention', + heading: 'Extension update confirmation required', + detail: 'An extension update is waiting for your confirmation.', + }, + }; + } + + if (hasLoopDetectionConfirmationRequest) { + return { + key: 'loop_detection_confirmation', + event: { + type: 'attention', + heading: 'Loop detection confirmation required', + detail: 'A loop detection prompt is waiting for your response.', + }, + }; + } + + return null; +} diff --git a/packages/cli/src/utils/terminalNotifications.test.ts b/packages/cli/src/utils/terminalNotifications.test.ts new file mode 100644 index 00000000000..82a81708014 --- /dev/null +++ b/packages/cli/src/utils/terminalNotifications.test.ts @@ -0,0 +1,208 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + buildRunEventNotificationContent, + MAX_NOTIFICATION_BODY_CHARS, + MAX_NOTIFICATION_SUBTITLE_CHARS, + MAX_NOTIFICATION_TITLE_CHARS, + notifyViaTerminal, + supportsOsc9Notifications, + truncateForNotification, +} from './terminalNotifications.js'; + +const writeToStdout = vi.hoisted(() => vi.fn()); +const debugLogger = vi.hoisted(() => ({ + debug: vi.fn(), +})); + +vi.mock('@google/gemini-cli-core', () => ({ + writeToStdout, + debugLogger, +})); + +describe('terminal notifications', () => { + const originalPlatform = process.platform; + + beforeEach(() => { + vi.resetAllMocks(); + vi.unstubAllEnvs(); + Object.defineProperty(process, 'platform', { + value: 'darwin', + configurable: true, + }); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + Object.defineProperty(process, 'platform', { + value: originalPlatform, + configurable: true, + }); + }); + + it('returns false without writing on non-macOS platforms', async () => { + Object.defineProperty(process, 'platform', { + value: 'linux', + configurable: true, + }); + + const shown = await notifyViaTerminal(true, { + title: 't', + body: 'b', + }); + + expect(shown).toBe(false); + expect(writeToStdout).not.toHaveBeenCalled(); + }); + + it('returns false without writing when disabled', async () => { + const shown = await notifyViaTerminal(false, { + title: 't', + body: 'b', + }); + + expect(shown).toBe(false); + expect(writeToStdout).not.toHaveBeenCalled(); + }); + + it('emits OSC 9 notification when supported terminal is detected', async () => { + vi.stubEnv('TERM_PROGRAM', 'iTerm.app'); + + const shown = await notifyViaTerminal(true, { + title: 'Title "quoted"', + subtitle: 'Sub\\title', + body: 'Body', + }); + + expect(shown).toBe(true); + expect(writeToStdout).toHaveBeenCalledTimes(1); + const emitted = String(writeToStdout.mock.calls[0][0]); + expect(emitted.startsWith('\x1b]9;')).toBe(true); + expect(emitted.endsWith('\x07')).toBe(true); + }); + + it('uses terminal capability-query terminalName when available', async () => { + vi.stubEnv('TERM_PROGRAM', 'vscode'); + + const shown = await notifyViaTerminal( + true, + { + title: 'Title', + body: 'Body', + }, + 'WezTerm 20240203', + ); + + expect(shown).toBe(true); + const emitted = String(writeToStdout.mock.calls[0][0]); + expect(emitted.startsWith('\x1b]9;')).toBe(true); + }); + + it('emits BEL fallback when OSC 9 is not supported', async () => { + vi.stubEnv('TERM_PROGRAM', ''); + vi.stubEnv('TERM', ''); + + const shown = await notifyViaTerminal(true, { + title: 'Title', + subtitle: 'Subtitle', + body: 'Body', + }); + + expect(shown).toBe(true); + expect(writeToStdout).toHaveBeenCalledWith('\x07'); + }); + + it('uses BEL fallback when WT_SESSION is set', async () => { + vi.stubEnv('WT_SESSION', '1'); + vi.stubEnv('TERM_PROGRAM', 'WezTerm'); + + const shown = await notifyViaTerminal(true, { + title: 'Title', + body: 'Body', + }); + + expect(shown).toBe(true); + expect(writeToStdout).toHaveBeenCalledWith('\x07'); + }); + + it('returns false and does not throw when terminal write fails', async () => { + writeToStdout.mockImplementation(() => { + throw new Error('no permissions'); + }); + + await expect( + notifyViaTerminal(true, { + title: 'Title', + body: 'Body', + }), + ).resolves.toBe(false); + expect(debugLogger.debug).toHaveBeenCalledTimes(1); + }); + + it('detects OSC 9 support using terminal signatures', () => { + expect(supportsOsc9Notifications({ TERM_PROGRAM: 'WezTerm' })).toBe(true); + expect(supportsOsc9Notifications({ TERM_PROGRAM: 'ghostty' })).toBe(true); + expect(supportsOsc9Notifications({ TERM_PROGRAM: 'iTerm.app' })).toBe(true); + expect(supportsOsc9Notifications({ TERM: 'xterm-kitty' })).toBe(true); + expect(supportsOsc9Notifications({ TERM: 'wezterm' })).toBe(true); + expect(supportsOsc9Notifications({}, 'WezTerm 20240203')).toBe(true); + expect( + supportsOsc9Notifications({ TERM_PROGRAM: 'WezTerm', WT_SESSION: '1' }), + ).toBe(false); + expect(supportsOsc9Notifications({})).toBe(false); + }); + + it('strips terminal control sequences and newlines from payload text', async () => { + vi.stubEnv('TERM_PROGRAM', 'iTerm.app'); + + const shown = await notifyViaTerminal(true, { + title: 'Title', + body: '\x1b[32mGreen\x1b[0m\nLine', + }); + + expect(shown).toBe(true); + const emitted = String(writeToStdout.mock.calls[0][0]); + const payload = emitted.slice('\x1b]9;'.length, -1); + expect(payload).toContain('Green'); + expect(payload).toContain('Line'); + expect(payload).not.toContain('[32m'); + expect(payload).not.toContain('\n'); + expect(payload).not.toContain('\r'); + }); + + it('truncates notification text with ellipsis', () => { + const input = 'x'.repeat(300); + const truncated = truncateForNotification(input, 12); + expect(truncated).toHaveLength(12); + expect(truncated.endsWith('...')).toBe(true); + }); + + it('handles unicode truncation via shared code-point helpers', () => { + const input = 'a🙂b🙂c🙂d🙂e'; + const truncated = truncateForNotification(input, 8); + expect(truncated.endsWith('...')).toBe(true); + }); + + it('builds bounded attention notification content', () => { + const content = buildRunEventNotificationContent({ + type: 'attention', + heading: 'h'.repeat(400), + detail: 'd'.repeat(400), + }); + + expect(content.title.length).toBeLessThanOrEqual( + MAX_NOTIFICATION_TITLE_CHARS, + ); + expect((content.subtitle ?? '').length).toBeLessThanOrEqual( + MAX_NOTIFICATION_SUBTITLE_CHARS, + ); + expect(content.body.length).toBeLessThanOrEqual( + MAX_NOTIFICATION_BODY_CHARS, + ); + }); +}); diff --git a/packages/cli/src/utils/terminalNotifications.ts b/packages/cli/src/utils/terminalNotifications.ts new file mode 100644 index 00000000000..3148e55e25c --- /dev/null +++ b/packages/cli/src/utils/terminalNotifications.ts @@ -0,0 +1,193 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { debugLogger, writeToStdout } from '@google/gemini-cli-core'; +import type { LoadedSettings } from '../config/settings.js'; +import { + cpLen, + cpSlice, + stripUnsafeCharacters, +} from '../ui/utils/textUtils.js'; + +export const MAX_NOTIFICATION_TITLE_CHARS = 48; +export const MAX_NOTIFICATION_SUBTITLE_CHARS = 64; +export const MAX_NOTIFICATION_BODY_CHARS = 180; + +const ELLIPSIS = '...'; +const BEL = '\x07'; +const OSC9_PREFIX = '\x1b]9;'; +const OSC9_SEPARATOR = ' | '; +const MAX_OSC9_MESSAGE_CHARS = + MAX_NOTIFICATION_TITLE_CHARS + + MAX_NOTIFICATION_SUBTITLE_CHARS + + MAX_NOTIFICATION_BODY_CHARS + + OSC9_SEPARATOR.length * 2; + +export interface RunEventNotificationContent { + title: string; + subtitle?: string; + body: string; +} + +export type RunEventNotificationEvent = + | { + type: 'attention'; + heading?: string; + detail?: string; + } + | { + type: 'session_complete'; + detail?: string; + }; + +function normalizeText(input: string): string { + return stripUnsafeCharacters(input).replace(/\s+/g, ' ').trim(); +} + +export function truncateForNotification( + input: string, + maxChars: number, +): string { + if (maxChars <= 0) { + return ''; + } + + const normalized = normalizeText(input); + if (cpLen(normalized) <= maxChars) { + return normalized; + } + + if (maxChars <= ELLIPSIS.length) { + return ELLIPSIS.slice(0, maxChars); + } + + return `${cpSlice(normalized, 0, maxChars - ELLIPSIS.length)}${ELLIPSIS}`; +} + +function sanitizeNotificationContent( + content: RunEventNotificationContent, +): RunEventNotificationContent { + const title = truncateForNotification( + content.title, + MAX_NOTIFICATION_TITLE_CHARS, + ); + const subtitle = content.subtitle + ? truncateForNotification(content.subtitle, MAX_NOTIFICATION_SUBTITLE_CHARS) + : undefined; + const body = truncateForNotification( + content.body, + MAX_NOTIFICATION_BODY_CHARS, + ); + + return { + title: title || 'Gemini CLI', + subtitle: subtitle || undefined, + body: body || 'Open Gemini CLI for details.', + }; +} + +export function buildRunEventNotificationContent( + event: RunEventNotificationEvent, +): RunEventNotificationContent { + if (event.type === 'attention') { + return sanitizeNotificationContent({ + title: 'Gemini CLI needs your attention', + subtitle: event.heading ?? 'Action required', + body: event.detail ?? 'Open Gemini CLI to continue.', + }); + } + + return sanitizeNotificationContent({ + title: 'Gemini CLI session complete', + subtitle: 'Run finished', + body: event.detail ?? 'The session finished successfully.', + }); +} + +export function isNotificationsEnabled(settings: LoadedSettings): boolean { + const general = settings.merged.general as + | { + enableNotifications?: boolean; + enableMacOsNotifications?: boolean; + } + | undefined; + + return ( + process.platform === 'darwin' && + (general?.enableNotifications === true || + general?.enableMacOsNotifications === true) + ); +} + +function hasOsc9TerminalSignature(value: string | undefined): boolean { + if (!value) { + return false; + } + + const normalized = value.toLowerCase(); + return ( + normalized.includes('wezterm') || + normalized.includes('ghostty') || + normalized.includes('iterm') || + normalized.includes('kitty') + ); +} + +export function supportsOsc9Notifications( + env: NodeJS.ProcessEnv = process.env, + terminalName?: string, +): boolean { + if (env['WT_SESSION']) { + return false; + } + + return ( + hasOsc9TerminalSignature(terminalName) || + hasOsc9TerminalSignature(env['TERM_PROGRAM']) || + hasOsc9TerminalSignature(env['TERM']) + ); +} + +function buildTerminalNotificationMessage( + content: RunEventNotificationContent, +): string { + const pieces = [content.title, content.subtitle, content.body].filter( + Boolean, + ); + const combined = pieces.join(OSC9_SEPARATOR); + return truncateForNotification(combined, MAX_OSC9_MESSAGE_CHARS); +} + +function emitOsc9Notification( + content: RunEventNotificationContent, + terminalName?: string, +): void { + const message = buildTerminalNotificationMessage(content); + if (!supportsOsc9Notifications(process.env, terminalName)) { + writeToStdout(BEL); + return; + } + + writeToStdout(`${OSC9_PREFIX}${message}${BEL}`); +} + +export async function notifyViaTerminal( + notificationsEnabled: boolean, + content: RunEventNotificationContent, + terminalName?: string, +): Promise { + if (!notificationsEnabled || process.platform !== 'darwin') { + return false; + } + + try { + emitOsc9Notification(sanitizeNotificationContent(content), terminalName); + return true; + } catch (error) { + debugLogger.debug('Failed to emit terminal notification:', error); + return false; + } +} diff --git a/schemas/settings.schema.json b/schemas/settings.schema.json index 12aec589735..89224815b73 100644 --- a/schemas/settings.schema.json +++ b/schemas/settings.schema.json @@ -81,6 +81,13 @@ "default": true, "type": "boolean" }, + "enableNotifications": { + "title": "Enable Notifications", + "description": "Enable run-event notifications for action-required prompts and session completion. Currently macOS only.", + "markdownDescription": "Enable run-event notifications for action-required prompts and session completion. Currently macOS only.\n\n- Category: `General`\n- Requires restart: `no`\n- Default: `false`", + "default": false, + "type": "boolean" + }, "checkpointing": { "title": "Checkpointing", "description": "Session checkpointing settings.",