mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-04-08 20:30:53 -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:
48
packages/cli/src/ui/utils/confirmingTool.ts
Normal file
48
packages/cli/src/ui/utils/confirmingTool.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
114
packages/cli/src/ui/utils/pendingAttentionNotification.test.ts
Normal file
114
packages/cli/src/ui/utils/pendingAttentionNotification.test.ts
Normal 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?');
|
||||
});
|
||||
});
|
||||
126
packages/cli/src/ui/utils/pendingAttentionNotification.ts
Normal file
126
packages/cli/src/ui/utils/pendingAttentionNotification.ts
Normal 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;
|
||||
}
|
||||
@@ -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