mirror of
https://github.com/QwenLM/qwen-code.git
synced 2025-12-21 01:07:46 +00:00
Sync upstream Gemini-CLI v0.8.2 (#838)
This commit is contained in:
@@ -10,8 +10,20 @@ import { type CommandContext } from './types.js';
|
||||
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
|
||||
import * as versionUtils from '../../utils/version.js';
|
||||
import { MessageType } from '../types.js';
|
||||
import { IdeClient } from '@qwen-code/qwen-code-core';
|
||||
|
||||
import type { IdeClient } from '../../../../core/src/ide/ide-client.js';
|
||||
vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('@qwen-code/qwen-code-core')>();
|
||||
return {
|
||||
...actual,
|
||||
IdeClient: {
|
||||
getInstance: vi.fn().mockResolvedValue({
|
||||
getDetectedIdeDisplayName: vi.fn().mockReturnValue('test-ide'),
|
||||
}),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../../utils/version.js', () => ({
|
||||
getCliVersion: vi.fn(),
|
||||
@@ -27,7 +39,6 @@ describe('aboutCommand', () => {
|
||||
services: {
|
||||
config: {
|
||||
getModel: vi.fn(),
|
||||
getIdeClient: vi.fn(),
|
||||
getIdeMode: vi.fn().mockReturnValue(true),
|
||||
},
|
||||
settings: {
|
||||
@@ -53,9 +64,6 @@ describe('aboutCommand', () => {
|
||||
Object.defineProperty(process, 'platform', {
|
||||
value: 'test-os',
|
||||
});
|
||||
vi.spyOn(mockContext.services.config!, 'getIdeClient').mockReturnValue({
|
||||
getDetectedIdeDisplayName: vi.fn().mockReturnValue('test-ide'),
|
||||
} as Partial<IdeClient> as IdeClient);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -129,9 +137,9 @@ describe('aboutCommand', () => {
|
||||
});
|
||||
|
||||
it('should not show ide client when it is not detected', async () => {
|
||||
vi.spyOn(mockContext.services.config!, 'getIdeClient').mockReturnValue({
|
||||
vi.mocked(IdeClient.getInstance).mockResolvedValue({
|
||||
getDetectedIdeDisplayName: vi.fn().mockReturnValue(undefined),
|
||||
} as Partial<IdeClient> as IdeClient);
|
||||
} as unknown as IdeClient);
|
||||
|
||||
process.env['SANDBOX'] = '';
|
||||
if (!aboutCommand.action) {
|
||||
|
||||
@@ -5,10 +5,11 @@
|
||||
*/
|
||||
|
||||
import { getCliVersion } from '../../utils/version.js';
|
||||
import type { SlashCommand } from './types.js';
|
||||
import type { CommandContext, SlashCommand } from './types.js';
|
||||
import { CommandKind } from './types.js';
|
||||
import process from 'node:process';
|
||||
import { MessageType, type HistoryItemAbout } from '../types.js';
|
||||
import { IdeClient } from '@qwen-code/qwen-code-core';
|
||||
|
||||
export const aboutCommand: SlashCommand = {
|
||||
name: 'about',
|
||||
@@ -29,10 +30,7 @@ export const aboutCommand: SlashCommand = {
|
||||
const selectedAuthType =
|
||||
context.services.settings.merged.security?.auth?.selectedType || '';
|
||||
const gcpProject = process.env['GOOGLE_CLOUD_PROJECT'] || '';
|
||||
const ideClient =
|
||||
(context.services.config?.getIdeMode() &&
|
||||
context.services.config?.getIdeClient()?.getDetectedIdeDisplayName()) ||
|
||||
'';
|
||||
const ideClient = await getIdeClientName(context);
|
||||
|
||||
const aboutItem: Omit<HistoryItemAbout, 'id'> = {
|
||||
type: MessageType.ABOUT,
|
||||
@@ -48,3 +46,11 @@ export const aboutCommand: SlashCommand = {
|
||||
context.ui.addItem(aboutItem, Date.now());
|
||||
},
|
||||
};
|
||||
|
||||
async function getIdeClientName(context: CommandContext) {
|
||||
if (!context.services.config?.getIdeMode()) {
|
||||
return '';
|
||||
}
|
||||
const ideClient = await IdeClient.getInstance();
|
||||
return ideClient?.getDetectedIdeDisplayName() ?? '';
|
||||
}
|
||||
|
||||
@@ -16,7 +16,19 @@ import { formatMemoryUsage } from '../utils/formatters.js';
|
||||
vi.mock('open');
|
||||
vi.mock('../../utils/version.js');
|
||||
vi.mock('../utils/formatters.js');
|
||||
vi.mock('@qwen-code/qwen-code-core');
|
||||
vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('@qwen-code/qwen-code-core')>();
|
||||
return {
|
||||
...actual,
|
||||
IdeClient: {
|
||||
getInstance: () => ({
|
||||
getDetectedIdeDisplayName: vi.fn().mockReturnValue('VSCode'),
|
||||
}),
|
||||
},
|
||||
sessionId: 'test-session-id',
|
||||
};
|
||||
});
|
||||
vi.mock('node:process', () => ({
|
||||
default: {
|
||||
platform: 'test-platform',
|
||||
@@ -31,9 +43,6 @@ describe('bugCommand', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(getCliVersion).mockResolvedValue('0.1.0');
|
||||
vi.mocked(formatMemoryUsage).mockReturnValue('100 MB');
|
||||
vi.mock('@qwen-code/qwen-code-core', () => ({
|
||||
sessionId: 'test-session-id',
|
||||
}));
|
||||
vi.stubEnv('SANDBOX', 'qwen-test');
|
||||
});
|
||||
|
||||
@@ -48,9 +57,6 @@ describe('bugCommand', () => {
|
||||
config: {
|
||||
getModel: () => 'qwen3-coder-plus',
|
||||
getBugCommand: () => undefined,
|
||||
getIdeClient: () => ({
|
||||
getDetectedIdeDisplayName: () => 'VSCode',
|
||||
}),
|
||||
getIdeMode: () => true,
|
||||
},
|
||||
},
|
||||
@@ -84,9 +90,6 @@ describe('bugCommand', () => {
|
||||
config: {
|
||||
getModel: () => 'qwen3-coder-plus',
|
||||
getBugCommand: () => ({ urlTemplate: customTemplate }),
|
||||
getIdeClient: () => ({
|
||||
getDetectedIdeDisplayName: () => 'VSCode',
|
||||
}),
|
||||
getIdeMode: () => true,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -15,7 +15,7 @@ import { MessageType } from '../types.js';
|
||||
import { GIT_COMMIT_INFO } from '../../generated/git-commit.js';
|
||||
import { formatMemoryUsage } from '../utils/formatters.js';
|
||||
import { getCliVersion } from '../../utils/version.js';
|
||||
import { sessionId } from '@qwen-code/qwen-code-core';
|
||||
import { IdeClient, sessionId } from '@qwen-code/qwen-code-core';
|
||||
|
||||
export const bugCommand: SlashCommand = {
|
||||
name: 'bug',
|
||||
@@ -37,10 +37,7 @@ export const bugCommand: SlashCommand = {
|
||||
const modelVersion = config?.getModel() || 'Unknown';
|
||||
const cliVersion = await getCliVersion();
|
||||
const memoryUsage = formatMemoryUsage(process.memoryUsage().rss);
|
||||
const ideClient =
|
||||
(context.services.config?.getIdeMode() &&
|
||||
context.services.config?.getIdeClient()?.getDetectedIdeDisplayName()) ||
|
||||
'';
|
||||
const ideClient = await getIdeClientName(context);
|
||||
|
||||
let info = `
|
||||
* **CLI Version:** ${cliVersion}
|
||||
@@ -90,3 +87,11 @@ export const bugCommand: SlashCommand = {
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
async function getIdeClientName(context: CommandContext) {
|
||||
if (!context.services.config?.getIdeMode()) {
|
||||
return '';
|
||||
}
|
||||
const ideClient = await IdeClient.getInstance();
|
||||
return ideClient.getDetectedIdeDisplayName() ?? '';
|
||||
}
|
||||
|
||||
@@ -17,13 +17,15 @@ import type { Content } from '@google/genai';
|
||||
import type { GeminiClient } from '@qwen-code/qwen-code-core';
|
||||
|
||||
import * as fsPromises from 'node:fs/promises';
|
||||
import { chatCommand } from './chatCommand.js';
|
||||
import { chatCommand, serializeHistoryToMarkdown } from './chatCommand.js';
|
||||
import type { Stats } from 'node:fs';
|
||||
import type { HistoryItemWithoutId } from '../types.js';
|
||||
import path from 'node:path';
|
||||
|
||||
vi.mock('fs/promises', () => ({
|
||||
stat: vi.fn(),
|
||||
readdir: vi.fn().mockResolvedValue(['file1.txt', 'file2.txt'] as string[]),
|
||||
writeFile: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('chatCommand', () => {
|
||||
@@ -37,7 +39,7 @@ describe('chatCommand', () => {
|
||||
let mockGetHistory: ReturnType<typeof vi.fn>;
|
||||
|
||||
const getSubCommand = (
|
||||
name: 'list' | 'save' | 'resume' | 'delete',
|
||||
name: 'list' | 'save' | 'resume' | 'delete' | 'share',
|
||||
): SlashCommand => {
|
||||
const subCommand = chatCommand.subCommands?.find(
|
||||
(cmd) => cmd.name === name,
|
||||
@@ -86,7 +88,7 @@ describe('chatCommand', () => {
|
||||
it('should have the correct main command definition', () => {
|
||||
expect(chatCommand.name).toBe('chat');
|
||||
expect(chatCommand.description).toBe('Manage conversation history.');
|
||||
expect(chatCommand.subCommands).toHaveLength(4);
|
||||
expect(chatCommand.subCommands).toHaveLength(5);
|
||||
});
|
||||
|
||||
describe('list subcommand', () => {
|
||||
@@ -407,4 +409,293 @@ describe('chatCommand', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('share subcommand', () => {
|
||||
let shareCommand: SlashCommand;
|
||||
const mockHistory = [
|
||||
{ role: 'user', parts: [{ text: 'context' }] },
|
||||
{ role: 'model', parts: [{ text: 'context response' }] },
|
||||
{ role: 'user', parts: [{ text: 'Hello' }] },
|
||||
{ role: 'model', parts: [{ text: 'Hi there!' }] },
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
shareCommand = getSubCommand('share');
|
||||
vi.spyOn(process, 'cwd').mockReturnValue(
|
||||
path.resolve('/usr/local/google/home/myuser/gemini-cli'),
|
||||
);
|
||||
vi.spyOn(Date, 'now').mockReturnValue(1234567890);
|
||||
mockGetHistory.mockReturnValue(mockHistory);
|
||||
mockFs.writeFile.mockClear();
|
||||
});
|
||||
|
||||
it('should default to a json file if no path is provided', async () => {
|
||||
const result = await shareCommand?.action?.(mockContext, '');
|
||||
const expectedPath = path.join(
|
||||
process.cwd(),
|
||||
'gemini-conversation-1234567890.json',
|
||||
);
|
||||
const [actualPath, actualContent] = mockFs.writeFile.mock.calls[0];
|
||||
expect(actualPath).toEqual(expectedPath);
|
||||
expect(actualContent).toEqual(JSON.stringify(mockHistory, null, 2));
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: `Conversation shared to ${expectedPath}`,
|
||||
});
|
||||
});
|
||||
|
||||
it('should share the conversation to a JSON file', async () => {
|
||||
const filePath = 'my-chat.json';
|
||||
const result = await shareCommand?.action?.(mockContext, filePath);
|
||||
const expectedPath = path.join(process.cwd(), 'my-chat.json');
|
||||
const [actualPath, actualContent] = mockFs.writeFile.mock.calls[0];
|
||||
expect(actualPath).toEqual(expectedPath);
|
||||
expect(actualContent).toEqual(JSON.stringify(mockHistory, null, 2));
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: `Conversation shared to ${expectedPath}`,
|
||||
});
|
||||
});
|
||||
|
||||
it('should share the conversation to a Markdown file', async () => {
|
||||
const filePath = 'my-chat.md';
|
||||
const result = await shareCommand?.action?.(mockContext, filePath);
|
||||
const expectedPath = path.join(process.cwd(), 'my-chat.md');
|
||||
const [actualPath, actualContent] = mockFs.writeFile.mock.calls[0];
|
||||
expect(actualPath).toEqual(expectedPath);
|
||||
const expectedContent = `🧑💻 ## USER
|
||||
|
||||
context
|
||||
|
||||
---
|
||||
|
||||
✨ ## MODEL
|
||||
|
||||
context response
|
||||
|
||||
---
|
||||
|
||||
🧑💻 ## USER
|
||||
|
||||
Hello
|
||||
|
||||
---
|
||||
|
||||
✨ ## MODEL
|
||||
|
||||
Hi there!`;
|
||||
expect(actualContent).toEqual(expectedContent);
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: `Conversation shared to ${expectedPath}`,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return an error for unsupported file extensions', async () => {
|
||||
const filePath = 'my-chat.txt';
|
||||
const result = await shareCommand?.action?.(mockContext, filePath);
|
||||
expect(mockFs.writeFile).not.toHaveBeenCalled();
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: 'Invalid file format. Only .md and .json are supported.',
|
||||
});
|
||||
});
|
||||
|
||||
it('should inform if there is no conversation to share', async () => {
|
||||
mockGetHistory.mockReturnValue([
|
||||
{ role: 'user', parts: [{ text: 'context' }] },
|
||||
{ role: 'model', parts: [{ text: 'context response' }] },
|
||||
]);
|
||||
const result = await shareCommand?.action?.(mockContext, 'my-chat.json');
|
||||
expect(mockFs.writeFile).not.toHaveBeenCalled();
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: 'No conversation found to share.',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle errors during file writing', async () => {
|
||||
const error = new Error('Permission denied');
|
||||
mockFs.writeFile.mockRejectedValue(error);
|
||||
const result = await shareCommand?.action?.(mockContext, 'my-chat.json');
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: `Error sharing conversation: ${error.message}`,
|
||||
});
|
||||
});
|
||||
|
||||
it('should output valid JSON schema', async () => {
|
||||
const filePath = 'my-chat.json';
|
||||
await shareCommand?.action?.(mockContext, filePath);
|
||||
const expectedPath = path.join(process.cwd(), 'my-chat.json');
|
||||
const [actualPath, actualContent] = mockFs.writeFile.mock.calls[0];
|
||||
expect(actualPath).toEqual(expectedPath);
|
||||
const parsedContent = JSON.parse(actualContent);
|
||||
expect(Array.isArray(parsedContent)).toBe(true);
|
||||
parsedContent.forEach((item: Content) => {
|
||||
expect(item).toHaveProperty('role');
|
||||
expect(item).toHaveProperty('parts');
|
||||
expect(Array.isArray(item.parts)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('should output correct markdown format', async () => {
|
||||
const filePath = 'my-chat.md';
|
||||
await shareCommand?.action?.(mockContext, filePath);
|
||||
const expectedPath = path.join(process.cwd(), 'my-chat.md');
|
||||
const [actualPath, actualContent] = mockFs.writeFile.mock.calls[0];
|
||||
expect(actualPath).toEqual(expectedPath);
|
||||
const entries = actualContent.split('\n\n---\n\n');
|
||||
expect(entries.length).toBe(mockHistory.length);
|
||||
entries.forEach((entry, index) => {
|
||||
const { role, parts } = mockHistory[index];
|
||||
const text = parts.map((p) => p.text).join('');
|
||||
const roleIcon = role === 'user' ? '🧑💻' : '✨';
|
||||
expect(entry).toBe(`${roleIcon} ## ${role.toUpperCase()}\n\n${text}`);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('serializeHistoryToMarkdown', () => {
|
||||
it('should correctly serialize chat history to Markdown with icons', () => {
|
||||
const history: Content[] = [
|
||||
{ role: 'user', parts: [{ text: 'Hello' }] },
|
||||
{ role: 'model', parts: [{ text: 'Hi there!' }] },
|
||||
{ role: 'user', parts: [{ text: 'How are you?' }] },
|
||||
];
|
||||
|
||||
const expectedMarkdown =
|
||||
'🧑💻 ## USER\n\nHello\n\n---\n\n' +
|
||||
'✨ ## MODEL\n\nHi there!\n\n---\n\n' +
|
||||
'🧑💻 ## USER\n\nHow are you?';
|
||||
|
||||
const result = serializeHistoryToMarkdown(history);
|
||||
expect(result).toBe(expectedMarkdown);
|
||||
});
|
||||
|
||||
it('should handle empty history', () => {
|
||||
const history: Content[] = [];
|
||||
const result = serializeHistoryToMarkdown(history);
|
||||
expect(result).toBe('');
|
||||
});
|
||||
|
||||
it('should handle items with no text parts', () => {
|
||||
const history: Content[] = [
|
||||
{ role: 'user', parts: [{ text: 'Hello' }] },
|
||||
{ role: 'model', parts: [] },
|
||||
{ role: 'user', parts: [{ text: 'How are you?' }] },
|
||||
];
|
||||
|
||||
const expectedMarkdown = `🧑💻 ## USER
|
||||
|
||||
Hello
|
||||
|
||||
---
|
||||
|
||||
✨ ## MODEL
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
🧑💻 ## USER
|
||||
|
||||
How are you?`;
|
||||
|
||||
const result = serializeHistoryToMarkdown(history);
|
||||
expect(result).toBe(expectedMarkdown);
|
||||
});
|
||||
|
||||
it('should correctly serialize function calls and responses', () => {
|
||||
const history: Content[] = [
|
||||
{
|
||||
role: 'user',
|
||||
parts: [{ text: 'Please call a function.' }],
|
||||
},
|
||||
{
|
||||
role: 'model',
|
||||
parts: [
|
||||
{
|
||||
functionCall: {
|
||||
name: 'my-function',
|
||||
args: { arg1: 'value1' },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
parts: [
|
||||
{
|
||||
functionResponse: {
|
||||
name: 'my-function',
|
||||
response: { result: 'success' },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const expectedMarkdown = `🧑💻 ## USER
|
||||
|
||||
Please call a function.
|
||||
|
||||
---
|
||||
|
||||
✨ ## MODEL
|
||||
|
||||
**Tool Command**:
|
||||
\`\`\`json
|
||||
{
|
||||
"name": "my-function",
|
||||
"args": {
|
||||
"arg1": "value1"
|
||||
}
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
---
|
||||
|
||||
🧑💻 ## USER
|
||||
|
||||
**Tool Response**:
|
||||
\`\`\`json
|
||||
{
|
||||
"name": "my-function",
|
||||
"response": {
|
||||
"result": "success"
|
||||
}
|
||||
}
|
||||
\`\`\``;
|
||||
|
||||
const result = serializeHistoryToMarkdown(history);
|
||||
expect(result).toBe(expectedMarkdown);
|
||||
});
|
||||
|
||||
it('should handle items with undefined role', () => {
|
||||
const history: Array<Partial<Content>> = [
|
||||
{ role: 'user', parts: [{ text: 'Hello' }] },
|
||||
{ parts: [{ text: 'Hi there!' }] },
|
||||
];
|
||||
|
||||
const expectedMarkdown = `🧑💻 ## USER
|
||||
|
||||
Hello
|
||||
|
||||
---
|
||||
|
||||
✨ ## MODEL
|
||||
|
||||
Hi there!`;
|
||||
|
||||
const result = serializeHistoryToMarkdown(history as Content[]);
|
||||
expect(result).toBe(expectedMarkdown);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
import * as fsPromises from 'node:fs/promises';
|
||||
import React from 'react';
|
||||
import { Text } from 'ink';
|
||||
import { Colors } from '../colors.js';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import type {
|
||||
CommandContext,
|
||||
SlashCommand,
|
||||
@@ -19,6 +19,7 @@ import { decodeTagName } from '@qwen-code/qwen-code-core';
|
||||
import path from 'node:path';
|
||||
import type { HistoryItemWithoutId } from '../types.js';
|
||||
import { MessageType } from '../types.js';
|
||||
import type { Content } from '@google/genai';
|
||||
|
||||
interface ChatDetail {
|
||||
name: string;
|
||||
@@ -126,7 +127,7 @@ const saveCommand: SlashCommand = {
|
||||
Text,
|
||||
null,
|
||||
'A checkpoint with the tag ',
|
||||
React.createElement(Text, { color: Colors.AccentPurple }, tag),
|
||||
React.createElement(Text, { color: theme.text.accent }, tag),
|
||||
' already exists. Do you want to overwrite it?',
|
||||
),
|
||||
originalInvocation: {
|
||||
@@ -274,9 +275,115 @@ const deleteCommand: SlashCommand = {
|
||||
},
|
||||
};
|
||||
|
||||
export function serializeHistoryToMarkdown(history: Content[]): string {
|
||||
return history
|
||||
.map((item) => {
|
||||
const text =
|
||||
item.parts
|
||||
?.map((part) => {
|
||||
if (part.text) {
|
||||
return part.text;
|
||||
}
|
||||
if (part.functionCall) {
|
||||
return `**Tool Command**:\n\`\`\`json\n${JSON.stringify(
|
||||
part.functionCall,
|
||||
null,
|
||||
2,
|
||||
)}\n\`\`\``;
|
||||
}
|
||||
if (part.functionResponse) {
|
||||
return `**Tool Response**:\n\`\`\`json\n${JSON.stringify(
|
||||
part.functionResponse,
|
||||
null,
|
||||
2,
|
||||
)}\n\`\`\``;
|
||||
}
|
||||
return '';
|
||||
})
|
||||
.join('') || '';
|
||||
const roleIcon = item.role === 'user' ? '🧑💻' : '✨';
|
||||
return `${roleIcon} ## ${(item.role || 'model').toUpperCase()}\n\n${text}`;
|
||||
})
|
||||
.join('\n\n---\n\n');
|
||||
}
|
||||
|
||||
const shareCommand: SlashCommand = {
|
||||
name: 'share',
|
||||
description:
|
||||
'Share the current conversation to a markdown or json file. Usage: /chat share <file>',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: async (context, args): Promise<MessageActionReturn> => {
|
||||
let filePathArg = args.trim();
|
||||
if (!filePathArg) {
|
||||
filePathArg = `gemini-conversation-${Date.now()}.json`;
|
||||
}
|
||||
|
||||
const filePath = path.resolve(filePathArg);
|
||||
const extension = path.extname(filePath);
|
||||
if (extension !== '.md' && extension !== '.json') {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: 'Invalid file format. Only .md and .json are supported.',
|
||||
};
|
||||
}
|
||||
|
||||
const chat = await context.services.config?.getGeminiClient()?.getChat();
|
||||
if (!chat) {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: 'No chat client available to share conversation.',
|
||||
};
|
||||
}
|
||||
|
||||
const history = chat.getHistory();
|
||||
|
||||
// An empty conversation has two hidden messages that setup the context for
|
||||
// the chat. Thus, to check whether a conversation has been started, we
|
||||
// can't check for length 0.
|
||||
if (history.length <= 2) {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: 'No conversation found to share.',
|
||||
};
|
||||
}
|
||||
|
||||
let content = '';
|
||||
if (extension === '.json') {
|
||||
content = JSON.stringify(history, null, 2);
|
||||
} else {
|
||||
content = serializeHistoryToMarkdown(history);
|
||||
}
|
||||
|
||||
try {
|
||||
await fsPromises.writeFile(filePath, content);
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: `Conversation shared to ${filePath}`,
|
||||
};
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: `Error sharing conversation: ${errorMessage}`,
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
export const chatCommand: SlashCommand = {
|
||||
name: 'chat',
|
||||
description: 'Manage conversation history.',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
subCommands: [listCommand, saveCommand, resumeCommand, deleteCommand],
|
||||
subCommands: [
|
||||
listCommand,
|
||||
saveCommand,
|
||||
resumeCommand,
|
||||
deleteCommand,
|
||||
shareCommand,
|
||||
],
|
||||
};
|
||||
|
||||
@@ -16,7 +16,7 @@ vi.mock('@qwen-code/qwen-code-core', async () => {
|
||||
return {
|
||||
...actual,
|
||||
uiTelemetryService: {
|
||||
resetLastPromptTokenCount: vi.fn(),
|
||||
setLastPromptTokenCount: vi.fn(),
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -57,9 +57,8 @@ describe('clearCommand', () => {
|
||||
expect(mockContext.ui.setDebugMessage).toHaveBeenCalledTimes(1);
|
||||
|
||||
expect(mockResetChat).toHaveBeenCalledTimes(1);
|
||||
expect(uiTelemetryService.resetLastPromptTokenCount).toHaveBeenCalledTimes(
|
||||
1,
|
||||
);
|
||||
expect(uiTelemetryService.setLastPromptTokenCount).toHaveBeenCalledWith(0);
|
||||
expect(uiTelemetryService.setLastPromptTokenCount).toHaveBeenCalledTimes(1);
|
||||
expect(mockContext.ui.clear).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Check the order of operations.
|
||||
@@ -67,7 +66,7 @@ describe('clearCommand', () => {
|
||||
.invocationCallOrder[0];
|
||||
const resetChatOrder = mockResetChat.mock.invocationCallOrder[0];
|
||||
const resetTelemetryOrder = (
|
||||
uiTelemetryService.resetLastPromptTokenCount as Mock
|
||||
uiTelemetryService.setLastPromptTokenCount as Mock
|
||||
).mock.invocationCallOrder[0];
|
||||
const clearOrder = (mockContext.ui.clear as Mock).mock
|
||||
.invocationCallOrder[0];
|
||||
@@ -94,9 +93,8 @@ describe('clearCommand', () => {
|
||||
'Clearing terminal.',
|
||||
);
|
||||
expect(mockResetChat).not.toHaveBeenCalled();
|
||||
expect(uiTelemetryService.resetLastPromptTokenCount).toHaveBeenCalledTimes(
|
||||
1,
|
||||
);
|
||||
expect(uiTelemetryService.setLastPromptTokenCount).toHaveBeenCalledWith(0);
|
||||
expect(uiTelemetryService.setLastPromptTokenCount).toHaveBeenCalledTimes(1);
|
||||
expect(nullConfigContext.ui.clear).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -24,7 +24,7 @@ export const clearCommand: SlashCommand = {
|
||||
context.ui.setDebugMessage('Clearing terminal.');
|
||||
}
|
||||
|
||||
uiTelemetryService.resetLastPromptTokenCount();
|
||||
uiTelemetryService.setLastPromptTokenCount(0);
|
||||
context.ui.clear();
|
||||
},
|
||||
};
|
||||
|
||||
@@ -9,6 +9,7 @@ import { CommandKind, type SlashCommand } from './types.js';
|
||||
export const corgiCommand: SlashCommand = {
|
||||
name: 'corgi',
|
||||
description: 'Toggles corgi mode.',
|
||||
hidden: true,
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: (context, _args) => {
|
||||
context.ui.toggleCorgiMode();
|
||||
|
||||
@@ -104,6 +104,7 @@ export const directoryCommand: SlashCommand = {
|
||||
config.getDebugMode(),
|
||||
config.getFileService(),
|
||||
config.getExtensionContextFilePaths(),
|
||||
config.getFolderTrust(),
|
||||
context.services.settings.merged.context?.importFormat ||
|
||||
'tree', // Use setting or default to 'tree'
|
||||
config.getFileFilteringOptions(),
|
||||
|
||||
@@ -4,64 +4,332 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { extensionsCommand } from './extensionsCommand.js';
|
||||
import { type CommandContext } from './types.js';
|
||||
import type { GeminiCLIExtension } from '@qwen-code/qwen-code-core';
|
||||
import {
|
||||
updateAllUpdatableExtensions,
|
||||
updateExtension,
|
||||
} from '../../config/extensions/update.js';
|
||||
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
|
||||
import { MessageType } from '../types.js';
|
||||
import { extensionsCommand } from './extensionsCommand.js';
|
||||
import { type CommandContext } from './types.js';
|
||||
import {
|
||||
describe,
|
||||
it,
|
||||
expect,
|
||||
vi,
|
||||
beforeEach,
|
||||
type MockedFunction,
|
||||
} from 'vitest';
|
||||
import { ExtensionUpdateState } from '../state/extensions.js';
|
||||
|
||||
vi.mock('../../config/extensions/update.js', () => ({
|
||||
updateExtension: vi.fn(),
|
||||
updateAllUpdatableExtensions: vi.fn(),
|
||||
checkForAllExtensionUpdates: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockUpdateExtension = updateExtension as MockedFunction<
|
||||
typeof updateExtension
|
||||
>;
|
||||
|
||||
const mockUpdateAllUpdatableExtensions =
|
||||
updateAllUpdatableExtensions as MockedFunction<
|
||||
typeof updateAllUpdatableExtensions
|
||||
>;
|
||||
|
||||
const mockGetExtensions = vi.fn();
|
||||
|
||||
describe('extensionsCommand', () => {
|
||||
let mockContext: CommandContext;
|
||||
|
||||
it('should display "No active extensions." when none are found', async () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
mockContext = createMockCommandContext({
|
||||
services: {
|
||||
config: {
|
||||
getExtensions: () => [],
|
||||
getExtensions: mockGetExtensions,
|
||||
getWorkingDir: () => '/test/dir',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!extensionsCommand.action) throw new Error('Action not defined');
|
||||
await extensionsCommand.action(mockContext, '');
|
||||
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
{
|
||||
type: MessageType.INFO,
|
||||
text: 'No active extensions.',
|
||||
ui: {
|
||||
dispatchExtensionStateUpdate: vi.fn(),
|
||||
},
|
||||
expect.any(Number),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('should list active extensions when they are found', async () => {
|
||||
const mockExtensions = [
|
||||
{ name: 'ext-one', version: '1.0.0', isActive: true },
|
||||
{ name: 'ext-two', version: '2.1.0', isActive: true },
|
||||
{ name: 'ext-three', version: '3.0.0', isActive: false },
|
||||
];
|
||||
mockContext = createMockCommandContext({
|
||||
services: {
|
||||
config: {
|
||||
getExtensions: () => mockExtensions,
|
||||
describe('list', () => {
|
||||
it('should add an EXTENSIONS_LIST item to the UI', async () => {
|
||||
if (!extensionsCommand.action) throw new Error('Action not defined');
|
||||
await extensionsCommand.action(mockContext, '');
|
||||
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
{
|
||||
type: MessageType.EXTENSIONS_LIST,
|
||||
},
|
||||
},
|
||||
expect.any(Number),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
const updateAction = extensionsCommand.subCommands?.find(
|
||||
(cmd) => cmd.name === 'update',
|
||||
)?.action;
|
||||
|
||||
if (!updateAction) {
|
||||
throw new Error('Update action not found');
|
||||
}
|
||||
|
||||
it('should show usage if no args are provided', async () => {
|
||||
await updateAction(mockContext, '');
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
{
|
||||
type: MessageType.ERROR,
|
||||
text: 'Usage: /extensions update <extension-names>|--all',
|
||||
},
|
||||
expect.any(Number),
|
||||
);
|
||||
});
|
||||
|
||||
if (!extensionsCommand.action) throw new Error('Action not defined');
|
||||
await extensionsCommand.action(mockContext, '');
|
||||
it('should inform user if there are no extensions to update with --all', async () => {
|
||||
mockUpdateAllUpdatableExtensions.mockResolvedValue([]);
|
||||
await updateAction(mockContext, '--all');
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
{
|
||||
type: MessageType.INFO,
|
||||
text: 'No extensions to update.',
|
||||
},
|
||||
expect.any(Number),
|
||||
);
|
||||
});
|
||||
|
||||
const expectedMessage =
|
||||
'Active extensions:\n\n' +
|
||||
` - \u001b[36mext-one (v1.0.0)\u001b[0m\n` +
|
||||
` - \u001b[36mext-two (v2.1.0)\u001b[0m\n`;
|
||||
it('should call setPendingItem and addItem in a finally block on success', async () => {
|
||||
mockUpdateAllUpdatableExtensions.mockResolvedValue([
|
||||
{
|
||||
name: 'ext-one',
|
||||
originalVersion: '1.0.0',
|
||||
updatedVersion: '1.0.1',
|
||||
},
|
||||
{
|
||||
name: 'ext-two',
|
||||
originalVersion: '2.0.0',
|
||||
updatedVersion: '2.0.1',
|
||||
},
|
||||
]);
|
||||
await updateAction(mockContext, '--all');
|
||||
expect(mockContext.ui.setPendingItem).toHaveBeenCalledWith({
|
||||
type: MessageType.EXTENSIONS_LIST,
|
||||
});
|
||||
expect(mockContext.ui.setPendingItem).toHaveBeenCalledWith(null);
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
{
|
||||
type: MessageType.EXTENSIONS_LIST,
|
||||
},
|
||||
expect.any(Number),
|
||||
);
|
||||
});
|
||||
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
{
|
||||
type: MessageType.INFO,
|
||||
text: expectedMessage,
|
||||
},
|
||||
expect.any(Number),
|
||||
);
|
||||
it('should call setPendingItem and addItem in a finally block on failure', async () => {
|
||||
mockUpdateAllUpdatableExtensions.mockRejectedValue(
|
||||
new Error('Something went wrong'),
|
||||
);
|
||||
await updateAction(mockContext, '--all');
|
||||
expect(mockContext.ui.setPendingItem).toHaveBeenCalledWith({
|
||||
type: MessageType.EXTENSIONS_LIST,
|
||||
});
|
||||
expect(mockContext.ui.setPendingItem).toHaveBeenCalledWith(null);
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
{
|
||||
type: MessageType.EXTENSIONS_LIST,
|
||||
},
|
||||
expect.any(Number),
|
||||
);
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
{
|
||||
type: MessageType.ERROR,
|
||||
text: 'Something went wrong',
|
||||
},
|
||||
expect.any(Number),
|
||||
);
|
||||
});
|
||||
|
||||
it('should update a single extension by name', async () => {
|
||||
const extension: GeminiCLIExtension = {
|
||||
name: 'ext-one',
|
||||
version: '1.0.0',
|
||||
isActive: true,
|
||||
path: '/test/dir/ext-one',
|
||||
installMetadata: {
|
||||
type: 'git',
|
||||
autoUpdate: false,
|
||||
source: 'https://github.com/some/extension.git',
|
||||
},
|
||||
};
|
||||
mockUpdateExtension.mockResolvedValue({
|
||||
name: extension.name,
|
||||
originalVersion: extension.version,
|
||||
updatedVersion: '1.0.1',
|
||||
});
|
||||
mockGetExtensions.mockReturnValue([extension]);
|
||||
mockContext.ui.extensionsUpdateState.set(extension.name, {
|
||||
status: ExtensionUpdateState.UPDATE_AVAILABLE,
|
||||
processed: false,
|
||||
});
|
||||
await updateAction(mockContext, 'ext-one');
|
||||
expect(mockUpdateExtension).toHaveBeenCalledWith(
|
||||
extension,
|
||||
'/test/dir',
|
||||
expect.any(Function),
|
||||
ExtensionUpdateState.UPDATE_AVAILABLE,
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle errors when updating a single extension', async () => {
|
||||
mockUpdateExtension.mockRejectedValue(new Error('Extension not found'));
|
||||
mockGetExtensions.mockReturnValue([]);
|
||||
await updateAction(mockContext, 'ext-one');
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
{
|
||||
type: MessageType.ERROR,
|
||||
text: 'Extension ext-one not found.',
|
||||
},
|
||||
expect.any(Number),
|
||||
);
|
||||
});
|
||||
|
||||
it('should update multiple extensions by name', async () => {
|
||||
const extensionOne: GeminiCLIExtension = {
|
||||
name: 'ext-one',
|
||||
version: '1.0.0',
|
||||
isActive: true,
|
||||
path: '/test/dir/ext-one',
|
||||
installMetadata: {
|
||||
type: 'git',
|
||||
autoUpdate: false,
|
||||
source: 'https://github.com/some/extension.git',
|
||||
},
|
||||
};
|
||||
const extensionTwo: GeminiCLIExtension = {
|
||||
name: 'ext-two',
|
||||
version: '1.0.0',
|
||||
isActive: true,
|
||||
path: '/test/dir/ext-two',
|
||||
installMetadata: {
|
||||
type: 'git',
|
||||
autoUpdate: false,
|
||||
source: 'https://github.com/some/extension.git',
|
||||
},
|
||||
};
|
||||
mockGetExtensions.mockReturnValue([extensionOne, extensionTwo]);
|
||||
mockContext.ui.extensionsUpdateState.set(
|
||||
extensionOne.name,
|
||||
ExtensionUpdateState.UPDATE_AVAILABLE,
|
||||
);
|
||||
mockContext.ui.extensionsUpdateState.set(
|
||||
extensionTwo.name,
|
||||
ExtensionUpdateState.UPDATE_AVAILABLE,
|
||||
);
|
||||
mockUpdateExtension
|
||||
.mockResolvedValueOnce({
|
||||
name: 'ext-one',
|
||||
originalVersion: '1.0.0',
|
||||
updatedVersion: '1.0.1',
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
name: 'ext-two',
|
||||
originalVersion: '2.0.0',
|
||||
updatedVersion: '2.0.1',
|
||||
});
|
||||
await updateAction(mockContext, 'ext-one ext-two');
|
||||
expect(mockUpdateExtension).toHaveBeenCalledTimes(2);
|
||||
expect(mockContext.ui.setPendingItem).toHaveBeenCalledWith({
|
||||
type: MessageType.EXTENSIONS_LIST,
|
||||
});
|
||||
expect(mockContext.ui.setPendingItem).toHaveBeenCalledWith(null);
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
{
|
||||
type: MessageType.EXTENSIONS_LIST,
|
||||
},
|
||||
expect.any(Number),
|
||||
);
|
||||
});
|
||||
|
||||
describe('completion', () => {
|
||||
const updateCompletion = extensionsCommand.subCommands?.find(
|
||||
(cmd) => cmd.name === 'update',
|
||||
)?.completion;
|
||||
|
||||
if (!updateCompletion) {
|
||||
throw new Error('Update completion not found');
|
||||
}
|
||||
|
||||
const extensionOne: GeminiCLIExtension = {
|
||||
name: 'ext-one',
|
||||
version: '1.0.0',
|
||||
isActive: true,
|
||||
path: '/test/dir/ext-one',
|
||||
installMetadata: {
|
||||
type: 'git',
|
||||
autoUpdate: false,
|
||||
source: 'https://github.com/some/extension.git',
|
||||
},
|
||||
};
|
||||
const extensionTwo: GeminiCLIExtension = {
|
||||
name: 'another-ext',
|
||||
version: '1.0.0',
|
||||
isActive: true,
|
||||
path: '/test/dir/another-ext',
|
||||
installMetadata: {
|
||||
type: 'git',
|
||||
autoUpdate: false,
|
||||
source: 'https://github.com/some/extension.git',
|
||||
},
|
||||
};
|
||||
const allExt: GeminiCLIExtension = {
|
||||
name: 'all-ext',
|
||||
version: '1.0.0',
|
||||
isActive: true,
|
||||
path: '/test/dir/all-ext',
|
||||
installMetadata: {
|
||||
type: 'git',
|
||||
autoUpdate: false,
|
||||
source: 'https://github.com/some/extension.git',
|
||||
},
|
||||
};
|
||||
|
||||
it.each([
|
||||
{
|
||||
description: 'should return matching extension names',
|
||||
extensions: [extensionOne, extensionTwo],
|
||||
partialArg: 'ext',
|
||||
expected: ['ext-one'],
|
||||
},
|
||||
{
|
||||
description: 'should return --all when partialArg matches',
|
||||
extensions: [],
|
||||
partialArg: '--al',
|
||||
expected: ['--all'],
|
||||
},
|
||||
{
|
||||
description:
|
||||
'should return both extension names and --all when both match',
|
||||
extensions: [allExt],
|
||||
partialArg: 'all',
|
||||
expected: ['--all', 'all-ext'],
|
||||
},
|
||||
{
|
||||
description: 'should return an empty array if no matches',
|
||||
extensions: [extensionOne],
|
||||
partialArg: 'nomatch',
|
||||
expected: [],
|
||||
},
|
||||
])('$description', async ({ extensions, partialArg, expected }) => {
|
||||
mockGetExtensions.mockReturnValue(extensions);
|
||||
const suggestions = await updateCompletion(mockContext, partialArg);
|
||||
expect(suggestions).toEqual(expected);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,43 +4,164 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { requestConsentInteractive } from '../../config/extension.js';
|
||||
import {
|
||||
updateAllUpdatableExtensions,
|
||||
type ExtensionUpdateInfo,
|
||||
updateExtension,
|
||||
checkForAllExtensionUpdates,
|
||||
} from '../../config/extensions/update.js';
|
||||
import { getErrorMessage } from '../../utils/errors.js';
|
||||
import { ExtensionUpdateState } from '../state/extensions.js';
|
||||
import { MessageType } from '../types.js';
|
||||
import {
|
||||
type CommandContext,
|
||||
type SlashCommand,
|
||||
CommandKind,
|
||||
} from './types.js';
|
||||
import { MessageType } from '../types.js';
|
||||
|
||||
export const extensionsCommand: SlashCommand = {
|
||||
name: 'extensions',
|
||||
description: 'list active extensions',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: async (context: CommandContext): Promise<void> => {
|
||||
const activeExtensions = context.services.config
|
||||
?.getExtensions()
|
||||
.filter((ext) => ext.isActive);
|
||||
if (!activeExtensions || activeExtensions.length === 0) {
|
||||
async function listAction(context: CommandContext) {
|
||||
context.ui.addItem(
|
||||
{
|
||||
type: MessageType.EXTENSIONS_LIST,
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
}
|
||||
|
||||
async function updateAction(context: CommandContext, args: string) {
|
||||
const updateArgs = args.split(' ').filter((value) => value.length > 0);
|
||||
const all = updateArgs.length === 1 && updateArgs[0] === '--all';
|
||||
const names = all ? undefined : updateArgs;
|
||||
let updateInfos: ExtensionUpdateInfo[] = [];
|
||||
|
||||
if (!all && names?.length === 0) {
|
||||
context.ui.addItem(
|
||||
{
|
||||
type: MessageType.ERROR,
|
||||
text: 'Usage: /extensions update <extension-names>|--all',
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await checkForAllExtensionUpdates(
|
||||
context.services.config!.getExtensions(),
|
||||
context.ui.dispatchExtensionStateUpdate,
|
||||
);
|
||||
context.ui.setPendingItem({
|
||||
type: MessageType.EXTENSIONS_LIST,
|
||||
});
|
||||
if (all) {
|
||||
updateInfos = await updateAllUpdatableExtensions(
|
||||
context.services.config!.getWorkingDir(),
|
||||
// We don't have the ability to prompt for consent yet in this flow.
|
||||
(description) =>
|
||||
requestConsentInteractive(
|
||||
description,
|
||||
context.ui.addConfirmUpdateExtensionRequest,
|
||||
),
|
||||
context.services.config!.getExtensions(),
|
||||
context.ui.extensionsUpdateState,
|
||||
context.ui.dispatchExtensionStateUpdate,
|
||||
);
|
||||
} else if (names?.length) {
|
||||
const workingDir = context.services.config!.getWorkingDir();
|
||||
const extensions = context.services.config!.getExtensions();
|
||||
for (const name of names) {
|
||||
const extension = extensions.find(
|
||||
(extension) => extension.name === name,
|
||||
);
|
||||
if (!extension) {
|
||||
context.ui.addItem(
|
||||
{
|
||||
type: MessageType.ERROR,
|
||||
text: `Extension ${name} not found.`,
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const updateInfo = await updateExtension(
|
||||
extension,
|
||||
workingDir,
|
||||
(description) =>
|
||||
requestConsentInteractive(
|
||||
description,
|
||||
context.ui.addConfirmUpdateExtensionRequest,
|
||||
),
|
||||
context.ui.extensionsUpdateState.get(extension.name)?.status ??
|
||||
ExtensionUpdateState.UNKNOWN,
|
||||
context.ui.dispatchExtensionStateUpdate,
|
||||
);
|
||||
if (updateInfo) updateInfos.push(updateInfo);
|
||||
}
|
||||
}
|
||||
|
||||
if (updateInfos.length === 0) {
|
||||
context.ui.addItem(
|
||||
{
|
||||
type: MessageType.INFO,
|
||||
text: 'No active extensions.',
|
||||
text: 'No extensions to update.',
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const extensionLines = activeExtensions.map(
|
||||
(ext) => ` - \u001b[36m${ext.name} (v${ext.version})\u001b[0m`,
|
||||
);
|
||||
const message = `Active extensions:\n\n${extensionLines.join('\n')}\n`;
|
||||
|
||||
} catch (error) {
|
||||
context.ui.addItem(
|
||||
{
|
||||
type: MessageType.INFO,
|
||||
text: message,
|
||||
type: MessageType.ERROR,
|
||||
text: getErrorMessage(error),
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
} finally {
|
||||
context.ui.addItem(
|
||||
{
|
||||
type: MessageType.EXTENSIONS_LIST,
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
context.ui.setPendingItem(null);
|
||||
}
|
||||
}
|
||||
|
||||
const listExtensionsCommand: SlashCommand = {
|
||||
name: 'list',
|
||||
description: 'List active extensions',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: listAction,
|
||||
};
|
||||
|
||||
const updateExtensionsCommand: SlashCommand = {
|
||||
name: 'update',
|
||||
description: 'Update extensions. Usage: update <extension-names>|--all',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: updateAction,
|
||||
completion: async (context, partialArg) => {
|
||||
const extensions = context.services.config?.getExtensions() ?? [];
|
||||
const extensionNames = extensions.map((ext) => ext.name);
|
||||
const suggestions = extensionNames.filter((name) =>
|
||||
name.startsWith(partialArg),
|
||||
);
|
||||
|
||||
if ('--all'.startsWith(partialArg) || 'all'.startsWith(partialArg)) {
|
||||
suggestions.unshift('--all');
|
||||
}
|
||||
|
||||
return suggestions;
|
||||
},
|
||||
};
|
||||
|
||||
export const extensionsCommand: SlashCommand = {
|
||||
name: 'extensions',
|
||||
description: 'Manage extensions',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
subCommands: [listExtensionsCommand, updateExtensionsCommand],
|
||||
action: (context, args) =>
|
||||
// Default to list if no subcommand is provided
|
||||
listExtensionsCommand.action!(context, args),
|
||||
};
|
||||
|
||||
@@ -8,26 +8,43 @@ import type { MockInstance } from 'vitest';
|
||||
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { ideCommand } from './ideCommand.js';
|
||||
import { type CommandContext } from './types.js';
|
||||
import { type Config, DetectedIde } from '@qwen-code/qwen-code-core';
|
||||
import { IDE_DEFINITIONS } from '@qwen-code/qwen-code-core';
|
||||
import * as core from '@qwen-code/qwen-code-core';
|
||||
|
||||
vi.mock('child_process');
|
||||
vi.mock('glob');
|
||||
vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
|
||||
const original = await importOriginal<typeof core>();
|
||||
return {
|
||||
...original,
|
||||
getOauthClient: vi.fn(original.getOauthClient),
|
||||
getIdeInstaller: vi.fn(original.getIdeInstaller),
|
||||
IdeClient: {
|
||||
getInstance: vi.fn(),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
describe('ideCommand', () => {
|
||||
let mockContext: CommandContext;
|
||||
let mockConfig: Config;
|
||||
let mockIdeClient: core.IdeClient;
|
||||
let platformSpy: MockInstance;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
|
||||
mockIdeClient = {
|
||||
reconnect: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
connect: vi.fn(),
|
||||
getCurrentIde: vi.fn(),
|
||||
getConnectionStatus: vi.fn(),
|
||||
getDetectedIdeDisplayName: vi.fn(),
|
||||
} as unknown as core.IdeClient;
|
||||
|
||||
vi.mocked(core.IdeClient.getInstance).mockResolvedValue(mockIdeClient);
|
||||
vi.mocked(mockIdeClient.getDetectedIdeDisplayName).mockReturnValue(
|
||||
'VS Code',
|
||||
);
|
||||
|
||||
mockContext = {
|
||||
ui: {
|
||||
addItem: vi.fn(),
|
||||
@@ -36,22 +53,14 @@ describe('ideCommand', () => {
|
||||
settings: {
|
||||
setValue: vi.fn(),
|
||||
},
|
||||
config: {
|
||||
getIdeMode: vi.fn(),
|
||||
setIdeMode: vi.fn(),
|
||||
getUsageStatisticsEnabled: vi.fn().mockReturnValue(false),
|
||||
},
|
||||
},
|
||||
} as unknown as CommandContext;
|
||||
|
||||
mockConfig = {
|
||||
getIdeMode: vi.fn(),
|
||||
getIdeClient: vi.fn(() => ({
|
||||
reconnect: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
getCurrentIde: vi.fn(),
|
||||
getDetectedIdeDisplayName: vi.fn(),
|
||||
getConnectionStatus: vi.fn(),
|
||||
})),
|
||||
setIdeModeAndSyncConnection: vi.fn(),
|
||||
setIdeMode: vi.fn(),
|
||||
} as unknown as Config;
|
||||
|
||||
platformSpy = vi.spyOn(process, 'platform', 'get');
|
||||
});
|
||||
|
||||
@@ -59,64 +68,52 @@ describe('ideCommand', () => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should return null if config is not provided', () => {
|
||||
const command = ideCommand(null);
|
||||
expect(command).toBeNull();
|
||||
it('should return the ide command', async () => {
|
||||
vi.mocked(mockIdeClient.getCurrentIde).mockReturnValue(
|
||||
IDE_DEFINITIONS.vscode,
|
||||
);
|
||||
vi.mocked(mockIdeClient.getConnectionStatus).mockReturnValue({
|
||||
status: core.IDEConnectionStatus.Disconnected,
|
||||
});
|
||||
const command = await ideCommand();
|
||||
expect(command).not.toBeNull();
|
||||
expect(command.name).toBe('ide');
|
||||
expect(command.subCommands).toHaveLength(3);
|
||||
expect(command.subCommands?.[0].name).toBe('enable');
|
||||
expect(command.subCommands?.[1].name).toBe('status');
|
||||
expect(command.subCommands?.[2].name).toBe('install');
|
||||
});
|
||||
|
||||
it('should return the ide command', () => {
|
||||
vi.mocked(mockConfig.getIdeMode).mockReturnValue(true);
|
||||
vi.mocked(mockConfig.getIdeClient).mockReturnValue({
|
||||
getCurrentIde: () => DetectedIde.VSCode,
|
||||
getDetectedIdeDisplayName: () => 'VS Code',
|
||||
getConnectionStatus: () => ({
|
||||
status: core.IDEConnectionStatus.Disconnected,
|
||||
}),
|
||||
} as ReturnType<Config['getIdeClient']>);
|
||||
const command = ideCommand(mockConfig);
|
||||
it('should show disable command when connected', async () => {
|
||||
vi.mocked(mockIdeClient.getCurrentIde).mockReturnValue(
|
||||
IDE_DEFINITIONS.vscode,
|
||||
);
|
||||
vi.mocked(mockIdeClient.getConnectionStatus).mockReturnValue({
|
||||
status: core.IDEConnectionStatus.Connected,
|
||||
});
|
||||
const command = await ideCommand();
|
||||
expect(command).not.toBeNull();
|
||||
expect(command?.name).toBe('ide');
|
||||
expect(command?.subCommands).toHaveLength(3);
|
||||
expect(command?.subCommands?.[0].name).toBe('enable');
|
||||
expect(command?.subCommands?.[1].name).toBe('status');
|
||||
expect(command?.subCommands?.[2].name).toBe('install');
|
||||
});
|
||||
|
||||
it('should show disable command when connected', () => {
|
||||
vi.mocked(mockConfig.getIdeMode).mockReturnValue(true);
|
||||
vi.mocked(mockConfig.getIdeClient).mockReturnValue({
|
||||
getCurrentIde: () => DetectedIde.VSCode,
|
||||
getDetectedIdeDisplayName: () => 'VS Code',
|
||||
getConnectionStatus: () => ({
|
||||
status: core.IDEConnectionStatus.Connected,
|
||||
}),
|
||||
} as ReturnType<Config['getIdeClient']>);
|
||||
const command = ideCommand(mockConfig);
|
||||
expect(command).not.toBeNull();
|
||||
const subCommandNames = command?.subCommands?.map((cmd) => cmd.name);
|
||||
const subCommandNames = command.subCommands?.map((cmd) => cmd.name);
|
||||
expect(subCommandNames).toContain('disable');
|
||||
expect(subCommandNames).not.toContain('enable');
|
||||
});
|
||||
|
||||
describe('status subcommand', () => {
|
||||
const mockGetConnectionStatus = vi.fn();
|
||||
beforeEach(() => {
|
||||
vi.mocked(mockConfig.getIdeClient).mockReturnValue({
|
||||
getConnectionStatus: mockGetConnectionStatus,
|
||||
getCurrentIde: () => DetectedIde.VSCode,
|
||||
getDetectedIdeDisplayName: () => 'VS Code',
|
||||
} as unknown as ReturnType<Config['getIdeClient']>);
|
||||
vi.mocked(mockIdeClient.getCurrentIde).mockReturnValue(
|
||||
IDE_DEFINITIONS.vscode,
|
||||
);
|
||||
});
|
||||
|
||||
it('should show connected status', async () => {
|
||||
mockGetConnectionStatus.mockReturnValue({
|
||||
vi.mocked(mockIdeClient.getConnectionStatus).mockReturnValue({
|
||||
status: core.IDEConnectionStatus.Connected,
|
||||
});
|
||||
const command = ideCommand(mockConfig);
|
||||
const command = await ideCommand();
|
||||
const result = await command!.subCommands!.find(
|
||||
(c) => c.name === 'status',
|
||||
)!.action!(mockContext, '');
|
||||
expect(mockGetConnectionStatus).toHaveBeenCalled();
|
||||
expect(vi.mocked(mockIdeClient.getConnectionStatus)).toHaveBeenCalled();
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
@@ -125,14 +122,14 @@ describe('ideCommand', () => {
|
||||
});
|
||||
|
||||
it('should show connecting status', async () => {
|
||||
mockGetConnectionStatus.mockReturnValue({
|
||||
vi.mocked(mockIdeClient.getConnectionStatus).mockReturnValue({
|
||||
status: core.IDEConnectionStatus.Connecting,
|
||||
});
|
||||
const command = ideCommand(mockConfig);
|
||||
const command = await ideCommand();
|
||||
const result = await command!.subCommands!.find(
|
||||
(c) => c.name === 'status',
|
||||
)!.action!(mockContext, '');
|
||||
expect(mockGetConnectionStatus).toHaveBeenCalled();
|
||||
expect(vi.mocked(mockIdeClient.getConnectionStatus)).toHaveBeenCalled();
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
@@ -140,14 +137,14 @@ describe('ideCommand', () => {
|
||||
});
|
||||
});
|
||||
it('should show disconnected status', async () => {
|
||||
mockGetConnectionStatus.mockReturnValue({
|
||||
vi.mocked(mockIdeClient.getConnectionStatus).mockReturnValue({
|
||||
status: core.IDEConnectionStatus.Disconnected,
|
||||
});
|
||||
const command = ideCommand(mockConfig);
|
||||
const command = await ideCommand();
|
||||
const result = await command!.subCommands!.find(
|
||||
(c) => c.name === 'status',
|
||||
)!.action!(mockContext, '');
|
||||
expect(mockGetConnectionStatus).toHaveBeenCalled();
|
||||
expect(vi.mocked(mockIdeClient.getConnectionStatus)).toHaveBeenCalled();
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
@@ -157,15 +154,15 @@ describe('ideCommand', () => {
|
||||
|
||||
it('should show disconnected status with details', async () => {
|
||||
const details = 'Something went wrong';
|
||||
mockGetConnectionStatus.mockReturnValue({
|
||||
vi.mocked(mockIdeClient.getConnectionStatus).mockReturnValue({
|
||||
status: core.IDEConnectionStatus.Disconnected,
|
||||
details,
|
||||
});
|
||||
const command = ideCommand(mockConfig);
|
||||
const command = await ideCommand();
|
||||
const result = await command!.subCommands!.find(
|
||||
(c) => c.name === 'status',
|
||||
)!.action!(mockContext, '');
|
||||
expect(mockGetConnectionStatus).toHaveBeenCalled();
|
||||
expect(vi.mocked(mockIdeClient.getConnectionStatus)).toHaveBeenCalled();
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
@@ -177,34 +174,39 @@ describe('ideCommand', () => {
|
||||
describe('install subcommand', () => {
|
||||
const mockInstall = vi.fn();
|
||||
beforeEach(() => {
|
||||
vi.mocked(mockConfig.getIdeMode).mockReturnValue(true);
|
||||
vi.mocked(mockConfig.getIdeClient).mockReturnValue({
|
||||
getCurrentIde: () => DetectedIde.VSCode,
|
||||
getConnectionStatus: () => ({
|
||||
status: core.IDEConnectionStatus.Disconnected,
|
||||
}),
|
||||
getDetectedIdeDisplayName: () => 'VS Code',
|
||||
} as unknown as ReturnType<Config['getIdeClient']>);
|
||||
vi.mocked(mockIdeClient.getCurrentIde).mockReturnValue(
|
||||
IDE_DEFINITIONS.vscode,
|
||||
);
|
||||
vi.mocked(mockIdeClient.getConnectionStatus).mockReturnValue({
|
||||
status: core.IDEConnectionStatus.Disconnected,
|
||||
});
|
||||
vi.mocked(core.getIdeInstaller).mockReturnValue({
|
||||
install: mockInstall,
|
||||
isInstalled: vi.fn(),
|
||||
});
|
||||
platformSpy.mockReturnValue('linux');
|
||||
});
|
||||
|
||||
it('should install the extension', async () => {
|
||||
vi.useFakeTimers();
|
||||
mockInstall.mockResolvedValue({
|
||||
success: true,
|
||||
message: 'Successfully installed.',
|
||||
});
|
||||
|
||||
const command = ideCommand(mockConfig);
|
||||
await command!.subCommands!.find((c) => c.name === 'install')!.action!(
|
||||
mockContext,
|
||||
'',
|
||||
);
|
||||
const command = await ideCommand();
|
||||
|
||||
expect(core.getIdeInstaller).toHaveBeenCalledWith('vscode');
|
||||
// For the polling loop inside the action.
|
||||
vi.mocked(mockIdeClient.getConnectionStatus).mockReturnValue({
|
||||
status: core.IDEConnectionStatus.Connected,
|
||||
});
|
||||
|
||||
const actionPromise = command!.subCommands!.find(
|
||||
(c) => c.name === 'install',
|
||||
)!.action!(mockContext, '');
|
||||
await vi.runAllTimersAsync();
|
||||
await actionPromise;
|
||||
|
||||
expect(core.getIdeInstaller).toHaveBeenCalledWith(IDE_DEFINITIONS.vscode);
|
||||
expect(mockInstall).toHaveBeenCalled();
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
@@ -220,6 +222,14 @@ describe('ideCommand', () => {
|
||||
}),
|
||||
expect.any(Number),
|
||||
);
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'info',
|
||||
text: '🟢 Connected to VS Code',
|
||||
}),
|
||||
expect.any(Number),
|
||||
);
|
||||
vi.useRealTimers();
|
||||
}, 10000);
|
||||
|
||||
it('should show an error if installation fails', async () => {
|
||||
@@ -228,13 +238,13 @@ describe('ideCommand', () => {
|
||||
message: 'Installation failed.',
|
||||
});
|
||||
|
||||
const command = ideCommand(mockConfig);
|
||||
const command = await ideCommand();
|
||||
await command!.subCommands!.find((c) => c.name === 'install')!.action!(
|
||||
mockContext,
|
||||
'',
|
||||
);
|
||||
|
||||
expect(core.getIdeInstaller).toHaveBeenCalledWith('vscode');
|
||||
expect(core.getIdeInstaller).toHaveBeenCalledWith(IDE_DEFINITIONS.vscode);
|
||||
expect(mockInstall).toHaveBeenCalled();
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
|
||||
@@ -4,12 +4,19 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { Config, IdeClient, File } from '@qwen-code/qwen-code-core';
|
||||
import {
|
||||
type Config,
|
||||
IdeClient,
|
||||
type File,
|
||||
logIdeConnection,
|
||||
IdeConnectionEvent,
|
||||
IdeConnectionType,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import {
|
||||
QWEN_CODE_COMPANION_EXTENSION_NAME,
|
||||
getIdeInstaller,
|
||||
IDEConnectionStatus,
|
||||
ideContext,
|
||||
ideContextStore,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import path from 'node:path';
|
||||
import type {
|
||||
@@ -83,7 +90,7 @@ async function getIdeStatusMessageWithFiles(ideClient: IdeClient): Promise<{
|
||||
switch (connection.status) {
|
||||
case IDEConnectionStatus.Connected: {
|
||||
let content = `🟢 Connected to ${ideClient.getDetectedIdeDisplayName()}`;
|
||||
const context = ideContext.getIdeContext();
|
||||
const context = ideContextStore.get();
|
||||
const openFiles = context?.workspaceState?.openFiles;
|
||||
if (openFiles && openFiles.length > 0) {
|
||||
content += formatFileList(openFiles);
|
||||
@@ -111,13 +118,24 @@ async function getIdeStatusMessageWithFiles(ideClient: IdeClient): Promise<{
|
||||
}
|
||||
}
|
||||
|
||||
export const ideCommand = (config: Config | null): SlashCommand | null => {
|
||||
if (!config) {
|
||||
return null;
|
||||
async function setIdeModeAndSyncConnection(
|
||||
config: Config,
|
||||
value: boolean,
|
||||
): Promise<void> {
|
||||
config.setIdeMode(value);
|
||||
const ideClient = await IdeClient.getInstance();
|
||||
if (value) {
|
||||
await ideClient.connect();
|
||||
logIdeConnection(config, new IdeConnectionEvent(IdeConnectionType.SESSION));
|
||||
} else {
|
||||
await ideClient.disconnect();
|
||||
}
|
||||
const ideClient = config.getIdeClient();
|
||||
}
|
||||
|
||||
export const ideCommand = async (): Promise<SlashCommand> => {
|
||||
const ideClient = await IdeClient.getInstance();
|
||||
const currentIDE = ideClient.getCurrentIde();
|
||||
if (!currentIDE || !ideClient.getDetectedIdeDisplayName()) {
|
||||
if (!currentIDE) {
|
||||
return {
|
||||
name: 'ide',
|
||||
description: 'manage IDE integration',
|
||||
@@ -194,7 +212,7 @@ export const ideCommand = (config: Config | null): SlashCommand | null => {
|
||||
);
|
||||
// Poll for up to 5 seconds for the extension to activate.
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await config.setIdeModeAndSyncConnection(true);
|
||||
await setIdeModeAndSyncConnection(context.services.config!, true);
|
||||
if (
|
||||
ideClient.getConnectionStatus().status ===
|
||||
IDEConnectionStatus.Connected
|
||||
@@ -236,7 +254,7 @@ export const ideCommand = (config: Config | null): SlashCommand | null => {
|
||||
'ide.enabled',
|
||||
true,
|
||||
);
|
||||
await config.setIdeModeAndSyncConnection(true);
|
||||
await setIdeModeAndSyncConnection(context.services.config!, true);
|
||||
const { messageType, content } = getIdeStatusMessage(ideClient);
|
||||
context.ui.addItem(
|
||||
{
|
||||
@@ -258,7 +276,7 @@ export const ideCommand = (config: Config | null): SlashCommand | null => {
|
||||
'ide.enabled',
|
||||
false,
|
||||
);
|
||||
await config.setIdeModeAndSyncConnection(false);
|
||||
await setIdeModeAndSyncConnection(context.services.config!, false);
|
||||
const { messageType, content } = getIdeStatusMessage(ideClient);
|
||||
context.ui.addItem(
|
||||
{
|
||||
|
||||
@@ -15,34 +15,28 @@ import {
|
||||
DiscoveredMCPTool,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
|
||||
import type { MessageActionReturn } from './types.js';
|
||||
import type { CallableTool } from '@google/genai';
|
||||
import { Type } from '@google/genai';
|
||||
import { MessageType } from '../types.js';
|
||||
|
||||
vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('@qwen-code/qwen-code-core')>();
|
||||
const mockAuthenticate = vi.fn();
|
||||
return {
|
||||
...actual,
|
||||
getMCPServerStatus: vi.fn(),
|
||||
getMCPDiscoveryState: vi.fn(),
|
||||
MCPOAuthProvider: {
|
||||
authenticate: vi.fn(),
|
||||
},
|
||||
MCPOAuthTokenStorage: {
|
||||
MCPOAuthProvider: vi.fn(() => ({
|
||||
authenticate: mockAuthenticate,
|
||||
})),
|
||||
MCPOAuthTokenStorage: vi.fn(() => ({
|
||||
getToken: vi.fn(),
|
||||
isTokenExpired: vi.fn(),
|
||||
},
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
// Helper function to check if result is a message action
|
||||
const isMessageAction = (result: unknown): result is MessageActionReturn =>
|
||||
result !== null &&
|
||||
typeof result === 'object' &&
|
||||
'type' in result &&
|
||||
result.type === 'message';
|
||||
|
||||
// Helper function to create a mock DiscoveredMCPTool
|
||||
const createMockMCPTool = (
|
||||
name: string,
|
||||
@@ -58,7 +52,6 @@ const createMockMCPTool = (
|
||||
name,
|
||||
description || `Description for ${name}`,
|
||||
{ type: Type.OBJECT, properties: {} },
|
||||
name, // serverToolName same as name for simplicity
|
||||
);
|
||||
|
||||
describe('mcpCommand', () => {
|
||||
@@ -68,6 +61,7 @@ describe('mcpCommand', () => {
|
||||
getMcpServers: ReturnType<typeof vi.fn>;
|
||||
getBlockedMcpServers: ReturnType<typeof vi.fn>;
|
||||
getPromptRegistry: ReturnType<typeof vi.fn>;
|
||||
getGeminiClient: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -93,6 +87,7 @@ describe('mcpCommand', () => {
|
||||
getAllPrompts: vi.fn().mockReturnValue([]),
|
||||
getPromptsByServer: vi.fn().mockReturnValue([]),
|
||||
}),
|
||||
getGeminiClient: vi.fn(),
|
||||
};
|
||||
|
||||
mockContext = createMockCommandContext({
|
||||
@@ -132,26 +127,6 @@ describe('mcpCommand', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('no MCP servers configured', () => {
|
||||
beforeEach(() => {
|
||||
mockConfig.getToolRegistry = vi.fn().mockReturnValue({
|
||||
getAllTools: vi.fn().mockReturnValue([]),
|
||||
});
|
||||
mockConfig.getMcpServers = vi.fn().mockReturnValue({});
|
||||
});
|
||||
|
||||
it('should display a message with a URL when no MCP servers are configured', async () => {
|
||||
const result = await mcpCommand.action!(mockContext, '');
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content:
|
||||
'No MCP servers configured. Please view MCP documentation in your browser: https://qwenlm.github.io/qwen-code-docs/en/tools/mcp-server/#how-to-set-up-your-mcp-server or use the cli /docs command',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('with configured MCP servers', () => {
|
||||
beforeEach(() => {
|
||||
const mockMcpServers = {
|
||||
@@ -189,870 +164,47 @@ describe('mcpCommand', () => {
|
||||
getAllTools: vi.fn().mockReturnValue(allTools),
|
||||
});
|
||||
|
||||
const result = await mcpCommand.action!(mockContext, '');
|
||||
await mcpCommand.action!(mockContext, '');
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: expect.stringContaining('Configured MCP servers:'),
|
||||
});
|
||||
|
||||
expect(isMessageAction(result)).toBe(true);
|
||||
if (isMessageAction(result)) {
|
||||
const message = result.content;
|
||||
// Server 1 - Connected
|
||||
expect(message).toContain(
|
||||
'🟢 \u001b[1mserver1\u001b[0m - Ready (2 tools)',
|
||||
);
|
||||
expect(message).toContain('server1_tool1');
|
||||
expect(message).toContain('server1_tool2');
|
||||
|
||||
// Server 2 - Connected
|
||||
expect(message).toContain(
|
||||
'🟢 \u001b[1mserver2\u001b[0m - Ready (1 tool)',
|
||||
);
|
||||
expect(message).toContain('server2_tool1');
|
||||
|
||||
// Server 3 - Disconnected but with cached tools, so shows as Ready
|
||||
expect(message).toContain(
|
||||
'🟢 \u001b[1mserver3\u001b[0m - Ready (1 tool)',
|
||||
);
|
||||
expect(message).toContain('server3_tool1');
|
||||
|
||||
// Check that helpful tips are displayed when no arguments are provided
|
||||
expect(message).toContain('💡 Tips:');
|
||||
expect(message).toContain('/mcp desc');
|
||||
expect(message).toContain('/mcp schema');
|
||||
expect(message).toContain('/mcp nodesc');
|
||||
expect(message).toContain('Ctrl+T');
|
||||
}
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: MessageType.MCP_STATUS,
|
||||
tools: allTools.map((tool) => ({
|
||||
serverName: tool.serverName,
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
schema: tool.schema,
|
||||
})),
|
||||
showTips: true,
|
||||
}),
|
||||
expect.any(Number),
|
||||
);
|
||||
});
|
||||
|
||||
it('should display tool descriptions when desc argument is used', async () => {
|
||||
const mockMcpServers = {
|
||||
server1: {
|
||||
command: 'cmd1',
|
||||
description: 'This is a server description',
|
||||
},
|
||||
};
|
||||
await mcpCommand.action!(mockContext, 'desc');
|
||||
|
||||
mockConfig.getMcpServers = vi.fn().mockReturnValue(mockMcpServers);
|
||||
|
||||
// Mock tools with descriptions using actual DiscoveredMCPTool instances
|
||||
const mockServerTools = [
|
||||
createMockMCPTool('tool1', 'server1', 'This is tool 1 description'),
|
||||
createMockMCPTool('tool2', 'server1', 'This is tool 2 description'),
|
||||
];
|
||||
|
||||
mockConfig.getToolRegistry = vi.fn().mockReturnValue({
|
||||
getAllTools: vi.fn().mockReturnValue(mockServerTools),
|
||||
});
|
||||
|
||||
const result = await mcpCommand.action!(mockContext, 'desc');
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: expect.stringContaining('Configured MCP servers:'),
|
||||
});
|
||||
|
||||
expect(isMessageAction(result)).toBe(true);
|
||||
if (isMessageAction(result)) {
|
||||
const message = result.content;
|
||||
|
||||
// Check that server description is included
|
||||
expect(message).toContain(
|
||||
'\u001b[1mserver1\u001b[0m - Ready (2 tools)',
|
||||
);
|
||||
expect(message).toContain(
|
||||
'\u001b[32mThis is a server description\u001b[0m',
|
||||
);
|
||||
|
||||
// Check that tool descriptions are included
|
||||
expect(message).toContain('\u001b[36mtool1\u001b[0m');
|
||||
expect(message).toContain(
|
||||
'\u001b[32mThis is tool 1 description\u001b[0m',
|
||||
);
|
||||
expect(message).toContain('\u001b[36mtool2\u001b[0m');
|
||||
expect(message).toContain(
|
||||
'\u001b[32mThis is tool 2 description\u001b[0m',
|
||||
);
|
||||
|
||||
// Check that tips are NOT displayed when arguments are provided
|
||||
expect(message).not.toContain('💡 Tips:');
|
||||
}
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: MessageType.MCP_STATUS,
|
||||
showDescriptions: true,
|
||||
showTips: false,
|
||||
}),
|
||||
expect.any(Number),
|
||||
);
|
||||
});
|
||||
|
||||
it('should not display descriptions when nodesc argument is used', async () => {
|
||||
const mockMcpServers = {
|
||||
server1: {
|
||||
command: 'cmd1',
|
||||
description: 'This is a server description',
|
||||
},
|
||||
};
|
||||
|
||||
mockConfig.getMcpServers = vi.fn().mockReturnValue(mockMcpServers);
|
||||
|
||||
const mockServerTools = [
|
||||
createMockMCPTool('tool1', 'server1', 'This is tool 1 description'),
|
||||
];
|
||||
|
||||
mockConfig.getToolRegistry = vi.fn().mockReturnValue({
|
||||
getAllTools: vi.fn().mockReturnValue(mockServerTools),
|
||||
});
|
||||
|
||||
const result = await mcpCommand.action!(mockContext, 'nodesc');
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: expect.stringContaining('Configured MCP servers:'),
|
||||
});
|
||||
|
||||
expect(isMessageAction(result)).toBe(true);
|
||||
if (isMessageAction(result)) {
|
||||
const message = result.content;
|
||||
|
||||
// Check that descriptions are not included
|
||||
expect(message).not.toContain('This is a server description');
|
||||
expect(message).not.toContain('This is tool 1 description');
|
||||
expect(message).toContain('\u001b[36mtool1\u001b[0m');
|
||||
|
||||
// Check that tips are NOT displayed when arguments are provided
|
||||
expect(message).not.toContain('💡 Tips:');
|
||||
}
|
||||
});
|
||||
|
||||
it('should indicate when a server has no tools', async () => {
|
||||
const mockMcpServers = {
|
||||
server1: { command: 'cmd1' },
|
||||
server2: { command: 'cmd2' },
|
||||
};
|
||||
|
||||
mockConfig.getMcpServers = vi.fn().mockReturnValue(mockMcpServers);
|
||||
|
||||
// Setup server statuses
|
||||
vi.mocked(getMCPServerStatus).mockImplementation((serverName) => {
|
||||
if (serverName === 'server1') return MCPServerStatus.CONNECTED;
|
||||
if (serverName === 'server2') return MCPServerStatus.DISCONNECTED;
|
||||
return MCPServerStatus.DISCONNECTED;
|
||||
});
|
||||
|
||||
// Mock tools - only server1 has tools
|
||||
const mockServerTools = [createMockMCPTool('server1_tool1', 'server1')];
|
||||
|
||||
mockConfig.getToolRegistry = vi.fn().mockReturnValue({
|
||||
getAllTools: vi.fn().mockReturnValue(mockServerTools),
|
||||
});
|
||||
|
||||
const result = await mcpCommand.action!(mockContext, '');
|
||||
|
||||
expect(isMessageAction(result)).toBe(true);
|
||||
if (isMessageAction(result)) {
|
||||
const message = result.content;
|
||||
expect(message).toContain(
|
||||
'🟢 \u001b[1mserver1\u001b[0m - Ready (1 tool)',
|
||||
);
|
||||
expect(message).toContain('\u001b[36mserver1_tool1\u001b[0m');
|
||||
expect(message).toContain(
|
||||
'🔴 \u001b[1mserver2\u001b[0m - Disconnected (0 tools cached)',
|
||||
);
|
||||
expect(message).toContain('No tools or prompts available');
|
||||
}
|
||||
});
|
||||
|
||||
it('should show startup indicator when servers are connecting', async () => {
|
||||
const mockMcpServers = {
|
||||
server1: { command: 'cmd1' },
|
||||
server2: { command: 'cmd2' },
|
||||
};
|
||||
|
||||
mockConfig.getMcpServers = vi.fn().mockReturnValue(mockMcpServers);
|
||||
|
||||
// Setup server statuses with one connecting
|
||||
vi.mocked(getMCPServerStatus).mockImplementation((serverName) => {
|
||||
if (serverName === 'server1') return MCPServerStatus.CONNECTED;
|
||||
if (serverName === 'server2') return MCPServerStatus.CONNECTING;
|
||||
return MCPServerStatus.DISCONNECTED;
|
||||
});
|
||||
|
||||
// Setup discovery state as in progress
|
||||
vi.mocked(getMCPDiscoveryState).mockReturnValue(
|
||||
MCPDiscoveryState.IN_PROGRESS,
|
||||
);
|
||||
|
||||
// Mock tools
|
||||
const mockServerTools = [
|
||||
createMockMCPTool('server1_tool1', 'server1'),
|
||||
createMockMCPTool('server2_tool1', 'server2'),
|
||||
];
|
||||
|
||||
mockConfig.getToolRegistry = vi.fn().mockReturnValue({
|
||||
getAllTools: vi.fn().mockReturnValue(mockServerTools),
|
||||
});
|
||||
|
||||
const result = await mcpCommand.action!(mockContext, '');
|
||||
|
||||
expect(isMessageAction(result)).toBe(true);
|
||||
if (isMessageAction(result)) {
|
||||
const message = result.content;
|
||||
|
||||
// Check that startup indicator is shown
|
||||
expect(message).toContain(
|
||||
'⏳ MCP servers are starting up (1 initializing)...',
|
||||
);
|
||||
expect(message).toContain(
|
||||
'Note: First startup may take longer. Tool availability will update automatically.',
|
||||
);
|
||||
|
||||
// Check server statuses
|
||||
expect(message).toContain(
|
||||
'🟢 \u001b[1mserver1\u001b[0m - Ready (1 tool)',
|
||||
);
|
||||
expect(message).toContain(
|
||||
'🔄 \u001b[1mserver2\u001b[0m - Starting... (first startup may take longer) (tools and prompts will appear when ready)',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('should display the extension name for servers from extensions', async () => {
|
||||
const mockMcpServers = {
|
||||
server1: { command: 'cmd1', extensionName: 'my-extension' },
|
||||
};
|
||||
mockConfig.getMcpServers = vi.fn().mockReturnValue(mockMcpServers);
|
||||
|
||||
const result = await mcpCommand.action!(mockContext, '');
|
||||
|
||||
expect(isMessageAction(result)).toBe(true);
|
||||
if (isMessageAction(result)) {
|
||||
const message = result.content;
|
||||
expect(message).toContain('server1 (from my-extension)');
|
||||
}
|
||||
});
|
||||
|
||||
it('should display blocked MCP servers', async () => {
|
||||
mockConfig.getMcpServers = vi.fn().mockReturnValue({});
|
||||
const blockedServers = [
|
||||
{ name: 'blocked-server', extensionName: 'my-extension' },
|
||||
];
|
||||
mockConfig.getBlockedMcpServers = vi.fn().mockReturnValue(blockedServers);
|
||||
|
||||
const result = await mcpCommand.action!(mockContext, '');
|
||||
|
||||
expect(isMessageAction(result)).toBe(true);
|
||||
if (isMessageAction(result)) {
|
||||
const message = result.content;
|
||||
expect(message).toContain(
|
||||
'🔴 \u001b[1mblocked-server (from my-extension)\u001b[0m - Blocked',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('should display both active and blocked servers correctly', async () => {
|
||||
const mockMcpServers = {
|
||||
server1: { command: 'cmd1', extensionName: 'my-extension' },
|
||||
};
|
||||
mockConfig.getMcpServers = vi.fn().mockReturnValue(mockMcpServers);
|
||||
const blockedServers = [
|
||||
{ name: 'blocked-server', extensionName: 'another-extension' },
|
||||
];
|
||||
mockConfig.getBlockedMcpServers = vi.fn().mockReturnValue(blockedServers);
|
||||
|
||||
const result = await mcpCommand.action!(mockContext, '');
|
||||
|
||||
expect(isMessageAction(result)).toBe(true);
|
||||
if (isMessageAction(result)) {
|
||||
const message = result.content;
|
||||
expect(message).toContain('server1 (from my-extension)');
|
||||
expect(message).toContain(
|
||||
'🔴 \u001b[1mblocked-server (from another-extension)\u001b[0m - Blocked',
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('schema functionality', () => {
|
||||
it('should display tool schemas when schema argument is used', async () => {
|
||||
const mockMcpServers = {
|
||||
server1: {
|
||||
command: 'cmd1',
|
||||
description: 'This is a server description',
|
||||
},
|
||||
};
|
||||
|
||||
mockConfig.getMcpServers = vi.fn().mockReturnValue(mockMcpServers);
|
||||
|
||||
// Create tools with parameter schemas
|
||||
const mockCallableTool1: CallableTool = {
|
||||
callTool: vi.fn(),
|
||||
tool: vi.fn(),
|
||||
} as unknown as CallableTool;
|
||||
const mockCallableTool2: CallableTool = {
|
||||
callTool: vi.fn(),
|
||||
tool: vi.fn(),
|
||||
} as unknown as CallableTool;
|
||||
|
||||
const tool1 = new DiscoveredMCPTool(
|
||||
mockCallableTool1,
|
||||
'server1',
|
||||
'tool1',
|
||||
'This is tool 1 description',
|
||||
{
|
||||
type: Type.OBJECT,
|
||||
properties: {
|
||||
param1: { type: Type.STRING, description: 'First parameter' },
|
||||
},
|
||||
required: ['param1'],
|
||||
},
|
||||
'tool1',
|
||||
);
|
||||
|
||||
const tool2 = new DiscoveredMCPTool(
|
||||
mockCallableTool2,
|
||||
'server1',
|
||||
'tool2',
|
||||
'This is tool 2 description',
|
||||
{
|
||||
type: Type.OBJECT,
|
||||
properties: {
|
||||
param2: { type: Type.NUMBER, description: 'Second parameter' },
|
||||
},
|
||||
required: ['param2'],
|
||||
},
|
||||
'tool2',
|
||||
);
|
||||
|
||||
const mockServerTools = [tool1, tool2];
|
||||
|
||||
mockConfig.getToolRegistry = vi.fn().mockReturnValue({
|
||||
getAllTools: vi.fn().mockReturnValue(mockServerTools),
|
||||
});
|
||||
|
||||
const result = await mcpCommand.action!(mockContext, 'schema');
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: expect.stringContaining('Configured MCP servers:'),
|
||||
});
|
||||
|
||||
expect(isMessageAction(result)).toBe(true);
|
||||
if (isMessageAction(result)) {
|
||||
const message = result.content;
|
||||
|
||||
// Check that server description is included
|
||||
expect(message).toContain('Ready (2 tools)');
|
||||
expect(message).toContain('This is a server description');
|
||||
|
||||
// Check that tool descriptions and schemas are included
|
||||
expect(message).toContain('This is tool 1 description');
|
||||
expect(message).toContain('Parameters:');
|
||||
expect(message).toContain('param1');
|
||||
expect(message).toContain('STRING');
|
||||
expect(message).toContain('This is tool 2 description');
|
||||
expect(message).toContain('param2');
|
||||
expect(message).toContain('NUMBER');
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle tools without parameter schemas gracefully', async () => {
|
||||
const mockMcpServers = {
|
||||
server1: { command: 'cmd1' },
|
||||
};
|
||||
|
||||
mockConfig.getMcpServers = vi.fn().mockReturnValue(mockMcpServers);
|
||||
|
||||
// Mock tools without parameter schemas
|
||||
const mockServerTools = [
|
||||
createMockMCPTool('tool1', 'server1', 'Tool without schema'),
|
||||
];
|
||||
|
||||
mockConfig.getToolRegistry = vi.fn().mockReturnValue({
|
||||
getAllTools: vi.fn().mockReturnValue(mockServerTools),
|
||||
});
|
||||
|
||||
const result = await mcpCommand.action!(mockContext, 'schema');
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: expect.stringContaining('Configured MCP servers:'),
|
||||
});
|
||||
|
||||
expect(isMessageAction(result)).toBe(true);
|
||||
if (isMessageAction(result)) {
|
||||
const message = result.content;
|
||||
expect(message).toContain('tool1');
|
||||
expect(message).toContain('Tool without schema');
|
||||
// Should not crash when parameterSchema is undefined
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('argument parsing', () => {
|
||||
beforeEach(() => {
|
||||
const mockMcpServers = {
|
||||
server1: {
|
||||
command: 'cmd1',
|
||||
description: 'Server description',
|
||||
},
|
||||
};
|
||||
|
||||
mockConfig.getMcpServers = vi.fn().mockReturnValue(mockMcpServers);
|
||||
|
||||
const mockServerTools = [
|
||||
createMockMCPTool('tool1', 'server1', 'Test tool'),
|
||||
];
|
||||
|
||||
mockConfig.getToolRegistry = vi.fn().mockReturnValue({
|
||||
getAllTools: vi.fn().mockReturnValue(mockServerTools),
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle "descriptions" as alias for "desc"', async () => {
|
||||
const result = await mcpCommand.action!(mockContext, 'descriptions');
|
||||
|
||||
expect(isMessageAction(result)).toBe(true);
|
||||
if (isMessageAction(result)) {
|
||||
const message = result.content;
|
||||
expect(message).toContain('Test tool');
|
||||
expect(message).toContain('Server description');
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle "nodescriptions" as alias for "nodesc"', async () => {
|
||||
const result = await mcpCommand.action!(mockContext, 'nodescriptions');
|
||||
|
||||
expect(isMessageAction(result)).toBe(true);
|
||||
if (isMessageAction(result)) {
|
||||
const message = result.content;
|
||||
expect(message).not.toContain('Test tool');
|
||||
expect(message).not.toContain('Server description');
|
||||
expect(message).toContain('\u001b[36mtool1\u001b[0m');
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle mixed case arguments', async () => {
|
||||
const result = await mcpCommand.action!(mockContext, 'DESC');
|
||||
|
||||
expect(isMessageAction(result)).toBe(true);
|
||||
if (isMessageAction(result)) {
|
||||
const message = result.content;
|
||||
expect(message).toContain('Test tool');
|
||||
expect(message).toContain('Server description');
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle multiple arguments - "schema desc"', async () => {
|
||||
const result = await mcpCommand.action!(mockContext, 'schema desc');
|
||||
|
||||
expect(isMessageAction(result)).toBe(true);
|
||||
if (isMessageAction(result)) {
|
||||
const message = result.content;
|
||||
expect(message).toContain('Test tool');
|
||||
expect(message).toContain('Server description');
|
||||
expect(message).toContain('Parameters:');
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle multiple arguments - "desc schema"', async () => {
|
||||
const result = await mcpCommand.action!(mockContext, 'desc schema');
|
||||
|
||||
expect(isMessageAction(result)).toBe(true);
|
||||
if (isMessageAction(result)) {
|
||||
const message = result.content;
|
||||
expect(message).toContain('Test tool');
|
||||
expect(message).toContain('Server description');
|
||||
expect(message).toContain('Parameters:');
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle "schema" alone showing descriptions', async () => {
|
||||
const result = await mcpCommand.action!(mockContext, 'schema');
|
||||
|
||||
expect(isMessageAction(result)).toBe(true);
|
||||
if (isMessageAction(result)) {
|
||||
const message = result.content;
|
||||
expect(message).toContain('Test tool');
|
||||
expect(message).toContain('Server description');
|
||||
expect(message).toContain('Parameters:');
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle "nodesc" overriding "schema" - "schema nodesc"', async () => {
|
||||
const result = await mcpCommand.action!(mockContext, 'schema nodesc');
|
||||
|
||||
expect(isMessageAction(result)).toBe(true);
|
||||
if (isMessageAction(result)) {
|
||||
const message = result.content;
|
||||
expect(message).not.toContain('Test tool');
|
||||
expect(message).not.toContain('Server description');
|
||||
expect(message).toContain('Parameters:'); // Schema should still show
|
||||
expect(message).toContain('\u001b[36mtool1\u001b[0m');
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle "nodesc" overriding "desc" - "desc nodesc"', async () => {
|
||||
const result = await mcpCommand.action!(mockContext, 'desc nodesc');
|
||||
|
||||
expect(isMessageAction(result)).toBe(true);
|
||||
if (isMessageAction(result)) {
|
||||
const message = result.content;
|
||||
expect(message).not.toContain('Test tool');
|
||||
expect(message).not.toContain('Server description');
|
||||
expect(message).not.toContain('Parameters:');
|
||||
expect(message).toContain('\u001b[36mtool1\u001b[0m');
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle "nodesc" overriding both "desc" and "schema" - "desc schema nodesc"', async () => {
|
||||
const result = await mcpCommand.action!(
|
||||
mockContext,
|
||||
'desc schema nodesc',
|
||||
);
|
||||
|
||||
expect(isMessageAction(result)).toBe(true);
|
||||
if (isMessageAction(result)) {
|
||||
const message = result.content;
|
||||
expect(message).not.toContain('Test tool');
|
||||
expect(message).not.toContain('Server description');
|
||||
expect(message).toContain('Parameters:'); // Schema should still show
|
||||
expect(message).toContain('\u001b[36mtool1\u001b[0m');
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle extra whitespace in arguments', async () => {
|
||||
const result = await mcpCommand.action!(mockContext, ' desc schema ');
|
||||
|
||||
expect(isMessageAction(result)).toBe(true);
|
||||
if (isMessageAction(result)) {
|
||||
const message = result.content;
|
||||
expect(message).toContain('Test tool');
|
||||
expect(message).toContain('Server description');
|
||||
expect(message).toContain('Parameters:');
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle empty arguments gracefully', async () => {
|
||||
const result = await mcpCommand.action!(mockContext, '');
|
||||
|
||||
expect(isMessageAction(result)).toBe(true);
|
||||
if (isMessageAction(result)) {
|
||||
const message = result.content;
|
||||
expect(message).not.toContain('Test tool');
|
||||
expect(message).not.toContain('Server description');
|
||||
expect(message).not.toContain('Parameters:');
|
||||
expect(message).toContain('\u001b[36mtool1\u001b[0m');
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle unknown arguments gracefully', async () => {
|
||||
const result = await mcpCommand.action!(mockContext, 'unknown arg');
|
||||
|
||||
expect(isMessageAction(result)).toBe(true);
|
||||
if (isMessageAction(result)) {
|
||||
const message = result.content;
|
||||
expect(message).not.toContain('Test tool');
|
||||
expect(message).not.toContain('Server description');
|
||||
expect(message).not.toContain('Parameters:');
|
||||
expect(message).toContain('\u001b[36mtool1\u001b[0m');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle empty server names gracefully', async () => {
|
||||
const mockMcpServers = {
|
||||
'': { command: 'cmd1' }, // Empty server name
|
||||
};
|
||||
|
||||
mockConfig.getMcpServers = vi.fn().mockReturnValue(mockMcpServers);
|
||||
mockConfig.getToolRegistry = vi.fn().mockReturnValue({
|
||||
getAllTools: vi.fn().mockReturnValue([]),
|
||||
});
|
||||
|
||||
const result = await mcpCommand.action!(mockContext, '');
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: expect.stringContaining('Configured MCP servers:'),
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle servers with special characters in names', async () => {
|
||||
const mockMcpServers = {
|
||||
'server-with-dashes': { command: 'cmd1' },
|
||||
server_with_underscores: { command: 'cmd2' },
|
||||
'server.with.dots': { command: 'cmd3' },
|
||||
};
|
||||
|
||||
mockConfig.getMcpServers = vi.fn().mockReturnValue(mockMcpServers);
|
||||
mockConfig.getToolRegistry = vi.fn().mockReturnValue({
|
||||
getAllTools: vi.fn().mockReturnValue([]),
|
||||
});
|
||||
|
||||
const result = await mcpCommand.action!(mockContext, '');
|
||||
|
||||
expect(isMessageAction(result)).toBe(true);
|
||||
if (isMessageAction(result)) {
|
||||
const message = result.content;
|
||||
expect(message).toContain('server-with-dashes');
|
||||
expect(message).toContain('server_with_underscores');
|
||||
expect(message).toContain('server.with.dots');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('auth subcommand', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should list OAuth-enabled servers when no server name is provided', async () => {
|
||||
const context = createMockCommandContext({
|
||||
services: {
|
||||
config: {
|
||||
getMcpServers: vi.fn().mockReturnValue({
|
||||
'oauth-server': { oauth: { enabled: true } },
|
||||
'regular-server': {},
|
||||
'another-oauth': { oauth: { enabled: true } },
|
||||
}),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const authCommand = mcpCommand.subCommands?.find(
|
||||
(cmd) => cmd.name === 'auth',
|
||||
);
|
||||
expect(authCommand).toBeDefined();
|
||||
|
||||
const result = await authCommand!.action!(context, '');
|
||||
expect(isMessageAction(result)).toBe(true);
|
||||
if (isMessageAction(result)) {
|
||||
expect(result.messageType).toBe('info');
|
||||
expect(result.content).toContain('oauth-server');
|
||||
expect(result.content).toContain('another-oauth');
|
||||
expect(result.content).not.toContain('regular-server');
|
||||
expect(result.content).toContain('/mcp auth <server-name>');
|
||||
}
|
||||
});
|
||||
|
||||
it('should show message when no OAuth servers are configured', async () => {
|
||||
const context = createMockCommandContext({
|
||||
services: {
|
||||
config: {
|
||||
getMcpServers: vi.fn().mockReturnValue({
|
||||
'regular-server': {},
|
||||
}),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const authCommand = mcpCommand.subCommands?.find(
|
||||
(cmd) => cmd.name === 'auth',
|
||||
);
|
||||
const result = await authCommand!.action!(context, '');
|
||||
|
||||
expect(isMessageAction(result)).toBe(true);
|
||||
if (isMessageAction(result)) {
|
||||
expect(result.messageType).toBe('info');
|
||||
expect(result.content).toBe(
|
||||
'No MCP servers configured with OAuth authentication.',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('should authenticate with a specific server', async () => {
|
||||
const mockToolRegistry = {
|
||||
discoverToolsForServer: vi.fn(),
|
||||
};
|
||||
const mockGeminiClient = {
|
||||
setTools: vi.fn(),
|
||||
};
|
||||
|
||||
const context = createMockCommandContext({
|
||||
services: {
|
||||
config: {
|
||||
getMcpServers: vi.fn().mockReturnValue({
|
||||
'test-server': {
|
||||
url: 'http://localhost:3000',
|
||||
oauth: { enabled: true },
|
||||
},
|
||||
}),
|
||||
getToolRegistry: vi.fn().mockReturnValue(mockToolRegistry),
|
||||
getGeminiClient: vi.fn().mockReturnValue(mockGeminiClient),
|
||||
getPromptRegistry: vi.fn().mockResolvedValue({
|
||||
removePromptsByServer: vi.fn(),
|
||||
}),
|
||||
},
|
||||
},
|
||||
});
|
||||
// Mock the reloadCommands function
|
||||
context.ui.reloadCommands = vi.fn();
|
||||
|
||||
const { MCPOAuthProvider } = await import('@qwen-code/qwen-code-core');
|
||||
|
||||
const authCommand = mcpCommand.subCommands?.find(
|
||||
(cmd) => cmd.name === 'auth',
|
||||
);
|
||||
const result = await authCommand!.action!(context, 'test-server');
|
||||
|
||||
expect(MCPOAuthProvider.authenticate).toHaveBeenCalledWith(
|
||||
'test-server',
|
||||
{ enabled: true },
|
||||
'http://localhost:3000',
|
||||
);
|
||||
expect(mockToolRegistry.discoverToolsForServer).toHaveBeenCalledWith(
|
||||
'test-server',
|
||||
);
|
||||
expect(mockGeminiClient.setTools).toHaveBeenCalled();
|
||||
expect(context.ui.reloadCommands).toHaveBeenCalledTimes(1);
|
||||
|
||||
expect(isMessageAction(result)).toBe(true);
|
||||
if (isMessageAction(result)) {
|
||||
expect(result.messageType).toBe('info');
|
||||
expect(result.content).toContain('Successfully authenticated');
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle authentication errors', async () => {
|
||||
const context = createMockCommandContext({
|
||||
services: {
|
||||
config: {
|
||||
getMcpServers: vi.fn().mockReturnValue({
|
||||
'test-server': { oauth: { enabled: true } },
|
||||
}),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { MCPOAuthProvider } = await import('@qwen-code/qwen-code-core');
|
||||
(
|
||||
MCPOAuthProvider.authenticate as ReturnType<typeof vi.fn>
|
||||
).mockRejectedValue(new Error('Auth failed'));
|
||||
|
||||
const authCommand = mcpCommand.subCommands?.find(
|
||||
(cmd) => cmd.name === 'auth',
|
||||
);
|
||||
const result = await authCommand!.action!(context, 'test-server');
|
||||
|
||||
expect(isMessageAction(result)).toBe(true);
|
||||
if (isMessageAction(result)) {
|
||||
expect(result.messageType).toBe('error');
|
||||
expect(result.content).toContain('Failed to authenticate');
|
||||
expect(result.content).toContain('Auth failed');
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle non-existent server', async () => {
|
||||
const context = createMockCommandContext({
|
||||
services: {
|
||||
config: {
|
||||
getMcpServers: vi.fn().mockReturnValue({
|
||||
'existing-server': {},
|
||||
}),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const authCommand = mcpCommand.subCommands?.find(
|
||||
(cmd) => cmd.name === 'auth',
|
||||
);
|
||||
const result = await authCommand!.action!(context, 'non-existent');
|
||||
|
||||
expect(isMessageAction(result)).toBe(true);
|
||||
if (isMessageAction(result)) {
|
||||
expect(result.messageType).toBe('error');
|
||||
expect(result.content).toContain("MCP server 'non-existent' not found");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('refresh subcommand', () => {
|
||||
it('should refresh the list of tools and display the status', async () => {
|
||||
const mockToolRegistry = {
|
||||
discoverMcpTools: vi.fn(),
|
||||
restartMcpServers: vi.fn(),
|
||||
getAllTools: vi.fn().mockReturnValue([]),
|
||||
};
|
||||
const mockGeminiClient = {
|
||||
setTools: vi.fn(),
|
||||
};
|
||||
|
||||
const context = createMockCommandContext({
|
||||
services: {
|
||||
config: {
|
||||
getMcpServers: vi.fn().mockReturnValue({ server1: {} }),
|
||||
getBlockedMcpServers: vi.fn().mockReturnValue([]),
|
||||
getToolRegistry: vi.fn().mockReturnValue(mockToolRegistry),
|
||||
getGeminiClient: vi.fn().mockReturnValue(mockGeminiClient),
|
||||
getPromptRegistry: vi.fn().mockResolvedValue({
|
||||
getPromptsByServer: vi.fn().mockReturnValue([]),
|
||||
}),
|
||||
},
|
||||
},
|
||||
});
|
||||
// Mock the reloadCommands function, which is new logic.
|
||||
context.ui.reloadCommands = vi.fn();
|
||||
|
||||
const refreshCommand = mcpCommand.subCommands?.find(
|
||||
(cmd) => cmd.name === 'refresh',
|
||||
);
|
||||
expect(refreshCommand).toBeDefined();
|
||||
|
||||
const result = await refreshCommand!.action!(context, '');
|
||||
|
||||
expect(context.ui.addItem).toHaveBeenCalledWith(
|
||||
{
|
||||
type: 'info',
|
||||
text: 'Restarting MCP servers...',
|
||||
},
|
||||
await mcpCommand.action!(mockContext, 'nodesc');
|
||||
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: MessageType.MCP_STATUS,
|
||||
showDescriptions: false,
|
||||
showTips: false,
|
||||
}),
|
||||
expect.any(Number),
|
||||
);
|
||||
expect(mockToolRegistry.restartMcpServers).toHaveBeenCalled();
|
||||
expect(mockGeminiClient.setTools).toHaveBeenCalled();
|
||||
expect(context.ui.reloadCommands).toHaveBeenCalledTimes(1);
|
||||
|
||||
expect(isMessageAction(result)).toBe(true);
|
||||
if (isMessageAction(result)) {
|
||||
expect(result.messageType).toBe('info');
|
||||
expect(result.content).toContain('Configured MCP servers:');
|
||||
}
|
||||
});
|
||||
|
||||
it('should show an error if config is not available', async () => {
|
||||
const contextWithoutConfig = createMockCommandContext({
|
||||
services: {
|
||||
config: null,
|
||||
},
|
||||
});
|
||||
|
||||
const refreshCommand = mcpCommand.subCommands?.find(
|
||||
(cmd) => cmd.name === 'refresh',
|
||||
);
|
||||
const result = await refreshCommand!.action!(contextWithoutConfig, '');
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: 'Config not loaded.',
|
||||
});
|
||||
});
|
||||
|
||||
it('should show an error if tool registry is not available', async () => {
|
||||
mockConfig.getToolRegistry = vi.fn().mockReturnValue(undefined);
|
||||
|
||||
const refreshCommand = mcpCommand.subCommands?.find(
|
||||
(cmd) => cmd.name === 'refresh',
|
||||
);
|
||||
const result = await refreshCommand!.action!(mockContext, '');
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: 'Could not retrieve tool registry.',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,300 +18,11 @@ import {
|
||||
getMCPServerStatus,
|
||||
MCPDiscoveryState,
|
||||
MCPServerStatus,
|
||||
mcpServerRequiresOAuth,
|
||||
getErrorMessage,
|
||||
MCPOAuthTokenStorage,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
|
||||
const COLOR_GREEN = '\u001b[32m';
|
||||
const COLOR_YELLOW = '\u001b[33m';
|
||||
const COLOR_RED = '\u001b[31m';
|
||||
const COLOR_CYAN = '\u001b[36m';
|
||||
const COLOR_GREY = '\u001b[90m';
|
||||
const RESET_COLOR = '\u001b[0m';
|
||||
|
||||
const getMcpStatus = async (
|
||||
context: CommandContext,
|
||||
showDescriptions: boolean,
|
||||
showSchema: boolean,
|
||||
showTips: boolean = false,
|
||||
): Promise<SlashCommandActionReturn> => {
|
||||
const { config } = context.services;
|
||||
if (!config) {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: 'Config not loaded.',
|
||||
};
|
||||
}
|
||||
|
||||
const toolRegistry = config.getToolRegistry();
|
||||
if (!toolRegistry) {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: 'Could not retrieve tool registry.',
|
||||
};
|
||||
}
|
||||
|
||||
const mcpServers = config.getMcpServers() || {};
|
||||
const serverNames = Object.keys(mcpServers);
|
||||
const blockedMcpServers = config.getBlockedMcpServers() || [];
|
||||
|
||||
if (serverNames.length === 0 && blockedMcpServers.length === 0) {
|
||||
const docsUrl =
|
||||
'https://qwenlm.github.io/qwen-code-docs/en/tools/mcp-server/#how-to-set-up-your-mcp-server';
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: `No MCP servers configured. Please view MCP documentation in your browser: ${docsUrl} or use the cli /docs command`,
|
||||
};
|
||||
}
|
||||
|
||||
// Check if any servers are still connecting
|
||||
const connectingServers = serverNames.filter(
|
||||
(name) => getMCPServerStatus(name) === MCPServerStatus.CONNECTING,
|
||||
);
|
||||
const discoveryState = getMCPDiscoveryState();
|
||||
|
||||
let message = '';
|
||||
|
||||
// Add overall discovery status message if needed
|
||||
if (
|
||||
discoveryState === MCPDiscoveryState.IN_PROGRESS ||
|
||||
connectingServers.length > 0
|
||||
) {
|
||||
message += `${COLOR_YELLOW}⏳ MCP servers are starting up (${connectingServers.length} initializing)...${RESET_COLOR}\n`;
|
||||
message += `${COLOR_CYAN}Note: First startup may take longer. Tool availability will update automatically.${RESET_COLOR}\n\n`;
|
||||
}
|
||||
|
||||
message += 'Configured MCP servers:\n\n';
|
||||
|
||||
const allTools = toolRegistry.getAllTools();
|
||||
for (const serverName of serverNames) {
|
||||
const serverTools = allTools.filter(
|
||||
(tool) =>
|
||||
tool instanceof DiscoveredMCPTool && tool.serverName === serverName,
|
||||
) as DiscoveredMCPTool[];
|
||||
const promptRegistry = await config.getPromptRegistry();
|
||||
const serverPrompts = promptRegistry.getPromptsByServer(serverName) || [];
|
||||
|
||||
const originalStatus = getMCPServerStatus(serverName);
|
||||
const hasCachedItems = serverTools.length > 0 || serverPrompts.length > 0;
|
||||
|
||||
// If the server is "disconnected" but has prompts or cached tools, display it as Ready
|
||||
// by using CONNECTED as the display status.
|
||||
const status =
|
||||
originalStatus === MCPServerStatus.DISCONNECTED && hasCachedItems
|
||||
? MCPServerStatus.CONNECTED
|
||||
: originalStatus;
|
||||
|
||||
// Add status indicator with descriptive text
|
||||
let statusIndicator = '';
|
||||
let statusText = '';
|
||||
switch (status) {
|
||||
case MCPServerStatus.CONNECTED:
|
||||
statusIndicator = '🟢';
|
||||
statusText = 'Ready';
|
||||
break;
|
||||
case MCPServerStatus.CONNECTING:
|
||||
statusIndicator = '🔄';
|
||||
statusText = 'Starting... (first startup may take longer)';
|
||||
break;
|
||||
case MCPServerStatus.DISCONNECTED:
|
||||
default:
|
||||
statusIndicator = '🔴';
|
||||
statusText = 'Disconnected';
|
||||
break;
|
||||
}
|
||||
|
||||
// Get server description if available
|
||||
const server = mcpServers[serverName];
|
||||
let serverDisplayName = serverName;
|
||||
if (server.extensionName) {
|
||||
serverDisplayName += ` (from ${server.extensionName})`;
|
||||
}
|
||||
|
||||
// Format server header with bold formatting and status
|
||||
message += `${statusIndicator} \u001b[1m${serverDisplayName}\u001b[0m - ${statusText}`;
|
||||
|
||||
let needsAuthHint = mcpServerRequiresOAuth.get(serverName) || false;
|
||||
// Add OAuth status if applicable
|
||||
if (server?.oauth?.enabled) {
|
||||
needsAuthHint = true;
|
||||
try {
|
||||
const { MCPOAuthTokenStorage } = await import(
|
||||
'@qwen-code/qwen-code-core'
|
||||
);
|
||||
const hasToken = await MCPOAuthTokenStorage.getToken(serverName);
|
||||
if (hasToken) {
|
||||
const isExpired = MCPOAuthTokenStorage.isTokenExpired(hasToken.token);
|
||||
if (isExpired) {
|
||||
message += ` ${COLOR_YELLOW}(OAuth token expired)${RESET_COLOR}`;
|
||||
} else {
|
||||
message += ` ${COLOR_GREEN}(OAuth authenticated)${RESET_COLOR}`;
|
||||
needsAuthHint = false;
|
||||
}
|
||||
} else {
|
||||
message += ` ${COLOR_RED}(OAuth not authenticated)${RESET_COLOR}`;
|
||||
}
|
||||
} catch (_err) {
|
||||
// If we can't check OAuth status, just continue
|
||||
}
|
||||
}
|
||||
|
||||
// Add tool count with conditional messaging
|
||||
if (status === MCPServerStatus.CONNECTED) {
|
||||
const parts = [];
|
||||
if (serverTools.length > 0) {
|
||||
parts.push(
|
||||
`${serverTools.length} ${serverTools.length === 1 ? 'tool' : 'tools'}`,
|
||||
);
|
||||
}
|
||||
if (serverPrompts.length > 0) {
|
||||
parts.push(
|
||||
`${serverPrompts.length} ${
|
||||
serverPrompts.length === 1 ? 'prompt' : 'prompts'
|
||||
}`,
|
||||
);
|
||||
}
|
||||
if (parts.length > 0) {
|
||||
message += ` (${parts.join(', ')})`;
|
||||
} else {
|
||||
message += ` (0 tools)`;
|
||||
}
|
||||
} else if (status === MCPServerStatus.CONNECTING) {
|
||||
message += ` (tools and prompts will appear when ready)`;
|
||||
} else {
|
||||
message += ` (${serverTools.length} tools cached)`;
|
||||
}
|
||||
|
||||
// Add server description with proper handling of multi-line descriptions
|
||||
if (showDescriptions && server?.description) {
|
||||
const descLines = server.description.trim().split('\n');
|
||||
if (descLines) {
|
||||
message += ':\n';
|
||||
for (const descLine of descLines) {
|
||||
message += ` ${COLOR_GREEN}${descLine}${RESET_COLOR}\n`;
|
||||
}
|
||||
} else {
|
||||
message += '\n';
|
||||
}
|
||||
} else {
|
||||
message += '\n';
|
||||
}
|
||||
|
||||
// Reset formatting after server entry
|
||||
message += RESET_COLOR;
|
||||
|
||||
if (serverTools.length > 0) {
|
||||
message += ` ${COLOR_CYAN}Tools:${RESET_COLOR}\n`;
|
||||
serverTools.forEach((tool) => {
|
||||
if (showDescriptions && tool.description) {
|
||||
// Format tool name in cyan using simple ANSI cyan color
|
||||
message += ` - ${COLOR_CYAN}${tool.name}${RESET_COLOR}`;
|
||||
|
||||
// Handle multi-line descriptions by properly indenting and preserving formatting
|
||||
const descLines = tool.description.trim().split('\n');
|
||||
if (descLines) {
|
||||
message += ':\n';
|
||||
for (const descLine of descLines) {
|
||||
message += ` ${COLOR_GREEN}${descLine}${RESET_COLOR}\n`;
|
||||
}
|
||||
} else {
|
||||
message += '\n';
|
||||
}
|
||||
// Reset is handled inline with each line now
|
||||
} else {
|
||||
// Use cyan color for the tool name even when not showing descriptions
|
||||
message += ` - ${COLOR_CYAN}${tool.name}${RESET_COLOR}\n`;
|
||||
}
|
||||
const parameters =
|
||||
tool.schema.parametersJsonSchema ?? tool.schema.parameters;
|
||||
if (showSchema && parameters) {
|
||||
// Prefix the parameters in cyan
|
||||
message += ` ${COLOR_CYAN}Parameters:${RESET_COLOR}\n`;
|
||||
|
||||
const paramsLines = JSON.stringify(parameters, null, 2)
|
||||
.trim()
|
||||
.split('\n');
|
||||
if (paramsLines) {
|
||||
for (const paramsLine of paramsLines) {
|
||||
message += ` ${COLOR_GREEN}${paramsLine}${RESET_COLOR}\n`;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
if (serverPrompts.length > 0) {
|
||||
if (serverTools.length > 0) {
|
||||
message += '\n';
|
||||
}
|
||||
message += ` ${COLOR_CYAN}Prompts:${RESET_COLOR}\n`;
|
||||
serverPrompts.forEach((prompt: DiscoveredMCPPrompt) => {
|
||||
if (showDescriptions && prompt.description) {
|
||||
message += ` - ${COLOR_CYAN}${prompt.name}${RESET_COLOR}`;
|
||||
const descLines = prompt.description.trim().split('\n');
|
||||
if (descLines) {
|
||||
message += ':\n';
|
||||
for (const descLine of descLines) {
|
||||
message += ` ${COLOR_GREEN}${descLine}${RESET_COLOR}\n`;
|
||||
}
|
||||
} else {
|
||||
message += '\n';
|
||||
}
|
||||
} else {
|
||||
message += ` - ${COLOR_CYAN}${prompt.name}${RESET_COLOR}\n`;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (serverTools.length === 0 && serverPrompts.length === 0) {
|
||||
message += ' No tools or prompts available\n';
|
||||
} else if (serverTools.length === 0) {
|
||||
message += ' No tools available';
|
||||
if (originalStatus === MCPServerStatus.DISCONNECTED && needsAuthHint) {
|
||||
message += ` ${COLOR_GREY}(type: "/mcp auth ${serverName}" to authenticate this server)${RESET_COLOR}`;
|
||||
}
|
||||
message += '\n';
|
||||
} else if (
|
||||
originalStatus === MCPServerStatus.DISCONNECTED &&
|
||||
needsAuthHint
|
||||
) {
|
||||
// This case is for when serverTools.length > 0
|
||||
message += ` ${COLOR_GREY}(type: "/mcp auth ${serverName}" to authenticate this server)${RESET_COLOR}\n`;
|
||||
}
|
||||
message += '\n';
|
||||
}
|
||||
|
||||
for (const server of blockedMcpServers) {
|
||||
let serverDisplayName = server.name;
|
||||
if (server.extensionName) {
|
||||
serverDisplayName += ` (from ${server.extensionName})`;
|
||||
}
|
||||
message += `🔴 \u001b[1m${serverDisplayName}\u001b[0m - Blocked\n\n`;
|
||||
}
|
||||
|
||||
// Add helpful tips when no arguments are provided
|
||||
if (showTips) {
|
||||
message += '\n';
|
||||
message += `${COLOR_CYAN}💡 Tips:${RESET_COLOR}\n`;
|
||||
message += ` • Use ${COLOR_CYAN}/mcp desc${RESET_COLOR} to show server and tool descriptions\n`;
|
||||
message += ` • Use ${COLOR_CYAN}/mcp schema${RESET_COLOR} to show tool parameter schemas\n`;
|
||||
message += ` • Use ${COLOR_CYAN}/mcp nodesc${RESET_COLOR} to hide descriptions\n`;
|
||||
message += ` • Use ${COLOR_CYAN}/mcp auth <server-name>${RESET_COLOR} to authenticate with OAuth-enabled servers\n`;
|
||||
message += ` • Press ${COLOR_CYAN}Ctrl+T${RESET_COLOR} to toggle tool descriptions on/off\n`;
|
||||
message += '\n';
|
||||
}
|
||||
|
||||
// Make sure to reset any ANSI formatting at the end to prevent it from affecting the terminal
|
||||
message += RESET_COLOR;
|
||||
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: message,
|
||||
};
|
||||
};
|
||||
import { appEvents, AppEvent } from '../../utils/events.js';
|
||||
import { MessageType, type HistoryItemMcpStatus } from '../types.js';
|
||||
|
||||
const authCommand: SlashCommand = {
|
||||
name: 'auth',
|
||||
@@ -367,6 +78,12 @@ const authCommand: SlashCommand = {
|
||||
// Always attempt OAuth authentication, even if not explicitly configured
|
||||
// The authentication process will discover OAuth requirements automatically
|
||||
|
||||
const displayListener = (message: string) => {
|
||||
context.ui.addItem({ type: 'info', text: message }, Date.now());
|
||||
};
|
||||
|
||||
appEvents.on(AppEvent.OauthDisplayMessage, displayListener);
|
||||
|
||||
try {
|
||||
context.ui.addItem(
|
||||
{
|
||||
@@ -384,12 +101,13 @@ const authCommand: SlashCommand = {
|
||||
oauthConfig = { enabled: false };
|
||||
}
|
||||
|
||||
// Pass the MCP server URL for OAuth discovery
|
||||
const mcpServerUrl = server.httpUrl || server.url;
|
||||
await MCPOAuthProvider.authenticate(
|
||||
const authProvider = new MCPOAuthProvider(new MCPOAuthTokenStorage());
|
||||
await authProvider.authenticate(
|
||||
serverName,
|
||||
oauthConfig,
|
||||
mcpServerUrl,
|
||||
appEvents,
|
||||
);
|
||||
|
||||
context.ui.addItem(
|
||||
@@ -432,6 +150,8 @@ const authCommand: SlashCommand = {
|
||||
messageType: 'error',
|
||||
content: `Failed to authenticate with MCP server '${serverName}': ${getErrorMessage(error)}`,
|
||||
};
|
||||
} finally {
|
||||
appEvents.removeListener(AppEvent.OauthDisplayMessage, displayListener);
|
||||
}
|
||||
},
|
||||
completion: async (context: CommandContext, partialArg: string) => {
|
||||
@@ -449,7 +169,28 @@ const listCommand: SlashCommand = {
|
||||
name: 'list',
|
||||
description: 'List configured MCP servers and tools',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: async (context: CommandContext, args: string) => {
|
||||
action: async (
|
||||
context: CommandContext,
|
||||
args: string,
|
||||
): Promise<void | MessageActionReturn> => {
|
||||
const { config } = context.services;
|
||||
if (!config) {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: 'Config not loaded.',
|
||||
};
|
||||
}
|
||||
|
||||
const toolRegistry = config.getToolRegistry();
|
||||
if (!toolRegistry) {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: 'Could not retrieve tool registry.',
|
||||
};
|
||||
}
|
||||
|
||||
const lowerCaseArgs = args.toLowerCase().split(/\s+/).filter(Boolean);
|
||||
|
||||
const hasDesc =
|
||||
@@ -459,14 +200,79 @@ const listCommand: SlashCommand = {
|
||||
lowerCaseArgs.includes('nodescriptions');
|
||||
const showSchema = lowerCaseArgs.includes('schema');
|
||||
|
||||
// Show descriptions if `desc` or `schema` is present,
|
||||
// but `nodesc` takes precedence and disables them.
|
||||
const showDescriptions = !hasNodesc && (hasDesc || showSchema);
|
||||
|
||||
// Show tips only when no arguments are provided
|
||||
const showTips = lowerCaseArgs.length === 0;
|
||||
|
||||
return getMcpStatus(context, showDescriptions, showSchema, showTips);
|
||||
const mcpServers = config.getMcpServers() || {};
|
||||
const serverNames = Object.keys(mcpServers);
|
||||
const blockedMcpServers = config.getBlockedMcpServers() || [];
|
||||
|
||||
const connectingServers = serverNames.filter(
|
||||
(name) => getMCPServerStatus(name) === MCPServerStatus.CONNECTING,
|
||||
);
|
||||
const discoveryState = getMCPDiscoveryState();
|
||||
const discoveryInProgress =
|
||||
discoveryState === MCPDiscoveryState.IN_PROGRESS ||
|
||||
connectingServers.length > 0;
|
||||
|
||||
const allTools = toolRegistry.getAllTools();
|
||||
const mcpTools = allTools.filter(
|
||||
(tool) => tool instanceof DiscoveredMCPTool,
|
||||
) as DiscoveredMCPTool[];
|
||||
|
||||
const promptRegistry = await config.getPromptRegistry();
|
||||
const mcpPrompts = promptRegistry
|
||||
.getAllPrompts()
|
||||
.filter(
|
||||
(prompt) =>
|
||||
'serverName' in prompt &&
|
||||
serverNames.includes(prompt.serverName as string),
|
||||
) as DiscoveredMCPPrompt[];
|
||||
|
||||
const authStatus: HistoryItemMcpStatus['authStatus'] = {};
|
||||
const tokenStorage = new MCPOAuthTokenStorage();
|
||||
for (const serverName of serverNames) {
|
||||
const server = mcpServers[serverName];
|
||||
if (server.oauth?.enabled) {
|
||||
const creds = await tokenStorage.getCredentials(serverName);
|
||||
if (creds) {
|
||||
if (creds.token.expiresAt && creds.token.expiresAt < Date.now()) {
|
||||
authStatus[serverName] = 'expired';
|
||||
} else {
|
||||
authStatus[serverName] = 'authenticated';
|
||||
}
|
||||
} else {
|
||||
authStatus[serverName] = 'unauthenticated';
|
||||
}
|
||||
} else {
|
||||
authStatus[serverName] = 'not-configured';
|
||||
}
|
||||
}
|
||||
|
||||
const mcpStatusItem: HistoryItemMcpStatus = {
|
||||
type: MessageType.MCP_STATUS,
|
||||
servers: mcpServers,
|
||||
tools: mcpTools.map((tool) => ({
|
||||
serverName: tool.serverName,
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
schema: tool.schema,
|
||||
})),
|
||||
prompts: mcpPrompts.map((prompt) => ({
|
||||
serverName: prompt.serverName as string,
|
||||
name: prompt.name,
|
||||
description: prompt.description,
|
||||
})),
|
||||
authStatus,
|
||||
blockedServers: blockedMcpServers,
|
||||
discoveryInProgress,
|
||||
connectingServers,
|
||||
showDescriptions,
|
||||
showSchema,
|
||||
showTips,
|
||||
};
|
||||
|
||||
context.ui.addItem(mcpStatusItem, Date.now());
|
||||
},
|
||||
};
|
||||
|
||||
@@ -476,7 +282,7 @@ const refreshCommand: SlashCommand = {
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: async (
|
||||
context: CommandContext,
|
||||
): Promise<SlashCommandActionReturn> => {
|
||||
): Promise<void | SlashCommandActionReturn> => {
|
||||
const { config } = context.services;
|
||||
if (!config) {
|
||||
return {
|
||||
@@ -514,7 +320,7 @@ const refreshCommand: SlashCommand = {
|
||||
// Reload the slash commands to reflect the changes.
|
||||
context.ui.reloadCommands();
|
||||
|
||||
return getMcpStatus(context, false, false, false);
|
||||
return listCommand.action!(context, '');
|
||||
},
|
||||
};
|
||||
|
||||
@@ -525,7 +331,10 @@ export const mcpCommand: SlashCommand = {
|
||||
kind: CommandKind.BUILT_IN,
|
||||
subCommands: [listCommand, authCommand, refreshCommand],
|
||||
// Default action when no subcommand is provided
|
||||
action: async (context: CommandContext, args: string) =>
|
||||
action: async (
|
||||
context: CommandContext,
|
||||
args: string,
|
||||
): Promise<void | SlashCommandActionReturn> =>
|
||||
// If no subcommand, run the list command
|
||||
listCommand.action!(context, args),
|
||||
};
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
getErrorMessage,
|
||||
loadServerHierarchicalMemory,
|
||||
type FileDiscoveryService,
|
||||
type LoadServerHierarchicalMemoryResponse,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
|
||||
vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
|
||||
@@ -225,6 +226,7 @@ describe('memoryCommand', () => {
|
||||
ignore: [],
|
||||
include: [],
|
||||
}),
|
||||
getFolderTrust: () => false,
|
||||
};
|
||||
|
||||
mockContext = createMockCommandContext({
|
||||
@@ -243,7 +245,7 @@ describe('memoryCommand', () => {
|
||||
it('should display success message when memory is refreshed with content', async () => {
|
||||
if (!refreshCommand.action) throw new Error('Command has no action');
|
||||
|
||||
const refreshResult = {
|
||||
const refreshResult: LoadServerHierarchicalMemoryResponse = {
|
||||
memoryContent: 'new memory content',
|
||||
fileCount: 2,
|
||||
};
|
||||
|
||||
@@ -266,6 +266,7 @@ export const memoryCommand: SlashCommand = {
|
||||
config.getDebugMode(),
|
||||
config.getFileService(),
|
||||
config.getExtensionContextFilePaths(),
|
||||
config.getFolderTrust(),
|
||||
context.services.settings.merged.context?.importFormat ||
|
||||
'tree', // Use setting or default to 'tree'
|
||||
config.getFileFilteringOptions(),
|
||||
|
||||
@@ -17,11 +17,7 @@ import * as availableModelsModule from '../models/availableModels.js';
|
||||
|
||||
// Mock the availableModels module
|
||||
vi.mock('../models/availableModels.js', () => ({
|
||||
AVAILABLE_MODELS_QWEN: [
|
||||
{ id: 'qwen3-coder-plus', label: 'qwen3-coder-plus' },
|
||||
{ id: 'qwen-vl-max-latest', label: 'qwen-vl-max', isVision: true },
|
||||
],
|
||||
getOpenAIAvailableModelFromEnv: vi.fn(),
|
||||
getAvailableModelsForAuthType: vi.fn(),
|
||||
}));
|
||||
|
||||
// Helper function to create a mock config
|
||||
@@ -35,8 +31,8 @@ function createMockConfig(
|
||||
|
||||
describe('modelCommand', () => {
|
||||
let mockContext: CommandContext;
|
||||
const mockGetOpenAIAvailableModelFromEnv = vi.mocked(
|
||||
availableModelsModule.getOpenAIAvailableModelFromEnv,
|
||||
const mockGetAvailableModelsForAuthType = vi.mocked(
|
||||
availableModelsModule.getAvailableModelsForAuthType,
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -91,6 +87,10 @@ describe('modelCommand', () => {
|
||||
});
|
||||
|
||||
it('should return dialog action for QWEN_OAUTH auth type', async () => {
|
||||
mockGetAvailableModelsForAuthType.mockReturnValue([
|
||||
{ id: 'qwen3-coder-plus', label: 'qwen3-coder-plus' },
|
||||
]);
|
||||
|
||||
const mockConfig = createMockConfig({
|
||||
model: 'test-model',
|
||||
authType: AuthType.QWEN_OAUTH,
|
||||
@@ -106,10 +106,9 @@ describe('modelCommand', () => {
|
||||
});
|
||||
|
||||
it('should return dialog action for USE_OPENAI auth type when model is available', async () => {
|
||||
mockGetOpenAIAvailableModelFromEnv.mockReturnValue({
|
||||
id: 'gpt-4',
|
||||
label: 'gpt-4',
|
||||
});
|
||||
mockGetAvailableModelsForAuthType.mockReturnValue([
|
||||
{ id: 'gpt-4', label: 'gpt-4' },
|
||||
]);
|
||||
|
||||
const mockConfig = createMockConfig({
|
||||
model: 'test-model',
|
||||
@@ -126,7 +125,7 @@ describe('modelCommand', () => {
|
||||
});
|
||||
|
||||
it('should return error for USE_OPENAI auth type when no model is available', async () => {
|
||||
mockGetOpenAIAvailableModelFromEnv.mockReturnValue(null);
|
||||
mockGetAvailableModelsForAuthType.mockReturnValue([]);
|
||||
|
||||
const mockConfig = createMockConfig({
|
||||
model: 'test-model',
|
||||
@@ -145,6 +144,8 @@ describe('modelCommand', () => {
|
||||
});
|
||||
|
||||
it('should return error for unsupported auth types', async () => {
|
||||
mockGetAvailableModelsForAuthType.mockReturnValue([]);
|
||||
|
||||
const mockConfig = createMockConfig({
|
||||
model: 'test-model',
|
||||
authType: 'UNSUPPORTED_AUTH_TYPE' as AuthType,
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { AuthType } from '@qwen-code/qwen-code-core';
|
||||
import type {
|
||||
SlashCommand,
|
||||
CommandContext,
|
||||
@@ -12,26 +11,7 @@ import type {
|
||||
MessageActionReturn,
|
||||
} from './types.js';
|
||||
import { CommandKind } from './types.js';
|
||||
import {
|
||||
AVAILABLE_MODELS_QWEN,
|
||||
getOpenAIAvailableModelFromEnv,
|
||||
type AvailableModel,
|
||||
} from '../models/availableModels.js';
|
||||
|
||||
function getAvailableModelsForAuthType(authType: AuthType): AvailableModel[] {
|
||||
switch (authType) {
|
||||
case AuthType.QWEN_OAUTH:
|
||||
return AVAILABLE_MODELS_QWEN;
|
||||
case AuthType.USE_OPENAI: {
|
||||
const openAIModel = getOpenAIAvailableModelFromEnv();
|
||||
return openAIModel ? [openAIModel] : [];
|
||||
}
|
||||
default:
|
||||
// For other auth types, return empty array for now
|
||||
// This can be expanded later according to the design doc
|
||||
return [];
|
||||
}
|
||||
}
|
||||
import { getAvailableModelsForAuthType } from '../models/availableModels.js';
|
||||
|
||||
export const modelCommand: SlashCommand = {
|
||||
name: 'model',
|
||||
|
||||
35
packages/cli/src/ui/commands/permissionsCommand.test.ts
Normal file
35
packages/cli/src/ui/commands/permissionsCommand.test.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { permissionsCommand } from './permissionsCommand.js';
|
||||
import { type CommandContext, CommandKind } from './types.js';
|
||||
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
|
||||
|
||||
describe('permissionsCommand', () => {
|
||||
let mockContext: CommandContext;
|
||||
|
||||
beforeEach(() => {
|
||||
mockContext = createMockCommandContext();
|
||||
});
|
||||
|
||||
it('should have the correct name and description', () => {
|
||||
expect(permissionsCommand.name).toBe('permissions');
|
||||
expect(permissionsCommand.description).toBe('Manage folder trust settings');
|
||||
});
|
||||
|
||||
it('should be a built-in command', () => {
|
||||
expect(permissionsCommand.kind).toBe(CommandKind.BUILT_IN);
|
||||
});
|
||||
|
||||
it('should return an action to open the permissions dialog', () => {
|
||||
const actionResult = permissionsCommand.action?.(mockContext, '');
|
||||
expect(actionResult).toEqual({
|
||||
type: 'dialog',
|
||||
dialog: 'permissions',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -7,12 +7,12 @@
|
||||
import type { OpenDialogActionReturn, SlashCommand } from './types.js';
|
||||
import { CommandKind } from './types.js';
|
||||
|
||||
export const privacyCommand: SlashCommand = {
|
||||
name: 'privacy',
|
||||
description: 'display the privacy notice',
|
||||
export const permissionsCommand: SlashCommand = {
|
||||
name: 'permissions',
|
||||
description: 'Manage folder trust settings',
|
||||
kind: CommandKind.BUILT_IN,
|
||||
action: (): OpenDialogActionReturn => ({
|
||||
type: 'dialog',
|
||||
dialog: 'privacy',
|
||||
dialog: 'permissions',
|
||||
}),
|
||||
};
|
||||
@@ -1,38 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { privacyCommand } from './privacyCommand.js';
|
||||
import { type CommandContext } from './types.js';
|
||||
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
|
||||
|
||||
describe('privacyCommand', () => {
|
||||
let mockContext: CommandContext;
|
||||
|
||||
beforeEach(() => {
|
||||
mockContext = createMockCommandContext();
|
||||
});
|
||||
|
||||
it('should return a dialog action to open the privacy dialog', () => {
|
||||
// Ensure the command has an action to test.
|
||||
if (!privacyCommand.action) {
|
||||
throw new Error('The privacy command must have an action.');
|
||||
}
|
||||
|
||||
const result = privacyCommand.action(mockContext, '');
|
||||
|
||||
// Assert that the action returns the correct object to trigger the privacy dialog.
|
||||
expect(result).toEqual({
|
||||
type: 'dialog',
|
||||
dialog: 'privacy',
|
||||
});
|
||||
});
|
||||
|
||||
it('should have the correct name and description', () => {
|
||||
expect(privacyCommand.name).toBe('privacy');
|
||||
expect(privacyCommand.description).toBe('display the privacy notice');
|
||||
});
|
||||
});
|
||||
@@ -100,6 +100,7 @@ export const summaryCommand: SlashCommand = {
|
||||
],
|
||||
{},
|
||||
new AbortController().signal,
|
||||
config.getModel(),
|
||||
);
|
||||
|
||||
// Extract text from response
|
||||
|
||||
@@ -61,9 +61,11 @@ describe('toolsCommand', () => {
|
||||
await toolsCommand.action(mockContext, '');
|
||||
|
||||
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
text: expect.stringContaining('No tools available'),
|
||||
}),
|
||||
{
|
||||
type: MessageType.TOOLS_LIST,
|
||||
tools: [],
|
||||
showDescriptions: false,
|
||||
},
|
||||
expect.any(Number),
|
||||
);
|
||||
});
|
||||
@@ -80,10 +82,12 @@ describe('toolsCommand', () => {
|
||||
if (!toolsCommand.action) throw new Error('Action not defined');
|
||||
await toolsCommand.action(mockContext, '');
|
||||
|
||||
const message = (mockContext.ui.addItem as vi.Mock).mock.calls[0][0].text;
|
||||
expect(message).not.toContain('Reads files from the local system.');
|
||||
expect(message).toContain('File Reader');
|
||||
expect(message).toContain('Code Editor');
|
||||
const [message] = (mockContext.ui.addItem as vi.Mock).mock.calls[0];
|
||||
expect(message.type).toBe(MessageType.TOOLS_LIST);
|
||||
expect(message.showDescriptions).toBe(false);
|
||||
expect(message.tools).toHaveLength(2);
|
||||
expect(message.tools[0].displayName).toBe('File Reader');
|
||||
expect(message.tools[1].displayName).toBe('Code Editor');
|
||||
});
|
||||
|
||||
it('should list tools with descriptions when "desc" arg is passed', async () => {
|
||||
@@ -98,8 +102,13 @@ describe('toolsCommand', () => {
|
||||
if (!toolsCommand.action) throw new Error('Action not defined');
|
||||
await toolsCommand.action(mockContext, 'desc');
|
||||
|
||||
const message = (mockContext.ui.addItem as vi.Mock).mock.calls[0][0].text;
|
||||
expect(message).toContain('Reads files from the local system.');
|
||||
expect(message).toContain('Edits code files.');
|
||||
const [message] = (mockContext.ui.addItem as vi.Mock).mock.calls[0];
|
||||
expect(message.type).toBe(MessageType.TOOLS_LIST);
|
||||
expect(message.showDescriptions).toBe(true);
|
||||
expect(message.tools).toHaveLength(2);
|
||||
expect(message.tools[0].description).toBe(
|
||||
'Reads files from the local system.',
|
||||
);
|
||||
expect(message.tools[1].description).toBe('Edits code files.');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
type SlashCommand,
|
||||
CommandKind,
|
||||
} from './types.js';
|
||||
import { MessageType } from '../types.js';
|
||||
import { MessageType, type HistoryItemToolsList } from '../types.js';
|
||||
|
||||
export const toolsCommand: SlashCommand = {
|
||||
name: 'tools',
|
||||
@@ -40,32 +40,16 @@ export const toolsCommand: SlashCommand = {
|
||||
// Filter out MCP tools by checking for the absence of a serverName property
|
||||
const geminiTools = tools.filter((tool) => !('serverName' in tool));
|
||||
|
||||
let message = 'Available Qwen Code tools:\n\n';
|
||||
const toolsListItem: HistoryItemToolsList = {
|
||||
type: MessageType.TOOLS_LIST,
|
||||
tools: geminiTools.map((tool) => ({
|
||||
name: tool.name,
|
||||
displayName: tool.displayName,
|
||||
description: tool.description,
|
||||
})),
|
||||
showDescriptions: useShowDescriptions,
|
||||
};
|
||||
|
||||
if (geminiTools.length > 0) {
|
||||
geminiTools.forEach((tool) => {
|
||||
if (useShowDescriptions && tool.description) {
|
||||
message += ` - \u001b[36m${tool.displayName} (${tool.name})\u001b[0m:\n`;
|
||||
|
||||
const greenColor = '\u001b[32m';
|
||||
const resetColor = '\u001b[0m';
|
||||
|
||||
// Handle multi-line descriptions
|
||||
const descLines = tool.description.trim().split('\n');
|
||||
for (const descLine of descLines) {
|
||||
message += ` ${greenColor}${descLine}${resetColor}\n`;
|
||||
}
|
||||
} else {
|
||||
message += ` - \u001b[36m${tool.displayName}\u001b[0m\n`;
|
||||
}
|
||||
});
|
||||
} else {
|
||||
message += ' No tools available\n';
|
||||
}
|
||||
message += '\n';
|
||||
|
||||
message += '\u001b[0m';
|
||||
|
||||
context.ui.addItem({ type: MessageType.INFO, text: message }, Date.now());
|
||||
context.ui.addItem(toolsListItem, Date.now());
|
||||
},
|
||||
};
|
||||
|
||||
@@ -4,13 +4,21 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { type ReactNode } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import type { Content, PartListUnion } from '@google/genai';
|
||||
import type { HistoryItemWithoutId, HistoryItem } from '../types.js';
|
||||
import type { Config, GitService, Logger } from '@qwen-code/qwen-code-core';
|
||||
import type {
|
||||
HistoryItemWithoutId,
|
||||
HistoryItem,
|
||||
ConfirmationRequest,
|
||||
} from '../types.js';
|
||||
import type { LoadedSettings } from '../../config/settings.js';
|
||||
import type { UseHistoryManagerReturn } from '../hooks/useHistoryManager.js';
|
||||
import type { SessionStatsState } from '../contexts/SessionContext.js';
|
||||
import type {
|
||||
ExtensionUpdateAction,
|
||||
ExtensionUpdateStatus,
|
||||
} from '../state/extensions.js';
|
||||
|
||||
// Grouped dependencies for clarity and easier mocking
|
||||
export interface CommandContext {
|
||||
@@ -61,6 +69,9 @@ export interface CommandContext {
|
||||
toggleVimEnabled: () => Promise<boolean>;
|
||||
setGeminiMdFileCount: (count: number) => void;
|
||||
reloadCommands: () => void;
|
||||
extensionsUpdateState: Map<string, ExtensionUpdateStatus>;
|
||||
dispatchExtensionStateUpdate: (action: ExtensionUpdateAction) => void;
|
||||
addConfirmUpdateExtensionRequest: (value: ConfirmationRequest) => void;
|
||||
};
|
||||
// Session-specific data
|
||||
session: {
|
||||
@@ -114,11 +125,11 @@ export interface OpenDialogActionReturn {
|
||||
| 'auth'
|
||||
| 'theme'
|
||||
| 'editor'
|
||||
| 'privacy'
|
||||
| 'settings'
|
||||
| 'model'
|
||||
| 'subagent_create'
|
||||
| 'subagent_list';
|
||||
| 'subagent_list'
|
||||
| 'permissions';
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -186,6 +197,7 @@ export interface SlashCommand {
|
||||
name: string;
|
||||
altNames?: string[];
|
||||
description: string;
|
||||
hidden?: boolean;
|
||||
|
||||
kind: CommandKind;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user