Allow users to type while waiting for MCP servers (#8021)

This commit is contained in:
Tommaso Sciortino
2025-09-08 16:37:36 -07:00
committed by GitHub
parent f0bbfe5f0a
commit 2b05cf3bb4
10 changed files with 122 additions and 95 deletions

View File

@@ -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 (
<Box>
<Text>
<Spinner /> {message}
</Text>
</Box>
);
};
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(
<InitializingComponent initialTotal={mcpServersCount} />,
);
}
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) {

View File

@@ -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(
<AppContainer
@@ -430,8 +429,8 @@ describe('AppContainer State Management', () => {
/>,
);
}).not.toThrow();
});
});
},
);
});
describe('Error Handling', () => {

View File

@@ -134,6 +134,8 @@ export const AppContainer = (props: AppContainerProps) => {
const [userTier, setUserTier] = useState<UserTierId | undefined>(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,

View File

@@ -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 && <ConfigInitDisplay />}
{uiState.messageQueue.length > 0 && (
<Box flexDirection="column" marginTop={1}>
{uiState.messageQueue

View File

@@ -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<string, McpClient>) => {
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 (
<Box marginTop={1}>
<Text>
<GeminiSpinner /> {message}
</Text>
</Box>
);
};

View File

@@ -30,10 +30,11 @@ export const GeminiRespondingSpinner: React.FC<
const streamingState = useStreamingContext();
const isScreenReaderEnabled = useIsScreenReaderEnabled();
if (streamingState === StreamingState.Responding) {
return isScreenReaderEnabled ? (
<Text>{SCREEN_READER_RESPONDING}</Text>
) : (
<Spinner type={spinnerType} />
return (
<GeminiSpinner
spinnerType={spinnerType}
altText={SCREEN_READER_RESPONDING}
/>
);
} 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<GeminiSpinnerProps> = ({
spinnerType = 'dots',
altText,
}) => {
const isScreenReaderEnabled = useIsScreenReaderEnabled();
return isScreenReaderEnabled ? (
<Text>{altText}</Text>
) : (
<Spinner type={spinnerType} />
);
};

View File

@@ -38,6 +38,7 @@ export interface UIState {
isThemeDialogOpen: boolean;
themeError: string | null;
isAuthenticating: boolean;
isConfigInitialized: boolean;
authError: string | null;
isAuthDialogOpen: boolean;
editorError: string | null;

View File

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

View File

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

View File

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