From 78de533c48a0002a8b742510a34256e310e2864f Mon Sep 17 00:00:00 2001 From: Dmitry Lyalin Date: Wed, 18 Feb 2026 15:28:17 -0500 Subject: [PATCH] feat(cli): add macOS run-event notifications (interactive only) (#19056) Co-authored-by: Tommaso Sciortino --- docs/cli/settings.md | 1 + docs/get-started/configuration.md | 5 + .../cli/src/config/settingsSchema.test.ts | 11 + packages/cli/src/config/settingsSchema.ts | 10 + packages/cli/src/gemini.test.tsx | 18 + packages/cli/src/gemini_cleanup.test.tsx | 53 +-- packages/cli/src/ui/AppContainer.test.tsx | 373 ++++++++++++++++++ packages/cli/src/ui/AppContainer.tsx | 28 +- .../SettingsDialog.test.tsx.snap | 54 +-- .../cli/src/ui/hooks/useConfirmingTool.ts | 49 +-- packages/cli/src/ui/hooks/useFocus.test.tsx | 35 +- packages/cli/src/ui/hooks/useFocus.ts | 13 +- .../src/ui/hooks/useRunEventNotifications.ts | 170 ++++++++ packages/cli/src/ui/utils/confirmingTool.ts | 48 +++ .../pendingAttentionNotification.test.ts | 114 ++++++ .../ui/utils/pendingAttentionNotification.ts | 126 ++++++ .../utils/terminalCapabilityManager.test.ts | 73 ++++ .../src/ui/utils/terminalCapabilityManager.ts | 26 ++ .../src/utils/terminalNotifications.test.ts | 163 ++++++++ .../cli/src/utils/terminalNotifications.ts | 126 ++++++ schemas/settings.schema.json | 7 + 21 files changed, 1396 insertions(+), 107 deletions(-) create mode 100644 packages/cli/src/ui/hooks/useRunEventNotifications.ts create mode 100644 packages/cli/src/ui/utils/confirmingTool.ts create mode 100644 packages/cli/src/ui/utils/pendingAttentionNotification.test.ts create mode 100644 packages/cli/src/ui/utils/pendingAttentionNotification.ts create mode 100644 packages/cli/src/utils/terminalNotifications.test.ts create mode 100644 packages/cli/src/utils/terminalNotifications.ts diff --git a/docs/cli/settings.md b/docs/cli/settings.md index 174c8d2299..baec68a27a 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 61adb9e13c..21dbebff22 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 bc558e77b8..2a2b535eea 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 c6fa4c80ca..5049bb3c55 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 9dac908a97..976d832abd 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 17e3380f2c..fb37bb94ec 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 64e80633e0..1ec43eae48 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 = @@ -165,6 +174,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(), })); @@ -172,6 +187,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 { useFocus } from './hooks/useFocus.js'; // Mock external utilities vi.mock('../utils/events.js'); @@ -280,6 +296,7 @@ describe('AppContainer State Management', () => { const mockedUseHookDisplayState = useHookDisplayState as Mock; const mockedUseTerminalTheme = useTerminalTheme as Mock; const mockedUseShellInactivityStatus = useShellInactivityStatus as Mock; + const mockedUseFocusState = useFocus as Mock; const DEFAULT_GEMINI_STREAM_MOCK = { streamingState: 'idle', @@ -417,6 +434,10 @@ describe('AppContainer State Management', () => { shouldShowFocusHint: false, inactivityStatus: 'none', }); + mockedUseFocusState.mockReturnValue({ + isFocused: true, + hasReceivedFocusEvent: true, + }); // Mock Config mockConfig = makeFakeConfig(); @@ -525,6 +546,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 446e737394..627027680a 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -156,6 +156,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) => { @@ -209,6 +211,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(), @@ -1247,7 +1250,7 @@ Logging in with Google... Restarting Gemini CLI to continue. sanitizationConfig: config.sanitizationConfig, }); - const isFocused = useFocus(); + const { isFocused, hasReceivedFocusEvent } = useFocus(); // Context file names computation const contextFileNames = useMemo(() => { @@ -1879,12 +1882,17 @@ 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; @@ -1902,6 +1910,20 @@ Logging in with Google... Restarting Gemini CLI to continue. allowPlanMode, }); + useRunEventNotifications({ + notificationsEnabled, + isFocused, + hasReceivedFocusEvent, + streamingState, + hasPendingActionRequired, + pendingHistoryItems, + commandConfirmationRequest, + authConsentRequest, + permissionConfirmationRequest, + hasConfirmUpdateExtensionRequests, + hasLoopDetectionConfirmationRequest, + }); + const isPassiveShortcutsHelpState = isInputActive && streamingState === StreamingState.Idle && 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 2252594d4b..8cf87c2f88 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 a7cd2939f1..210238cafe 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 070156b184..86484cc1b9 100644 --- a/packages/cli/src/ui/hooks/useFocus.test.tsx +++ b/packages/cli/src/ui/hooks/useFocus.test.tsx @@ -72,7 +72,7 @@ describe('useFocus', () => { it('should initialize with focus and enable focus reporting', () => { const { result } = renderFocusHook(); - expect(result.current).toBe(true); + expect(result.current.isFocused).toBe(true); expect(stdout.write).toHaveBeenCalledWith('\x1b[?1004h'); }); @@ -80,7 +80,7 @@ describe('useFocus', () => { const { result } = renderFocusHook(); // Initial state is focused - expect(result.current).toBe(true); + expect(result.current.isFocused).toBe(true); // Simulate focus-out event act(() => { @@ -88,7 +88,7 @@ describe('useFocus', () => { }); // State should now be unfocused - expect(result.current).toBe(false); + expect(result.current.isFocused).toBe(false); }); it('should set isFocused to true when a focus-in event is received', () => { @@ -98,7 +98,7 @@ describe('useFocus', () => { act(() => { stdin.emit('data', '\x1b[O'); }); - expect(result.current).toBe(false); + expect(result.current.isFocused).toBe(false); // Simulate focus-in event act(() => { @@ -106,7 +106,7 @@ describe('useFocus', () => { }); // State should now be focused - expect(result.current).toBe(true); + expect(result.current.isFocused).toBe(true); }); it('should clean up and disable focus reporting on unmount', () => { @@ -130,22 +130,22 @@ describe('useFocus', () => { act(() => { stdin.emit('data', '\x1b[O'); }); - expect(result.current).toBe(false); + expect(result.current.isFocused).toBe(false); act(() => { stdin.emit('data', '\x1b[O'); }); - expect(result.current).toBe(false); + expect(result.current.isFocused).toBe(false); act(() => { stdin.emit('data', '\x1b[I'); }); - expect(result.current).toBe(true); + expect(result.current.isFocused).toBe(true); act(() => { stdin.emit('data', '\x1b[I'); }); - expect(result.current).toBe(true); + expect(result.current.isFocused).toBe(true); }); it('restores focus on keypress after focus is lost', () => { @@ -155,12 +155,25 @@ describe('useFocus', () => { act(() => { stdin.emit('data', '\x1b[O'); }); - expect(result.current).toBe(false); + expect(result.current.isFocused).toBe(false); // Simulate a keypress act(() => { stdin.emit('data', 'a'); }); - expect(result.current).toBe(true); + expect(result.current.isFocused).toBe(true); + }); + + it('tracks whether any focus event has been received', () => { + const { result } = renderFocusHook(); + + 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 65288cb0da..638e9c0cc8 100644 --- a/packages/cli/src/ui/hooks/useFocus.ts +++ b/packages/cli/src/ui/hooks/useFocus.ts @@ -16,10 +16,14 @@ export const DISABLE_FOCUS_REPORTING = '\x1b[?1004l'; export const FOCUS_IN = '\x1b[I'; export const FOCUS_OUT = '\x1b[O'; -export const useFocus = () => { +export const useFocus = (): { + isFocused: boolean; + hasReceivedFocusEvent: boolean; +} => { const { stdin } = useStdin(); const { stdout } = useStdout(); const [isFocused, setIsFocused] = useState(true); + const [hasReceivedFocusEvent, setHasReceivedFocusEvent] = useState(false); useEffect(() => { const handleData = (data: Buffer) => { @@ -28,8 +32,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 +64,8 @@ export const useFocus = () => { { isActive: true }, ); - return isFocused; + return { + isFocused, + hasReceivedFocusEvent, + }; }; diff --git a/packages/cli/src/ui/hooks/useRunEventNotifications.ts b/packages/cli/src/ui/hooks/useRunEventNotifications.ts new file mode 100644 index 0000000000..3051847afb --- /dev/null +++ b/packages/cli/src/ui/hooks/useRunEventNotifications.ts @@ -0,0 +1,170 @@ +/** + * @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, +}: 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), + ); + }, [ + isFocused, + hasReceivedFocusEvent, + notificationsEnabled, + pendingAttentionNotification, + ]); + + 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.', + }), + ); + }, [ + streamingState, + isFocused, + hasReceivedFocusEvent, + notificationsEnabled, + hasPendingActionRequired, + ]); +} diff --git a/packages/cli/src/ui/utils/confirmingTool.ts b/packages/cli/src/ui/utils/confirmingTool.ts new file mode 100644 index 0000000000..86579f1d1f --- /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 0000000000..34c59dd231 --- /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 0000000000..5a92dde38c --- /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/ui/utils/terminalCapabilityManager.test.ts b/packages/cli/src/ui/utils/terminalCapabilityManager.test.ts index 846fe2d8cb..c5c05db38b 100644 --- a/packages/cli/src/ui/utils/terminalCapabilityManager.test.ts +++ b/packages/cli/src/ui/utils/terminalCapabilityManager.test.ts @@ -302,4 +302,77 @@ describe('TerminalCapabilityManager', () => { ); }); }); + + describe('supportsOsc9Notifications', () => { + const manager = TerminalCapabilityManager.getInstance(); + + it.each([ + { + name: 'WezTerm (terminal name)', + terminalName: 'WezTerm', + env: {}, + expected: true, + }, + { + name: 'iTerm.app (terminal name)', + terminalName: 'iTerm.app', + env: {}, + expected: true, + }, + { + name: 'ghostty (terminal name)', + terminalName: 'ghostty', + env: {}, + expected: true, + }, + { + name: 'kitty (terminal name)', + terminalName: 'kitty', + env: {}, + expected: true, + }, + { + name: 'some-other-term (terminal name)', + terminalName: 'some-other-term', + env: {}, + expected: false, + }, + { + name: 'iTerm.app (TERM_PROGRAM)', + terminalName: undefined, + env: { TERM_PROGRAM: 'iTerm.app' }, + expected: true, + }, + { + name: 'vscode (TERM_PROGRAM)', + terminalName: undefined, + env: { TERM_PROGRAM: 'vscode' }, + expected: false, + }, + { + name: 'xterm-kitty (TERM)', + terminalName: undefined, + env: { TERM: 'xterm-kitty' }, + expected: true, + }, + { + name: 'xterm-256color (TERM)', + terminalName: undefined, + env: { TERM: 'xterm-256color' }, + expected: false, + }, + { + name: 'Windows Terminal (WT_SESSION)', + terminalName: 'iTerm.app', + env: { WT_SESSION: 'some-guid' }, + expected: false, + }, + ])( + 'should return $expected for $name', + ({ terminalName, env, expected }) => { + vi.spyOn(manager, 'getTerminalName').mockReturnValue(terminalName); + expect(manager.supportsOsc9Notifications(env)).toBe(expected); + }, + ); + }); }); diff --git a/packages/cli/src/ui/utils/terminalCapabilityManager.ts b/packages/cli/src/ui/utils/terminalCapabilityManager.ts index 76de5b831e..a161b2aa1b 100644 --- a/packages/cli/src/ui/utils/terminalCapabilityManager.ts +++ b/packages/cli/src/ui/utils/terminalCapabilityManager.ts @@ -269,6 +269,32 @@ export class TerminalCapabilityManager { isKittyProtocolEnabled(): boolean { return this.kittyEnabled; } + + supportsOsc9Notifications(env: NodeJS.ProcessEnv = process.env): boolean { + if (env['WT_SESSION']) { + return false; + } + + return ( + this.hasOsc9TerminalSignature(this.getTerminalName()) || + this.hasOsc9TerminalSignature(env['TERM_PROGRAM']) || + this.hasOsc9TerminalSignature(env['TERM']) + ); + } + + private 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 const terminalCapabilityManager = diff --git a/packages/cli/src/utils/terminalNotifications.test.ts b/packages/cli/src/utils/terminalNotifications.test.ts new file mode 100644 index 0000000000..7efa1c4f34 --- /dev/null +++ b/packages/cli/src/utils/terminalNotifications.test.ts @@ -0,0 +1,163 @@ +/** + * @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, +} 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('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('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('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 0000000000..d774e852d3 --- /dev/null +++ b/packages/cli/src/utils/terminalNotifications.ts @@ -0,0 +1,126 @@ +/** + * @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 { sanitizeForDisplay } from '../ui/utils/textUtils.js'; +import { TerminalCapabilityManager } from '../ui/utils/terminalCapabilityManager.js'; + +export const MAX_NOTIFICATION_TITLE_CHARS = 48; +export const MAX_NOTIFICATION_SUBTITLE_CHARS = 64; +export const MAX_NOTIFICATION_BODY_CHARS = 180; + +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 sanitizeNotificationContent( + content: RunEventNotificationContent, +): RunEventNotificationContent { + const title = sanitizeForDisplay(content.title, MAX_NOTIFICATION_TITLE_CHARS); + const subtitle = content.subtitle + ? sanitizeForDisplay(content.subtitle, MAX_NOTIFICATION_SUBTITLE_CHARS) + : undefined; + const body = sanitizeForDisplay(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 buildTerminalNotificationMessage( + content: RunEventNotificationContent, +): string { + const pieces = [content.title, content.subtitle, content.body].filter( + Boolean, + ); + const combined = pieces.join(OSC9_SEPARATOR); + return sanitizeForDisplay(combined, MAX_OSC9_MESSAGE_CHARS); +} + +function emitOsc9Notification(content: RunEventNotificationContent): void { + const message = buildTerminalNotificationMessage(content); + if (!TerminalCapabilityManager.getInstance().supportsOsc9Notifications()) { + writeToStdout(BEL); + return; + } + + writeToStdout(`${OSC9_PREFIX}${message}${BEL}`); +} + +export async function notifyViaTerminal( + notificationsEnabled: boolean, + content: RunEventNotificationContent, +): Promise { + if (!notificationsEnabled || process.platform !== 'darwin') { + return false; + } + + try { + emitOsc9Notification(sanitizeNotificationContent(content)); + 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 10c5fa9627..eaf3431723 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.",