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
+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,
]);
}