diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 03e001546b..0bfdeba120 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -162,6 +162,7 @@ import { import { LoginWithGoogleRestartDialog } from './auth/LoginWithGoogleRestartDialog.js'; import { NewAgentsChoice } from './components/NewAgentsNotification.js'; import { isSlashCommand } from './utils/commandUtils.js'; +import { parseSlashCommand } from '../utils/commands.js'; import { useTerminalTheme } from './hooks/useTerminalTheme.js'; import { useTimedMessage } from './hooks/useTimedMessage.js'; import { useIsHelpDismissKey } from './utils/shortcutsHelp.js'; @@ -1289,6 +1290,18 @@ Logging in with Google... Restarting Gemini CLI to continue. ...pendingGeminiHistoryItems, ]); + if (isSlash && isAgentRunning) { + const { commandToExecute } = parseSlashCommand( + submittedValue, + slashCommands ?? [], + ); + if (commandToExecute?.isSafeConcurrent) { + void handleSlashCommand(submittedValue); + addInput(submittedValue); + return; + } + } + if (config.isModelSteeringEnabled() && isAgentRunning && !isSlash) { handleHintSubmit(submittedValue); addInput(submittedValue); @@ -1332,6 +1345,8 @@ Logging in with Google... Restarting Gemini CLI to continue. addMessage, addInput, submitQuery, + handleSlashCommand, + slashCommands, isMcpReady, streamingState, messageQueue.length, diff --git a/packages/cli/src/ui/commands/aboutCommand.ts b/packages/cli/src/ui/commands/aboutCommand.ts index 6c1f82c95b..afd1ada9cd 100644 --- a/packages/cli/src/ui/commands/aboutCommand.ts +++ b/packages/cli/src/ui/commands/aboutCommand.ts @@ -23,6 +23,7 @@ export const aboutCommand: SlashCommand = { description: 'Show version info', kind: CommandKind.BUILT_IN, autoExecute: true, + isSafeConcurrent: true, action: async (context) => { const osVersion = process.platform; let sandboxEnv = 'no sandbox'; diff --git a/packages/cli/src/ui/commands/settingsCommand.ts b/packages/cli/src/ui/commands/settingsCommand.ts index fe3ac3f322..48ad6355ca 100644 --- a/packages/cli/src/ui/commands/settingsCommand.ts +++ b/packages/cli/src/ui/commands/settingsCommand.ts @@ -15,6 +15,7 @@ export const settingsCommand: SlashCommand = { description: 'View and edit Gemini CLI settings', kind: CommandKind.BUILT_IN, autoExecute: true, + isSafeConcurrent: true, action: (_context, _args): OpenDialogActionReturn => ({ type: 'dialog', dialog: 'settings', diff --git a/packages/cli/src/ui/commands/statsCommand.ts b/packages/cli/src/ui/commands/statsCommand.ts index 1ded006618..fe991e97ed 100644 --- a/packages/cli/src/ui/commands/statsCommand.ts +++ b/packages/cli/src/ui/commands/statsCommand.ts @@ -84,6 +84,7 @@ export const statsCommand: SlashCommand = { description: 'Check session stats. Usage: /stats [session|model|tools]', kind: CommandKind.BUILT_IN, autoExecute: false, + isSafeConcurrent: true, action: async (context: CommandContext) => { await defaultSessionView(context); }, @@ -93,6 +94,7 @@ export const statsCommand: SlashCommand = { description: 'Show session-specific usage statistics', kind: CommandKind.BUILT_IN, autoExecute: true, + isSafeConcurrent: true, action: async (context: CommandContext) => { await defaultSessionView(context); }, @@ -102,6 +104,7 @@ export const statsCommand: SlashCommand = { description: 'Show model-specific usage statistics', kind: CommandKind.BUILT_IN, autoExecute: true, + isSafeConcurrent: true, action: (context: CommandContext) => { const { selectedAuthType, userEmail, tier } = getUserIdentity(context); const currentModel = context.services.config?.getModel(); @@ -125,6 +128,7 @@ export const statsCommand: SlashCommand = { description: 'Show tool-specific usage statistics', kind: CommandKind.BUILT_IN, autoExecute: true, + isSafeConcurrent: true, action: (context: CommandContext) => { context.ui.addItem({ type: MessageType.TOOL_STATS, diff --git a/packages/cli/src/ui/commands/types.ts b/packages/cli/src/ui/commands/types.ts index 28f52461e4..7bd640090f 100644 --- a/packages/cli/src/ui/commands/types.ts +++ b/packages/cli/src/ui/commands/types.ts @@ -207,6 +207,11 @@ export interface SlashCommand { */ 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 extensionName?: string; extensionId?: string; diff --git a/packages/cli/src/ui/commands/vimCommand.ts b/packages/cli/src/ui/commands/vimCommand.ts index ebbb54d3b0..74d54ee5ef 100644 --- a/packages/cli/src/ui/commands/vimCommand.ts +++ b/packages/cli/src/ui/commands/vimCommand.ts @@ -11,6 +11,7 @@ export const vimCommand: SlashCommand = { description: 'Toggle vim mode on/off', kind: CommandKind.BUILT_IN, autoExecute: true, + isSafeConcurrent: true, action: async (context, _args) => { const newVimState = await context.ui.toggleVimEnabled(); diff --git a/packages/cli/src/ui/components/InputPrompt.test.tsx b/packages/cli/src/ui/components/InputPrompt.test.tsx index 15f6e2f8c4..c092e600b9 100644 --- a/packages/cli/src/ui/components/InputPrompt.test.tsx +++ b/packages/cli/src/ui/components/InputPrompt.test.tsx @@ -94,6 +94,12 @@ afterEach(() => { }); const mockSlashCommands: SlashCommand[] = [ + { + name: 'stats', + description: 'Check stats', + kind: CommandKind.BUILT_IN, + isSafeConcurrent: true, + }, { name: 'clear', kind: CommandKind.BUILT_IN, @@ -3876,6 +3882,13 @@ describe('InputPrompt', () => { shouldSubmit: false, 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', bufferText: 'ls', diff --git a/packages/cli/src/ui/components/InputPrompt.tsx b/packages/cli/src/ui/components/InputPrompt.tsx index 94b1d2dc00..fd6f091af8 100644 --- a/packages/cli/src/ui/components/InputPrompt.tsx +++ b/packages/cli/src/ui/components/InputPrompt.tsx @@ -58,6 +58,7 @@ import { isAutoExecutableCommand, isSlashCommand, } from '../utils/commandUtils.js'; +import { parseSlashCommand } from '../../utils/commands.js'; import * as path from 'node:path'; import { SCREEN_READER_USER_PREFIX } from '../textConstants.js'; import { getSafeLowColorBackground } from '../themes/color-utils.js'; @@ -408,6 +409,17 @@ export const InputPrompt: React.FC = ({ (isSlash || isShell) && streamingState === StreamingState.Responding ) { + if (isSlash) { + const { commandToExecute } = parseSlashCommand( + trimmedMessage, + slashCommands, + ); + if (commandToExecute?.isSafeConcurrent) { + inputHistory.handleSubmit(trimmedMessage); + return; + } + } + setQueueErrorMessage( `${isShell ? 'Shell' : 'Slash'} commands cannot be queued`, ); @@ -415,7 +427,13 @@ export const InputPrompt: React.FC = ({ } 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