mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-03-12 23:21:27 -07:00
Allow users to type while waiting for MCP servers (#8021)
This commit is contained in:
committed by
GitHub
parent
f0bbfe5f0a
commit
2b05cf3bb4
@@ -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) {
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
46
packages/cli/src/ui/components/ConfigInitDisplay.tsx
Normal file
46
packages/cli/src/ui/components/ConfigInitDisplay.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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} />
|
||||
);
|
||||
};
|
||||
|
||||
@@ -38,6 +38,7 @@ export interface UIState {
|
||||
isThemeDialogOpen: boolean;
|
||||
themeError: string | null;
|
||||
isAuthenticating: boolean;
|
||||
isConfigInitialized: boolean;
|
||||
authError: string | null;
|
||||
isAuthDialogOpen: boolean;
|
||||
editorError: string | null;
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user