feat(cli): allow safe tools to execute concurrently while agent is busy (#21988)

This commit is contained in:
Spencer
2026-03-12 12:03:53 -04:00
committed by GitHub
parent e700a9220b
commit 73c589f9e3
8 changed files with 59 additions and 1 deletions
+15
View File
@@ -162,6 +162,7 @@ import {
import { LoginWithGoogleRestartDialog } from './auth/LoginWithGoogleRestartDialog.js'; import { LoginWithGoogleRestartDialog } from './auth/LoginWithGoogleRestartDialog.js';
import { NewAgentsChoice } from './components/NewAgentsNotification.js'; import { NewAgentsChoice } from './components/NewAgentsNotification.js';
import { isSlashCommand } from './utils/commandUtils.js'; import { isSlashCommand } from './utils/commandUtils.js';
import { parseSlashCommand } from '../utils/commands.js';
import { useTerminalTheme } from './hooks/useTerminalTheme.js'; import { useTerminalTheme } from './hooks/useTerminalTheme.js';
import { useTimedMessage } from './hooks/useTimedMessage.js'; import { useTimedMessage } from './hooks/useTimedMessage.js';
import { useIsHelpDismissKey } from './utils/shortcutsHelp.js'; import { useIsHelpDismissKey } from './utils/shortcutsHelp.js';
@@ -1289,6 +1290,18 @@ Logging in with Google... Restarting Gemini CLI to continue.
...pendingGeminiHistoryItems, ...pendingGeminiHistoryItems,
]); ]);
if (isSlash && isAgentRunning) {
const { commandToExecute } = parseSlashCommand(
submittedValue,
slashCommands ?? [],
);
if (commandToExecute?.isSafeConcurrent) {
void handleSlashCommand(submittedValue);
addInput(submittedValue);
return;
}
}
if (config.isModelSteeringEnabled() && isAgentRunning && !isSlash) { if (config.isModelSteeringEnabled() && isAgentRunning && !isSlash) {
handleHintSubmit(submittedValue); handleHintSubmit(submittedValue);
addInput(submittedValue); addInput(submittedValue);
@@ -1332,6 +1345,8 @@ Logging in with Google... Restarting Gemini CLI to continue.
addMessage, addMessage,
addInput, addInput,
submitQuery, submitQuery,
handleSlashCommand,
slashCommands,
isMcpReady, isMcpReady,
streamingState, streamingState,
messageQueue.length, messageQueue.length,
@@ -23,6 +23,7 @@ export const aboutCommand: SlashCommand = {
description: 'Show version info', description: 'Show version info',
kind: CommandKind.BUILT_IN, kind: CommandKind.BUILT_IN,
autoExecute: true, autoExecute: true,
isSafeConcurrent: true,
action: async (context) => { action: async (context) => {
const osVersion = process.platform; const osVersion = process.platform;
let sandboxEnv = 'no sandbox'; let sandboxEnv = 'no sandbox';
@@ -15,6 +15,7 @@ export const settingsCommand: SlashCommand = {
description: 'View and edit Gemini CLI settings', description: 'View and edit Gemini CLI settings',
kind: CommandKind.BUILT_IN, kind: CommandKind.BUILT_IN,
autoExecute: true, autoExecute: true,
isSafeConcurrent: true,
action: (_context, _args): OpenDialogActionReturn => ({ action: (_context, _args): OpenDialogActionReturn => ({
type: 'dialog', type: 'dialog',
dialog: 'settings', dialog: 'settings',
@@ -84,6 +84,7 @@ export const statsCommand: SlashCommand = {
description: 'Check session stats. Usage: /stats [session|model|tools]', description: 'Check session stats. Usage: /stats [session|model|tools]',
kind: CommandKind.BUILT_IN, kind: CommandKind.BUILT_IN,
autoExecute: false, autoExecute: false,
isSafeConcurrent: true,
action: async (context: CommandContext) => { action: async (context: CommandContext) => {
await defaultSessionView(context); await defaultSessionView(context);
}, },
@@ -93,6 +94,7 @@ export const statsCommand: SlashCommand = {
description: 'Show session-specific usage statistics', description: 'Show session-specific usage statistics',
kind: CommandKind.BUILT_IN, kind: CommandKind.BUILT_IN,
autoExecute: true, autoExecute: true,
isSafeConcurrent: true,
action: async (context: CommandContext) => { action: async (context: CommandContext) => {
await defaultSessionView(context); await defaultSessionView(context);
}, },
@@ -102,6 +104,7 @@ export const statsCommand: SlashCommand = {
description: 'Show model-specific usage statistics', description: 'Show model-specific usage statistics',
kind: CommandKind.BUILT_IN, kind: CommandKind.BUILT_IN,
autoExecute: true, autoExecute: true,
isSafeConcurrent: true,
action: (context: CommandContext) => { action: (context: CommandContext) => {
const { selectedAuthType, userEmail, tier } = getUserIdentity(context); const { selectedAuthType, userEmail, tier } = getUserIdentity(context);
const currentModel = context.services.config?.getModel(); const currentModel = context.services.config?.getModel();
@@ -125,6 +128,7 @@ export const statsCommand: SlashCommand = {
description: 'Show tool-specific usage statistics', description: 'Show tool-specific usage statistics',
kind: CommandKind.BUILT_IN, kind: CommandKind.BUILT_IN,
autoExecute: true, autoExecute: true,
isSafeConcurrent: true,
action: (context: CommandContext) => { action: (context: CommandContext) => {
context.ui.addItem({ context.ui.addItem({
type: MessageType.TOOL_STATS, type: MessageType.TOOL_STATS,
+5
View File
@@ -207,6 +207,11 @@ export interface SlashCommand {
*/ */
autoExecute?: boolean; autoExecute?: boolean;
/**
* Whether this command can be safely executed while the agent is busy (e.g. streaming a response).
*/
isSafeConcurrent?: boolean;
// Optional metadata for extension commands // Optional metadata for extension commands
extensionName?: string; extensionName?: string;
extensionId?: string; extensionId?: string;
@@ -11,6 +11,7 @@ export const vimCommand: SlashCommand = {
description: 'Toggle vim mode on/off', description: 'Toggle vim mode on/off',
kind: CommandKind.BUILT_IN, kind: CommandKind.BUILT_IN,
autoExecute: true, autoExecute: true,
isSafeConcurrent: true,
action: async (context, _args) => { action: async (context, _args) => {
const newVimState = await context.ui.toggleVimEnabled(); const newVimState = await context.ui.toggleVimEnabled();
@@ -94,6 +94,12 @@ afterEach(() => {
}); });
const mockSlashCommands: SlashCommand[] = [ const mockSlashCommands: SlashCommand[] = [
{
name: 'stats',
description: 'Check stats',
kind: CommandKind.BUILT_IN,
isSafeConcurrent: true,
},
{ {
name: 'clear', name: 'clear',
kind: CommandKind.BUILT_IN, kind: CommandKind.BUILT_IN,
@@ -3876,6 +3882,13 @@ describe('InputPrompt', () => {
shouldSubmit: false, shouldSubmit: false,
errorMessage: 'Slash commands cannot be queued', errorMessage: 'Slash commands cannot be queued',
}, },
{
name: 'should allow concurrent-safe slash commands',
bufferText: '/stats',
shellMode: false,
shouldSubmit: true,
errorMessage: null,
},
{ {
name: 'should prevent shell commands', name: 'should prevent shell commands',
bufferText: 'ls', bufferText: 'ls',
+19 -1
View File
@@ -58,6 +58,7 @@ import {
isAutoExecutableCommand, isAutoExecutableCommand,
isSlashCommand, isSlashCommand,
} from '../utils/commandUtils.js'; } from '../utils/commandUtils.js';
import { parseSlashCommand } from '../../utils/commands.js';
import * as path from 'node:path'; import * as path from 'node:path';
import { SCREEN_READER_USER_PREFIX } from '../textConstants.js'; import { SCREEN_READER_USER_PREFIX } from '../textConstants.js';
import { getSafeLowColorBackground } from '../themes/color-utils.js'; import { getSafeLowColorBackground } from '../themes/color-utils.js';
@@ -408,6 +409,17 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
(isSlash || isShell) && (isSlash || isShell) &&
streamingState === StreamingState.Responding streamingState === StreamingState.Responding
) { ) {
if (isSlash) {
const { commandToExecute } = parseSlashCommand(
trimmedMessage,
slashCommands,
);
if (commandToExecute?.isSafeConcurrent) {
inputHistory.handleSubmit(trimmedMessage);
return;
}
}
setQueueErrorMessage( setQueueErrorMessage(
`${isShell ? 'Shell' : 'Slash'} commands cannot be queued`, `${isShell ? 'Shell' : 'Slash'} commands cannot be queued`,
); );
@@ -415,7 +427,13 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
} }
inputHistory.handleSubmit(trimmedMessage); inputHistory.handleSubmit(trimmedMessage);
}, },
[inputHistory, shellModeActive, streamingState, setQueueErrorMessage], [
inputHistory,
shellModeActive,
streamingState,
setQueueErrorMessage,
slashCommands,
],
); );
// Effect to reset completion if history navigation just occurred and set the text // Effect to reset completion if history navigation just occurred and set the text