mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-03-15 08:31:14 -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:
@@ -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` |
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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<typeof import('@google/gemini-cli-core')>();
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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<typeof import('@google/gemini-cli-core')>();
|
||||
@@ -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<typeof import('./utils/cleanup.js')>();
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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 │
|
||||
|
||||
@@ -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],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
170
packages/cli/src/ui/hooks/useRunEventNotifications.ts
Normal file
170
packages/cli/src/ui/hooks/useRunEventNotifications.ts
Normal file
@@ -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,
|
||||
]);
|
||||
}
|
||||
48
packages/cli/src/ui/utils/confirmingTool.ts
Normal file
48
packages/cli/src/ui/utils/confirmingTool.ts
Normal file
@@ -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,
|
||||
};
|
||||
}
|
||||
114
packages/cli/src/ui/utils/pendingAttentionNotification.test.ts
Normal file
114
packages/cli/src/ui/utils/pendingAttentionNotification.test.ts
Normal file
@@ -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?');
|
||||
});
|
||||
});
|
||||
126
packages/cli/src/ui/utils/pendingAttentionNotification.ts
Normal file
126
packages/cli/src/ui/utils/pendingAttentionNotification.ts
Normal file
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 =
|
||||
|
||||
163
packages/cli/src/utils/terminalNotifications.test.ts
Normal file
163
packages/cli/src/utils/terminalNotifications.test.ts
Normal file
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
126
packages/cli/src/utils/terminalNotifications.ts
Normal file
126
packages/cli/src/utils/terminalNotifications.ts
Normal file
@@ -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<boolean> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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.",
|
||||
|
||||
Reference in New Issue
Block a user