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

Co-authored-by: Tommaso Sciortino <sciortino@gmail.com>
This commit is contained in:
Dmitry Lyalin
2026-02-18 15:28:17 -05:00
committed by GitHub
parent 8f6a711a3a
commit 78de533c48
21 changed files with 1396 additions and 107 deletions
+373
View File
@@ -49,6 +49,15 @@ const mockIdeClient = vi.hoisted(() => ({
const mocks = vi.hoisted(() => ({
mockStdout: { write: vi.fn() },
}));
const terminalNotificationsMocks = vi.hoisted(() => ({
notifyViaTerminal: vi.fn().mockResolvedValue(true),
isNotificationsEnabled: vi.fn(() => true),
buildRunEventNotificationContent: vi.fn((event) => ({
title: 'Mock Notification',
subtitle: 'Mock Subtitle',
body: JSON.stringify(event),
})),
}));
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const actual =
@@ -165,6 +174,12 @@ vi.mock('./hooks/useShellInactivityStatus.js', () => ({
inactivityStatus: 'none',
})),
}));
vi.mock('../utils/terminalNotifications.js', () => ({
notifyViaTerminal: terminalNotificationsMocks.notifyViaTerminal,
isNotificationsEnabled: terminalNotificationsMocks.isNotificationsEnabled,
buildRunEventNotificationContent:
terminalNotificationsMocks.buildRunEventNotificationContent,
}));
vi.mock('./hooks/useTerminalTheme.js', () => ({
useTerminalTheme: vi.fn(),
}));
@@ -172,6 +187,7 @@ vi.mock('./hooks/useTerminalTheme.js', () => ({
import { useHookDisplayState } from './hooks/useHookDisplayState.js';
import { useTerminalTheme } from './hooks/useTerminalTheme.js';
import { useShellInactivityStatus } from './hooks/useShellInactivityStatus.js';
import { useFocus } from './hooks/useFocus.js';
// Mock external utilities
vi.mock('../utils/events.js');
@@ -280,6 +296,7 @@ describe('AppContainer State Management', () => {
const mockedUseHookDisplayState = useHookDisplayState as Mock;
const mockedUseTerminalTheme = useTerminalTheme as Mock;
const mockedUseShellInactivityStatus = useShellInactivityStatus as Mock;
const mockedUseFocusState = useFocus as Mock;
const DEFAULT_GEMINI_STREAM_MOCK = {
streamingState: 'idle',
@@ -417,6 +434,10 @@ describe('AppContainer State Management', () => {
shouldShowFocusHint: false,
inactivityStatus: 'none',
});
mockedUseFocusState.mockReturnValue({
isFocused: true,
hasReceivedFocusEvent: true,
});
// Mock Config
mockConfig = makeFakeConfig();
@@ -525,6 +546,358 @@ describe('AppContainer State Management', () => {
});
describe('State Initialization', () => {
it('sends a macOS notification when confirmation is pending and terminal is unfocused', async () => {
mockedUseFocusState.mockReturnValue({
isFocused: false,
hasReceivedFocusEvent: true,
});
mockedUseGeminiStream.mockReturnValue({
...DEFAULT_GEMINI_STREAM_MOCK,
pendingHistoryItems: [
{
type: 'tool_group',
tools: [
{
callId: 'call-1',
name: 'run_shell_command',
description: 'Run command',
resultDisplay: undefined,
status: CoreToolCallStatus.AwaitingApproval,
confirmationDetails: {
type: 'exec',
title: 'Run shell command',
command: 'ls',
rootCommand: 'ls',
rootCommands: ['ls'],
},
},
],
},
],
});
let unmount: (() => void) | undefined;
await act(async () => {
const rendered = renderAppContainer();
unmount = rendered.unmount;
});
await waitFor(() =>
expect(terminalNotificationsMocks.notifyViaTerminal).toHaveBeenCalled(),
);
expect(
terminalNotificationsMocks.buildRunEventNotificationContent,
).toHaveBeenCalledWith(
expect.objectContaining({
type: 'attention',
}),
);
await act(async () => {
unmount?.();
});
});
it('does not send attention notification when terminal is focused', async () => {
mockedUseFocusState.mockReturnValue({
isFocused: true,
hasReceivedFocusEvent: true,
});
mockedUseGeminiStream.mockReturnValue({
...DEFAULT_GEMINI_STREAM_MOCK,
pendingHistoryItems: [
{
type: 'tool_group',
tools: [
{
callId: 'call-2',
name: 'run_shell_command',
description: 'Run command',
resultDisplay: undefined,
status: CoreToolCallStatus.AwaitingApproval,
confirmationDetails: {
type: 'exec',
title: 'Run shell command',
command: 'ls',
rootCommand: 'ls',
rootCommands: ['ls'],
},
},
],
},
],
});
let unmount: (() => void) | undefined;
await act(async () => {
const rendered = renderAppContainer();
unmount = rendered.unmount;
});
expect(
terminalNotificationsMocks.notifyViaTerminal,
).not.toHaveBeenCalled();
await act(async () => {
unmount?.();
});
});
it('sends attention notification when focus reporting is unavailable', async () => {
mockedUseFocusState.mockReturnValue({
isFocused: true,
hasReceivedFocusEvent: false,
});
mockedUseGeminiStream.mockReturnValue({
...DEFAULT_GEMINI_STREAM_MOCK,
pendingHistoryItems: [
{
type: 'tool_group',
tools: [
{
callId: 'call-focus-unknown',
name: 'run_shell_command',
description: 'Run command',
resultDisplay: undefined,
status: CoreToolCallStatus.AwaitingApproval,
confirmationDetails: {
type: 'exec',
title: 'Run shell command',
command: 'ls',
rootCommand: 'ls',
rootCommands: ['ls'],
},
},
],
},
],
});
let unmount: (() => void) | undefined;
await act(async () => {
const rendered = renderAppContainer();
unmount = rendered.unmount;
});
await waitFor(() =>
expect(terminalNotificationsMocks.notifyViaTerminal).toHaveBeenCalled(),
);
await act(async () => {
unmount?.();
});
});
it('sends a macOS notification when a response completes while unfocused', async () => {
mockedUseFocusState.mockReturnValue({
isFocused: false,
hasReceivedFocusEvent: true,
});
let currentStreamingState: 'idle' | 'responding' = 'responding';
mockedUseGeminiStream.mockImplementation(() => ({
...DEFAULT_GEMINI_STREAM_MOCK,
streamingState: currentStreamingState,
}));
let unmount: (() => void) | undefined;
let rerender: ((tree: ReactElement) => void) | undefined;
await act(async () => {
const rendered = renderAppContainer();
unmount = rendered.unmount;
rerender = rendered.rerender;
});
currentStreamingState = 'idle';
await act(async () => {
rerender?.(getAppContainer());
});
await waitFor(() =>
expect(
terminalNotificationsMocks.buildRunEventNotificationContent,
).toHaveBeenCalledWith(
expect.objectContaining({
type: 'session_complete',
detail: 'Gemini CLI finished responding.',
}),
),
);
expect(terminalNotificationsMocks.notifyViaTerminal).toHaveBeenCalled();
await act(async () => {
unmount?.();
});
});
it('sends completion notification when focus reporting is unavailable', async () => {
mockedUseFocusState.mockReturnValue({
isFocused: true,
hasReceivedFocusEvent: false,
});
let currentStreamingState: 'idle' | 'responding' = 'responding';
mockedUseGeminiStream.mockImplementation(() => ({
...DEFAULT_GEMINI_STREAM_MOCK,
streamingState: currentStreamingState,
}));
let unmount: (() => void) | undefined;
let rerender: ((tree: ReactElement) => void) | undefined;
await act(async () => {
const rendered = renderAppContainer();
unmount = rendered.unmount;
rerender = rendered.rerender;
});
currentStreamingState = 'idle';
await act(async () => {
rerender?.(getAppContainer());
});
await waitFor(() =>
expect(
terminalNotificationsMocks.buildRunEventNotificationContent,
).toHaveBeenCalledWith(
expect.objectContaining({
type: 'session_complete',
detail: 'Gemini CLI finished responding.',
}),
),
);
await act(async () => {
unmount?.();
});
});
it('does not send completion notification when another action-required dialog is pending', async () => {
mockedUseFocusState.mockReturnValue({
isFocused: false,
hasReceivedFocusEvent: true,
});
mockedUseQuotaAndFallback.mockReturnValue({
proQuotaRequest: { kind: 'upgrade' },
handleProQuotaChoice: vi.fn(),
});
let currentStreamingState: 'idle' | 'responding' = 'responding';
mockedUseGeminiStream.mockImplementation(() => ({
...DEFAULT_GEMINI_STREAM_MOCK,
streamingState: currentStreamingState,
}));
let unmount: (() => void) | undefined;
let rerender: ((tree: ReactElement) => void) | undefined;
await act(async () => {
const rendered = renderAppContainer();
unmount = rendered.unmount;
rerender = rendered.rerender;
});
currentStreamingState = 'idle';
await act(async () => {
rerender?.(getAppContainer());
});
expect(
terminalNotificationsMocks.notifyViaTerminal,
).not.toHaveBeenCalled();
await act(async () => {
unmount?.();
});
});
it('can send repeated attention notifications for the same key after pending state clears', async () => {
mockedUseFocusState.mockReturnValue({
isFocused: false,
hasReceivedFocusEvent: true,
});
let pendingHistoryItems = [
{
type: 'tool_group',
tools: [
{
callId: 'repeat-key-call',
name: 'run_shell_command',
description: 'Run command',
resultDisplay: undefined,
status: CoreToolCallStatus.AwaitingApproval,
confirmationDetails: {
type: 'exec',
title: 'Run shell command',
command: 'ls',
rootCommand: 'ls',
rootCommands: ['ls'],
},
},
],
},
];
mockedUseGeminiStream.mockImplementation(() => ({
...DEFAULT_GEMINI_STREAM_MOCK,
pendingHistoryItems,
}));
let unmount: (() => void) | undefined;
let rerender: ((tree: ReactElement) => void) | undefined;
await act(async () => {
const rendered = renderAppContainer();
unmount = rendered.unmount;
rerender = rendered.rerender;
});
await waitFor(() =>
expect(
terminalNotificationsMocks.notifyViaTerminal,
).toHaveBeenCalledTimes(1),
);
pendingHistoryItems = [];
await act(async () => {
rerender?.(getAppContainer());
});
pendingHistoryItems = [
{
type: 'tool_group',
tools: [
{
callId: 'repeat-key-call',
name: 'run_shell_command',
description: 'Run command',
resultDisplay: undefined,
status: CoreToolCallStatus.AwaitingApproval,
confirmationDetails: {
type: 'exec',
title: 'Run shell command',
command: 'ls',
rootCommand: 'ls',
rootCommands: ['ls'],
},
},
],
},
];
await act(async () => {
rerender?.(getAppContainer());
});
await waitFor(() =>
expect(
terminalNotificationsMocks.notifyViaTerminal,
).toHaveBeenCalledTimes(2),
);
await act(async () => {
unmount?.();
});
});
it('initializes with theme error from initialization result', async () => {
const initResultWithError = {
...mockInitResult,
+25 -3
View File
@@ -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 │
+7 -42
View File
@@ -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],
);
}
+24 -11
View File
@@ -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);
});
});
+11 -2
View File
@@ -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,
};
};
@@ -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,
]);
}
@@ -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,
};
}
@@ -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?');
});
});
@@ -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 =