Merge branch 'main' into feature/extension-management-tui

This commit is contained in:
LaZzyMan 2026-03-06 17:19:40 +08:00
commit 43faa51378
39 changed files with 3255 additions and 315 deletions

View file

@ -1322,7 +1322,7 @@ describe('loadCliConfig with allowed-mcp-server-names', () => {
});
});
it('should read excludeMCPServers from settings', async () => {
it('should read excludeMCPServers from settings but still return all servers', async () => {
process.argv = ['node', 'script.js'];
const argv = await parseArguments();
const settings: Settings = {
@ -1330,12 +1330,18 @@ describe('loadCliConfig with allowed-mcp-server-names', () => {
mcp: { excluded: ['server1', 'server2'] },
};
const config = await loadCliConfig(settings, argv, undefined, []);
// getMcpServers() now returns all servers, use isMcpServerDisabled() to check status
expect(config.getMcpServers()).toEqual({
server1: { url: 'http://localhost:8080' },
server2: { url: 'http://localhost:8081' },
server3: { url: 'http://localhost:8082' },
});
expect(config.isMcpServerDisabled('server1')).toBe(true);
expect(config.isMcpServerDisabled('server2')).toBe(true);
expect(config.isMcpServerDisabled('server3')).toBe(false);
});
it('should override allowMCPServers with excludeMCPServers if overlapping', async () => {
it('should apply allowedMcpServers filter but excluded servers are still returned', async () => {
process.argv = ['node', 'script.js'];
const argv = await parseArguments();
const settings: Settings = {
@ -1346,9 +1352,14 @@ describe('loadCliConfig with allowed-mcp-server-names', () => {
},
};
const config = await loadCliConfig(settings, argv, undefined, []);
// allowedMcpServers filters which servers are available
// but excluded servers are still returned by getMcpServers()
expect(config.getMcpServers()).toEqual({
server1: { url: 'http://localhost:8080' },
server2: { url: 'http://localhost:8081' },
});
expect(config.isMcpServerDisabled('server1')).toBe(true);
expect(config.isMcpServerDisabled('server2')).toBe(false);
});
it('should prioritize mcp server flag if set', async () => {

View file

@ -97,7 +97,7 @@ export default {
// ============================================================================
'Analyzes the project and creates a tailored QWEN.md file.':
'Analysiert das Projekt und erstellt eine maßgeschneiderte QWEN.md-Datei.',
'list available Qwen Code tools. Usage: /tools [desc]':
'List available Qwen Code tools. Usage: /tools [desc]':
'Verfügbare Qwen Code Werkzeuge auflisten. Verwendung: /tools [desc]',
'Available Qwen Code CLI tools:': 'Verfügbare Qwen Code CLI-Werkzeuge:',
'No tools available': 'Keine Werkzeuge verfügbar',
@ -360,7 +360,9 @@ export default {
'Show tool-specific usage statistics.':
'Werkzeugspezifische Nutzungsstatistiken anzeigen.',
'exit the cli': 'CLI beenden',
'list configured MCP servers and tools, or authenticate with OAuth-enabled servers':
'Open MCP management dialog, or authenticate with OAuth-enabled servers':
'MCP-Verwaltungsdialog öffnen oder mit OAuth-fähigem Server authentifizieren',
'List configured MCP servers and tools, or authenticate with OAuth-enabled servers':
'Konfigurierte MCP-Server und Werkzeuge auflisten oder mit OAuth-fähigen Servern authentifizieren',
'Manage workspace directories': 'Arbeitsbereichsverzeichnisse verwalten',
'Add directories to the workspace. Use comma to separate multiple paths':
@ -882,9 +884,101 @@ export default {
'Do you want to proceed?': 'Möchten Sie fortfahren?',
'Yes, allow once': 'Ja, einmal erlauben',
'Allow always': 'Immer erlauben',
Yes: 'Ja',
No: 'Nein',
'No (esc)': 'Nein (Esc)',
'Yes, allow always for this session': 'Ja, für diese Sitzung immer erlauben',
// MCP Management Dialog (translations for MCP UI components)
'Manage MCP servers': 'MCP-Server verwalten',
'Server Detail': 'Serverdetails',
'Disable Server': 'Server deaktivieren',
Tools: 'Werkzeuge',
'Tool Detail': 'Werkzeugdetails',
'MCP Management': 'MCP-Verwaltung',
'Loading...': 'Lädt...',
'Unknown step': 'Unbekannter Schritt',
'Esc to back': 'Esc zurück',
'↑↓ to navigate · Enter to select · Esc to close':
'↑↓ navigieren · Enter auswählen · Esc schließen',
'↑↓ to navigate · Enter to select · Esc to back':
'↑↓ navigieren · Enter auswählen · Esc zurück',
'↑↓ to navigate · Enter to confirm · Esc to back':
'↑↓ navigieren · Enter bestätigen · Esc zurück',
'User Settings (global)': 'Benutzereinstellungen (global)',
'Workspace Settings (project-specific)':
'Arbeitsbereichseinstellungen (projektspezifisch)',
'Disable server:': 'Server deaktivieren:',
'Select where to add the server to the exclude list:':
'Wählen Sie, wo der Server zur Ausschlussliste hinzugefügt werden soll:',
'Press Enter to confirm, Esc to cancel':
'Enter zum Bestätigen, Esc zum Abbrechen',
Disable: 'Deaktivieren',
Enable: 'Aktivieren',
Reconnect: 'Neu verbinden',
'View tools': 'Werkzeuge anzeigen',
'Status:': 'Status:',
'Command:': 'Befehl:',
'Working Directory:': 'Arbeitsverzeichnis:',
'Capabilities:': 'Fähigkeiten:',
'No server selected': 'Kein Server ausgewählt',
'(disabled)': '(deaktiviert)',
'Error:': 'Fehler:',
Extension: 'Erweiterung',
tool: 'Werkzeug',
tools: 'Werkzeuge',
connected: 'verbunden',
connecting: 'verbindet',
disconnected: 'getrennt',
error: 'Fehler',
// MCP Server List
'User MCPs': 'Benutzer-MCPs',
'Project MCPs': 'Projekt-MCPs',
'Extension MCPs': 'Erweiterungs-MCPs',
server: 'Server',
servers: 'Server',
'Add MCP servers to your settings to get started.':
'Fügen Sie MCP-Server zu Ihren Einstellungen hinzu, um zu beginnen.',
'Run qwen --debug to see error logs':
'Führen Sie qwen --debug aus, um Fehlerprotokolle anzuzeigen',
// MCP Tool List
'No tools available for this server.':
'Keine Werkzeuge für diesen Server verfügbar.',
destructive: 'destruktiv',
'read-only': 'schreibgeschützt',
'open-world': 'offene Welt',
idempotent: 'idempotent',
'Tools for {{name}}': 'Werkzeuge für {{name}}',
'{{current}}/{{total}}': '{{current}}/{{total}}',
// MCP Tool Detail
required: 'erforderlich',
Type: 'Typ',
Enum: 'Aufzählung',
Parameters: 'Parameter',
'No tool selected': 'Kein Werkzeug ausgewählt',
Annotations: 'Anmerkungen',
Title: 'Titel',
'Read Only': 'Schreibgeschützt',
Destructive: 'Destruktiv',
Idempotent: 'Idempotent',
'Open World': 'Offene Welt',
Server: 'Server',
// Invalid tool related translations
'{{count}} invalid tools': '{{count}} ungültige Werkzeuge',
invalid: 'ungültig',
'invalid: {{reason}}': 'ungültig: {{reason}}',
'missing name': 'Name fehlt',
'missing description': 'Beschreibung fehlt',
'(unnamed)': '(unbenannt)',
'Warning: This tool cannot be called by the LLM':
'Warnung: Dieses Werkzeug kann nicht vom LLM aufgerufen werden',
Reason: 'Grund',
'Tools must have both name and description to be used by the LLM.':
'Werkzeuge müssen sowohl einen Namen als auch eine Beschreibung haben, um vom LLM verwendet zu werden.',
'Modify in progress:': 'Änderung in Bearbeitung:',
'Save and close external editor to continue':
'Speichern und externen Editor schließen, um fortzufahren',

View file

@ -116,8 +116,8 @@ export default {
// ============================================================================
'Analyzes the project and creates a tailored QWEN.md file.':
'Analyzes the project and creates a tailored QWEN.md file.',
'list available Qwen Code tools. Usage: /tools [desc]':
'list available Qwen Code tools. Usage: /tools [desc]',
'List available Qwen Code tools. Usage: /tools [desc]':
'List available Qwen Code tools. Usage: /tools [desc]',
'Available Qwen Code CLI tools:': 'Available Qwen Code CLI tools:',
'No tools available': 'No tools available',
'View or change the approval mode for tool usage':
@ -443,8 +443,10 @@ export default {
'Show tool-specific usage statistics.':
'Show tool-specific usage statistics.',
'exit the cli': 'exit the cli',
'list configured MCP servers and tools, or authenticate with OAuth-enabled servers':
'list configured MCP servers and tools, or authenticate with OAuth-enabled servers',
'Open MCP management dialog, or authenticate with OAuth-enabled servers':
'Open MCP management dialog, or authenticate with OAuth-enabled servers',
'List configured MCP servers and tools, or authenticate with OAuth-enabled servers':
'List configured MCP servers and tools, or authenticate with OAuth-enabled servers',
'Manage workspace directories': 'Manage workspace directories',
'Add directories to the workspace. Use comma to separate multiple paths':
'Add directories to the workspace. Use comma to separate multiple paths',
@ -793,6 +795,7 @@ export default {
'List configured MCP servers and tools':
'List configured MCP servers and tools',
'Restarts MCP servers.': 'Restarts MCP servers.',
'Open MCP management dialog': 'Open MCP management dialog',
'Config not loaded.': 'Config not loaded.',
'Could not retrieve tool registry.': 'Could not retrieve tool registry.',
'No MCP servers configured with OAuth authentication.':
@ -809,6 +812,96 @@ export default {
"Re-discovering tools from '{{name}}'...":
"Re-discovering tools from '{{name}}'...",
// ============================================================================
// MCP Management Dialog
// ============================================================================
'Manage MCP servers': 'Manage MCP servers',
'Server Detail': 'Server Detail',
'Disable Server': 'Disable Server',
Tools: 'Tools',
'Tool Detail': 'Tool Detail',
'MCP Management': 'MCP Management',
'Loading...': 'Loading...',
'Unknown step': 'Unknown step',
'Esc to back': 'Esc to back',
'↑↓ to navigate · Enter to select · Esc to close':
'↑↓ to navigate · Enter to select · Esc to close',
'↑↓ to navigate · Enter to select · Esc to back':
'↑↓ to navigate · Enter to select · Esc to back',
'↑↓ to navigate · Enter to confirm · Esc to back':
'↑↓ to navigate · Enter to confirm · Esc to back',
'User Settings (global)': 'User Settings (global)',
'Workspace Settings (project-specific)':
'Workspace Settings (project-specific)',
'Disable server:': 'Disable server:',
'Select where to add the server to the exclude list:':
'Select where to add the server to the exclude list:',
'Press Enter to confirm, Esc to cancel':
'Press Enter to confirm, Esc to cancel',
'View tools': 'View tools',
Reconnect: 'Reconnect',
Enable: 'Enable',
Disable: 'Disable',
'Command:': 'Command:',
'Working Directory:': 'Working Directory:',
'Capabilities:': 'Capabilities:',
'No server selected': 'No server selected',
prompts: 'prompts',
'(disabled)': '(disabled)',
'Error:': 'Error:',
Extension: 'Extension',
tool: 'tool',
tools: 'tools',
connected: 'connected',
connecting: 'connecting',
disconnected: 'disconnected',
// MCP Server List
'User MCPs': 'User MCPs',
'Project MCPs': 'Project MCPs',
'Extension MCPs': 'Extension MCPs',
server: 'server',
servers: 'servers',
'Add MCP servers to your settings to get started.':
'Add MCP servers to your settings to get started.',
'Run qwen --debug to see error logs': 'Run qwen --debug to see error logs',
// MCP Tool List
'No tools available for this server.': 'No tools available for this server.',
destructive: 'destructive',
'read-only': 'read-only',
'open-world': 'open-world',
idempotent: 'idempotent',
'Tools for {{name}}': 'Tools for {{name}}',
'{{current}}/{{total}}': '{{current}}/{{total}}',
// MCP Tool Detail
required: 'required',
Type: 'Type',
Enum: 'Enum',
Parameters: 'Parameters',
'No tool selected': 'No tool selected',
Annotations: 'Annotations',
Title: 'Title',
'Read Only': 'Read Only',
Destructive: 'Destructive',
Idempotent: 'Idempotent',
'Open World': 'Open World',
Server: 'Server',
// Invalid tool related translations
'{{count}} invalid tools': '{{count}} invalid tools',
invalid: 'invalid',
'invalid: {{reason}}': 'invalid: {{reason}}',
'missing name': 'missing name',
'missing description': 'missing description',
'(unnamed)': '(unnamed)',
'Warning: This tool cannot be called by the LLM':
'Warning: This tool cannot be called by the LLM',
Reason: 'Reason',
'Tools must have both name and description to be used by the LLM.':
'Tools must have both name and description to be used by the LLM.',
// ============================================================================
// Commands - Chat
// ============================================================================
@ -941,6 +1034,7 @@ export default {
'Do you want to proceed?': 'Do you want to proceed?',
'Yes, allow once': 'Yes, allow once',
'Allow always': 'Allow always',
Yes: 'Yes',
No: 'No',
'No (esc)': 'No (esc)',
'Yes, allow always for this session': 'Yes, allow always for this session',

View file

@ -83,7 +83,7 @@ export default {
// ============================================================================
'Analyzes the project and creates a tailored QWEN.md file.':
'プロジェクトを分析し、カスタマイズされた QWEN.md ファイルを作成',
'list available Qwen Code tools. Usage: /tools [desc]':
'List available Qwen Code tools. Usage: /tools [desc]':
'利用可能な Qwen Code ツールを一覧表示。使い方: /tools [desc]',
'Available Qwen Code CLI tools:': '利用可能な Qwen Code CLI ツール:',
'No tools available': '利用可能なツールはありません',
@ -317,7 +317,9 @@ export default {
'セッション統計を確認。使い方: /stats [model|tools]',
'Show model-specific usage statistics.': 'モデル別の使用統計を表示',
'Show tool-specific usage statistics.': 'ツール別の使用統計を表示',
'list configured MCP servers and tools, or authenticate with OAuth-enabled servers':
'Open MCP management dialog, or authenticate with OAuth-enabled servers':
'MCP管理ダイアログを開く、またはOAuth対応サーバーで認証',
'List configured MCP servers and tools, or authenticate with OAuth-enabled servers':
'設定済みのMCPサーバーとツールを一覧表示、またはOAuth対応サーバーで認証',
'Manage workspace directories': 'ワークスペースディレクトリを管理',
'Add directories to the workspace. Use comma to separate multiple paths':
@ -622,9 +624,101 @@ export default {
'Do you want to proceed?': '続行しますか?',
'Yes, allow once': 'はい(今回のみ許可)',
'Allow always': '常に許可する',
Yes: 'はい',
No: 'いいえ',
'No (esc)': 'いいえ (Esc)',
'Yes, allow always for this session': 'はい、このセッションで常に許可',
// MCP Management - Core translations
'Manage MCP servers': 'MCPサーバーを管理',
'Server Detail': 'サーバー詳細',
'Disable Server': 'サーバーを無効化',
Tools: 'ツール',
'Tool Detail': 'ツール詳細',
'MCP Management': 'MCP管理',
'Loading...': '読み込み中...',
'Unknown step': '不明なステップ',
'Esc to back': 'Esc 戻る',
'↑↓ to navigate · Enter to select · Esc to close':
'↑↓ ナビゲート · Enter 選択 · Esc 閉じる',
'↑↓ to navigate · Enter to select · Esc to back':
'↑↓ ナビゲート · Enter 選択 · Esc 戻る',
'↑↓ to navigate · Enter to confirm · Esc to back':
'↑↓ ナビゲート · Enter 確認 · Esc 戻る',
'User Settings (global)': 'ユーザー設定(グローバル)',
'Workspace Settings (project-specific)':
'ワークスペース設定(プロジェクト固有)',
'Disable server:': 'サーバーを無効化:',
'Select where to add the server to the exclude list:':
'サーバーを除外リストに追加する場所を選択してください:',
'Press Enter to confirm, Esc to cancel': 'Enter で確認、Esc でキャンセル',
Disable: '無効化',
Enable: '有効化',
Reconnect: '再接続',
'View tools': 'ツールを表示',
'Status:': 'ステータス:',
'Source:': 'ソース:',
'Command:': 'コマンド:',
'Working Directory:': '作業ディレクトリ:',
'Capabilities:': '機能:',
'No server selected': 'サーバーが選択されていません',
'(disabled)': '(無効)',
'Error:': 'エラー:',
Extension: '拡張機能',
tool: 'ツール',
tools: 'ツール',
connected: '接続済み',
connecting: '接続中',
disconnected: '切断済み',
error: 'エラー',
// MCP Server List
'User MCPs': 'ユーザーMCP',
'Project MCPs': 'プロジェクトMCP',
'Extension MCPs': '拡張機能MCP',
server: 'サーバー',
servers: 'サーバー',
'Add MCP servers to your settings to get started.':
'設定にMCPサーバーを追加して開始してください。',
'Run qwen --debug to see error logs':
'qwen --debug を実行してエラーログを確認してください',
// MCP Tool List
'No tools available for this server.':
'このサーバーには使用可能なツールがありません。',
destructive: '破壊的',
'read-only': '読み取り専用',
'open-world': 'オープンワールド',
idempotent: '冪等',
'Tools for {{name}}': '{{name}} のツール',
'{{current}}/{{total}}': '{{current}}/{{total}}',
// MCP Tool Detail
required: '必須',
Type: '型',
Enum: '列挙',
Parameters: 'パラメータ',
'No tool selected': 'ツールが選択されていません',
Annotations: '注釈',
Title: 'タイトル',
'Read Only': '読み取り専用',
Destructive: '破壊的',
Idempotent: '冪等',
'Open World': 'オープンワールド',
Server: 'サーバー',
// Invalid tool related translations
'{{count}} invalid tools': '{{count}} 個の無効なツール',
invalid: '無効',
'invalid: {{reason}}': '無効: {{reason}}',
'missing name': '名前なし',
'missing description': '説明なし',
'(unnamed)': '(名前なし)',
'Warning: This tool cannot be called by the LLM':
'警告: このツールはLLMによって呼び出すことができません',
Reason: '理由',
'Tools must have both name and description to be used by the LLM.':
'ツールはLLMによって使用されるには名前と説明の両方が必要です。',
'Modify in progress:': '変更中:',
'Save and close external editor to continue':
'続行するには外部エディタを保存して閉じてください',

View file

@ -109,8 +109,8 @@ export default {
// ============================================================================
'Analyzes the project and creates a tailored QWEN.md file.':
'Analisa o projeto e cria um arquivo QWEN.md personalizado.',
'list available Qwen Code tools. Usage: /tools [desc]':
'listar ferramentas Qwen Code disponíveis. Uso: /tools [desc]',
'List available Qwen Code tools. Usage: /tools [desc]':
'Listar ferramentas Qwen Code disponíveis. Uso: /tools [desc]',
'Available Qwen Code CLI tools:': 'Ferramentas CLI do Qwen Code disponíveis:',
'No tools available': 'Nenhuma ferramenta disponível',
'View or change the approval mode for tool usage':
@ -385,8 +385,10 @@ export default {
'Show tool-specific usage statistics.':
'Mostrar estatísticas de uso específicas da ferramenta.',
'exit the cli': 'sair da cli',
'list configured MCP servers and tools, or authenticate with OAuth-enabled servers':
'listar servidores e ferramentas MCP configurados, ou autenticar com servidores habilitados para OAuth',
'Open MCP management dialog, or authenticate with OAuth-enabled servers':
'Abrir diálogo de gerenciamento MCP ou autenticar com servidor habilitado para OAuth',
'List configured MCP servers and tools, or authenticate with OAuth-enabled servers':
'Listar servidores e ferramentas MCP configurados, ou autenticar com servidores habilitados para OAuth',
'Manage workspace directories': 'Gerenciar diretórios do workspace',
'Add directories to the workspace. Use comma to separate multiple paths':
'Adicionar diretórios ao workspace. Use vírgula para separar vários caminhos',
@ -888,9 +890,102 @@ export default {
'Do you want to proceed?': 'Você deseja prosseguir?',
'Yes, allow once': 'Sim, permitir uma vez',
'Allow always': 'Permitir sempre',
Yes: 'Sim',
No: 'Não',
'No (esc)': 'Não (esc)',
'Yes, allow always for this session': 'Sim, permitir sempre para esta sessão',
// MCP Management - Core translations
'Manage MCP servers': 'Gerenciar servidores MCP',
'Server Detail': 'Detalhes do servidor',
'Disable Server': 'Desativar servidor',
Tools: 'Ferramentas',
'Tool Detail': 'Detalhes da ferramenta',
'MCP Management': 'Gerenciamento MCP',
'Loading...': 'Carregando...',
'Unknown step': 'Etapa desconhecida',
'Esc to back': 'Esc para voltar',
'↑↓ to navigate · Enter to select · Esc to close':
'↑↓ navegar · Enter selecionar · Esc fechar',
'↑↓ to navigate · Enter to select · Esc to back':
'↑↓ navegar · Enter selecionar · Esc voltar',
'↑↓ to navigate · Enter to confirm · Esc to back':
'↑↓ navegar · Enter confirmar · Esc voltar',
'User Settings (global)': 'Configurações do usuário (global)',
'Workspace Settings (project-specific)':
'Configurações do workspace (específico do projeto)',
'Disable server:': 'Desativar servidor:',
'Select where to add the server to the exclude list:':
'Selecione onde adicionar o servidor à lista de exclusão:',
'Press Enter to confirm, Esc to cancel':
'Enter para confirmar, Esc para cancelar',
Disable: 'Desativar',
Enable: 'Ativar',
Reconnect: 'Reconectar',
'View tools': 'Ver ferramentas',
'Status:': 'Status:',
'Source:': 'Fonte:',
'Command:': 'Comando:',
'Working Directory:': 'Diretório de trabalho:',
'Capabilities:': 'Capacidades:',
'No server selected': 'Nenhum servidor selecionado',
'(disabled)': '(desativado)',
'Error:': 'Erro:',
Extension: 'Extensão',
tool: 'ferramenta',
tools: 'ferramentas',
connected: 'conectado',
connecting: 'conectando',
disconnected: 'desconectado',
error: 'erro',
// MCP Server List
'User MCPs': 'MCPs do usuário',
'Project MCPs': 'MCPs do projeto',
'Extension MCPs': 'MCPs de extensão',
server: 'servidor',
servers: 'servidores',
'Add MCP servers to your settings to get started.':
'Adicione servidores MCP às suas configurações para começar.',
'Run qwen --debug to see error logs':
'Execute qwen --debug para ver os logs de erro',
// MCP Tool List
'No tools available for this server.':
'Nenhuma ferramenta disponível para este servidor.',
destructive: 'destrutivo',
'read-only': 'somente leitura',
'open-world': 'mundo aberto',
idempotent: 'idempotente',
'Tools for {{name}}': 'Ferramentas para {{name}}',
'{{current}}/{{total}}': '{{current}}/{{total}}',
// MCP Tool Detail
required: 'obrigatório',
Type: 'Tipo',
Enum: 'Enumeração',
Parameters: 'Parâmetros',
'No tool selected': 'Nenhuma ferramenta selecionada',
Annotations: 'Anotações',
Title: 'Título',
'Read Only': 'Somente leitura',
Destructive: 'Destrutivo',
Idempotent: 'Idempotente',
'Open World': 'Mundo aberto',
Server: 'Servidor',
// Invalid tool related translations
'{{count}} invalid tools': '{{count}} ferramentas inválidas',
invalid: 'inválido',
'invalid: {{reason}}': 'inválido: {{reason}}',
'missing name': 'nome ausente',
'missing description': 'descrição ausente',
'(unnamed)': '(sem nome)',
'Warning: This tool cannot be called by the LLM':
'Aviso: Esta ferramenta não pode ser chamada pelo LLM',
Reason: 'Motivo',
'Tools must have both name and description to be used by the LLM.':
'As ferramentas devem ter tanto nome quanto descrição para serem usadas pelo LLM.',
'Modify in progress:': 'Modificação em progresso:',
'Save and close external editor to continue':
'Salve e feche o editor externo para continuar',

View file

@ -117,7 +117,7 @@ export default {
// ============================================================================
'Analyzes the project and creates a tailored QWEN.md file.':
'Анализ проекта и создание адаптированного файла QWEN.md',
'list available Qwen Code tools. Usage: /tools [desc]':
'List available Qwen Code tools. Usage: /tools [desc]':
'Просмотр доступных инструментов Qwen Code. Использование: /tools [desc]',
'Available Qwen Code CLI tools:': 'Доступные инструменты Qwen Code CLI:',
'No tools available': 'Нет доступных инструментов',
@ -380,7 +380,9 @@ export default {
'Show tool-specific usage statistics.':
'Показать статистику использования инструментов.',
'exit the cli': 'Выход из CLI',
'list configured MCP servers and tools, or authenticate with OAuth-enabled servers':
'Open MCP management dialog, or authenticate with OAuth-enabled servers':
'Открыть диалог управления MCP или авторизоваться на сервере с поддержкой OAuth',
'List configured MCP servers and tools, or authenticate with OAuth-enabled servers':
'Показать настроенные MCP-серверы и инструменты, или авторизоваться на серверах с поддержкой OAuth',
'Manage workspace directories':
'Управление директориями рабочего пространства',
@ -889,9 +891,36 @@ export default {
'Do you want to proceed?': 'Вы хотите продолжить?',
'Yes, allow once': 'Да, разрешить один раз',
'Allow always': 'Всегда разрешать',
Yes: 'Да',
No: 'Нет',
'No (esc)': 'Нет (esc)',
'Yes, allow always for this session': 'Да, всегда разрешать для этой сессии',
// MCP Management - Core translations
Disable: 'Отключить',
Enable: 'Включить',
Reconnect: 'Переподключить',
'View tools': 'Просмотреть инструменты',
'(disabled)': '(отключен)',
'Error:': 'Ошибка:',
Extension: 'Расширение',
tool: 'инструмент',
connected: 'подключен',
connecting: 'подключение',
disconnected: 'отключен',
error: 'ошибка',
// Invalid tool related translations
'{{count}} invalid tools': '{{count}} недействительных инструментов',
invalid: 'недействительный',
'invalid: {{reason}}': 'недействительно: {{reason}}',
'missing name': 'отсутствует имя',
'missing description': 'отсутствует описание',
'(unnamed)': '(без имени)',
'Warning: This tool cannot be called by the LLM':
'Предупреждение: Этот инструмент не может быть вызван LLM',
Reason: 'Причина',
'Tools must have both name and description to be used by the LLM.':
'Инструменты должны иметь как имя, так и описание, чтобы использоваться LLM.',
'Modify in progress:': 'Идет изменение:',
'Save and close external editor to continue':
'Сохраните и закройте внешний редактор для продолжения',
@ -1461,6 +1490,75 @@ export default {
'Доступны новые конфигурации моделей для {{region}}. Обновить сейчас?',
'{{region}} configuration updated successfully. Model switched to "{{model}}".':
'Конфигурация {{region}} успешно обновлена. Модель переключена на "{{model}}".',
'Authenticated successfully with {{region}}. API key and model configs saved to settings.json (backed up).':
'Успешная аутентификация с {{region}}. API-ключ и конфигурации моделей сохранены в settings.json (резервная копия создана).',
// ============================================================================
// MCP Management Dialog
// ============================================================================
'MCP Management': 'Управление MCP',
'Server List': 'Список серверов',
'Server Detail': 'Детали сервера',
'Disable Server': 'Отключить сервер',
'Tool List': 'Список инструментов',
'Tool Detail': 'Детали инструмента',
'Loading...': 'Загрузка...',
'Unknown step': 'Неизвестный шаг',
'Esc to back': 'Esc для возврата',
'↑↓ to navigate · Enter to select · Esc to close':
'↑↓ навигация · Enter выбрать · Esc закрыть',
'↑↓ to navigate · Enter to select · Esc to back':
'↑↓ навигация · Enter выбрать · Esc назад',
'↑↓ to navigate · Enter to confirm · Esc to back':
'↑↓ навигация · Enter подтвердить · Esc назад',
'User Settings (global)': 'Настройки пользователя (глобальные)',
'Workspace Settings (project-specific)':
'Настройки рабочего пространства (проектные)',
'Disable server:': 'Отключить сервер:',
'Select where to add the server to the exclude list:':
'Выберите, где добавить сервер в список исключений:',
'Press Enter to confirm, Esc to cancel':
'Enter для подтверждения, Esc для отмены',
'Status:': 'Статус:',
'Command:': 'Команда:',
'Working Directory:': 'Рабочий каталог:',
'Capabilities:': 'Возможности:',
'No server selected': 'Сервер не выбран',
// MCP Server List
'User MCPs': 'MCP пользователя',
'Project MCPs': 'MCP проекта',
'Extension MCPs': 'MCP расширений',
server: 'сервер',
servers: 'серверов',
'Add MCP servers to your settings to get started.':
'Добавьте серверы MCP в настройки, чтобы начать.',
'Run qwen --debug to see error logs':
'Запустите qwen --debug для просмотра журналов ошибок',
// MCP Tool List
'No tools available for this server.':
'Для этого сервера нет доступных инструментов.',
destructive: 'деструктивный',
'read-only': 'только чтение',
'open-world': 'открытый мир',
idempotent: 'идемпотентный',
'Tools for {{name}}': 'Инструменты для {{name}}',
'{{current}}/{{total}}': '{{current}}/{{total}}',
// MCP Tool Detail
required: 'обязательный',
Type: 'Тип',
Enum: 'Перечисление',
Parameters: 'Параметры',
'No tool selected': 'Инструмент не выбран',
Annotations: 'Аннотации',
Title: 'Заголовок',
'Read Only': 'Только чтение',
Destructive: 'Деструктивный',
Idempotent: 'Идемпотентный',
'Open World': 'Открытый мир',
Server: 'Сервер',
'{{region}} configuration updated successfully.':
'Конфигурация {{region}} успешно обновлена.',
'Authenticated successfully with {{region}}. API key and model configs saved to settings.json.':

View file

@ -114,7 +114,7 @@ export default {
// ============================================================================
'Analyzes the project and creates a tailored QWEN.md file.':
'分析项目并创建定制的 QWEN.md 文件',
'list available Qwen Code tools. Usage: /tools [desc]':
'List available Qwen Code tools. Usage: /tools [desc]':
'列出可用的 Qwen Code 工具。用法:/tools [desc]',
'Available Qwen Code CLI tools:': '可用的 Qwen Code CLI 工具:',
'No tools available': '没有可用工具',
@ -423,7 +423,9 @@ export default {
'Show model-specific usage statistics.': '显示模型相关的使用统计信息',
'Show tool-specific usage statistics.': '显示工具相关的使用统计信息',
'exit the cli': '退出命令行界面',
'list configured MCP servers and tools, or authenticate with OAuth-enabled servers':
'Open MCP management dialog, or authenticate with OAuth-enabled servers':
'打开 MCP 管理对话框,或在支持 OAuth 的服务器上进行身份验证',
'List configured MCP servers and tools, or authenticate with OAuth-enabled servers':
'列出已配置的 MCP 服务器和工具,或使用支持 OAuth 的服务器进行身份验证',
'Manage workspace directories': '管理工作区目录',
'Add directories to the workspace. Use comma to separate multiple paths':
@ -747,6 +749,7 @@ export default {
'使用支持 OAuth 的 MCP 服务器进行认证',
'List configured MCP servers and tools': '列出已配置的 MCP 服务器和工具',
'Restarts MCP servers.': '重启 MCP 服务器',
'Open MCP management dialog': '打开 MCP 管理对话框',
'Config not loaded.': '配置未加载',
'Could not retrieve tool registry.': '无法检索工具注册表',
'No MCP servers configured with OAuth authentication.':
@ -762,6 +765,92 @@ export default {
"Re-discovering tools from '{{name}}'...":
"正在重新发现 '{{name}}' 的工具...",
// ============================================================================
// MCP Management Dialog
// ============================================================================
'Manage MCP servers': '管理 MCP 服务器',
'Server Detail': '服务器详情',
'Disable Server': '禁用服务器',
Tools: '工具',
'Tool Detail': '工具详情',
'MCP Management': 'MCP 管理',
'Loading...': '加载中...',
'Unknown step': '未知步骤',
'Esc to back': 'Esc 返回',
'↑↓ to navigate · Enter to select · Esc to close':
'↑↓ 导航 · Enter 选择 · Esc 关闭',
'↑↓ to navigate · Enter to select · Esc to back':
'↑↓ 导航 · Enter 选择 · Esc 返回',
'↑↓ to navigate · Enter to confirm · Esc to back':
'↑↓ 导航 · Enter 确认 · Esc 返回',
'User Settings (global)': '用户设置(全局)',
'Workspace Settings (project-specific)': '工作区设置(项目级)',
'Disable server:': '禁用服务器:',
'Select where to add the server to the exclude list:':
'选择将服务器添加到排除列表的位置:',
'Press Enter to confirm, Esc to cancel': '按 Enter 确认Esc 取消',
'View tools': '查看工具',
Reconnect: '重新连接',
Enable: '启用',
Disable: '禁用',
'(disabled)': '(已禁用)',
'Error:': '错误:',
Extension: '扩展',
tool: '工具',
tools: '个工具',
connected: '已连接',
connecting: '连接中',
disconnected: '已断开',
// MCP Server List
'User MCPs': '用户 MCP',
'Project MCPs': '项目 MCP',
'Extension MCPs': '扩展 MCP',
server: '个服务器',
servers: '个服务器',
'Add MCP servers to your settings to get started.':
'请在设置中添加 MCP 服务器以开始使用。',
'Run qwen --debug to see error logs': '运行 qwen --debug 查看错误日志',
// MCP Server Detail
'Command:': '命令:',
'Working Directory:': '工作目录:',
'Capabilities:': '功能:',
// MCP Tool List
'No tools available for this server.': '此服务器没有可用工具。',
destructive: '破坏性',
'read-only': '只读',
'open-world': '开放世界',
idempotent: '幂等',
'Tools for {{name}}': '{{name}} 的工具',
'{{current}}/{{total}}': '{{current}}/{{total}}',
// MCP Tool Detail
Type: '类型',
Parameters: '参数',
'No tool selected': '未选择工具',
Annotations: '注解',
Title: '标题',
'Read Only': '只读',
Destructive: '破坏性',
Idempotent: '幂等',
'Open World': '开放世界',
Server: '服务器',
// Invalid tool related translations
'{{count}} invalid tools': '{{count}} 个无效工具',
invalid: '无效',
'invalid: {{reason}}': '无效:{{reason}}',
'missing name': '缺少名称',
'missing description': '缺少描述',
'(unnamed)': '(未命名)',
'Warning: This tool cannot be called by the LLM':
'警告:此工具无法被 LLM 调用',
Reason: '原因',
'Tools must have both name and description to be used by the LLM.':
'工具必须同时具有名称和描述才能被 LLM 使用。',
// ============================================================================
// Commands - Chat
// ============================================================================
@ -887,6 +976,7 @@ export default {
'Do you want to proceed?': '是否继续?',
'Yes, allow once': '是,允许一次',
'Allow always': '总是允许',
Yes: '是',
No: '否',
'No (esc)': '否 (esc)',
'Yes, allow always for this session': '是,本次会话总是允许',

View file

@ -103,6 +103,7 @@ import { useInitializationAuthError } from './hooks/useInitializationAuthError.j
import { useSubagentCreateDialog } from './hooks/useSubagentCreateDialog.js';
import { useAgentsManagerDialog } from './hooks/useAgentsManagerDialog.js';
import { useExtensionsManagerDialog } from './hooks/useExtensionsManagerDialog.js';
import { useMcpDialog } from './hooks/useMcpDialog.js';
import { useAttentionNotifications } from './hooks/useAttentionNotifications.js';
import {
requestConsentInteractive,
@ -499,6 +500,7 @@ export const AppContainer = (props: AppContainerProps) => {
openExtensionsManagerDialog,
closeExtensionsManagerDialog,
} = useExtensionsManagerDialog();
const { isMcpDialogOpen, openMcpDialog, closeMcpDialog } = useMcpDialog();
const slashCommandActions = useMemo(
() => ({
@ -522,6 +524,7 @@ export const AppContainer = (props: AppContainerProps) => {
openSubagentCreateDialog,
openAgentsManagerDialog,
openExtensionsManagerDialog,
openMcpDialog,
openResumeDialog,
}),
[
@ -538,6 +541,7 @@ export const AppContainer = (props: AppContainerProps) => {
openSubagentCreateDialog,
openAgentsManagerDialog,
openExtensionsManagerDialog,
openMcpDialog,
openResumeDialog,
],
);
@ -1307,6 +1311,7 @@ export const AppContainer = (props: AppContainerProps) => {
showIdeRestartPrompt ||
isSubagentCreateDialogOpen ||
isAgentsManagerDialogOpen ||
isMcpDialogOpen ||
isApprovalModeDialogOpen ||
isResumeDialogOpen ||
isExtensionsManagerDialogOpen;
@ -1421,6 +1426,8 @@ export const AppContainer = (props: AppContainerProps) => {
isAgentsManagerDialogOpen,
// Extensions manager dialog
isExtensionsManagerDialogOpen,
// MCP dialog
isMcpDialogOpen,
// Feedback dialog
isFeedbackDialogOpen,
}),
@ -1513,6 +1520,8 @@ export const AppContainer = (props: AppContainerProps) => {
isAgentsManagerDialogOpen,
// Extensions manager dialog
isExtensionsManagerDialogOpen,
// MCP dialog
isMcpDialogOpen,
// Feedback dialog
isFeedbackDialogOpen,
],
@ -1556,6 +1565,8 @@ export const AppContainer = (props: AppContainerProps) => {
closeAgentsManagerDialog,
// Extensions manager dialog
closeExtensionsManagerDialog,
// MCP dialog
closeMcpDialog,
// Resume session dialog
openResumeDialog,
closeResumeDialog,
@ -1601,6 +1612,8 @@ export const AppContainer = (props: AppContainerProps) => {
closeAgentsManagerDialog,
// Extensions manager dialog
closeExtensionsManagerDialog,
// MCP dialog
closeMcpDialog,
// Resume session dialog
openResumeDialog,
closeResumeDialog,

View file

@ -12,13 +12,8 @@ import {
MCPDiscoveryState,
getMCPServerStatus,
getMCPDiscoveryState,
DiscoveredMCPTool,
} from '@qwen-code/qwen-code-core';
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')>();
@ -37,23 +32,6 @@ vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
};
});
// Helper function to create a mock DiscoveredMCPTool
const createMockMCPTool = (
name: string,
serverName: string,
description?: string,
) =>
new DiscoveredMCPTool(
{
callTool: vi.fn(),
tool: vi.fn(),
} as unknown as CallableTool,
serverName,
name,
description || `Description for ${name}`,
{ type: Type.OBJECT, properties: {} },
);
describe('mcpCommand', () => {
let mockContext: ReturnType<typeof createMockCommandContext>;
let mockConfig: {
@ -70,7 +48,7 @@ describe('mcpCommand', () => {
// Set up default mock environment
vi.unstubAllEnvs();
// Default mock implementations
// Default mock implementations - these are kept for auth subcommand tests
vi.mocked(getMCPServerStatus).mockReturnValue(MCPServerStatus.CONNECTED);
vi.mocked(getMCPDiscoveryState).mockReturnValue(
MCPDiscoveryState.COMPLETED,
@ -98,7 +76,16 @@ describe('mcpCommand', () => {
});
describe('basic functionality', () => {
it('should show an error if config is not available', async () => {
it('should open MCP management dialog by default', async () => {
const result = await mcpCommand.action!(mockContext, '');
expect(result).toEqual({
type: 'dialog',
dialog: 'mcp',
});
});
it('should open MCP management dialog even if config is not available', async () => {
const contextWithoutConfig = createMockCommandContext({
services: {
config: null,
@ -108,21 +95,19 @@ describe('mcpCommand', () => {
const result = await mcpCommand.action!(contextWithoutConfig, '');
expect(result).toEqual({
type: 'message',
messageType: 'error',
content: 'Config not loaded.',
type: 'dialog',
dialog: 'mcp',
});
});
it('should show an error if tool registry is not available', async () => {
it('should open MCP management dialog even if tool registry is not available', async () => {
mockConfig.getToolRegistry = vi.fn().mockReturnValue(undefined);
const result = await mcpCommand.action!(mockContext, '');
expect(result).toEqual({
type: 'message',
messageType: 'error',
content: 'Could not retrieve tool registry.',
type: 'dialog',
dialog: 'mcp',
});
});
});
@ -138,73 +123,31 @@ describe('mcpCommand', () => {
mockConfig.getMcpServers = vi.fn().mockReturnValue(mockMcpServers);
});
it('should display configured MCP servers with status indicators and their tools', async () => {
// Setup getMCPServerStatus mock implementation
vi.mocked(getMCPServerStatus).mockImplementation((serverName) => {
if (serverName === 'server1') return MCPServerStatus.CONNECTED;
if (serverName === 'server2') return MCPServerStatus.CONNECTED;
return MCPServerStatus.DISCONNECTED; // server3
it('should open MCP management dialog regardless of server configuration', async () => {
const result = await mcpCommand.action!(mockContext, '');
expect(result).toEqual({
type: 'dialog',
dialog: 'mcp',
});
// Mock tools from each server using actual DiscoveredMCPTool instances
const mockServer1Tools = [
createMockMCPTool('server1_tool1', 'server1'),
createMockMCPTool('server1_tool2', 'server1'),
];
const mockServer2Tools = [createMockMCPTool('server2_tool1', 'server2')];
const mockServer3Tools = [createMockMCPTool('server3_tool1', 'server3')];
const allTools = [
...mockServer1Tools,
...mockServer2Tools,
...mockServer3Tools,
];
mockConfig.getToolRegistry = vi.fn().mockReturnValue({
getAllTools: vi.fn().mockReturnValue(allTools),
});
await mcpCommand.action!(mockContext, '');
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 () => {
await mcpCommand.action!(mockContext, 'desc');
it('should open MCP management dialog with desc argument', async () => {
const result = await mcpCommand.action!(mockContext, 'desc');
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageType.MCP_STATUS,
showDescriptions: true,
showTips: false,
}),
expect.any(Number),
);
expect(result).toEqual({
type: 'dialog',
dialog: 'mcp',
});
});
it('should not display descriptions when nodesc argument is used', async () => {
await mcpCommand.action!(mockContext, 'nodesc');
it('should open MCP management dialog with nodesc argument', async () => {
const result = await mcpCommand.action!(mockContext, 'nodesc');
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageType.MCP_STATUS,
showDescriptions: false,
showTips: false,
}),
expect.any(Number),
);
expect(result).toEqual({
type: 'dialog',
dialog: 'mcp',
});
});
});
});

View file

@ -6,24 +6,17 @@
import type {
SlashCommand,
SlashCommandActionReturn,
CommandContext,
MessageActionReturn,
OpenDialogActionReturn,
} from './types.js';
import { CommandKind } from './types.js';
import type { DiscoveredMCPPrompt } from '@qwen-code/qwen-code-core';
import {
DiscoveredMCPTool,
getMCPDiscoveryState,
getMCPServerStatus,
MCPDiscoveryState,
MCPServerStatus,
getErrorMessage,
MCPOAuthTokenStorage,
MCPOAuthProvider,
} from '@qwen-code/qwen-code-core';
import { appEvents, AppEvent } from '../../utils/events.js';
import { MessageType, type HistoryItemMcpStatus } from '../types.js';
import { t } from '../../i18n/index.js';
const authCommand: SlashCommand = {
@ -189,183 +182,30 @@ const authCommand: SlashCommand = {
},
};
const listCommand: SlashCommand = {
name: 'list',
const manageCommand: SlashCommand = {
name: 'manage',
get description() {
return t('List configured MCP servers and tools');
return t('Open MCP management dialog');
},
kind: CommandKind.BUILT_IN,
action: async (
context: CommandContext,
args: string,
): Promise<void | MessageActionReturn> => {
const { config } = context.services;
if (!config) {
return {
type: 'message',
messageType: 'error',
content: t('Config not loaded.'),
};
}
const toolRegistry = config.getToolRegistry();
if (!toolRegistry) {
return {
type: 'message',
messageType: 'error',
content: t('Could not retrieve tool registry.'),
};
}
const lowerCaseArgs = args.toLowerCase().split(/\s+/).filter(Boolean);
const hasDesc =
lowerCaseArgs.includes('desc') || lowerCaseArgs.includes('descriptions');
const hasNodesc =
lowerCaseArgs.includes('nodesc') ||
lowerCaseArgs.includes('nodescriptions');
const showSchema = lowerCaseArgs.includes('schema');
const showDescriptions = !hasNodesc && (hasDesc || showSchema);
const showTips = lowerCaseArgs.length === 0;
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());
},
};
const refreshCommand: SlashCommand = {
name: 'refresh',
get description() {
return t('Restarts MCP servers.');
},
kind: CommandKind.BUILT_IN,
action: async (
context: CommandContext,
): Promise<void | SlashCommandActionReturn> => {
const { config } = context.services;
if (!config) {
return {
type: 'message',
messageType: 'error',
content: t('Config not loaded.'),
};
}
const toolRegistry = config.getToolRegistry();
if (!toolRegistry) {
return {
type: 'message',
messageType: 'error',
content: t('Could not retrieve tool registry.'),
};
}
context.ui.addItem(
{
type: 'info',
text: t('Restarting MCP servers...'),
},
Date.now(),
);
await toolRegistry.restartMcpServers();
// Update the client with the new tools
const geminiClient = config.getGeminiClient();
if (geminiClient) {
await geminiClient.setTools();
}
// Reload the slash commands to reflect the changes.
context.ui.reloadCommands();
return listCommand.action!(context, '');
},
action: async (): Promise<OpenDialogActionReturn> => ({
type: 'dialog',
dialog: 'mcp',
}),
};
export const mcpCommand: SlashCommand = {
name: 'mcp',
get description() {
return t(
'list configured MCP servers and tools, or authenticate with OAuth-enabled servers',
'Open MCP management dialog, or authenticate with OAuth-enabled servers',
);
},
kind: CommandKind.BUILT_IN,
subCommands: [listCommand, authCommand, refreshCommand],
// Default action when no subcommand is provided
action: async (
context: CommandContext,
args: string,
): Promise<void | SlashCommandActionReturn> =>
// If no subcommand, run the list command
listCommand.action!(context, args),
subCommands: [manageCommand, authCommand],
// Default action when no subcommand is provided - open dialog
action: async (): Promise<OpenDialogActionReturn> => ({
type: 'dialog',
dialog: 'mcp',
}),
};

View file

@ -149,7 +149,8 @@ export interface OpenDialogActionReturn {
| 'permissions'
| 'approval-mode'
| 'resume'
| 'extensions_manage';
| 'extensions_manage'
| 'mcp';
}
/**

View file

@ -35,6 +35,7 @@ import { WelcomeBackDialog } from './WelcomeBackDialog.js';
import { AgentCreationWizard } from './subagents/create/AgentCreationWizard.js';
import { AgentsManagerDialog } from './subagents/manage/AgentsManagerDialog.js';
import { ExtensionsManagerDialog } from './extensions/ExtensionsManagerDialog.js';
import { MCPManagementDialog } from './mcp/MCPManagementDialog.js';
import { SessionPicker } from './SessionPicker.js';
interface DialogManagerProps {
@ -301,6 +302,9 @@ export const DialogManager = ({
/>
);
}
if (uiState.isMcpDialogOpen) {
return <MCPManagementDialog onClose={uiActions.closeMcpDialog} />;
}
if (uiState.isResumeDialogOpen) {
return (

View file

@ -0,0 +1,554 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import { useState, useCallback, useEffect, useMemo } from 'react';
import { Box, Text } from 'ink';
import { theme } from '../../semantic-colors.js';
import { useKeypress } from '../../hooks/useKeypress.js';
import { t } from '../../../i18n/index.js';
import type {
MCPManagementDialogProps,
MCPServerDisplayInfo,
MCPToolDisplayInfo,
} from './types.js';
import { MCP_MANAGEMENT_STEPS } from './types.js';
import { ServerListStep } from './steps/ServerListStep.js';
import { ServerDetailStep } from './steps/ServerDetailStep.js';
import { ToolListStep } from './steps/ToolListStep.js';
import { ToolDetailStep } from './steps/ToolDetailStep.js';
import { DisableScopeSelectStep } from './steps/DisableScopeSelectStep.js';
import { useConfig } from '../../contexts/ConfigContext.js';
import {
getMCPServerStatus,
DiscoveredMCPTool,
type MCPServerConfig,
type AnyDeclarativeTool,
type DiscoveredMCPPrompt,
createDebugLogger,
} from '@qwen-code/qwen-code-core';
import { loadSettings, SettingScope } from '../../../config/settings.js';
import { isToolValid, getToolInvalidReasons } from './utils.js';
const debugLogger = createDebugLogger('MCP_DIALOG');
export const MCPManagementDialog: React.FC<MCPManagementDialogProps> = ({
onClose,
}) => {
const config = useConfig();
const [servers, setServers] = useState<MCPServerDisplayInfo[]>([]);
const [selectedServerIndex, setSelectedServerIndex] = useState<number>(-1);
const [selectedTool, setSelectedTool] = useState<MCPToolDisplayInfo | null>(
null,
);
const [navigationStack, setNavigationStack] = useState<string[]>([
MCP_MANAGEMENT_STEPS.SERVER_LIST,
]);
const [isLoading, setIsLoading] = useState(true);
// Load MCP server data - extracted to a separate function for reuse
const fetchServerData = useCallback(async (): Promise<
MCPServerDisplayInfo[]
> => {
if (!config) return [];
const mcpServers = config.getMcpServers() || {};
const toolRegistry = config.getToolRegistry();
const promptRegistry = config.getPromptRegistry();
// Get settings to determine the scope of each server
const settings = loadSettings();
const userSettings = settings.forScope(SettingScope.User).settings;
const workspaceSettings = settings.forScope(
SettingScope.Workspace,
).settings;
const serverInfos: MCPServerDisplayInfo[] = [];
for (const [name, serverConfig] of Object.entries(mcpServers) as Array<
[string, MCPServerConfig]
>) {
const status = getMCPServerStatus(name);
// Get tools for this server
const allTools: AnyDeclarativeTool[] = toolRegistry?.getAllTools() || [];
const serverTools = allTools.filter(
(t): t is DiscoveredMCPTool =>
t instanceof DiscoveredMCPTool && t.serverName === name,
);
// Get prompts for this server
const allPrompts: DiscoveredMCPPrompt[] =
promptRegistry?.getAllPrompts() || [];
const serverPrompts = allPrompts.filter(
(p) => 'serverName' in p && p.serverName === name,
);
// Determine source type
let source: 'user' | 'project' | 'extension' = 'user';
if (serverConfig.extensionName) {
source = 'extension';
}
// Determine the scope of the configuration
let scope: 'user' | 'workspace' | 'extension' = 'user';
if (serverConfig.extensionName) {
scope = 'extension';
} else if (workspaceSettings.mcpServers?.[name]) {
scope = 'workspace';
} else if (userSettings.mcpServers?.[name]) {
scope = 'user';
}
// Use config.isMcpServerDisabled() to check if server is disabled
const isDisabled = config.isMcpServerDisabled(name);
// Count invalid tools (missing name or description)
const invalidToolCount = serverTools.filter(
(t) => !t.name || !t.description,
).length;
serverInfos.push({
name,
status,
source,
scope,
config: serverConfig,
toolCount: serverTools.length,
invalidToolCount,
promptCount: serverPrompts.length,
isDisabled,
});
}
return serverInfos;
}, [config]);
// Load MCP server data on initial render
useEffect(() => {
const loadServers = async () => {
setIsLoading(true);
try {
const serverInfos = await fetchServerData();
setServers(serverInfos);
} catch (error) {
debugLogger.error('Error loading MCP servers:', error);
} finally {
setIsLoading(false);
}
};
loadServers();
}, [fetchServerData]);
// Selected server
const selectedServer = useMemo(() => {
if (selectedServerIndex >= 0 && selectedServerIndex < servers.length) {
return servers[selectedServerIndex];
}
return null;
}, [servers, selectedServerIndex]);
// Current step
const getCurrentStep = useCallback(
() =>
navigationStack[navigationStack.length - 1] ||
MCP_MANAGEMENT_STEPS.SERVER_LIST,
[navigationStack],
);
// Navigation handlers
const handleNavigateToStep = useCallback((step: string) => {
setNavigationStack((prev) => [...prev, step]);
}, []);
const handleNavigateBack = useCallback(() => {
setNavigationStack((prev) => {
if (prev.length <= 1) return prev;
return prev.slice(0, -1);
});
}, []);
// Select server
const handleSelectServer = useCallback(
(index: number) => {
setSelectedServerIndex(index);
handleNavigateToStep(MCP_MANAGEMENT_STEPS.SERVER_DETAIL);
},
[handleNavigateToStep],
);
// Get server tool list
const getServerTools = useCallback((): MCPToolDisplayInfo[] => {
if (!config || !selectedServer) return [];
const toolRegistry = config.getToolRegistry();
if (!toolRegistry) return [];
const allTools: AnyDeclarativeTool[] = toolRegistry.getAllTools();
const mcpTools: DiscoveredMCPTool[] = [];
for (const tool of allTools) {
if (
tool instanceof DiscoveredMCPTool &&
tool.serverName === selectedServer.name
) {
mcpTools.push(tool);
}
}
return mcpTools.map((tool) => {
// Check if tool is valid (has both name and description required by LLM)
const isValid = isToolValid(tool.name, tool.description);
let invalidReason: string | undefined;
if (!isValid) {
const reasons = getToolInvalidReasons(tool.name, tool.description);
invalidReason = reasons.map((r) => t(r)).join(', ');
}
return {
name: tool.name || t('(unnamed)'),
description: tool.description,
serverName: tool.serverName,
schema: tool.parameterSchema as object | undefined,
annotations: tool.annotations,
isValid,
invalidReason,
};
});
}, [config, selectedServer]);
// View tool list
const handleViewTools = useCallback(() => {
handleNavigateToStep(MCP_MANAGEMENT_STEPS.TOOL_LIST);
}, [handleNavigateToStep]);
// Select tool
const handleSelectTool = useCallback(
(tool: MCPToolDisplayInfo) => {
setSelectedTool(tool);
handleNavigateToStep(MCP_MANAGEMENT_STEPS.TOOL_DETAIL);
},
[handleNavigateToStep],
);
// Reload server data - uses the extracted fetchServerData function
const reloadServers = useCallback(async () => {
setIsLoading(true);
try {
const serverInfos = await fetchServerData();
setServers(serverInfos);
} catch (error) {
debugLogger.error('Error reloading MCP servers:', error);
} finally {
setIsLoading(false);
}
}, [fetchServerData]);
// Reconnect server
const handleReconnect = useCallback(async () => {
if (!config || !selectedServer) return;
try {
setIsLoading(true);
const toolRegistry = config.getToolRegistry();
if (toolRegistry) {
await toolRegistry.discoverToolsForServer(selectedServer.name);
}
// Reload server data to update status
await reloadServers();
} catch (error) {
debugLogger.error(
`Error reconnecting to server '${selectedServer.name}':`,
error,
);
} finally {
setIsLoading(false);
}
}, [config, selectedServer, reloadServers]);
// Enable server
const handleEnableServer = useCallback(async () => {
if (!config || !selectedServer) return;
try {
setIsLoading(true);
const server = selectedServer;
const settings = loadSettings();
// Remove from user and workspace exclusion lists
for (const scope of [SettingScope.User, SettingScope.Workspace]) {
const scopeSettings = settings.forScope(scope).settings;
const currentExcluded = scopeSettings.mcp?.excluded || [];
if (currentExcluded.includes(server.name)) {
const newExcluded = currentExcluded.filter(
(name: string) => name !== server.name,
);
settings.setValue(scope, 'mcp.excluded', newExcluded);
}
}
// Update runtime config exclusion list
const currentExcluded = config.getExcludedMcpServers() || [];
const newExcluded = currentExcluded.filter(
(name: string) => name !== server.name,
);
config.setExcludedMcpServers(newExcluded);
// Rediscover tools for this server
const toolRegistry = config.getToolRegistry();
if (toolRegistry) {
await toolRegistry.discoverToolsForServer(server.name);
}
// Reload server data
await reloadServers();
} catch (error) {
debugLogger.error(
`Error enabling server '${selectedServer.name}':`,
error,
);
} finally {
setIsLoading(false);
}
}, [config, selectedServer, reloadServers]);
// Handle disable/enable action
const handleDisable = useCallback(() => {
if (!selectedServer) return;
// If server is already disabled, enable it directly
if (selectedServer.isDisabled) {
void handleEnableServer();
} else {
// Otherwise navigate to disable scope selection
handleNavigateToStep(MCP_MANAGEMENT_STEPS.DISABLE_SCOPE_SELECT);
}
}, [selectedServer, handleEnableServer, handleNavigateToStep]);
// Execute disable after selecting scope
const handleSelectDisableScope = useCallback(
async (scope: 'user' | 'workspace') => {
if (!config || !selectedServer) return;
try {
setIsLoading(true);
const server = selectedServer;
const settings = loadSettings();
// Get current exclusion list
const scopeSettings = settings.forScope(
scope === 'user' ? SettingScope.User : SettingScope.Workspace,
).settings;
const currentExcluded = scopeSettings.mcp?.excluded || [];
// If server is not in exclusion list, add it
if (!currentExcluded.includes(server.name)) {
const newExcluded = [...currentExcluded, server.name];
settings.setValue(
scope === 'user' ? SettingScope.User : SettingScope.Workspace,
'mcp.excluded',
newExcluded,
);
}
// Use new disableMcpServer method to disable server
const toolRegistry = config.getToolRegistry();
if (toolRegistry) {
await toolRegistry.disableMcpServer(server.name);
}
// Reload server list
await reloadServers();
// Return to server detail page
handleNavigateBack();
} catch (error) {
debugLogger.error(
`Error disabling server '${selectedServer.name}':`,
error,
);
} finally {
setIsLoading(false);
}
},
[config, selectedServer, handleNavigateBack, reloadServers],
);
// Render step header
const renderStepHeader = useCallback(() => {
const currentStep = getCurrentStep();
let headerText = '';
switch (currentStep) {
case MCP_MANAGEMENT_STEPS.SERVER_LIST:
headerText = t('Manage MCP servers');
break;
case MCP_MANAGEMENT_STEPS.SERVER_DETAIL:
headerText = selectedServer?.name || t('Server Detail');
break;
case MCP_MANAGEMENT_STEPS.DISABLE_SCOPE_SELECT:
headerText = t('Disable Server');
break;
case MCP_MANAGEMENT_STEPS.TOOL_LIST:
headerText = t('Tools');
break;
case MCP_MANAGEMENT_STEPS.TOOL_DETAIL:
headerText = selectedTool?.name || t('Tool Detail');
break;
default:
headerText = t('MCP Management');
}
return (
<Box>
<Text color={theme.text.accent} bold>
{headerText}
</Text>
</Box>
);
}, [getCurrentStep, selectedServer, selectedTool]);
// Render step content
const renderStepContent = useCallback(() => {
if (isLoading) {
return <Text color={theme.text.secondary}>{t('Loading...')}</Text>;
}
const currentStep = getCurrentStep();
switch (currentStep) {
case MCP_MANAGEMENT_STEPS.SERVER_LIST:
return (
<ServerListStep servers={servers} onSelect={handleSelectServer} />
);
case MCP_MANAGEMENT_STEPS.SERVER_DETAIL:
return (
<ServerDetailStep
server={selectedServer}
onViewTools={handleViewTools}
onReconnect={handleReconnect}
onDisable={handleDisable}
onBack={handleNavigateBack}
/>
);
case MCP_MANAGEMENT_STEPS.DISABLE_SCOPE_SELECT:
return (
<DisableScopeSelectStep
server={selectedServer}
onSelectScope={handleSelectDisableScope}
onBack={handleNavigateBack}
/>
);
case MCP_MANAGEMENT_STEPS.TOOL_LIST:
return (
<ToolListStep
tools={getServerTools()}
serverName={selectedServer?.name || ''}
onSelect={handleSelectTool}
onBack={handleNavigateBack}
/>
);
case MCP_MANAGEMENT_STEPS.TOOL_DETAIL:
return (
<ToolDetailStep tool={selectedTool} onBack={handleNavigateBack} />
);
default:
return (
<Box>
<Text color={theme.status.error}>{t('Unknown step')}</Text>
</Box>
);
}
}, [
isLoading,
getCurrentStep,
servers,
selectedServer,
selectedTool,
handleSelectServer,
handleViewTools,
handleReconnect,
handleDisable,
handleNavigateBack,
handleSelectTool,
handleSelectDisableScope,
getServerTools,
]);
// Render step footer
const renderStepFooter = useCallback(() => {
const currentStep = getCurrentStep();
let footerText = '';
switch (currentStep) {
case MCP_MANAGEMENT_STEPS.SERVER_LIST:
if (servers.length === 0) {
footerText = t('Esc to close');
} else {
footerText = t('↑↓ to navigate · Enter to select · Esc to close');
}
break;
case MCP_MANAGEMENT_STEPS.SERVER_DETAIL:
footerText = t('↑↓ to navigate · Enter to select · Esc to back');
break;
case MCP_MANAGEMENT_STEPS.DISABLE_SCOPE_SELECT:
footerText = t('↑↓ to navigate · Enter to confirm · Esc to back');
break;
case MCP_MANAGEMENT_STEPS.TOOL_LIST:
footerText = t('↑↓ to navigate · Enter to select · Esc to back');
break;
case MCP_MANAGEMENT_STEPS.TOOL_DETAIL:
footerText = t('Esc to back');
break;
default:
footerText = t('Esc to close');
}
return (
<Box>
<Text color={theme.text.secondary}>{footerText}</Text>
</Box>
);
}, [getCurrentStep, servers.length]);
// ESC key handler - only close dialog, child components handle back navigation to avoid duplicate triggers
useKeypress(
(key) => {
if (
key.name === 'escape' &&
getCurrentStep() === MCP_MANAGEMENT_STEPS.SERVER_LIST
) {
onClose();
}
},
{ isActive: true },
);
return (
<Box flexDirection="column">
<Box
borderStyle="single"
borderColor={theme.border.default}
flexDirection="column"
padding={1}
width="100%"
gap={1}
>
{renderStepHeader()}
{renderStepContent()}
{renderStepFooter()}
</Box>
</Box>
);
};

View file

@ -0,0 +1,47 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
/**
* MCP管理相关常量
*/
/**
*
*/
export const MAX_DISPLAY_TOOLS = 10;
/**
* prompt数量
*/
export const MAX_DISPLAY_PROMPTS = 10;
/**
*
*/
export const VISIBLE_LOGS_COUNT = 15;
/**
*
*/
export const VISIBLE_TOOLS_COUNT = 10;
/**
*
*/
export const SOURCE_DISPLAY_NAMES: Record<string, string> = {
user: 'User MCPs',
project: 'Project MCPs',
extension: 'Extension MCPs',
};
/**
*
*/
export const STATUS_TEXT: Record<string, string> = {
connected: 'connected',
connecting: 'connecting',
disconnected: 'failed',
};

View file

@ -0,0 +1,30 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
// Main Dialog
export { MCPManagementDialog } from './MCPManagementDialog.js';
// Steps
export { ServerListStep } from './steps/ServerListStep.js';
export { ServerDetailStep } from './steps/ServerDetailStep.js';
export { ToolListStep } from './steps/ToolListStep.js';
export { ToolDetailStep } from './steps/ToolDetailStep.js';
// Types
export type {
MCPManagementDialogProps,
MCPServerDisplayInfo,
MCPToolDisplayInfo,
MCPPromptDisplayInfo,
ServerListStepProps,
ServerDetailStepProps,
ToolListStepProps,
ToolDetailStepProps,
MCPManagementStep,
} from './types.js';
// Constants
export { MCP_MANAGEMENT_STEPS } from './types.js';

View file

@ -0,0 +1,88 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import { useState } from 'react';
import { Box, Text } from 'ink';
import { theme } from '../../../semantic-colors.js';
import { useKeypress } from '../../../hooks/useKeypress.js';
import { RadioButtonSelect } from '../../shared/RadioButtonSelect.js';
import { t } from '../../../../i18n/index.js';
import type { DisableScopeSelectStepProps } from '../types.js';
export const DisableScopeSelectStep: React.FC<DisableScopeSelectStepProps> = ({
server,
onSelectScope,
onBack,
}) => {
const [selectedScope, setSelectedScope] = useState<'user' | 'workspace'>(
'user',
);
const scopes = [
{
key: 'user',
get label() {
return t('User Settings (global)');
},
value: 'user' as const,
},
{
key: 'workspace',
get label() {
return t('Workspace Settings (project-specific)');
},
value: 'workspace' as const,
},
];
useKeypress(
(key) => {
if (key.name === 'escape') {
onBack();
} else if (key.name === 'return') {
onSelectScope(selectedScope);
}
},
{ isActive: true },
);
if (!server) {
return (
<Box>
<Text color={theme.status.error}>{t('No server selected')}</Text>
</Box>
);
}
return (
<Box flexDirection="column" gap={1}>
<Box flexDirection="column">
<Text color={theme.text.primary}>
{t('Disable server:')} {server.name}
</Text>
<Box marginTop={1}>
<Text color={theme.text.secondary}>
{t('Select where to add the server to the exclude list:')}
</Text>
</Box>
</Box>
<Box marginTop={1}>
<RadioButtonSelect<'user' | 'workspace'>
items={scopes}
onHighlight={(value: 'user' | 'workspace') => setSelectedScope(value)}
onSelect={(value: 'user' | 'workspace') => onSelectScope(value)}
/>
</Box>
<Box marginTop={1}>
<Text color={theme.text.secondary}>
{t('Press Enter to confirm, Esc to cancel')}
</Text>
</Box>
</Box>
);
};

View file

@ -0,0 +1,223 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import { useState } from 'react';
import { Box, Text } from 'ink';
import { theme } from '../../../semantic-colors.js';
import { useKeypress } from '../../../hooks/useKeypress.js';
import { RadioButtonSelect } from '../../shared/RadioButtonSelect.js';
import { t } from '../../../../i18n/index.js';
import type { ServerDetailStepProps } from '../types.js';
import {
getStatusColor,
getStatusIcon,
formatServerCommand,
} from '../utils.js';
// 标签列宽度
const LABEL_WIDTH = 15;
type ServerAction = 'view-tools' | 'reconnect' | 'toggle-disable';
export const ServerDetailStep: React.FC<ServerDetailStepProps> = ({
server,
onViewTools,
onReconnect,
onDisable,
onBack,
}) => {
const [selectedAction, setSelectedAction] =
useState<ServerAction>('view-tools');
const statusColor = server ? getStatusColor(server.status) : 'gray';
const actions = [
{
key: 'view-tools',
get label() {
return t('View tools');
},
value: 'view-tools' as const,
},
{
key: 'reconnect',
get label() {
return t('Reconnect');
},
value: 'reconnect' as const,
},
{
key: 'toggle-disable',
get label() {
return server?.isDisabled ? t('Enable') : t('Disable');
},
value: 'toggle-disable' as const,
},
];
useKeypress(
(key) => {
if (key.name === 'escape') {
onBack();
} else if (key.name === 'return') {
switch (selectedAction) {
case 'view-tools':
onViewTools();
break;
case 'reconnect':
onReconnect?.();
break;
case 'toggle-disable':
onDisable?.();
break;
default:
break;
}
}
},
{ isActive: true },
);
if (!server) {
return (
<Box>
<Text color={theme.status.error}>{t('No server selected')}</Text>
</Box>
);
}
return (
<Box flexDirection="column" gap={1}>
{/* 服务器详情 */}
<Box flexDirection="column">
<Box>
<Box width={LABEL_WIDTH}>
<Text color={theme.text.primary}>{t('Status:')}</Text>
</Box>
<Box>
<Text
color={
statusColor === 'green'
? theme.status.success
: statusColor === 'yellow'
? theme.status.warning
: theme.status.error
}
>
{getStatusIcon(server.status)} {t(server.status)}
{server.isDisabled && (
<Text color={theme.status.warning}> {t('(disabled)')}</Text>
)}
</Text>
</Box>
</Box>
<Box>
<Box width={LABEL_WIDTH}>
<Text color={theme.text.primary}>{t('Source:')}</Text>
</Box>
<Box>
<Text color={theme.text.secondary}>
{server.scope === 'user'
? t('User Settings')
: server.scope === 'workspace'
? t('Workspace Settings')
: t('Extension')}
</Text>
</Box>
</Box>
<Box>
<Box width={LABEL_WIDTH}>
<Text color={theme.text.primary}>{t('Command:')}</Text>
</Box>
<Box>
<Text wrap="truncate">{formatServerCommand(server)}</Text>
</Box>
</Box>
{server.config.cwd && (
<Box>
<Box width={LABEL_WIDTH}>
<Text color={theme.text.primary}>{t('Working Directory:')}</Text>
</Box>
<Box>
<Text wrap="truncate">{server.config.cwd}</Text>
</Box>
</Box>
)}
<Box>
<Box width={LABEL_WIDTH}>
<Text color={theme.text.primary}>{t('Capabilities:')}</Text>
</Box>
<Box>
<Text>
{server.toolCount > 0 ? t('tools') : ''}
{server.toolCount > 0 && server.promptCount > 0 ? ', ' : ''}
{server.promptCount > 0 ? t('prompts') : ''}
</Text>
</Box>
</Box>
<Box>
<Box width={LABEL_WIDTH}>
<Text color={theme.text.primary}>{t('Tools:')}</Text>
</Box>
<Box>
<Text>
{server.toolCount}{' '}
{server.toolCount === 1 ? t('tool') : t('tools')}
{!!server.invalidToolCount && server.invalidToolCount > 0 && (
<Text color={theme.status.warning}>
{' '}
({server.invalidToolCount}{' '}
{server.invalidToolCount === 1 ? t('invalid') : t('invalid')})
</Text>
)}
</Text>
</Box>
</Box>
{server.errorMessage && (
<Box>
<Box width={LABEL_WIDTH}>
<Text color={theme.status.error}>{t('Error:')}</Text>
</Box>
<Box>
<Text color={theme.status.error} wrap="wrap">
{server.errorMessage}
</Text>
</Box>
</Box>
)}
</Box>
{/* 操作列表 */}
<Box>
<RadioButtonSelect<ServerAction>
items={actions}
onHighlight={(value: ServerAction) => setSelectedAction(value)}
onSelect={(value: ServerAction) => {
switch (value) {
case 'view-tools':
onViewTools();
break;
case 'reconnect':
onReconnect?.();
break;
case 'toggle-disable':
onDisable?.();
break;
default:
break;
}
}}
/>
</Box>
</Box>
);
};

View file

@ -0,0 +1,185 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import { useState, useMemo } from 'react';
import { Box, Text } from 'ink';
import { theme } from '../../../semantic-colors.js';
import { useKeypress } from '../../../hooks/useKeypress.js';
import { t } from '../../../../i18n/index.js';
import type { ServerListStepProps, MCPServerDisplayInfo } from '../types.js';
import {
groupServersBySource,
getStatusIcon,
getStatusColor,
} from '../utils.js';
export const ServerListStep: React.FC<ServerListStepProps> = ({
servers,
onSelect,
}) => {
const [selectedIndex, setSelectedIndex] = useState(0);
const groupedServers = useMemo(
() => groupServersBySource(servers),
[servers],
);
// 动态计算服务器名称列的最大宽度(基于实际内容)
const serverNameWidth = useMemo(() => {
if (servers.length === 0) return 20;
const maxLength = Math.max(...servers.map((s) => s.name.length));
// 最小 20最大 35留一些余量
return Math.min(Math.max(maxLength + 2, 20), 35);
}, [servers]);
// 计算扁平化的服务器列表用于导航
const flatServers = useMemo(() => {
const result: MCPServerDisplayInfo[] = [];
for (const group of groupedServers) {
result.push(...group.servers);
}
return result;
}, [groupedServers]);
// 键盘导航
useKeypress(
(key) => {
if (key.name === 'up') {
setSelectedIndex((prev) => Math.max(0, prev - 1));
} else if (key.name === 'down') {
setSelectedIndex((prev) => Math.min(flatServers.length - 1, prev + 1));
} else if (key.name === 'return') {
onSelect(selectedIndex);
}
},
{ isActive: true },
);
if (servers.length === 0) {
return (
<Box flexDirection="column">
<Text color={theme.text.secondary}>
{t('No MCP servers configured.')}
</Text>
<Text color={theme.text.secondary}>
{t('Add MCP servers to your settings to get started.')}
</Text>
</Box>
);
}
// 计算当前选中项在分组中的位置
const getSelectionPosition = (globalIndex: number) => {
let currentIndex = 0;
for (const group of groupedServers) {
if (globalIndex < currentIndex + group.servers.length) {
return {
groupIndex: groupedServers.indexOf(group),
itemIndex: globalIndex - currentIndex,
};
}
currentIndex += group.servers.length;
}
return { groupIndex: 0, itemIndex: 0 };
};
const currentPosition = getSelectionPosition(selectedIndex);
return (
<Box flexDirection="column">
{/* 服务器统计 */}
<Box marginBottom={1}>
<Text color={theme.text.secondary}>
{servers.length} {servers.length === 1 ? t('server') : t('servers')}
</Text>
</Box>
{/* 分组服务器列表 */}
{groupedServers.map((group, groupIndex) => (
<Box key={group.source} flexDirection="column" marginBottom={1}>
<Text bold color={theme.text.primary}>
{group.displayName}
{group.servers[0]?.configPath && (
<Text color={theme.text.secondary}>
{' '}
({group.servers[0].configPath})
</Text>
)}
</Text>
<Box flexDirection="column" marginTop={1}>
{group.servers.map((server, itemIndex) => {
const isSelected =
groupIndex === currentPosition.groupIndex &&
itemIndex === currentPosition.itemIndex;
const statusColor = getStatusColor(server.status);
return (
<Box key={server.name}>
<Box minWidth={2}>
<Text
color={
isSelected ? theme.text.accent : theme.text.primary
}
>
{isSelected ? '' : ' '}
</Text>
</Box>
{/* 服务器名称 - 固定宽度 */}
<Box width={serverNameWidth}>
<Text
color={
isSelected ? theme.text.accent : theme.text.primary
}
wrap="truncate"
>
{server.name}
</Text>
</Box>
<Text color={theme.text.secondary}> · </Text>
{/* 状态图标和文本 */}
<Text
color={
statusColor === 'green'
? theme.status.success
: statusColor === 'yellow'
? theme.status.warning
: theme.status.error
}
>
{getStatusIcon(server.status)} {t(server.status)}
</Text>
{/* 显示 Scope 和禁用状态 */}
<Text color={theme.text.secondary}> [{server.scope}]</Text>
{server.isDisabled && (
<Text color={theme.status.warning}> {t('(disabled)')}</Text>
)}
{/* 显示无效工具警告 */}
{!!server.invalidToolCount && server.invalidToolCount > 0 && (
<Text color={theme.status.warning}>
{' '}
{t('{{count}} invalid tools', {
count: String(server.invalidToolCount),
})}
</Text>
)}
</Box>
);
})}
</Box>
</Box>
))}
{/* 提示信息 */}
{servers.some((s) => s.status === 'disconnected') && (
<Box>
<Text color={theme.status.warning}>
{t('Run qwen --debug to see error logs')}
</Text>
</Box>
)}
</Box>
);
};

View file

@ -0,0 +1,217 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import { Box, Text } from 'ink';
import { theme } from '../../../semantic-colors.js';
import { useKeypress } from '../../../hooks/useKeypress.js';
import { t } from '../../../../i18n/index.js';
import type { ToolDetailStepProps } from '../types.js';
/**
*
*/
const truncate = (str: string, maxLen: number = 50): string => {
if (str.length <= maxLen) return str;
return str.substring(0, maxLen - 3) + '...';
};
/**
*
*/
const renderParameter = (
name: string,
param: Record<string, unknown>,
isRequired: boolean,
): React.ReactNode => {
const type = (param['type'] as string) || 'any';
const description = (param['description'] as string) || '';
const defaultValue = param['default'];
const enumValues = param['enum'] as string[] | undefined;
return (
<Box key={name} flexDirection="column" marginTop={1}>
<Box>
<Text color={theme.text.primary}> {name}</Text>
{isRequired && (
<Text color={theme.status.error}> ({t('required')})</Text>
)}
</Box>
<Box marginLeft={2}>
<Text color={theme.text.secondary}>{t('Type')}: </Text>
<Text color={theme.status.success}>{type}</Text>
</Box>
{description && (
<Box marginLeft={2}>
<Text color={theme.text.secondary} wrap="wrap">
{truncate(description, 80)}
</Text>
</Box>
)}
{enumValues && enumValues.length > 0 && (
<Box marginLeft={2}>
<Text color={theme.text.secondary}>
{t('Enum')}: {enumValues.join(', ')}
</Text>
</Box>
)}
{defaultValue !== undefined && (
<Box marginLeft={2}>
<Text color={theme.text.secondary}>
{t('Default')}:{' '}
{typeof defaultValue === 'string'
? `"${truncate(defaultValue, 30)}"`
: String(defaultValue)}
</Text>
</Box>
)}
</Box>
);
};
/**
*
*/
const ParametersList: React.FC<{
properties: Record<string, unknown>;
required: string[];
}> = ({ properties, required }) => {
const requiredSet = new Set(required);
return (
<Box flexDirection="column">
<Text color={theme.text.secondary}>{t('Parameters')}:</Text>
<Box marginLeft={2} flexDirection="column">
{Object.entries(properties).map(([name, param]) =>
renderParameter(
name,
param as Record<string, unknown>,
requiredSet.has(name),
),
)}
</Box>
</Box>
);
};
/**
* schema的关键信息使
*/
const SchemaSummary: React.FC<{ schema: object }> = ({ schema }) => {
const obj = schema as Record<string, unknown>;
const properties = obj['properties'] as Record<string, unknown> | undefined;
const required = (obj['required'] as string[]) || [];
return (
<Box flexDirection="column">
{/* 参数列表 */}
{properties && Object.keys(properties).length > 0 && (
<ParametersList properties={properties} required={required} />
)}
</Box>
);
};
export const ToolDetailStep: React.FC<ToolDetailStepProps> = ({
tool,
onBack,
}) => {
useKeypress(
(key) => {
if (key.name === 'escape') {
onBack();
}
},
{ isActive: true },
);
if (!tool) {
return (
<Box>
<Text color={theme.status.error}>{t('No tool selected')}</Text>
</Box>
);
}
return (
<Box flexDirection="column" gap={1}>
{/* 无效工具警告 */}
{!tool.isValid && (
<Box flexDirection="column" marginBottom={1}>
<Text color={theme.status.error} bold>
{t('Warning: This tool cannot be called by the LLM')}
</Text>
<Text color={theme.status.error}>
{t('Reason')}: {tool.invalidReason || t('unknown')}
</Text>
<Text color={theme.text.secondary}>
{t(
'Tools must have both name and description to be used by the LLM.',
)}
</Text>
</Box>
)}
{/* 工具描述 */}
{tool.description && (
<Box>
<Text wrap="wrap">{tool.description}</Text>
</Box>
)}
{/* 工具注解 */}
{tool.annotations && (
<Box flexDirection="column" marginTop={1}>
<Text color={theme.text.secondary}>{t('Annotations')}:</Text>
<Box marginLeft={2} flexDirection="column">
{tool.annotations.title && (
<Text color={theme.text.secondary}>
{t('Title')}: {tool.annotations.title}
</Text>
)}
{tool.annotations.readOnlyHint !== undefined && (
<Text color={theme.text.secondary}>
{t('Read Only')}:{' '}
{tool.annotations.readOnlyHint ? t('Yes') : t('No')}
</Text>
)}
{tool.annotations.destructiveHint !== undefined && (
<Text color={theme.text.secondary}>
{t('Destructive')}:{' '}
{tool.annotations.destructiveHint ? t('Yes') : t('No')}
</Text>
)}
{tool.annotations.idempotentHint !== undefined && (
<Text color={theme.text.secondary}>
{t('Idempotent')}:{' '}
{tool.annotations.idempotentHint ? t('Yes') : t('No')}
</Text>
)}
{tool.annotations.openWorldHint !== undefined && (
<Text color={theme.text.secondary}>
{t('Open World')}:{' '}
{tool.annotations.openWorldHint ? t('Yes') : t('No')}
</Text>
)}
</Box>
</Box>
)}
{/* Schema */}
{tool.schema && (
<Box flexDirection="column" marginTop={1}>
<SchemaSummary schema={tool.schema} />
</Box>
)}
{/* 所属服务器 */}
<Box marginTop={1}>
<Text color={theme.text.secondary}>
{t('Server')}: {tool.serverName}
</Text>
</Box>
</Box>
);
};

View file

@ -0,0 +1,157 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import { useState, useMemo } from 'react';
import { Box, Text } from 'ink';
import { theme } from '../../../semantic-colors.js';
import { useKeypress } from '../../../hooks/useKeypress.js';
import { t } from '../../../../i18n/index.js';
import type { ToolListStepProps, MCPToolDisplayInfo } from '../types.js';
import { VISIBLE_TOOLS_COUNT } from '../constants.js';
export const ToolListStep: React.FC<ToolListStepProps> = ({
tools,
serverName,
onSelect,
onBack,
}) => {
const [selectedIndex, setSelectedIndex] = useState(0);
// 动态计算工具名称列的最大宽度(基于实际内容)
const toolNameWidth = useMemo(() => {
if (tools.length === 0) return 30;
const maxLength = Math.max(...tools.map((t) => t.name.length));
// 最小 30最大 50留一些余量
return Math.min(Math.max(maxLength + 2, 30), 50);
}, [tools]);
// 计算可视区域的起始索引(滚动窗口)
const scrollOffset = useMemo(() => {
if (tools.length <= VISIBLE_TOOLS_COUNT) {
return 0;
}
// 确保选中项在可视区域内
if (selectedIndex < VISIBLE_TOOLS_COUNT - 1) {
return 0;
}
return Math.min(
selectedIndex - VISIBLE_TOOLS_COUNT + 1,
tools.length - VISIBLE_TOOLS_COUNT,
);
}, [selectedIndex, tools.length]);
// 当前可视的工具列表
const displayTools = useMemo(
() => tools.slice(scrollOffset, scrollOffset + VISIBLE_TOOLS_COUNT),
[tools, scrollOffset],
);
useKeypress(
(key) => {
if (key.name === 'escape') {
onBack();
} else if (key.name === 'up') {
setSelectedIndex((prev) => Math.max(0, prev - 1));
} else if (key.name === 'down') {
setSelectedIndex((prev) => Math.min(tools.length - 1, prev + 1));
} else if (key.name === 'return') {
if (tools[selectedIndex]) {
onSelect(tools[selectedIndex]);
}
}
},
{ isActive: true },
);
if (tools.length === 0) {
return (
<Box flexDirection="column">
<Text color={theme.text.secondary}>
{t('No tools available for this server.')}
</Text>
</Box>
);
}
const getToolAnnotations = (tool: MCPToolDisplayInfo): string => {
const hints: string[] = [];
if (tool.annotations?.destructiveHint) hints.push(t('destructive'));
if (tool.annotations?.readOnlyHint) hints.push(t('read-only'));
if (tool.annotations?.openWorldHint) hints.push(t('open-world'));
if (tool.annotations?.idempotentHint) hints.push(t('idempotent'));
return hints.join(', ');
};
return (
<Box flexDirection="column">
{/* 标题 */}
<Box marginBottom={1}>
<Text bold>{t('Tools for {{name}}', { name: serverName })}</Text>
<Text color={theme.text.secondary}>
{' '}
({tools.length} {tools.length === 1 ? t('tool') : t('tools')})
</Text>
</Box>
{/* 工具列表 */}
<Box flexDirection="column">
{displayTools.map((tool, index) => {
const actualIndex = scrollOffset + index;
const isSelected = actualIndex === selectedIndex;
const annotations = getToolAnnotations(tool);
return (
<Box key={tool.name}>
{/* 选择器和序号 */}
<Box minWidth={4}>
<Text
color={isSelected ? theme.text.accent : theme.text.primary}
>
{isSelected ? '' : ' '}
</Text>
<Text color={theme.text.secondary}>{actualIndex + 1}.</Text>
</Box>
{/* 工具名称 - 固定宽度 */}
<Box width={toolNameWidth}>
<Text
color={isSelected ? theme.text.accent : theme.text.primary}
wrap="truncate"
>
{tool.name}
</Text>
</Box>
{/* 显示无效工具警告 */}
{!tool.isValid && (
<Text color={theme.status.warning}>
{t('invalid: {{reason}}', {
reason: tool.invalidReason || t('unknown'),
})}
</Text>
)}
{annotations && tool.isValid && (
<Text color={theme.text.secondary}>{annotations}</Text>
)}
</Box>
);
})}
</Box>
{/* 滚动提示 */}
{tools.length > VISIBLE_TOOLS_COUNT && (
<Box marginTop={1}>
<Text color={theme.text.secondary}>
{scrollOffset > 0 ? '↑ ' : ' '}
{t('{{current}}/{{total}}', {
current: (selectedIndex + 1).toString(),
total: tools.length.toString(),
})}
{scrollOffset + VISIBLE_TOOLS_COUNT < tools.length ? ' ↓' : ''}
</Text>
</Box>
)}
</Box>
);
};

View file

@ -0,0 +1,180 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import type {
MCPServerConfig,
MCPServerStatus,
} from '@qwen-code/qwen-code-core';
/**
* MCP管理步骤定义
*/
export const MCP_MANAGEMENT_STEPS = {
SERVER_LIST: 'server-list',
SERVER_DETAIL: 'server-detail',
DISABLE_SCOPE_SELECT: 'disable-scope-select',
TOOL_LIST: 'tool-list',
TOOL_DETAIL: 'tool-detail',
} as const;
export type MCPManagementStep =
(typeof MCP_MANAGEMENT_STEPS)[keyof typeof MCP_MANAGEMENT_STEPS];
/**
* MCP服务器显示信息
*/
export interface MCPServerDisplayInfo {
/** 服务器名称 */
name: string;
/** 连接状态 */
status: MCPServerStatus;
/** 来源类型 */
source: 'user' | 'project' | 'extension';
/** 配置所在的 scope */
scope: 'user' | 'workspace' | 'extension';
/** 配置文件路径 */
configPath?: string;
/** 服务器配置 */
config: MCPServerConfig;
/** 工具数量 */
toolCount: number;
/** 无效工具数量缺少name或description */
invalidToolCount?: number;
/** Prompt数量 */
promptCount: number;
/** 错误信息 */
errorMessage?: string;
/** 是否被禁用(在排除列表中) */
isDisabled: boolean;
}
/**
* MCP工具显示信息
*/
export interface MCPToolDisplayInfo {
/** 工具名称 */
name: string;
/** 工具描述 */
description?: string;
/** 所属服务器 */
serverName: string;
/** 工具schema */
schema?: object;
/** 工具注解 */
annotations?: {
title?: string;
readOnlyHint?: boolean;
destructiveHint?: boolean;
idempotentHint?: boolean;
openWorldHint?: boolean;
};
/** 工具是否有效有name和description才能被LLM调用 */
isValid: boolean;
/** 无效原因当isValid为false时 */
invalidReason?: string;
}
/**
* MCP Prompt显示信息
*/
export interface MCPPromptDisplayInfo {
/** Prompt名称 */
name: string;
/** Prompt描述 */
description?: string;
/** 所属服务器 */
serverName: string;
/** 参数定义 */
arguments?: Array<{
name: string;
description?: string;
required?: boolean;
}>;
}
/**
*
*/
export interface GroupedServers {
/** 来源标识 */
source: string;
/** 来源显示名称 */
displayName: string;
/** 配置文件路径 */
configPath?: string;
/** 服务器列表 */
servers: MCPServerDisplayInfo[];
}
/**
* ServerListStep组件属性
*/
export interface ServerListStepProps {
/** 服务器列表 */
servers: MCPServerDisplayInfo[];
/** 选择回调 */
onSelect: (index: number) => void;
}
/**
* ServerDetailStep组件属性
*/
export interface ServerDetailStepProps {
/** 选中的服务器 */
server: MCPServerDisplayInfo | null;
/** 查看工具列表回调 */
onViewTools: () => void;
/** 重新连接回调 */
onReconnect?: () => void;
/** 禁用服务器回调 */
onDisable?: () => void;
/** 返回回调 */
onBack: () => void;
}
/**
* DisableScopeSelectStep组件属性
*/
export interface DisableScopeSelectStepProps {
/** 选中的服务器 */
server: MCPServerDisplayInfo | null;
/** 选择 scope 回调 */
onSelectScope: (scope: 'user' | 'workspace') => void;
/** 返回回调 */
onBack: () => void;
}
/**
* ToolListStep组件属性
*/
export interface ToolListStepProps {
/** 工具列表 */
tools: MCPToolDisplayInfo[];
/** 服务器名称 */
serverName: string;
/** 选择回调 */
onSelect: (tool: MCPToolDisplayInfo) => void;
/** 返回回调 */
onBack: () => void;
}
/**
* ToolDetailStep组件属性
*/
export interface ToolDetailStepProps {
/** 工具信息 */
tool: MCPToolDisplayInfo | null;
/** 返回回调 */
onBack: () => void;
}
/**
* MCP管理对话框属性
*/
export interface MCPManagementDialogProps {
/** 关闭回调 */
onClose: () => void;
}

View file

@ -0,0 +1,159 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import {
groupServersBySource,
getStatusColor,
getStatusIcon,
truncateText,
formatServerCommand,
isToolValid,
getToolInvalidReasons,
} from './utils.js';
import type { MCPServerDisplayInfo } from './types.js';
import { MCPServerStatus } from '@qwen-code/qwen-code-core';
describe('MCP utils', () => {
describe('groupServersBySource', () => {
it('should group servers by source', () => {
const servers: MCPServerDisplayInfo[] = [
{
name: 'server1',
status: MCPServerStatus.CONNECTED,
source: 'user',
scope: 'user',
config: { command: 'cmd1' },
toolCount: 1,
promptCount: 0,
isDisabled: false,
},
{
name: 'server2',
status: MCPServerStatus.CONNECTED,
source: 'extension',
scope: 'extension',
config: { command: 'cmd2' },
toolCount: 2,
promptCount: 0,
isDisabled: false,
},
];
const result = groupServersBySource(servers);
expect(result).toHaveLength(2);
expect(result[0].source).toBe('user');
expect(result[0].servers).toHaveLength(1);
expect(result[1].source).toBe('extension');
});
});
describe('getStatusColor', () => {
it('should return correct colors for each status', () => {
expect(getStatusColor(MCPServerStatus.CONNECTED)).toBe('green');
expect(getStatusColor(MCPServerStatus.CONNECTING)).toBe('yellow');
expect(getStatusColor(MCPServerStatus.DISCONNECTED)).toBe('red');
expect(getStatusColor('unknown' as MCPServerStatus)).toBe('gray');
});
});
describe('getStatusIcon', () => {
it('should return correct icons for each status', () => {
expect(getStatusIcon(MCPServerStatus.CONNECTED)).toBe('✓');
expect(getStatusIcon(MCPServerStatus.CONNECTING)).toBe('…');
expect(getStatusIcon(MCPServerStatus.DISCONNECTED)).toBe('✗');
expect(getStatusIcon('unknown' as MCPServerStatus)).toBe('?');
});
});
describe('truncateText', () => {
it('should truncate text longer than maxLength', () => {
expect(truncateText('hello world', 8)).toBe('hello...');
});
it('should not truncate text shorter than maxLength', () => {
expect(truncateText('hello', 10)).toBe('hello');
});
});
describe('formatServerCommand', () => {
it('should format http URL', () => {
const server = {
config: { httpUrl: 'http://localhost:3000' },
} as MCPServerDisplayInfo;
expect(formatServerCommand(server)).toBe('http://localhost:3000 (http)');
});
it('should format stdio command', () => {
const server = {
config: { command: 'node', args: ['server.js'] },
} as MCPServerDisplayInfo;
expect(formatServerCommand(server)).toBe('node server.js (stdio)');
});
it('should return Unknown for empty config', () => {
const server = { config: {} } as MCPServerDisplayInfo;
expect(formatServerCommand(server)).toBe('Unknown');
});
});
describe('isToolValid', () => {
it('should return true for valid tool with name and description', () => {
expect(isToolValid('toolName', 'A description')).toBe(true);
});
it('should return false for tool without name', () => {
expect(isToolValid(undefined, 'A description')).toBe(false);
expect(isToolValid('', 'A description')).toBe(false);
});
it('should return false for tool without description', () => {
expect(isToolValid('toolName', undefined)).toBe(false);
expect(isToolValid('toolName', '')).toBe(false);
});
it('should return false for tool without both name and description', () => {
expect(isToolValid(undefined, undefined)).toBe(false);
expect(isToolValid('', '')).toBe(false);
});
});
describe('getToolInvalidReasons', () => {
it('should return empty array for valid tool', () => {
expect(getToolInvalidReasons('toolName', 'A description')).toEqual([]);
});
it('should return missing name reason', () => {
expect(getToolInvalidReasons(undefined, 'A description')).toEqual([
'missing name',
]);
expect(getToolInvalidReasons('', 'A description')).toEqual([
'missing name',
]);
});
it('should return missing description reason', () => {
expect(getToolInvalidReasons('toolName', undefined)).toEqual([
'missing description',
]);
expect(getToolInvalidReasons('toolName', '')).toEqual([
'missing description',
]);
});
it('should return both reasons when both are missing', () => {
expect(getToolInvalidReasons(undefined, undefined)).toEqual([
'missing name',
'missing description',
]);
expect(getToolInvalidReasons('', '')).toEqual([
'missing name',
'missing description',
]);
});
});
});

View file

@ -0,0 +1,129 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import type { MCPServerDisplayInfo, GroupedServers } from './types.js';
import { SOURCE_DISPLAY_NAMES } from './constants.js';
/**
*
*/
export function groupServersBySource(
servers: MCPServerDisplayInfo[],
): GroupedServers[] {
const groups = new Map<string, MCPServerDisplayInfo[]>();
for (const server of servers) {
const existing = groups.get(server.source);
if (existing) {
existing.push(server);
} else {
groups.set(server.source, [server]);
}
}
// 按优先级排序: user > project > extension
const sourceOrder = ['user', 'project', 'extension'];
const result: GroupedServers[] = [];
for (const source of sourceOrder) {
const servers = groups.get(source);
if (servers && servers.length > 0) {
result.push({
source,
displayName: SOURCE_DISPLAY_NAMES[source] || source,
servers,
});
}
}
return result;
}
/**
*
*/
export function getStatusColor(
status: string,
): 'green' | 'yellow' | 'red' | 'gray' {
switch (status) {
case 'connected':
return 'green';
case 'connecting':
return 'yellow';
case 'disconnected':
return 'red';
default:
return 'gray';
}
}
/**
*
*/
export function getStatusIcon(status: string): string {
switch (status) {
case 'connected':
return '✓';
case 'connecting':
return '…';
case 'disconnected':
return '✗';
default:
return '?';
}
}
/**
*
*/
export function truncateText(text: string, maxLength: number): string {
if (text.length <= maxLength) return text;
return text.substring(0, maxLength - 3) + '...';
}
/**
*
*/
export function formatServerCommand(server: MCPServerDisplayInfo): string {
const config = server.config;
if (config.httpUrl) {
return `${config.httpUrl} (http)`;
}
if (config.url) {
return `${config.url} (sse)`;
}
if (config.command) {
const args = config.args?.join(' ') || '';
return `${config.command} ${args} (stdio)`.trim();
}
return 'Unknown';
}
/**
* Check if a tool is valid (has both name and description required by LLM)
* @param name - Tool name
* @param description - Tool description
* @returns boolean indicating if the tool is valid
*/
export function isToolValid(name?: string, description?: string): boolean {
return !!name && !!description;
}
/**
* Get the reason why a tool is invalid
* @param name - Tool name
* @param description - Tool description
* @returns Array of missing fields
*/
export function getToolInvalidReasons(
name?: string,
description?: string,
): string[] {
const reasons: string[] = [];
if (!name) reasons.push('missing name');
if (!description) reasons.push('missing description');
return reasons;
}

View file

@ -1369,6 +1369,105 @@ describe('KeypressContext - Kitty Protocol', () => {
});
});
describe('Kitty keypad private-use keys', () => {
it.each([
{ keyCode: 57399, digit: '0' },
{ keyCode: 57400, digit: '1' },
{ keyCode: 57401, digit: '2' },
{ keyCode: 57402, digit: '3' },
{ keyCode: 57403, digit: '4' },
{ keyCode: 57404, digit: '5' },
{ keyCode: 57405, digit: '6' },
{ keyCode: 57406, digit: '7' },
{ keyCode: 57407, digit: '8' },
{ keyCode: 57408, digit: '9' },
])(
'parses kitty keypad digit keyCode $keyCode as "$digit"',
({ keyCode, digit }) => {
const keyHandler = vi.fn();
const { result } = renderHook(() => useKeypressContext(), { wrapper });
act(() => result.current.subscribe(keyHandler));
act(() => stdin.sendKittySequence(`\x1b[${keyCode}u`));
expect(keyHandler).toHaveBeenCalledWith(
expect.objectContaining({
name: digit,
sequence: digit,
kittyProtocol: true,
}),
);
},
);
it.each([
{ keyCode: 57409, char: '.' },
{ keyCode: 57410, char: '/' },
{ keyCode: 57411, char: '*' },
{ keyCode: 57412, char: '-' },
{ keyCode: 57413, char: '+' },
{ keyCode: 57415, char: '=' },
{ keyCode: 57416, char: ',' },
])(
'parses kitty keypad printable keyCode $keyCode as "$char"',
({ keyCode, char }) => {
const keyHandler = vi.fn();
const { result } = renderHook(() => useKeypressContext(), { wrapper });
act(() => result.current.subscribe(keyHandler));
act(() => stdin.sendKittySequence(`\x1b[${keyCode}u`));
expect(keyHandler).toHaveBeenCalledWith(
expect.objectContaining({
name: char,
sequence: char,
kittyProtocol: true,
}),
);
},
);
it.each([
{ keyCode: 57417, name: 'left' },
{ keyCode: 57418, name: 'right' },
{ keyCode: 57419, name: 'up' },
{ keyCode: 57420, name: 'down' },
{ keyCode: 57421, name: 'pageup' },
{ keyCode: 57422, name: 'pagedown' },
{ keyCode: 57423, name: 'home' },
{ keyCode: 57424, name: 'end' },
{ keyCode: 57425, name: 'insert' },
{ keyCode: 57426, name: 'delete' },
])(
'parses kitty keypad functional keyCode $keyCode as $name',
({ keyCode, name }) => {
const keyHandler = vi.fn();
const { result } = renderHook(() => useKeypressContext(), { wrapper });
act(() => result.current.subscribe(keyHandler));
act(() => stdin.sendKittySequence(`\x1b[${keyCode};5u`));
expect(keyHandler).toHaveBeenCalledWith(
expect.objectContaining({
name,
ctrl: true,
kittyProtocol: true,
}),
);
},
);
it('does not emit a placeholder for unmapped private-use keyCodes', () => {
const keyHandler = vi.fn();
const { result } = renderHook(() => useKeypressContext(), { wrapper });
act(() => result.current.subscribe(keyHandler));
act(() => stdin.sendKittySequence(`\x1b[57398u`));
expect(keyHandler).not.toHaveBeenCalled();
});
});
describe('Shift+Tab forms', () => {
it.each([
{ sequence: `\x1b[Z`, description: 'legacy reverse Tab' },

View file

@ -47,6 +47,42 @@ export const DRAG_COMPLETION_TIMEOUT_MS = 100; // Broadcast full path after 100m
export const SINGLE_QUOTE = "'";
export const DOUBLE_QUOTE = '"';
// Kitty keypad private-use keycodes (0xE000-0xE026)
// Reference: https://sw.kovidgoyal.net/kitty/keyboard-protocol/#functional-key-definitions
const KITTY_KEYPAD_PRINTABLE_KEYCODE_TO_CHAR: Record<number, string> = {
57399: '0',
57400: '1',
57401: '2',
57402: '3',
57403: '4',
57404: '5',
57405: '6',
57406: '7',
57407: '8',
57408: '9',
57409: '.',
57410: '/',
57411: '*',
57412: '-',
57413: '+',
// 57414 is keypad Enter - handled separately via CSI~ sequence
57415: '=',
57416: ',',
};
const KITTY_KEYPAD_FUNCTIONAL_KEYCODE_TO_NAME: Record<number, string> = {
57417: 'left',
57418: 'right',
57419: 'up',
57420: 'down',
57421: 'pageup',
57422: 'pagedown',
57423: 'home',
57424: 'end',
57425: 'insert',
57426: 'delete',
};
export interface Key {
name: string;
ctrl: boolean;
@ -332,14 +368,52 @@ export function KeypressProvider({
};
}
if (!ctrl) {
const keypadChar = KITTY_KEYPAD_PRINTABLE_KEYCODE_TO_CHAR[keyCode];
if (keypadChar) {
return {
key: {
name: keypadChar,
ctrl: false,
meta: alt,
shift,
paste: false,
sequence: keypadChar,
kittyProtocol: true,
},
length: m[0].length,
};
}
}
const keypadName = KITTY_KEYPAD_FUNCTIONAL_KEYCODE_TO_NAME[keyCode];
if (keypadName) {
return {
key: {
name: keypadName,
ctrl,
meta: alt,
shift,
paste: false,
sequence: buffer.slice(0, m[0].length),
kittyProtocol: true,
},
length: m[0].length,
};
}
// Printable CSI-u keys (including space) should behave like regular
// character input so downstream text inputs receive the literal char.
// Kitty uses the Unicode private use area for some functional keys
// such as keypad events, so exclude that range from generic printable
// conversion and handle mapped keys explicitly above.
if (
terminator === 'u' &&
!ctrl &&
keyCode >= 32 &&
keyCode !== 127 &&
keyCode <= 0x10ffff
keyCode <= 0x10ffff &&
!(keyCode >= 0xe000 && keyCode <= 0xf8ff)
) {
const char = String.fromCodePoint(keyCode);
const printableName =

View file

@ -76,6 +76,8 @@ export interface UIActions {
closeAgentsManagerDialog: () => void;
// Extensions manager dialog
closeExtensionsManagerDialog: () => void;
// MCP dialog
closeMcpDialog: () => void;
// Resume session dialog
openResumeDialog: () => void;
closeResumeDialog: () => void;

View file

@ -127,6 +127,8 @@ export interface UIState {
isAgentsManagerDialogOpen: boolean;
// Extensions manager dialog
isExtensionsManagerDialogOpen: boolean;
// MCP dialog
isMcpDialogOpen: boolean;
// Feedback dialog
isFeedbackDialogOpen: boolean;
}

View file

@ -79,6 +79,7 @@ interface SlashCommandProcessorActions {
openSubagentCreateDialog: () => void;
openAgentsManagerDialog: () => void;
openExtensionsManagerDialog: () => void;
openMcpDialog: () => void;
}
/**
@ -477,6 +478,9 @@ export const useSlashCommandProcessor = (
case 'subagent_list':
actions.openAgentsManagerDialog();
return { type: 'handled' };
case 'mcp':
actions.openMcpDialog();
return { type: 'handled' };
case 'approval-mode':
actions.openApprovalModeDialog();
return { type: 'handled' };

View file

@ -0,0 +1,31 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import { useState, useCallback } from 'react';
export interface UseMcpDialogReturn {
isMcpDialogOpen: boolean;
openMcpDialog: () => void;
closeMcpDialog: () => void;
}
export const useMcpDialog = (): UseMcpDialogReturn => {
const [isMcpDialogOpen, setIsMcpDialogOpen] = useState(false);
const openMcpDialog = useCallback(() => {
setIsMcpDialogOpen(true);
}, []);
const closeMcpDialog = useCallback(() => {
setIsMcpDialogOpen(false);
}, []);
return {
isMcpDialogOpen,
openMcpDialog,
closeMcpDialog,
};
};