From 2b05cf3bb4d3b8610e6174183537c75be864206b Mon Sep 17 00:00:00 2001 From: Tommaso Sciortino Date: Mon, 8 Sep 2025 16:37:36 -0700 Subject: [PATCH] Allow users to type while waiting for MCP servers (#8021) --- packages/cli/src/gemini.tsx | 59 ++----------------- packages/cli/src/ui/AppContainer.test.tsx | 11 ++-- packages/cli/src/ui/AppContainer.tsx | 21 +++++-- packages/cli/src/ui/components/Composer.tsx | 3 + .../src/ui/components/ConfigInitDisplay.tsx | 46 +++++++++++++++ .../ui/components/GeminiRespondingSpinner.tsx | 26 ++++++-- .../cli/src/ui/contexts/UIStateContext.tsx | 1 + .../cli/src/ui/hooks/useMessageQueue.test.ts | 9 +++ packages/cli/src/ui/hooks/useMessageQueue.ts | 10 +++- packages/core/src/tools/mcp-client-manager.ts | 31 ++-------- 10 files changed, 122 insertions(+), 95 deletions(-) create mode 100644 packages/cli/src/ui/components/ConfigInitDisplay.tsx diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx index d7b77ef19a..bb58022d22 100644 --- a/packages/cli/src/gemini.tsx +++ b/packages/cli/src/gemini.tsx @@ -4,9 +4,8 @@ * SPDX-License-Identifier: Apache-2.0 */ -import React, { useState, useEffect } from 'react'; -import { render, Box, Text } from 'ink'; -import Spinner from 'ink-spinner'; +import React from 'react'; +import { render } from 'ink'; import { AppContainer } from './ui/AppContainer.js'; import { loadCliConfig, parseArguments } from './config/config.js'; import { readStdin } from './utils/readStdin.js'; @@ -117,38 +116,6 @@ async function relaunchWithAdditionalArgs(additionalArgs: string[]) { process.exit(0); } -const InitializingComponent = ({ initialTotal }: { initialTotal: number }) => { - const [total, setTotal] = useState(initialTotal); - const [connected, setConnected] = useState(0); - - useEffect(() => { - const onStart = ({ count }: { count: number }) => setTotal(count); - const onChange = () => { - setConnected((val) => val + 1); - }; - - appEvents.on('mcp-servers-discovery-start', onStart); - appEvents.on('mcp-server-connected', onChange); - appEvents.on('mcp-server-error', onChange); - - return () => { - appEvents.off('mcp-servers-discovery-start', onStart); - appEvents.off('mcp-server-connected', onChange); - appEvents.off('mcp-server-error', onChange); - }; - }, []); - - const message = `Connecting to MCP servers... (${connected}/${total})`; - - return ( - - - {message} - - - ); -}; - import { runZedIntegration } from './zed-integration/zedIntegration.js'; export function setupUnhandledRejectionHandler() { @@ -315,25 +282,6 @@ export async function main() { setMaxSizedBoxDebugging(config.getDebugMode()); - const mcpServers = config.getMcpServers(); - const mcpServersCount = mcpServers ? Object.keys(mcpServers).length : 0; - - let spinnerInstance; - if (config.isInteractive() && mcpServersCount > 0) { - spinnerInstance = render( - , - ); - } - - await config.initialize(); - - if (spinnerInstance) { - // Small UX detail to show the completion message for a bit before unmounting. - await new Promise((f) => setTimeout(f, 100)); - spinnerInstance.clear(); - spinnerInstance.unmount(); - } - // Load custom themes from settings themeManager.loadCustomThemes(settings.merged.ui?.customThemes); @@ -446,6 +394,9 @@ export async function main() { ); return; } + + await config.initialize(); + // If not a TTY, read from stdin // This is for cases where the user pipes input directly into the command if (!process.stdin.isTTY) { diff --git a/packages/cli/src/ui/AppContainer.test.tsx b/packages/cli/src/ui/AppContainer.test.tsx index a5d83ca383..621e16c7df 100644 --- a/packages/cli/src/ui/AppContainer.test.tsx +++ b/packages/cli/src/ui/AppContainer.test.tsx @@ -416,10 +416,9 @@ describe('AppContainer State Management', () => { }); describe('Version Handling', () => { - it('handles different version formats', () => { - const versions = ['1.0.0', '2.1.3-beta', '3.0.0-nightly']; - - versions.forEach((version) => { + it.each(['1.0.0', '2.1.3-beta', '3.0.0-nightly'])( + 'handles version format: %s', + (version) => { expect(() => { render( { />, ); }).not.toThrow(); - }); - }); + }, + ); }); describe('Error Handling', () => { diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 17e08b54ea..a5977bb615 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -134,6 +134,8 @@ export const AppContainer = (props: AppContainerProps) => { const [userTier, setUserTier] = useState(undefined); + const [isConfigInitialized, setConfigInitialized] = useState(false); + // Auto-accept indicator const showAutoAcceptIndicator = useAutoAcceptIndicator({ config, @@ -157,16 +159,22 @@ export const AppContainer = (props: AppContainerProps) => { const staticExtraHeight = 3; useEffect(() => { + (async () => { + // Note: the program will not work if this fails so let errors be + // handled by the global catch. + await config.initialize(); + setConfigInitialized(true); + })(); registerCleanup(async () => { const ideClient = await IdeClient.getInstance(); await ideClient.disconnect(); }); }, [config]); - useEffect(() => { - const cleanup = setUpdateHandler(historyManager.addItem, setUpdateInfo); - return cleanup; - }, [historyManager.addItem]); + useEffect( + () => setUpdateHandler(historyManager.addItem, setUpdateInfo), + [historyManager.addItem], + ); // Watch for model changes (e.g., from Flash fallback) useEffect(() => { @@ -517,6 +525,7 @@ Logging in with Google... Please restart Gemini CLI to continue. const { messageQueue, addMessage, clearQueue, getQueuedMessagesText } = useMessageQueue({ + isConfigInitialized, streamingState, submitQuery, }); @@ -612,6 +621,7 @@ Logging in with Google... Please restart Gemini CLI to continue. useEffect(() => { if ( initialPrompt && + isConfigInitialized && !initialPromptSubmitted.current && !isAuthenticating && !isAuthDialogOpen && @@ -625,6 +635,7 @@ Logging in with Google... Please restart Gemini CLI to continue. } }, [ initialPrompt, + isConfigInitialized, handleFinalSubmit, isAuthenticating, isAuthDialogOpen, @@ -926,6 +937,7 @@ Logging in with Google... Please restart Gemini CLI to continue. isThemeDialogOpen, themeError, isAuthenticating, + isConfigInitialized, authError, isAuthDialogOpen, editorError, @@ -997,6 +1009,7 @@ Logging in with Google... Please restart Gemini CLI to continue. isThemeDialogOpen, themeError, isAuthenticating, + isConfigInitialized, authError, isAuthDialogOpen, editorError, diff --git a/packages/cli/src/ui/components/Composer.tsx b/packages/cli/src/ui/components/Composer.tsx index d84dc602bd..fa5322e4ae 100644 --- a/packages/cli/src/ui/components/Composer.tsx +++ b/packages/cli/src/ui/components/Composer.tsx @@ -23,6 +23,7 @@ import { useConfig } from '../contexts/ConfigContext.js'; import { useSettings } from '../contexts/SettingsContext.js'; import { ApprovalMode } from '@google/gemini-cli-core'; import { StreamingState } from '../types.js'; +import { ConfigInitDisplay } from '../components/ConfigInitDisplay.js'; const MAX_DISPLAYED_QUEUED_MESSAGES = 3; @@ -72,6 +73,8 @@ export const Composer = () => { elapsedTime={uiState.elapsedTime} /> + {!uiState.isConfigInitialized && } + {uiState.messageQueue.length > 0 && ( {uiState.messageQueue diff --git a/packages/cli/src/ui/components/ConfigInitDisplay.tsx b/packages/cli/src/ui/components/ConfigInitDisplay.tsx new file mode 100644 index 0000000000..f4ca44ad74 --- /dev/null +++ b/packages/cli/src/ui/components/ConfigInitDisplay.tsx @@ -0,0 +1,46 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useEffect, useState } from 'react'; +import { appEvents } from './../../utils/events.js'; +import { Box, Text } from 'ink'; +import { useConfig } from '../contexts/ConfigContext.js'; +import { type McpClient, MCPServerStatus } from '@google/gemini-cli-core'; +import { GeminiSpinner } from './GeminiRespondingSpinner.js'; + +export const ConfigInitDisplay = () => { + const config = useConfig(); + const [message, setMessage] = useState('Initializing...'); + + useEffect(() => { + const onChange = (clients?: Map) => { + if (!clients || clients.size === 0) { + setMessage(`Initializing...`); + return; + } + let connected = 0; + for (const client of clients.values()) { + if (client.getStatus() === MCPServerStatus.CONNECTED) { + connected++; + } + } + setMessage(`Connecting to MCP servers... (${connected}/${clients.size})`); + }; + + appEvents.on('mcp-client-update', onChange); + return () => { + appEvents.off('mcp-client-update', onChange); + }; + }, [config]); + + return ( + + + {message} + + + ); +}; diff --git a/packages/cli/src/ui/components/GeminiRespondingSpinner.tsx b/packages/cli/src/ui/components/GeminiRespondingSpinner.tsx index caf774e2a8..057faaffc3 100644 --- a/packages/cli/src/ui/components/GeminiRespondingSpinner.tsx +++ b/packages/cli/src/ui/components/GeminiRespondingSpinner.tsx @@ -30,10 +30,11 @@ export const GeminiRespondingSpinner: React.FC< const streamingState = useStreamingContext(); const isScreenReaderEnabled = useIsScreenReaderEnabled(); if (streamingState === StreamingState.Responding) { - return isScreenReaderEnabled ? ( - {SCREEN_READER_RESPONDING} - ) : ( - + return ( + ); } else if (nonRespondingDisplay) { return isScreenReaderEnabled ? ( @@ -44,3 +45,20 @@ export const GeminiRespondingSpinner: React.FC< } return null; }; + +interface GeminiSpinnerProps { + spinnerType?: SpinnerName; + altText?: string; +} + +export const GeminiSpinner: React.FC = ({ + spinnerType = 'dots', + altText, +}) => { + const isScreenReaderEnabled = useIsScreenReaderEnabled(); + return isScreenReaderEnabled ? ( + {altText} + ) : ( + + ); +}; diff --git a/packages/cli/src/ui/contexts/UIStateContext.tsx b/packages/cli/src/ui/contexts/UIStateContext.tsx index f09d34d63f..0c62ebaf57 100644 --- a/packages/cli/src/ui/contexts/UIStateContext.tsx +++ b/packages/cli/src/ui/contexts/UIStateContext.tsx @@ -38,6 +38,7 @@ export interface UIState { isThemeDialogOpen: boolean; themeError: string | null; isAuthenticating: boolean; + isConfigInitialized: boolean; authError: string | null; isAuthDialogOpen: boolean; editorError: string | null; diff --git a/packages/cli/src/ui/hooks/useMessageQueue.test.ts b/packages/cli/src/ui/hooks/useMessageQueue.test.ts index 01e49afe5f..33dbf3211c 100644 --- a/packages/cli/src/ui/hooks/useMessageQueue.test.ts +++ b/packages/cli/src/ui/hooks/useMessageQueue.test.ts @@ -25,6 +25,7 @@ describe('useMessageQueue', () => { it('should initialize with empty queue', () => { const { result } = renderHook(() => useMessageQueue({ + isConfigInitialized: true, streamingState: StreamingState.Idle, submitQuery: mockSubmitQuery, }), @@ -37,6 +38,7 @@ describe('useMessageQueue', () => { it('should add messages to queue', () => { const { result } = renderHook(() => useMessageQueue({ + isConfigInitialized: true, streamingState: StreamingState.Responding, submitQuery: mockSubmitQuery, }), @@ -56,6 +58,7 @@ describe('useMessageQueue', () => { it('should filter out empty messages', () => { const { result } = renderHook(() => useMessageQueue({ + isConfigInitialized: true, streamingState: StreamingState.Responding, submitQuery: mockSubmitQuery, }), @@ -77,6 +80,7 @@ describe('useMessageQueue', () => { it('should clear queue', () => { const { result } = renderHook(() => useMessageQueue({ + isConfigInitialized: true, streamingState: StreamingState.Responding, submitQuery: mockSubmitQuery, }), @@ -98,6 +102,7 @@ describe('useMessageQueue', () => { it('should return queued messages as text with double newlines', () => { const { result } = renderHook(() => useMessageQueue({ + isConfigInitialized: true, streamingState: StreamingState.Responding, submitQuery: mockSubmitQuery, }), @@ -118,6 +123,7 @@ describe('useMessageQueue', () => { const { result, rerender } = renderHook( ({ streamingState }) => useMessageQueue({ + isConfigInitialized: true, streamingState, submitQuery: mockSubmitQuery, }), @@ -145,6 +151,7 @@ describe('useMessageQueue', () => { const { rerender } = renderHook( ({ streamingState }) => useMessageQueue({ + isConfigInitialized: true, streamingState, submitQuery: mockSubmitQuery, }), @@ -163,6 +170,7 @@ describe('useMessageQueue', () => { const { result, rerender } = renderHook( ({ streamingState }) => useMessageQueue({ + isConfigInitialized: true, streamingState, submitQuery: mockSubmitQuery, }), @@ -187,6 +195,7 @@ describe('useMessageQueue', () => { const { result, rerender } = renderHook( ({ streamingState }) => useMessageQueue({ + isConfigInitialized: true, streamingState, submitQuery: mockSubmitQuery, }), diff --git a/packages/cli/src/ui/hooks/useMessageQueue.ts b/packages/cli/src/ui/hooks/useMessageQueue.ts index f7bbe1ebe7..517040fecf 100644 --- a/packages/cli/src/ui/hooks/useMessageQueue.ts +++ b/packages/cli/src/ui/hooks/useMessageQueue.ts @@ -8,6 +8,7 @@ import { useCallback, useEffect, useState } from 'react'; import { StreamingState } from '../types.js'; export interface UseMessageQueueOptions { + isConfigInitialized: boolean; streamingState: StreamingState; submitQuery: (query: string) => void; } @@ -25,6 +26,7 @@ export interface UseMessageQueueReturn { * sends them when streaming completes. */ export function useMessageQueue({ + isConfigInitialized, streamingState, submitQuery, }: UseMessageQueueOptions): UseMessageQueueReturn { @@ -51,14 +53,18 @@ export function useMessageQueue({ // Process queued messages when streaming becomes idle useEffect(() => { - if (streamingState === StreamingState.Idle && messageQueue.length > 0) { + if ( + isConfigInitialized && + streamingState === StreamingState.Idle && + messageQueue.length > 0 + ) { // Combine all messages with double newlines for clarity const combinedMessage = messageQueue.join('\n\n'); // Clear the queue and submit setMessageQueue([]); submitQuery(combinedMessage); } - }, [streamingState, messageQueue, submitQuery]); + }, [isConfigInitialized, streamingState, messageQueue, submitQuery]); return { messageQueue, diff --git a/packages/core/src/tools/mcp-client-manager.ts b/packages/core/src/tools/mcp-client-manager.ts index dcb4589106..93e25ea8b2 100644 --- a/packages/core/src/tools/mcp-client-manager.ts +++ b/packages/core/src/tools/mcp-client-manager.ts @@ -66,22 +66,11 @@ export class McpClientManager { this.mcpServerCommand, ); - const serverEntries = Object.entries(servers); - const total = serverEntries.length; - - this.eventEmitter?.emit('mcp-servers-discovery-start', { count: total }); - this.discoveryState = MCPDiscoveryState.IN_PROGRESS; - const discoveryPromises = serverEntries.map( - async ([name, config], index) => { - const current = index + 1; - this.eventEmitter?.emit('mcp-server-connecting', { - name, - current, - total, - }); - + this.eventEmitter?.emit('mcp-client-update', this.clients); + const discoveryPromises = Object.entries(servers).map( + async ([name, config]) => { const client = new McpClient( name, config, @@ -92,21 +81,13 @@ export class McpClientManager { ); this.clients.set(name, client); + this.eventEmitter?.emit('mcp-client-update', this.clients); try { await client.connect(); await client.discover(cliConfig); - this.eventEmitter?.emit('mcp-server-connected', { - name, - current, - total, - }); + this.eventEmitter?.emit('mcp-client-update', this.clients); } catch (error) { - this.eventEmitter?.emit('mcp-server-error', { - name, - current, - total, - error, - }); + this.eventEmitter?.emit('mcp-client-update', this.clients); // Log the error but don't let a single failed server stop the others console.error( `Error during discovery for server '${name}': ${getErrorMessage(