mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-03-27 14:30: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:
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user