mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-07-09 01:27:41 -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:
@@ -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,
|
||||
|
||||
@@ -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 │
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
@@ -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 =
|
||||
|
||||
Reference in New Issue
Block a user