mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-04-21 10:34:35 -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:
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
Reference in New Issue
Block a user