mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-04-24 20:14:44 -07:00
feat(cli): add macOS run-event notifications (interactive only) (#19056)
Co-authored-by: Tommaso Sciortino <sciortino@gmail.com>
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user