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

View File

@@ -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,
};
}

View File

@@ -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?');
});
});

View File

@@ -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;
}

View File

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

View File

@@ -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 =