feat(cli): add macOS run-event notifications (interactive only) (#19056)

Co-authored-by: Tommaso Sciortino <sciortino@gmail.com>
This commit is contained in:
Dmitry Lyalin
2026-02-18 15:28:17 -05:00
committed by GitHub
parent 8f6a711a3a
commit 78de533c48
21 changed files with 1396 additions and 107 deletions
+373
View File
@@ -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,