2026-02-18 15:28:17 -05:00
|
|
|
/**
|
|
|
|
|
* @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;';
|
2026-04-17 02:45:54 +08:00
|
|
|
const OSC777_PREFIX = '\x1b]777;notify;';
|
|
|
|
|
const OSC_TEXT_SEPARATOR = ' | ';
|
2026-02-18 15:28:17 -05:00
|
|
|
|
|
|
|
|
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
|
2026-03-26 20:10:49 +00:00
|
|
|
| { enableNotifications?: boolean }
|
2026-02-18 15:28:17 -05:00
|
|
|
| undefined;
|
|
|
|
|
|
2026-03-26 20:10:49 +00:00
|
|
|
return general?.enableNotifications === true;
|
2026-02-18 15:28:17 -05:00
|
|
|
}
|
|
|
|
|
|
2026-04-17 02:45:54 +08:00
|
|
|
export enum TerminalNotificationMethod {
|
|
|
|
|
Auto = 'auto',
|
|
|
|
|
Osc9 = 'osc9',
|
|
|
|
|
Osc777 = 'osc777',
|
|
|
|
|
Bell = 'bell',
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function getNotificationMethod(
|
|
|
|
|
settings: LoadedSettings,
|
|
|
|
|
): TerminalNotificationMethod {
|
|
|
|
|
switch (settings.merged.general?.notificationMethod) {
|
|
|
|
|
case TerminalNotificationMethod.Osc9:
|
|
|
|
|
return TerminalNotificationMethod.Osc9;
|
|
|
|
|
case TerminalNotificationMethod.Osc777:
|
|
|
|
|
return TerminalNotificationMethod.Osc777;
|
|
|
|
|
case TerminalNotificationMethod.Bell:
|
|
|
|
|
return TerminalNotificationMethod.Bell;
|
|
|
|
|
default:
|
|
|
|
|
return TerminalNotificationMethod.Auto;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function wrapWithPassthrough(sequence: string): string {
|
|
|
|
|
const capabilityManager = TerminalCapabilityManager.getInstance();
|
|
|
|
|
if (capabilityManager.isTmux()) {
|
|
|
|
|
// eslint-disable-next-line no-control-regex
|
|
|
|
|
return `\x1bPtmux;${sequence.replace(/\x1b/g, '\x1b\x1b')}\x1b\\`;
|
|
|
|
|
} else if (capabilityManager.isScreen()) {
|
|
|
|
|
return `\x1bP${sequence}\x1b\\`;
|
|
|
|
|
}
|
|
|
|
|
return sequence;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function emitOsc9Notification(content: RunEventNotificationContent): void {
|
|
|
|
|
const sanitized = sanitizeNotificationContent(content);
|
|
|
|
|
const pieces = [sanitized.title, sanitized.subtitle, sanitized.body].filter(
|
2026-02-18 15:28:17 -05:00
|
|
|
Boolean,
|
|
|
|
|
);
|
2026-04-17 02:45:54 +08:00
|
|
|
const combined = pieces.join(OSC_TEXT_SEPARATOR);
|
|
|
|
|
|
|
|
|
|
writeToStdout(wrapWithPassthrough(`${OSC9_PREFIX}${combined}${BEL}`));
|
2026-02-18 15:28:17 -05:00
|
|
|
}
|
|
|
|
|
|
2026-04-17 02:45:54 +08:00
|
|
|
function emitOsc777Notification(content: RunEventNotificationContent): void {
|
|
|
|
|
const sanitized = sanitizeNotificationContent(content);
|
|
|
|
|
const bodyParts = [sanitized.subtitle, sanitized.body].filter(Boolean);
|
|
|
|
|
const body = bodyParts.join(OSC_TEXT_SEPARATOR);
|
2026-02-18 15:28:17 -05:00
|
|
|
|
2026-04-17 02:45:54 +08:00
|
|
|
// Replace ';' with ':' to avoid breaking the OSC 777 sequence
|
|
|
|
|
const safeTitle = sanitized.title.replace(/;/g, ':');
|
|
|
|
|
const safeBody = body.replace(/;/g, ':');
|
|
|
|
|
|
|
|
|
|
writeToStdout(
|
|
|
|
|
wrapWithPassthrough(`${OSC777_PREFIX}${safeTitle};${safeBody}${BEL}`),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function emitBellNotification(): void {
|
|
|
|
|
writeToStdout(BEL);
|
2026-02-18 15:28:17 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function notifyViaTerminal(
|
|
|
|
|
notificationsEnabled: boolean,
|
|
|
|
|
content: RunEventNotificationContent,
|
2026-04-17 02:45:54 +08:00
|
|
|
method: TerminalNotificationMethod = TerminalNotificationMethod.Auto,
|
2026-02-18 15:28:17 -05:00
|
|
|
): Promise<boolean> {
|
2026-03-26 20:10:49 +00:00
|
|
|
if (!notificationsEnabled) {
|
2026-02-18 15:28:17 -05:00
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
2026-04-17 02:45:54 +08:00
|
|
|
if (method === TerminalNotificationMethod.Osc9) {
|
|
|
|
|
emitOsc9Notification(content);
|
|
|
|
|
} else if (method === TerminalNotificationMethod.Osc777) {
|
|
|
|
|
emitOsc777Notification(content);
|
|
|
|
|
} else if (method === TerminalNotificationMethod.Bell) {
|
|
|
|
|
emitBellNotification();
|
|
|
|
|
} else {
|
|
|
|
|
// auto
|
|
|
|
|
const capabilityManager = TerminalCapabilityManager.getInstance();
|
|
|
|
|
if (capabilityManager.isITerm2()) {
|
|
|
|
|
emitOsc9Notification(content);
|
|
|
|
|
} else if (
|
|
|
|
|
capabilityManager.isAlacritty() ||
|
|
|
|
|
capabilityManager.isAppleTerminal() ||
|
|
|
|
|
capabilityManager.isVSCodeTerminal() ||
|
|
|
|
|
capabilityManager.isWindowsTerminal()
|
|
|
|
|
) {
|
|
|
|
|
emitBellNotification();
|
|
|
|
|
} else {
|
|
|
|
|
emitOsc777Notification(content);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-18 15:28:17 -05:00
|
|
|
return true;
|
|
|
|
|
} catch (error) {
|
|
|
|
|
debugLogger.debug('Failed to emit terminal notification:', error);
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
}
|