Sync upstream Gemini-CLI v0.8.2 (#838)

This commit is contained in:
tanzhenxin
2025-10-23 09:27:04 +08:00
committed by GitHub
parent 096fabb5d6
commit eb95c131be
644 changed files with 70389 additions and 23709 deletions

View File

@@ -0,0 +1,110 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { render } from 'ink-testing-library';
import { vi } from 'vitest';
import { useUIState } from '../../contexts/UIStateContext.js';
import { ExtensionUpdateState } from '../../state/extensions.js';
import { ExtensionsList } from './ExtensionsList.js';
import { createMockCommandContext } from '../../../test-utils/mockCommandContext.js';
vi.mock('../../contexts/UIStateContext.js');
const mockUseUIState = vi.mocked(useUIState);
const mockExtensions = [
{ name: 'ext-one', version: '1.0.0', isActive: true },
{ name: 'ext-two', version: '2.1.0', isActive: true },
{ name: 'ext-disabled', version: '3.0.0', isActive: false },
];
describe('<ExtensionsList />', () => {
beforeEach(() => {
vi.resetAllMocks();
});
const mockUIState = (
extensions: unknown[],
extensionsUpdateState: Map<string, ExtensionUpdateState>,
disabledExtensions: string[] = [],
) => {
mockUseUIState.mockReturnValue({
commandContext: createMockCommandContext({
services: {
config: {
getExtensions: () => extensions,
},
settings: {
merged: {
extensions: {
disabled: disabledExtensions,
},
},
},
},
}),
extensionsUpdateState,
// Add other required properties from UIState if needed by the component
} as never);
};
it('should render "No extensions installed." if there are no extensions', () => {
mockUIState([], new Map());
const { lastFrame } = render(<ExtensionsList />);
expect(lastFrame()).toContain('No extensions installed.');
});
it('should render a list of extensions with their version and status', () => {
mockUIState(mockExtensions, new Map(), ['ext-disabled']);
const { lastFrame } = render(<ExtensionsList />);
const output = lastFrame();
expect(output).toContain('ext-one (v1.0.0) - active');
expect(output).toContain('ext-two (v2.1.0) - active');
expect(output).toContain('ext-disabled (v3.0.0) - disabled');
});
it('should display "unknown state" if an extension has no update state', () => {
mockUIState([mockExtensions[0]], new Map());
const { lastFrame } = render(<ExtensionsList />);
expect(lastFrame()).toContain('(unknown state)');
});
const stateTestCases = [
{
state: ExtensionUpdateState.CHECKING_FOR_UPDATES,
expectedText: '(checking for updates)',
},
{
state: ExtensionUpdateState.UPDATING,
expectedText: '(updating)',
},
{
state: ExtensionUpdateState.UPDATE_AVAILABLE,
expectedText: '(update available)',
},
{
state: ExtensionUpdateState.UPDATED_NEEDS_RESTART,
expectedText: '(updated, needs restart)',
},
{
state: ExtensionUpdateState.ERROR,
expectedText: '(error)',
},
{
state: ExtensionUpdateState.UP_TO_DATE,
expectedText: '(up to date)',
},
];
for (const { state, expectedText } of stateTestCases) {
it(`should correctly display the state: ${state}`, () => {
const updateState = new Map([[mockExtensions[0].name, state]]);
mockUIState([mockExtensions[0]], updateState);
const { lastFrame } = render(<ExtensionsList />);
expect(lastFrame()).toContain(expectedText);
});
}
});

View File

@@ -0,0 +1,67 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { Box, Text } from 'ink';
import { useUIState } from '../../contexts/UIStateContext.js';
import { ExtensionUpdateState } from '../../state/extensions.js';
export const ExtensionsList = () => {
const { commandContext, extensionsUpdateState } = useUIState();
const allExtensions = commandContext.services.config!.getExtensions();
const settings = commandContext.services.settings;
const disabledExtensions = settings.merged.extensions?.disabled ?? [];
if (allExtensions.length === 0) {
return <Text>No extensions installed.</Text>;
}
return (
<Box flexDirection="column" marginTop={1} marginBottom={1}>
<Text>Installed extensions:</Text>
<Box flexDirection="column" paddingLeft={2}>
{allExtensions.map((ext) => {
const state = extensionsUpdateState.get(ext.name);
const isActive = !disabledExtensions.includes(ext.name);
const activeString = isActive ? 'active' : 'disabled';
let stateColor = 'gray';
const stateText = state || 'unknown state';
switch (state) {
case ExtensionUpdateState.CHECKING_FOR_UPDATES:
case ExtensionUpdateState.UPDATING:
stateColor = 'cyan';
break;
case ExtensionUpdateState.UPDATE_AVAILABLE:
case ExtensionUpdateState.UPDATED_NEEDS_RESTART:
stateColor = 'yellow';
break;
case ExtensionUpdateState.ERROR:
stateColor = 'red';
break;
case ExtensionUpdateState.UP_TO_DATE:
case ExtensionUpdateState.NOT_UPDATABLE:
stateColor = 'green';
break;
default:
console.error(`Unhandled ExtensionUpdateState ${state}`);
break;
}
return (
<Box key={ext.name}>
<Text>
<Text color="cyan">{`${ext.name} (v${ext.version})`}</Text>
{` - ${activeString}`}
{<Text color={stateColor}>{` (${stateText})`}</Text>}
</Text>
</Box>
);
})}
</Box>
</Box>
);
};

View File

@@ -0,0 +1,163 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { render } from 'ink-testing-library';
import { describe, it, expect, vi } from 'vitest';
import { McpStatus } from './McpStatus.js';
import { MCPServerStatus } from '@qwen-code/qwen-code-core';
import { MessageType } from '../../types.js';
describe('McpStatus', () => {
const baseProps = {
type: MessageType.MCP_STATUS,
servers: {
'server-1': {
url: 'http://localhost:8080',
name: 'server-1',
description: 'A test server',
},
},
tools: [
{
serverName: 'server-1',
name: 'tool-1',
description: 'A test tool',
schema: {
parameters: {
type: 'object',
properties: {
param1: { type: 'string' },
},
},
},
},
],
prompts: [],
blockedServers: [],
serverStatus: () => MCPServerStatus.CONNECTED,
authStatus: {},
discoveryInProgress: false,
connectingServers: [],
showDescriptions: true,
showSchema: false,
showTips: false,
};
it('renders correctly with a connected server', () => {
const { lastFrame } = render(<McpStatus {...baseProps} />);
expect(lastFrame()).toMatchSnapshot();
});
it('renders correctly with authenticated OAuth status', () => {
const { lastFrame } = render(
<McpStatus {...baseProps} authStatus={{ 'server-1': 'authenticated' }} />,
);
expect(lastFrame()).toMatchSnapshot();
});
it('renders correctly with expired OAuth status', () => {
const { lastFrame } = render(
<McpStatus {...baseProps} authStatus={{ 'server-1': 'expired' }} />,
);
expect(lastFrame()).toMatchSnapshot();
});
it('renders correctly with unauthenticated OAuth status', () => {
const { lastFrame } = render(
<McpStatus
{...baseProps}
authStatus={{ 'server-1': 'unauthenticated' }}
/>,
);
expect(lastFrame()).toMatchSnapshot();
});
it('renders correctly with a disconnected server', async () => {
vi.spyOn(
await import('@qwen-code/qwen-code-core'),
'getMCPServerStatus',
).mockReturnValue(MCPServerStatus.DISCONNECTED);
const { lastFrame } = render(<McpStatus {...baseProps} />);
expect(lastFrame()).toMatchSnapshot();
});
it('renders correctly when discovery is in progress', () => {
const { lastFrame } = render(
<McpStatus {...baseProps} discoveryInProgress={true} />,
);
expect(lastFrame()).toMatchSnapshot();
});
it('renders correctly with schema enabled', () => {
const { lastFrame } = render(
<McpStatus {...baseProps} showSchema={true} />,
);
expect(lastFrame()).toMatchSnapshot();
});
it('renders correctly with parametersJsonSchema', () => {
const { lastFrame } = render(
<McpStatus
{...baseProps}
tools={[
{
serverName: 'server-1',
name: 'tool-1',
description: 'A test tool',
schema: {
parametersJsonSchema: {
type: 'object',
properties: {
param1: { type: 'string' },
},
},
},
},
]}
showSchema={true}
/>,
);
expect(lastFrame()).toMatchSnapshot();
});
it('renders correctly with tips enabled', () => {
const { lastFrame } = render(<McpStatus {...baseProps} showTips={true} />);
expect(lastFrame()).toMatchSnapshot();
});
it('renders correctly with prompts', () => {
const { lastFrame } = render(
<McpStatus
{...baseProps}
prompts={[
{
serverName: 'server-1',
name: 'prompt-1',
description: 'A test prompt',
},
]}
/>,
);
expect(lastFrame()).toMatchSnapshot();
});
it('renders correctly with a blocked server', () => {
const { lastFrame } = render(
<McpStatus
{...baseProps}
blockedServers={[{ name: 'server-1', extensionName: 'test-extension' }]}
/>,
);
expect(lastFrame()).toMatchSnapshot();
});
it('renders correctly with a connecting server', () => {
const { lastFrame } = render(
<McpStatus {...baseProps} connectingServers={['server-1']} />,
);
expect(lastFrame()).toMatchSnapshot();
});
});

View File

@@ -0,0 +1,281 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type { MCPServerConfig } from '@qwen-code/qwen-code-core';
import { MCPServerStatus } from '@qwen-code/qwen-code-core';
import { Box, Text } from 'ink';
import type React from 'react';
import { theme } from '../../semantic-colors.js';
import type {
HistoryItemMcpStatus,
JsonMcpPrompt,
JsonMcpTool,
} from '../../types.js';
interface McpStatusProps {
servers: Record<string, MCPServerConfig>;
tools: JsonMcpTool[];
prompts: JsonMcpPrompt[];
blockedServers: Array<{ name: string; extensionName: string }>;
serverStatus: (serverName: string) => MCPServerStatus;
authStatus: HistoryItemMcpStatus['authStatus'];
discoveryInProgress: boolean;
connectingServers: string[];
showDescriptions: boolean;
showSchema: boolean;
showTips: boolean;
}
export const McpStatus: React.FC<McpStatusProps> = ({
servers,
tools,
prompts,
blockedServers,
serverStatus,
authStatus,
discoveryInProgress,
connectingServers,
showDescriptions,
showSchema,
showTips,
}) => {
const serverNames = Object.keys(servers);
if (serverNames.length === 0 && blockedServers.length === 0) {
return (
<Box flexDirection="column">
<Text>No MCP servers configured.</Text>
<Text>
Please view MCP documentation in your browser:{' '}
<Text color={theme.text.link}>
https://goo.gle/gemini-cli-docs-mcp
</Text>{' '}
or use the cli /docs command
</Text>
</Box>
);
}
return (
<Box flexDirection="column">
{discoveryInProgress && (
<Box flexDirection="column" marginBottom={1}>
<Text color={theme.status.warning}>
MCP servers are starting up ({connectingServers.length}{' '}
initializing)...
</Text>
<Text color={theme.text.primary}>
Note: First startup may take longer. Tool availability will update
automatically.
</Text>
</Box>
)}
<Text bold>Configured MCP servers:</Text>
<Box height={1} />
{serverNames.map((serverName) => {
const server = servers[serverName];
const serverTools = tools.filter(
(tool) => tool.serverName === serverName,
);
const serverPrompts = prompts.filter(
(prompt) => prompt.serverName === serverName,
);
const originalStatus = serverStatus(serverName);
const hasCachedItems =
serverTools.length > 0 || serverPrompts.length > 0;
const status =
originalStatus === MCPServerStatus.DISCONNECTED && hasCachedItems
? MCPServerStatus.CONNECTED
: originalStatus;
let statusIndicator = '';
let statusText = '';
let statusColor = theme.text.primary;
switch (status) {
case MCPServerStatus.CONNECTED:
statusIndicator = '🟢';
statusText = 'Ready';
statusColor = theme.status.success;
break;
case MCPServerStatus.CONNECTING:
statusIndicator = '🔄';
statusText = 'Starting... (first startup may take longer)';
statusColor = theme.status.warning;
break;
case MCPServerStatus.DISCONNECTED:
default:
statusIndicator = '🔴';
statusText = 'Disconnected';
statusColor = theme.status.error;
break;
}
let serverDisplayName = serverName;
if (server.extensionName) {
serverDisplayName += ` (from ${server.extensionName})`;
}
const toolCount = serverTools.length;
const promptCount = serverPrompts.length;
const parts = [];
if (toolCount > 0) {
parts.push(`${toolCount} ${toolCount === 1 ? 'tool' : 'tools'}`);
}
if (promptCount > 0) {
parts.push(
`${promptCount} ${promptCount === 1 ? 'prompt' : 'prompts'}`,
);
}
const serverAuthStatus = authStatus[serverName];
let authStatusNode: React.ReactNode = null;
if (serverAuthStatus === 'authenticated') {
authStatusNode = <Text> (OAuth)</Text>;
} else if (serverAuthStatus === 'expired') {
authStatusNode = (
<Text color={theme.status.error}> (OAuth expired)</Text>
);
} else if (serverAuthStatus === 'unauthenticated') {
authStatusNode = (
<Text color={theme.status.warning}> (OAuth not authenticated)</Text>
);
}
return (
<Box key={serverName} flexDirection="column" marginBottom={1}>
<Box>
<Text color={statusColor}>{statusIndicator} </Text>
<Text bold>{serverDisplayName}</Text>
<Text>
{' - '}
{statusText}
{status === MCPServerStatus.CONNECTED &&
parts.length > 0 &&
` (${parts.join(', ')})`}
</Text>
{authStatusNode}
</Box>
{status === MCPServerStatus.CONNECTING && (
<Text> (tools and prompts will appear when ready)</Text>
)}
{status === MCPServerStatus.DISCONNECTED && toolCount > 0 && (
<Text> ({toolCount} tools cached)</Text>
)}
{showDescriptions && server?.description && (
<Text color={theme.text.secondary}>
{server.description.trim()}
</Text>
)}
{serverTools.length > 0 && (
<Box flexDirection="column" marginLeft={2}>
<Text color={theme.text.primary}>Tools:</Text>
{serverTools.map((tool) => {
const schemaContent =
showSchema &&
tool.schema &&
(tool.schema.parametersJsonSchema || tool.schema.parameters)
? JSON.stringify(
tool.schema.parametersJsonSchema ??
tool.schema.parameters,
null,
2,
)
: null;
return (
<Box key={tool.name} flexDirection="column">
<Text>
- <Text color={theme.text.primary}>{tool.name}</Text>
</Text>
{showDescriptions && tool.description && (
<Box marginLeft={2}>
<Text color={theme.text.secondary}>
{tool.description.trim()}
</Text>
</Box>
)}
{schemaContent && (
<Box flexDirection="column" marginLeft={4}>
<Text color={theme.text.secondary}>Parameters:</Text>
<Text color={theme.text.secondary}>
{schemaContent}
</Text>
</Box>
)}
</Box>
);
})}
</Box>
)}
{serverPrompts.length > 0 && (
<Box flexDirection="column" marginLeft={2}>
<Text color={theme.text.primary}>Prompts:</Text>
{serverPrompts.map((prompt) => (
<Box key={prompt.name} flexDirection="column">
<Text>
- <Text color={theme.text.primary}>{prompt.name}</Text>
</Text>
{showDescriptions && prompt.description && (
<Box marginLeft={2}>
<Text color={theme.text.primary}>
{prompt.description.trim()}
</Text>
</Box>
)}
</Box>
))}
</Box>
)}
</Box>
);
})}
{blockedServers.map((server) => (
<Box key={server.name} marginBottom={1}>
<Text color={theme.status.error}>🔴 </Text>
<Text bold>
{server.name}
{server.extensionName ? ` (from ${server.extensionName})` : ''}
</Text>
<Text> - Blocked</Text>
</Box>
))}
{showTips && (
<Box flexDirection="column" marginTop={1}>
<Text color={theme.text.accent}>💡 Tips:</Text>
<Text>
{' '}- Use <Text color={theme.text.accent}>/mcp desc</Text> to show
server and tool descriptions
</Text>
<Text>
{' '}- Use <Text color={theme.text.accent}>/mcp schema</Text> to
show tool parameter schemas
</Text>
<Text>
{' '}- Use <Text color={theme.text.accent}>/mcp nodesc</Text> to
hide descriptions
</Text>
<Text>
{' '}- Use{' '}
<Text color={theme.text.accent}>/mcp auth &lt;server-name&gt;</Text>{' '}
to authenticate with OAuth-enabled servers
</Text>
<Text>
{' '}- Press <Text color={theme.text.accent}>Ctrl+T</Text> to
toggle tool descriptions on/off
</Text>
</Box>
)}
</Box>
);
};

View File

@@ -0,0 +1,62 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { render } from 'ink-testing-library';
import { describe, it, expect } from 'vitest';
import { ToolsList } from './ToolsList.js';
import { type ToolDefinition } from '../../types.js';
const mockTools: ToolDefinition[] = [
{
name: 'test-tool-one',
displayName: 'Test Tool One',
description: 'This is the first test tool.',
},
{
name: 'test-tool-two',
displayName: 'Test Tool Two',
description: `This is the second test tool.
1. Tool descriptions support markdown formatting.
2. **note** use this tool wisely and be sure to consider how this tool interacts with word wrap.
3. **important** this tool is awesome.`,
},
{
name: 'test-tool-three',
displayName: 'Test Tool Three',
description: 'This is the third test tool.',
},
];
describe('<ToolsList />', () => {
it('renders correctly with descriptions', () => {
const { lastFrame } = render(
<ToolsList
tools={mockTools}
showDescriptions={true}
terminalWidth={40}
/>,
);
expect(lastFrame()).toMatchSnapshot();
});
it('renders correctly without descriptions', () => {
const { lastFrame } = render(
<ToolsList
tools={mockTools}
showDescriptions={false}
terminalWidth={40}
/>,
);
expect(lastFrame()).toMatchSnapshot();
});
it('renders correctly with no tools', () => {
const { lastFrame } = render(
<ToolsList tools={[]} showDescriptions={true} terminalWidth={40} />,
);
expect(lastFrame()).toMatchSnapshot();
});
});

View File

@@ -0,0 +1,52 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { Box, Text } from 'ink';
import { theme } from '../../semantic-colors.js';
import { type ToolDefinition } from '../../types.js';
import { MarkdownDisplay } from '../../utils/MarkdownDisplay.js';
interface ToolsListProps {
tools: readonly ToolDefinition[];
showDescriptions: boolean;
terminalWidth: number;
}
export const ToolsList: React.FC<ToolsListProps> = ({
tools,
showDescriptions,
terminalWidth,
}) => (
<Box flexDirection="column" marginBottom={1}>
<Text bold color={theme.text.primary}>
Available Gemini CLI tools:
</Text>
<Box height={1} />
{tools.length > 0 ? (
tools.map((tool) => (
<Box key={tool.name} flexDirection="row">
<Text color={theme.text.primary}>{' '}- </Text>
<Box flexDirection="column">
<Text bold color={theme.text.accent}>
{tool.displayName}
{showDescriptions ? ` (${tool.name})` : ''}
</Text>
{showDescriptions && tool.description && (
<MarkdownDisplay
terminalWidth={terminalWidth}
text={tool.description}
isPending={false}
/>
)}
</Box>
</Box>
))
) : (
<Text color={theme.text.primary}> No tools available</Text>
)}
</Box>
);

View File

@@ -0,0 +1,166 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`McpStatus > renders correctly when discovery is in progress 1`] = `
"⏳ MCP servers are starting up (0 initializing)...
Note: First startup may take longer. Tool availability will update automatically.
Configured MCP servers:
🟢 server-1 - Ready (1 tool)
A test server
Tools:
- tool-1
A test tool
"
`;
exports[`McpStatus > renders correctly with a blocked server 1`] = `
"Configured MCP servers:
🟢 server-1 - Ready (1 tool)
A test server
Tools:
- tool-1
A test tool
🔴 server-1 (from test-extension) - Blocked
"
`;
exports[`McpStatus > renders correctly with a connected server 1`] = `
"Configured MCP servers:
🟢 server-1 - Ready (1 tool)
A test server
Tools:
- tool-1
A test tool
"
`;
exports[`McpStatus > renders correctly with a connecting server 1`] = `
"Configured MCP servers:
🟢 server-1 - Ready (1 tool)
A test server
Tools:
- tool-1
A test tool
"
`;
exports[`McpStatus > renders correctly with a disconnected server 1`] = `
"Configured MCP servers:
🟢 server-1 - Ready (1 tool)
A test server
Tools:
- tool-1
A test tool
"
`;
exports[`McpStatus > renders correctly with authenticated OAuth status 1`] = `
"Configured MCP servers:
🟢 server-1 - Ready (1 tool) (OAuth)
A test server
Tools:
- tool-1
A test tool
"
`;
exports[`McpStatus > renders correctly with expired OAuth status 1`] = `
"Configured MCP servers:
🟢 server-1 - Ready (1 tool) (OAuth expired)
A test server
Tools:
- tool-1
A test tool
"
`;
exports[`McpStatus > renders correctly with parametersJsonSchema 1`] = `
"Configured MCP servers:
🟢 server-1 - Ready (1 tool)
A test server
Tools:
- tool-1
A test tool
Parameters:
{
"type": "object",
"properties": {
"param1": {
"type": "string"
}
}
}
"
`;
exports[`McpStatus > renders correctly with prompts 1`] = `
"Configured MCP servers:
🟢 server-1 - Ready (1 tool, 1 prompt)
A test server
Tools:
- tool-1
A test tool
Prompts:
- prompt-1
A test prompt
"
`;
exports[`McpStatus > renders correctly with schema enabled 1`] = `
"Configured MCP servers:
🟢 server-1 - Ready (1 tool)
A test server
Tools:
- tool-1
A test tool
Parameters:
{
"type": "object",
"properties": {
"param1": {
"type": "string"
}
}
}
"
`;
exports[`McpStatus > renders correctly with tips enabled 1`] = `
"Configured MCP servers:
🟢 server-1 - Ready (1 tool)
A test server
Tools:
- tool-1
A test tool
💡 Tips:
- Use /mcp desc to show server and tool descriptions
- Use /mcp schema to show tool parameter schemas
- Use /mcp nodesc to hide descriptions
- Use /mcp auth <server-name> to authenticate with OAuth-enabled servers
- Press Ctrl+T to toggle tool descriptions on/off"
`;
exports[`McpStatus > renders correctly with unauthenticated OAuth status 1`] = `
"Configured MCP servers:
🟢 server-1 - Ready (1 tool) (OAuth not authenticated)
A test server
Tools:
- tool-1
A test tool
"
`;

View File

@@ -0,0 +1,32 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`<ToolsList /> > renders correctly with descriptions 1`] = `
"Available Gemini CLI tools:
- Test Tool One (test-tool-one)
This is the first test tool.
- Test Tool Two (test-tool-two)
This is the second test tool.
1. Tool descriptions support markdown formatting.
2. note use this tool wisely and be sure to consider how this tool interacts with word wrap.
3. important this tool is awesome.
- Test Tool Three (test-tool-three)
This is the third test tool.
"
`;
exports[`<ToolsList /> > renders correctly with no tools 1`] = `
"Available Gemini CLI tools:
No tools available
"
`;
exports[`<ToolsList /> > renders correctly without descriptions 1`] = `
"Available Gemini CLI tools:
- Test Tool One
- Test Tool Two
- Test Tool Three
"
`;