From 4d27950a95543d106fff992375c8f6ea438127a3 Mon Sep 17 00:00:00 2001 From: LaZzyMan Date: Sat, 28 Feb 2026 16:06:34 +0800 Subject: [PATCH] feat: Add interactive TUI for extension management --- docs/users/extension/introduction.md | 16 +- packages/cli/src/i18n/locales/en.js | 67 +++ packages/cli/src/i18n/locales/zh.js | 64 ++- packages/cli/src/ui/AppContainer.tsx | 19 +- .../cli/src/ui/commands/extensionsCommand.ts | 423 +------------- packages/cli/src/ui/commands/types.ts | 3 +- .../cli/src/ui/components/DialogManager.tsx | 10 + .../ExtensionsManagerDialog.test.tsx | 153 ++++++ .../extensions/ExtensionsManagerDialog.tsx | 517 ++++++++++++++++++ .../ExtensionsManagerDialog.test.tsx.snap | 53 ++ .../cli/src/ui/components/extensions/index.ts | 9 + .../steps/ActionSelectionStep.test.tsx | 98 ++++ .../extensions/steps/ActionSelectionStep.tsx | 116 ++++ .../extensions/steps/ExtensionDetailStep.tsx | 105 ++++ .../steps/ExtensionListStep.test.tsx | 123 +++++ .../extensions/steps/ExtensionListStep.tsx | 151 +++++ .../extensions/steps/ScopeSelectStep.tsx | 83 +++ .../extensions/steps/UninstallConfirmStep.tsx | 68 +++ .../ActionSelectionStep.test.tsx.snap | 166 ++++++ .../ExtensionListStep.test.tsx.snap | 171 ++++++ .../ui/components/extensions/steps/index.ts | 11 + .../cli/src/ui/components/extensions/types.ts | 89 +++ .../cli/src/ui/contexts/UIActionsContext.tsx | 2 + .../cli/src/ui/contexts/UIStateContext.tsx | 2 + .../cli/src/ui/hooks/slashCommandProcessor.ts | 4 + .../ui/hooks/useExtensionsManagerDialog.ts | 33 ++ packages/core/src/extension/github.ts | 1 + 27 files changed, 2132 insertions(+), 425 deletions(-) create mode 100644 packages/cli/src/ui/components/extensions/ExtensionsManagerDialog.test.tsx create mode 100644 packages/cli/src/ui/components/extensions/ExtensionsManagerDialog.tsx create mode 100644 packages/cli/src/ui/components/extensions/__snapshots__/ExtensionsManagerDialog.test.tsx.snap create mode 100644 packages/cli/src/ui/components/extensions/index.ts create mode 100644 packages/cli/src/ui/components/extensions/steps/ActionSelectionStep.test.tsx create mode 100644 packages/cli/src/ui/components/extensions/steps/ActionSelectionStep.tsx create mode 100644 packages/cli/src/ui/components/extensions/steps/ExtensionDetailStep.tsx create mode 100644 packages/cli/src/ui/components/extensions/steps/ExtensionListStep.test.tsx create mode 100644 packages/cli/src/ui/components/extensions/steps/ExtensionListStep.tsx create mode 100644 packages/cli/src/ui/components/extensions/steps/ScopeSelectStep.tsx create mode 100644 packages/cli/src/ui/components/extensions/steps/UninstallConfirmStep.tsx create mode 100644 packages/cli/src/ui/components/extensions/steps/__snapshots__/ActionSelectionStep.test.tsx.snap create mode 100644 packages/cli/src/ui/components/extensions/steps/__snapshots__/ExtensionListStep.test.tsx.snap create mode 100644 packages/cli/src/ui/components/extensions/steps/index.ts create mode 100644 packages/cli/src/ui/components/extensions/types.ts create mode 100644 packages/cli/src/ui/hooks/useExtensionsManagerDialog.ts diff --git a/docs/users/extension/introduction.md b/docs/users/extension/introduction.md index 1d7160768..0efb25b7c 100644 --- a/docs/users/extension/introduction.md +++ b/docs/users/extension/introduction.md @@ -12,17 +12,11 @@ We offer a suite of extension management tools using both `qwen extensions` CLI You can manage extensions at runtime within the interactive CLI using `/extensions` slash commands. These commands support hot-reloading, meaning changes take effect immediately without restarting the application. -| Command | Description | -| ------------------------------------------------------ | ----------------------------------------------------------------- | -| `/extensions` or `/extensions list` | List all installed extensions with their status | -| `/extensions install ` | Install an extension from a git URL, local path, or marketplace | -| `/extensions uninstall ` | Uninstall an extension | -| `/extensions enable --scope ` | Enable an extension | -| `/extensions disable --scope ` | Disable an extension | -| `/extensions update ` | Update a specific extension | -| `/extensions update --all` | Update all extensions with available updates | -| `/extensions detail ` | Show details of an extension | -| `/extensions explore [source]` | Open extensions source page(Gemini or ClaudeCode) in your browser | +| Command | Description | +| ------------------------------------- | ----------------------------------------------------------------- | +| `/extensions` or `/extensions manage` | Manage all installed extensions | +| `/extensions install ` | Install an extension from a git URL, local path, or marketplace | +| `/extensions explore [source]` | Open extensions source page(Gemini or ClaudeCode) in your browser | ### CLI Extension Management diff --git a/packages/cli/src/i18n/locales/en.js b/packages/cli/src/i18n/locales/en.js index 775f470b7..2054923c9 100644 --- a/packages/cli/src/i18n/locales/en.js +++ b/packages/cli/src/i18n/locales/en.js @@ -288,6 +288,73 @@ export default { 'Failed to save and edit subagent: {{error}}': 'Failed to save and edit subagent: {{error}}', + // ============================================================================ + // Extensions - Management Dialog + // ============================================================================ + 'Manage Extensions': 'Manage Extensions', + 'Extension Details': 'Extension Details', + 'View Extension': 'View Extension', + 'Update Extension': 'Update Extension', + 'Disable Extension': 'Disable Extension', + 'Enable Extension': 'Enable Extension', + 'Uninstall Extension': 'Uninstall Extension', + 'Select Scope': 'Select Scope', + 'User Scope': 'User Scope', + 'Workspace Scope': 'Workspace Scope', + 'No extensions found.': 'No extensions found.', + Active: 'Active', + Disabled: 'Disabled', + 'Update available': 'Update available', + 'Up to date': 'Up to date', + 'Checking...': 'Checking...', + 'Updating...': 'Updating...', + Unknown: 'Unknown', + Error: 'Error', + 'Version:': 'Version:', + 'Status:': 'Status:', + 'Are you sure you want to uninstall extension "{{name}}"?': + 'Are you sure you want to uninstall extension "{{name}}"?', + 'This action cannot be undone.': 'This action cannot be undone.', + 'Extension "{{name}}" disabled successfully.': + 'Extension "{{name}}" disabled successfully.', + 'Extension "{{name}}" enabled successfully.': + 'Extension "{{name}}" enabled successfully.', + 'Extension "{{name}}" updated successfully.': + 'Extension "{{name}}" updated successfully.', + 'Failed to update extension "{{name}}": {{error}}': + 'Failed to update extension "{{name}}": {{error}}', + 'Select the scope for this action:': 'Select the scope for this action:', + 'User - Applies to all projects': 'User - Applies to all projects', + 'Workspace - Applies to current project only': + 'Workspace - Applies to current project only', + // Extension dialog - missing keys + 'Name:': 'Name:', + 'MCP Servers:': 'MCP Servers:', + 'Settings:': 'Settings:', + active: 'active', + disabled: 'disabled', + 'View Details': 'View Details', + 'Update failed:': 'Update failed:', + 'Updating {{name}}...': 'Updating {{name}}...', + 'Update complete!': 'Update complete!', + 'User (global)': 'User (global)', + 'Workspace (project-specific)': 'Workspace (project-specific)', + 'Disable "{{name}}" - Select Scope': 'Disable "{{name}}" - Select Scope', + 'Enable "{{name}}" - Select Scope': 'Enable "{{name}}" - Select Scope', + 'No extension selected': 'No extension selected', + 'Press Y/Enter to confirm, N/Esc to cancel': + 'Press Y/Enter to confirm, N/Esc to cancel', + 'Y/Enter to confirm, N/Esc to cancel': 'Y/Enter to confirm, N/Esc to cancel', + '{{count}} extensions installed': '{{count}} extensions installed', + "Use '/extensions install' to install your first extension.": + "Use '/extensions install' to install your first extension.", + // Update status values + 'up to date': 'up to date', + 'update available': 'update available', + 'checking...': 'checking...', + 'not updatable': 'not updatable', + error: 'error', + // ============================================================================ // Commands - General (continued) // ============================================================================ diff --git a/packages/cli/src/i18n/locales/zh.js b/packages/cli/src/i18n/locales/zh.js index b0db2d0e5..4bc35cb58 100644 --- a/packages/cli/src/i18n/locales/zh.js +++ b/packages/cli/src/i18n/locales/zh.js @@ -33,7 +33,7 @@ export default { '!': '!', '!npm run start': '!npm run start', 'start server': 'start server', - 'Commands:': '命令:', + 'Commands:': '命令:', 'shell command': 'shell 命令', 'Model Context Protocol command (from external servers)': '模型上下文协议命令(来自外部服务器)', @@ -277,6 +277,68 @@ export default { 'Failed to save and edit subagent: {{error}}': '保存并编辑子智能体失败: {{error}}', + // ============================================================================ + // Extensions - Management Dialog + // ============================================================================ + 'Manage Extensions': '管理扩展', + 'Extension Details': '扩展详情', + 'View Extension': '查看扩展', + 'Update Extension': '更新扩展', + 'Disable Extension': '禁用扩展', + 'Enable Extension': '启用扩展', + 'Uninstall Extension': '卸载扩展', + 'Select Scope': '选择作用域', + 'User Scope': '用户作用域', + 'Workspace Scope': '工作区作用域', + 'No extensions found.': '未找到扩展。', + Active: '已启用', + Disabled: '已禁用', + 'Update available': '有可用更新', + 'Up to date': '已是最新', + 'Checking...': '检查中...', + 'Updating...': '更新中...', + Unknown: '未知', + Error: '错误', + 'Version:': '版本:', + 'Status:': '状态:', + 'Are you sure you want to uninstall extension "{{name}}"?': + '确定要卸载扩展 "{{name}}" 吗?', + 'This action cannot be undone.': '此操作无法撤销。', + 'Extension "{{name}}" disabled successfully.': '扩展 "{{name}}" 禁用成功。', + 'Extension "{{name}}" enabled successfully.': '扩展 "{{name}}" 启用成功。', + 'Extension "{{name}}" updated successfully.': '扩展 "{{name}}" 更新成功。', + 'Failed to update extension "{{name}}": {{error}}': + '更新扩展 "{{name}}" 失败:{{error}}', + 'Select the scope for this action:': '选择此操作的作用域:', + 'User - Applies to all projects': '用户 - 应用于所有项目', + 'Workspace - Applies to current project only': '工作区 - 仅应用于当前项目', + // Extension dialog - missing keys + 'Name:': '名称:', + 'MCP Servers:': 'MCP 服务器:', + 'Settings:': '设置:', + active: '已启用', + disabled: '已禁用', + 'View Details': '查看详情', + 'Update failed:': '更新失败:', + 'Updating {{name}}...': '正在更新 {{name}}...', + 'Update complete!': '更新完成!', + 'User (global)': '用户(全局)', + 'Workspace (project-specific)': '工作区(项目特定)', + 'Disable "{{name}}" - Select Scope': '禁用 "{{name}}" - 选择作用域', + 'Enable "{{name}}" - Select Scope': '启用 "{{name}}" - 选择作用域', + 'No extension selected': '未选择扩展', + 'Press Y/Enter to confirm, N/Esc to cancel': '按 Y/Enter 确认,N/Esc 取消', + 'Y/Enter to confirm, N/Esc to cancel': 'Y/Enter 确认,N/Esc 取消', + '{{count}} extensions installed': '已安装 {{count}} 个扩展', + "Use '/extensions install' to install your first extension.": + "使用 '/extensions install' 安装您的第一个扩展。", + // Update status values + 'up to date': '已是最新', + 'update available': '有可用更新', + 'checking...': '检查中...', + 'not updatable': '不可更新', + error: '错误', + // ============================================================================ // Commands - General (continued) // ============================================================================ diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 53e1ea9e3..92431ac2a 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -104,6 +104,7 @@ import { type VisionSwitchOutcome } from './components/ModelSwitchDialog.js'; import { processVisionSwitchOutcome } from './hooks/useVisionAutoSwitch.js'; import { useSubagentCreateDialog } from './hooks/useSubagentCreateDialog.js'; import { useAgentsManagerDialog } from './hooks/useAgentsManagerDialog.js'; +import { useExtensionsManagerDialog } from './hooks/useExtensionsManagerDialog.js'; import { useAttentionNotifications } from './hooks/useAttentionNotifications.js'; import { requestConsentInteractive, @@ -495,6 +496,11 @@ export const AppContainer = (props: AppContainerProps) => { openAgentsManagerDialog, closeAgentsManagerDialog, } = useAgentsManagerDialog(); + const { + isExtensionsManagerDialogOpen, + openExtensionsManagerDialog, + closeExtensionsManagerDialog, + } = useExtensionsManagerDialog(); // Vision model auto-switch dialog state (must be before slashCommandActions) const [isVisionSwitchDialogOpen, setIsVisionSwitchDialogOpen] = @@ -529,6 +535,7 @@ export const AppContainer = (props: AppContainerProps) => { addConfirmUpdateExtensionRequest, openSubagentCreateDialog, openAgentsManagerDialog, + openExtensionsManagerDialog, openResumeDialog, }), [ @@ -544,6 +551,7 @@ export const AppContainer = (props: AppContainerProps) => { addConfirmUpdateExtensionRequest, openSubagentCreateDialog, openAgentsManagerDialog, + openExtensionsManagerDialog, openResumeDialog, ], ); @@ -1343,7 +1351,8 @@ export const AppContainer = (props: AppContainerProps) => { isSubagentCreateDialogOpen || isAgentsManagerDialogOpen || isApprovalModeDialogOpen || - isResumeDialogOpen; + isResumeDialogOpen || + isExtensionsManagerDialogOpen; const { isFeedbackDialogOpen, @@ -1455,6 +1464,8 @@ export const AppContainer = (props: AppContainerProps) => { // Subagent dialogs isSubagentCreateDialogOpen, isAgentsManagerDialogOpen, + // Extensions manager dialog + isExtensionsManagerDialogOpen, // Feedback dialog isFeedbackDialogOpen, }), @@ -1547,6 +1558,8 @@ export const AppContainer = (props: AppContainerProps) => { // Subagent dialogs isSubagentCreateDialogOpen, isAgentsManagerDialogOpen, + // Extensions manager dialog + isExtensionsManagerDialogOpen, // Feedback dialog isFeedbackDialogOpen, ], @@ -1589,6 +1602,8 @@ export const AppContainer = (props: AppContainerProps) => { // Subagent dialogs closeSubagentCreateDialog, closeAgentsManagerDialog, + // Extensions manager dialog + closeExtensionsManagerDialog, // Resume session dialog openResumeDialog, closeResumeDialog, @@ -1632,6 +1647,8 @@ export const AppContainer = (props: AppContainerProps) => { // Subagent dialogs closeSubagentCreateDialog, closeAgentsManagerDialog, + // Extensions manager dialog + closeExtensionsManagerDialog, // Resume session dialog openResumeDialog, closeResumeDialog, diff --git a/packages/cli/src/ui/commands/extensionsCommand.ts b/packages/cli/src/ui/commands/extensionsCommand.ts index 132f92901..e9a07d97d 100644 --- a/packages/cli/src/ui/commands/extensionsCommand.ts +++ b/packages/cli/src/ui/commands/extensionsCommand.ts @@ -5,7 +5,6 @@ */ import { getErrorMessage } from '../../utils/errors.js'; -import { ExtensionUpdateState } from '../state/extensions.js'; import { MessageType } from '../types.js'; import { type CommandContext, @@ -16,12 +15,9 @@ import { t } from '../../i18n/index.js'; import { ExtensionManager, parseInstallSource, - type ExtensionUpdateInfo, + createDebugLogger, } from '@qwen-code/qwen-code-core'; -import { createDebugLogger } from '@qwen-code/qwen-code-core'; -import { SettingScope } from '../../config/settings.js'; import open from 'open'; -import { extensionToOutputString } from '../../commands/extensions/utils.js'; const debugLogger = createDebugLogger('EXTENSIONS_COMMAND'); const EXTENSION_EXPLORE_URL = { @@ -113,7 +109,7 @@ async function exploreAction(context: CommandContext, args: string) { } } -async function listAction(context: CommandContext) { +async function listAction(context: CommandContext, _args: string) { const extensions = context.services.config ? context.services.config.getExtensions() : []; @@ -122,121 +118,10 @@ async function listAction(context: CommandContext) { return; } - context.ui.addItem( - { - type: MessageType.EXTENSIONS_LIST, - }, - Date.now(), - ); -} - -async function updateAction(context: CommandContext, args: string) { - const updateArgs = args.split(' ').filter((value) => value.length > 0); - const all = updateArgs.length === 1 && updateArgs[0] === '--all'; - const names = all ? undefined : updateArgs; - - if (!all && names?.length === 0) { - context.ui.addItem( - { - type: MessageType.ERROR, - text: t('Usage: /extensions update |--all'), - }, - Date.now(), - ); - return; - } - - let updateInfos: ExtensionUpdateInfo[] = []; - - const extensionManager = context.services.config!.getExtensionManager(); - const extensions = context.services.config - ? context.services.config.getExtensions() - : []; - - if (showMessageIfNoExtensions(context, extensions)) { - return Promise.resolve(); - } - - try { - context.ui.dispatchExtensionStateUpdate({ type: 'BATCH_CHECK_START' }); - await extensionManager.checkForAllExtensionUpdates((extensionName, state) => - context.ui.dispatchExtensionStateUpdate({ - type: 'SET_STATE', - payload: { name: extensionName, state }, - }), - ); - context.ui.dispatchExtensionStateUpdate({ type: 'BATCH_CHECK_END' }); - - context.ui.setPendingItem({ - type: MessageType.EXTENSIONS_LIST, - }); - if (all) { - updateInfos = await extensionManager.updateAllUpdatableExtensions( - context.ui.extensionsUpdateState, - (extensionName, state) => - context.ui.dispatchExtensionStateUpdate({ - type: 'SET_STATE', - payload: { name: extensionName, state }, - }), - ); - } else if (names?.length) { - const extensions = context.services.config!.getExtensions(); - for (const name of names) { - const extension = extensions.find( - (extension) => extension.name === name, - ); - if (!extension) { - context.ui.addItem( - { - type: MessageType.ERROR, - text: t('Extension "{{name}}" not found.', { name }), - }, - Date.now(), - ); - continue; - } - const updateInfo = await extensionManager.updateExtension( - extension, - context.ui.extensionsUpdateState.get(extension.name)?.status ?? - ExtensionUpdateState.UNKNOWN, - (extensionName, state) => - context.ui.dispatchExtensionStateUpdate({ - type: 'SET_STATE', - payload: { name: extensionName, state }, - }), - ); - if (updateInfo) updateInfos.push(updateInfo); - } - } - - if (updateInfos.length === 0) { - context.ui.addItem( - { - type: MessageType.INFO, - text: t('No extensions to update.'), - }, - Date.now(), - ); - return; - } - } catch (error) { - context.ui.addItem( - { - type: MessageType.ERROR, - text: getErrorMessage(error), - }, - Date.now(), - ); - } finally { - context.ui.addItem( - { - type: MessageType.EXTENSIONS_LIST, - }, - Date.now(), - ); - context.ui.reloadCommands(); - context.ui.setPendingItem(null); - } + return { + type: 'dialog' as const, + dialog: 'extensions_manage' as const, + }; } async function installAction(context: CommandContext, args: string) { @@ -296,235 +181,6 @@ async function installAction(context: CommandContext, args: string) { } } -async function uninstallAction(context: CommandContext, args: string) { - const extensionManager = context.services.config?.getExtensionManager(); - if (!(extensionManager instanceof ExtensionManager)) { - debugLogger.error( - `Cannot ${context.invocation?.name} extensions in this environment`, - ); - return; - } - - const name = args.trim(); - if (!name) { - context.ui.addItem( - { - type: MessageType.ERROR, - text: t('Usage: /extensions uninstall '), - }, - Date.now(), - ); - return; - } - - context.ui.addItem( - { - type: MessageType.INFO, - text: t('Uninstalling extension "{{name}}"...', { name }), - }, - Date.now(), - ); - - try { - await extensionManager.uninstallExtension(name, false); - context.ui.addItem( - { - type: MessageType.INFO, - text: t('Extension "{{name}}" uninstalled successfully.', { name }), - }, - Date.now(), - ); - context.ui.reloadCommands(); - } catch (error) { - context.ui.addItem( - { - type: MessageType.ERROR, - text: t('Failed to uninstall extension "{{name}}": {{error}}', { - name, - error: getErrorMessage(error), - }), - }, - Date.now(), - ); - } -} - -function getEnableDisableContext( - context: CommandContext, - argumentsString: string, -): { - extensionManager: ExtensionManager; - names: string[]; - scope: SettingScope; -} | null { - const extensionManager = context.services.config?.getExtensionManager(); - if (!(extensionManager instanceof ExtensionManager)) { - debugLogger.error( - `Cannot ${context.invocation?.name} extensions in this environment`, - ); - return null; - } - const parts = argumentsString.split(' '); - const name = parts[0]; - if ( - name === '' || - !( - (parts.length === 2 && parts[1].startsWith('--scope=')) || // --scope= - (parts.length === 3 && parts[1] === '--scope') // --scope - ) - ) { - context.ui.addItem( - { - type: MessageType.ERROR, - text: t( - 'Usage: /extensions {{command}} [--scope=]', - { - command: context.invocation?.name ?? '', - }, - ), - }, - Date.now(), - ); - return null; - } - let scope: SettingScope; - // Transform `--scope=` to `--scope `. - if (parts.length === 2) { - parts.push(...parts[1].split('=')); - parts.splice(1, 1); - } - switch (parts[2].toLowerCase()) { - case 'workspace': - scope = SettingScope.Workspace; - break; - case 'user': - scope = SettingScope.User; - break; - default: - context.ui.addItem( - { - type: MessageType.ERROR, - text: t( - 'Unsupported scope "{{scope}}", should be one of "user" or "workspace"', - { - scope: parts[2], - }, - ), - }, - Date.now(), - ); - return null; - } - let names: string[] = []; - if (name === '--all') { - let extensions = extensionManager.getLoadedExtensions(); - if (context.invocation?.name === 'enable') { - extensions = extensions.filter((ext) => !ext.isActive); - } - if (context.invocation?.name === 'disable') { - extensions = extensions.filter((ext) => ext.isActive); - } - names = extensions.map((ext) => ext.name); - } else { - names = [name]; - } - - return { - extensionManager, - names, - scope, - }; -} - -async function disableAction(context: CommandContext, args: string) { - const enableContext = getEnableDisableContext(context, args); - if (!enableContext) return; - - const { names, scope, extensionManager } = enableContext; - for (const name of names) { - await extensionManager.disableExtension(name, scope); - context.ui.addItem( - { - type: MessageType.INFO, - text: t('Extension "{{name}}" disabled for scope "{{scope}}"', { - name, - scope, - }), - }, - Date.now(), - ); - context.ui.reloadCommands(); - } -} - -async function enableAction(context: CommandContext, args: string) { - const enableContext = getEnableDisableContext(context, args); - if (!enableContext) return; - - const { names, scope, extensionManager } = enableContext; - for (const name of names) { - await extensionManager.enableExtension(name, scope); - context.ui.addItem( - { - type: MessageType.INFO, - text: t('Extension "{{name}}" enabled for scope "{{scope}}"', { - name, - scope, - }), - }, - Date.now(), - ); - context.ui.reloadCommands(); - } -} - -async function detailAction(context: CommandContext, args: string) { - const extensionManager = context.services.config?.getExtensionManager(); - if (!(extensionManager instanceof ExtensionManager)) { - debugLogger.error( - `Cannot ${context.invocation?.name} extensions in this environment`, - ); - return; - } - - const name = args.trim(); - if (!name) { - context.ui.addItem( - { - type: MessageType.ERROR, - text: t('Usage: /extensions detail '), - }, - Date.now(), - ); - return; - } - - const extensions = context.services.config!.getExtensions(); - const extension = extensions.find((extension) => extension.name === name); - if (!extension) { - context.ui.addItem( - { - type: MessageType.ERROR, - text: t('Extension "{{name}}" not found.', { name }), - }, - Date.now(), - ); - return; - } - context.ui.addItem( - { - type: MessageType.INFO, - text: extensionToOutputString( - extension, - extensionManager, - process.cwd(), - true, - ), - }, - Date.now(), - ); -} - export async function completeExtensions( context: CommandContext, partialArg: string, @@ -589,45 +245,15 @@ const exploreExtensionsCommand: SlashCommand = { completion: completeExtensionsExplore, }; -const listExtensionsCommand: SlashCommand = { - name: 'list', +const manageExtensionsCommand: SlashCommand = { + name: 'manage', get description() { - return t('List active extensions'); + return t('Manage installed extensions'); }, kind: CommandKind.BUILT_IN, action: listAction, }; -const updateExtensionsCommand: SlashCommand = { - name: 'update', - get description() { - return t('Update extensions. Usage: update |--all'); - }, - kind: CommandKind.BUILT_IN, - action: updateAction, - completion: completeExtensions, -}; - -const disableCommand: SlashCommand = { - name: 'disable', - get description() { - return t('Disable an extension'); - }, - kind: CommandKind.BUILT_IN, - action: disableAction, - completion: completeExtensionsAndScopes, -}; - -const enableCommand: SlashCommand = { - name: 'enable', - get description() { - return t('Enable an extension'); - }, - kind: CommandKind.BUILT_IN, - action: enableAction, - completion: completeExtensionsAndScopes, -}; - const installCommand: SlashCommand = { name: 'install', get description() { @@ -637,26 +263,6 @@ const installCommand: SlashCommand = { action: installAction, }; -const uninstallCommand: SlashCommand = { - name: 'uninstall', - get description() { - return t('Uninstall an extension'); - }, - kind: CommandKind.BUILT_IN, - action: uninstallAction, - completion: completeExtensions, -}; - -const detailCommand: SlashCommand = { - name: 'detail', - get description() { - return t('Get detail of an extension'); - }, - kind: CommandKind.BUILT_IN, - action: detailAction, - completion: completeExtensions, -}; - export const extensionsCommand: SlashCommand = { name: 'extensions', get description() { @@ -664,16 +270,11 @@ export const extensionsCommand: SlashCommand = { }, kind: CommandKind.BUILT_IN, subCommands: [ - listExtensionsCommand, - updateExtensionsCommand, - disableCommand, - enableCommand, + manageExtensionsCommand, installCommand, - uninstallCommand, exploreExtensionsCommand, - detailCommand, ], - action: (context, args) => + action: async (context, args) => // Default to list if no subcommand is provided - listExtensionsCommand.action!(context, args), + manageExtensionsCommand.action!(context, args), }; diff --git a/packages/cli/src/ui/commands/types.ts b/packages/cli/src/ui/commands/types.ts index 6c03ec136..b0abd2f6a 100644 --- a/packages/cli/src/ui/commands/types.ts +++ b/packages/cli/src/ui/commands/types.ts @@ -146,7 +146,8 @@ export interface OpenDialogActionReturn { | 'subagent_list' | 'permissions' | 'approval-mode' - | 'resume'; + | 'resume' + | 'extensions_manage'; } /** diff --git a/packages/cli/src/ui/components/DialogManager.tsx b/packages/cli/src/ui/components/DialogManager.tsx index dbb6f2207..511f8ab13 100644 --- a/packages/cli/src/ui/components/DialogManager.tsx +++ b/packages/cli/src/ui/components/DialogManager.tsx @@ -35,6 +35,7 @@ import { WelcomeBackDialog } from './WelcomeBackDialog.js'; import { ModelSwitchDialog } from './ModelSwitchDialog.js'; import { AgentCreationWizard } from './subagents/create/AgentCreationWizard.js'; import { AgentsManagerDialog } from './subagents/manage/AgentsManagerDialog.js'; +import { ExtensionsManagerDialog } from './extensions/ExtensionsManagerDialog.js'; import { SessionPicker } from './SessionPicker.js'; interface DialogManagerProps { @@ -297,6 +298,15 @@ export const DialogManager = ({ ); } + if (uiState.isExtensionsManagerDialogOpen) { + return ( + + ); + } + if (uiState.isResumeDialogOpen) { return ( + ({ + id: name, + name, + version, + path: `/home/user/.qwen/extensions/${name}`, + isActive, + installMetadata: { + type: 'git', + source: `github:user/${name}`, + }, + mcpServers: {}, + commands: [], + skills: [], + agents: [], + resolvedSettings: [], + config: {}, + contextFiles: [], + }) as unknown as Extension; + +const createMockConfig = (extensions: Extension[] = []): Config => + ({ + getExtensions: () => extensions, + getExtensionManager: () => ({ + getLoadedExtensions: () => extensions, + refreshCache: vi.fn().mockResolvedValue(undefined), + checkForAllExtensionUpdates: vi.fn().mockResolvedValue(undefined), + disableExtension: vi.fn().mockResolvedValue(undefined), + enableExtension: vi.fn().mockResolvedValue(undefined), + uninstallExtension: vi.fn().mockResolvedValue(undefined), + updateExtension: vi.fn().mockResolvedValue(undefined), + }), + getLoadedExtensions: () => extensions, + }) as unknown as Config; + +const createMockUIState = ( + extensionsUpdateState = new Map(), +): UIState => + ({ + extensionsUpdateState, + }) as unknown as UIState; + +describe('ExtensionsManagerDialog Snapshots', () => { + const baseProps = { + onClose: vi.fn(), + config: createMockConfig(), + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should render empty state when no extensions installed', () => { + const uiState = createMockUIState(); + const { lastFrame } = render( + + + + + , + ); + + expect(lastFrame()).toMatchSnapshot(); + }); + + it('should render extension list with extensions', () => { + const extensions = [ + createMockExtension('test-extension', true), + createMockExtension('another-extension', false), + ]; + const uiState = createMockUIState( + new Map([ + ['test-extension', ExtensionUpdateState.UP_TO_DATE], + ['another-extension', ExtensionUpdateState.UPDATE_AVAILABLE], + ]), + ); + const { lastFrame } = render( + + + + + , + ); + + expect(lastFrame()).toMatchSnapshot(); + }); + + it('should render with update available status', () => { + const extensions = [createMockExtension('outdated-extension', true)]; + const uiState = createMockUIState( + new Map([['outdated-extension', ExtensionUpdateState.UPDATE_AVAILABLE]]), + ); + const { lastFrame } = render( + + + + + , + ); + + expect(lastFrame()).toMatchSnapshot(); + }); + + it('should render with checking status', () => { + const extensions = [createMockExtension('checking-extension', true)]; + const uiState = createMockUIState( + new Map([ + ['checking-extension', ExtensionUpdateState.CHECKING_FOR_UPDATES], + ]), + ); + const { lastFrame } = render( + + + + + , + ); + + expect(lastFrame()).toMatchSnapshot(); + }); +}); diff --git a/packages/cli/src/ui/components/extensions/ExtensionsManagerDialog.tsx b/packages/cli/src/ui/components/extensions/ExtensionsManagerDialog.tsx new file mode 100644 index 000000000..015f6be66 --- /dev/null +++ b/packages/cli/src/ui/components/extensions/ExtensionsManagerDialog.tsx @@ -0,0 +1,517 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useState, useCallback, useMemo, useEffect } from 'react'; +import { Box, Text } from 'ink'; +import { + ExtensionListStep, + ExtensionDetailStep, + ActionSelectionStep, + UninstallConfirmStep, + ScopeSelectStep, +} from './steps/index.js'; +import { MANAGEMENT_STEPS, type ExtensionAction } from './types.js'; +import { theme } from '../../semantic-colors.js'; +import { useKeypress } from '../../hooks/useKeypress.js'; +import { useUIState } from '../../contexts/UIStateContext.js'; +import { t } from '../../../i18n/index.js'; +import type { Extension, Config } from '@qwen-code/qwen-code-core'; +import { SettingScope, createDebugLogger } from '@qwen-code/qwen-code-core'; +import { ExtensionUpdateState } from '../../state/extensions.js'; + +interface ExtensionsManagerDialogProps { + onClose: () => void; + config: Config | null; +} + +const debugLogger = createDebugLogger('EXTENSIONS_MANAGER_DIALOG'); + +export function ExtensionsManagerDialog({ + onClose, + config, +}: ExtensionsManagerDialogProps) { + const { extensionsUpdateState } = useUIState(); + + const [extensions, setExtensions] = useState([]); + const [selectedExtensionIndex, setSelectedExtensionIndex] = + useState(-1); + const [navigationStack, setNavigationStack] = useState([ + MANAGEMENT_STEPS.EXTENSION_LIST, + ]); + const [updateInProgress, setUpdateInProgress] = useState(false); + const [updateError, setUpdateError] = useState(null); + const [successMessage, setSuccessMessage] = useState(null); + + // Load extensions + const loadExtensions = useCallback(async () => { + if (!config) return; + + const extensionManager = config.getExtensionManager(); + if (!extensionManager) { + debugLogger.error('ExtensionManager not available'); + return; + } + + try { + await extensionManager.refreshCache(); + const loadedExtensions = extensionManager.getLoadedExtensions(); + setExtensions(loadedExtensions); + } catch (error) { + debugLogger.error('Failed to load extensions:', error); + } + }, [config]); + + // Initial load + useEffect(() => { + loadExtensions(); + }, [loadExtensions]); + + // Memoized selected extension + const selectedExtension = useMemo( + () => + selectedExtensionIndex >= 0 ? extensions[selectedExtensionIndex] : null, + [extensions, selectedExtensionIndex], + ); + + // Check if update is available for selected extension + const hasUpdateAvailable = useMemo(() => { + if (!selectedExtension) return false; + const state = extensionsUpdateState.get(selectedExtension.name); + return state === ExtensionUpdateState.UPDATE_AVAILABLE; + }, [selectedExtension, extensionsUpdateState]); + + // Helper to get current step + const getCurrentStep = useCallback( + () => + navigationStack[navigationStack.length - 1] || + MANAGEMENT_STEPS.EXTENSION_LIST, + [navigationStack], + ); + + const handleSelectExtension = useCallback((extensionIndex: number) => { + setSelectedExtensionIndex(extensionIndex); + setSuccessMessage(null); // Clear success message when navigating + setNavigationStack((prev) => [...prev, MANAGEMENT_STEPS.ACTION_SELECTION]); + }, []); + + 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); + }); + }, []); + + const handleUpdateExtension = useCallback(async () => { + if (!config || !selectedExtension) return; + + setUpdateInProgress(true); + setUpdateError(null); + + try { + const extensionManager = config.getExtensionManager(); + if (!extensionManager) { + throw new Error('ExtensionManager not available'); + } + + const state = extensionsUpdateState.get(selectedExtension.name); + if (state !== ExtensionUpdateState.UPDATE_AVAILABLE) { + throw new Error('No update available'); + } + + // Use the extension manager to update + await extensionManager.updateExtension( + selectedExtension, + ExtensionUpdateState.UPDATE_AVAILABLE, + (name, newState) => { + debugLogger.debug(`Update state for ${name}:`, newState); + }, + ); + + // Reload extensions after update to get new version info + await loadExtensions(); + + // Trigger a re-check of update status for all extensions + await extensionManager.checkForAllExtensionUpdates((name, newState) => { + debugLogger.debug(`Recheck update state for ${name}:`, newState); + }); + + // Show success message + setSuccessMessage( + t('Extension "{{name}}" updated successfully.', { + name: selectedExtension.name, + }), + ); + + // Go back to action selection + handleNavigateBack(); + } catch (error) { + debugLogger.error('Failed to update extension:', error); + setUpdateError( + error instanceof Error ? error.message : 'Unknown error occurred', + ); + } finally { + setUpdateInProgress(false); + } + }, [ + config, + selectedExtension, + extensionsUpdateState, + loadExtensions, + handleNavigateBack, + ]); + + const handleActionSelect = useCallback( + (action: ExtensionAction) => { + switch (action) { + case 'view': + handleNavigateToStep(MANAGEMENT_STEPS.EXTENSION_DETAIL); + break; + case 'update': + handleNavigateToStep(MANAGEMENT_STEPS.UPDATE_PROGRESS); + handleUpdateExtension(); + break; + case 'disable': + handleNavigateToStep(MANAGEMENT_STEPS.DISABLE_SCOPE_SELECT); + break; + case 'enable': + handleNavigateToStep(MANAGEMENT_STEPS.ENABLE_SCOPE_SELECT); + break; + case 'uninstall': + handleNavigateToStep(MANAGEMENT_STEPS.UNINSTALL_CONFIRMATION); + break; + default: + break; + } + }, + [handleNavigateToStep, handleUpdateExtension], + ); + + const handleDisableExtension = useCallback( + async (scope: 'user' | 'workspace') => { + if (!config || !selectedExtension) return; + + try { + const extensionManager = config.getExtensionManager(); + if (!extensionManager) { + throw new Error('ExtensionManager not available'); + } + + const settingScope = + scope === 'user' ? SettingScope.User : SettingScope.Workspace; + + await extensionManager.disableExtension( + selectedExtension.name, + settingScope, + ); + + // Update local state + setExtensions((prev) => + prev.map((ext) => + ext.name === selectedExtension.name + ? { ...ext, isActive: false } + : ext, + ), + ); + + // Show success message + setSuccessMessage( + t('Extension "{{name}}" disabled successfully.', { + name: selectedExtension.name, + }), + ); + + // Go back to extension list to show success message + setNavigationStack([MANAGEMENT_STEPS.EXTENSION_LIST]); + } catch (error) { + debugLogger.error('Failed to disable extension:', error); + } + }, + [config, selectedExtension], + ); + + const handleEnableExtension = useCallback( + async (scope: 'user' | 'workspace') => { + if (!config || !selectedExtension) return; + + try { + const extensionManager = config.getExtensionManager(); + if (!extensionManager) { + throw new Error('ExtensionManager not available'); + } + + const settingScope = + scope === 'user' ? SettingScope.User : SettingScope.Workspace; + + await extensionManager.enableExtension( + selectedExtension.name, + settingScope, + ); + + // Update local state + setExtensions((prev) => + prev.map((ext) => + ext.name === selectedExtension.name + ? { ...ext, isActive: true } + : ext, + ), + ); + + // Show success message + setSuccessMessage( + t('Extension "{{name}}" enabled successfully.', { + name: selectedExtension.name, + }), + ); + + // Go back to extension list to show success message + setNavigationStack([MANAGEMENT_STEPS.EXTENSION_LIST]); + } catch (error) { + debugLogger.error('Failed to enable extension:', error); + } + }, + [config, selectedExtension], + ); + + const handleUninstallExtension = useCallback( + async (extension: Extension) => { + if (!config) return; + + try { + const extensionManager = config.getExtensionManager(); + if (!extensionManager) { + throw new Error('ExtensionManager not available'); + } + + await extensionManager.uninstallExtension(extension.name, false); + + // Reload extensions + await loadExtensions(); + + // Navigate back to extension list + setNavigationStack([MANAGEMENT_STEPS.EXTENSION_LIST]); + setSelectedExtensionIndex(-1); + } catch (error) { + debugLogger.error('Failed to uninstall extension:', error); + throw error; + } + }, + [config, loadExtensions], + ); + + // Centralized ESC key handling + useKeypress( + (key) => { + if (key.name !== 'escape') { + return; + } + + const currentStep = getCurrentStep(); + // If there's a success message, clear it first instead of closing + if (successMessage && currentStep === MANAGEMENT_STEPS.EXTENSION_LIST) { + setSuccessMessage(null); + return; + } + if (currentStep === MANAGEMENT_STEPS.EXTENSION_LIST) { + onClose(); + } else { + handleNavigateBack(); + } + }, + { isActive: true }, + ); + + const renderStepHeader = useCallback(() => { + const currentStep = getCurrentStep(); + const getStepHeaderText = () => { + switch (currentStep) { + case MANAGEMENT_STEPS.EXTENSION_LIST: + return t('Manage Extensions'); + case MANAGEMENT_STEPS.ACTION_SELECTION: + return selectedExtension?.name || t('Choose Action'); + case MANAGEMENT_STEPS.EXTENSION_DETAIL: + return t('Extension Details'); + case MANAGEMENT_STEPS.DISABLE_SCOPE_SELECT: + return t('Disable Extension'); + case MANAGEMENT_STEPS.ENABLE_SCOPE_SELECT: + return t('Enable Extension'); + case MANAGEMENT_STEPS.UNINSTALL_CONFIRMATION: + return t('Uninstall Extension'); + case MANAGEMENT_STEPS.UPDATE_PROGRESS: + return t('Update Extension'); + default: + return t('Unknown Step'); + } + }; + + return ( + + {getStepHeaderText()} + + ); + }, [getCurrentStep, selectedExtension]); + + const renderStepFooter = useCallback(() => { + const currentStep = getCurrentStep(); + const getNavigationInstructions = () => { + if (currentStep === MANAGEMENT_STEPS.EXTENSION_LIST) { + if (extensions.length === 0) { + return t('Esc to close'); + } + return t('Enter to select, ↑↓ to navigate, Esc to close'); + } + + if (currentStep === MANAGEMENT_STEPS.EXTENSION_DETAIL) { + return t('Esc to go back'); + } + + if (currentStep === MANAGEMENT_STEPS.UNINSTALL_CONFIRMATION) { + return t('Y/Enter to confirm, N/Esc to cancel'); + } + + if (currentStep === MANAGEMENT_STEPS.UPDATE_PROGRESS) { + return updateInProgress ? t('Updating...') : ''; + } + + return t('Enter to select, ↑↓ to navigate, Esc to go back'); + }; + + return ( + + {getNavigationInstructions()} + + ); + }, [getCurrentStep, extensions.length, updateInProgress]); + + const renderStepContent = useCallback(() => { + const currentStep = getCurrentStep(); + + // Show success message if present (only on extension list step) + if (successMessage && currentStep === MANAGEMENT_STEPS.EXTENSION_LIST) { + return ( + + {successMessage} + + ); + } + + if (updateError && currentStep === MANAGEMENT_STEPS.UPDATE_PROGRESS) { + return ( + + {t('Update failed:')} + {updateError} + + ); + } + + switch (currentStep) { + case MANAGEMENT_STEPS.EXTENSION_LIST: + return ( + + ); + case MANAGEMENT_STEPS.ACTION_SELECTION: + return ( + + ); + case MANAGEMENT_STEPS.EXTENSION_DETAIL: + return ; + case MANAGEMENT_STEPS.DISABLE_SCOPE_SELECT: + return ( + + ); + case MANAGEMENT_STEPS.ENABLE_SCOPE_SELECT: + return ( + + ); + case MANAGEMENT_STEPS.UNINSTALL_CONFIRMATION: + return ( + + ); + case MANAGEMENT_STEPS.UPDATE_PROGRESS: + return ( + + + {updateInProgress + ? t('Updating {{name}}...', { + name: selectedExtension?.name || '', + }) + : t('Update complete!')} + + + ); + default: + return ( + + + {t('Invalid step: {{step}}', { step: currentStep })} + + + ); + } + }, [ + getCurrentStep, + extensions, + extensionsUpdateState, + selectedExtension, + hasUpdateAvailable, + updateInProgress, + updateError, + successMessage, + handleSelectExtension, + handleNavigateToStep, + handleNavigateBack, + handleActionSelect, + handleDisableExtension, + handleEnableExtension, + handleUninstallExtension, + ]); + + return ( + + + {renderStepHeader()} + {renderStepContent()} + {renderStepFooter()} + + + ); +} diff --git a/packages/cli/src/ui/components/extensions/__snapshots__/ExtensionsManagerDialog.test.tsx.snap b/packages/cli/src/ui/components/extensions/__snapshots__/ExtensionsManagerDialog.test.tsx.snap new file mode 100644 index 000000000..af6ba07c4 --- /dev/null +++ b/packages/cli/src/ui/components/extensions/__snapshots__/ExtensionsManagerDialog.test.tsx.snap @@ -0,0 +1,53 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`ExtensionsManagerDialog Snapshots > should render empty state when no extensions installed 1`] = ` +"┌──────────────────────────────────────────────────────────────────────────────────────────────────┐ +│ │ +│ Manage Extensions │ +│ │ +│ No extensions installed. │ +│ Use '/extensions install' to install your first extension. │ +│ │ +│ Esc to close │ +│ │ +└──────────────────────────────────────────────────────────────────────────────────────────────────┘" +`; + +exports[`ExtensionsManagerDialog Snapshots > should render extension list with extensions 1`] = ` +"┌──────────────────────────────────────────────────────────────────────────────────────────────────┐ +│ │ +│ Manage Extensions │ +│ │ +│ No extensions installed. │ +│ Use '/extensions install' to install your first extension. │ +│ │ +│ Esc to close │ +│ │ +└──────────────────────────────────────────────────────────────────────────────────────────────────┘" +`; + +exports[`ExtensionsManagerDialog Snapshots > should render with checking status 1`] = ` +"┌──────────────────────────────────────────────────────────────────────────────────────────────────┐ +│ │ +│ Manage Extensions │ +│ │ +│ No extensions installed. │ +│ Use '/extensions install' to install your first extension. │ +│ │ +│ Esc to close │ +│ │ +└──────────────────────────────────────────────────────────────────────────────────────────────────┘" +`; + +exports[`ExtensionsManagerDialog Snapshots > should render with update available status 1`] = ` +"┌──────────────────────────────────────────────────────────────────────────────────────────────────┐ +│ │ +│ Manage Extensions │ +│ │ +│ No extensions installed. │ +│ Use '/extensions install' to install your first extension. │ +│ │ +│ Esc to close │ +│ │ +└──────────────────────────────────────────────────────────────────────────────────────────────────┘" +`; diff --git a/packages/cli/src/ui/components/extensions/index.ts b/packages/cli/src/ui/components/extensions/index.ts new file mode 100644 index 000000000..e368898af --- /dev/null +++ b/packages/cli/src/ui/components/extensions/index.ts @@ -0,0 +1,9 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +export { ExtensionsManagerDialog } from './ExtensionsManagerDialog.js'; +export type { ExtensionsManagerDialogProps } from './types.js'; +export { MANAGEMENT_STEPS } from './types.js'; diff --git a/packages/cli/src/ui/components/extensions/steps/ActionSelectionStep.test.tsx b/packages/cli/src/ui/components/extensions/steps/ActionSelectionStep.test.tsx new file mode 100644 index 000000000..97861a4cb --- /dev/null +++ b/packages/cli/src/ui/components/extensions/steps/ActionSelectionStep.test.tsx @@ -0,0 +1,98 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { render } from 'ink-testing-library'; +import { describe, it, expect, vi } from 'vitest'; +import { ActionSelectionStep } from './ActionSelectionStep.js'; +import type { Extension } from '@qwen-code/qwen-code-core'; + +const createMockExtension = (name: string, isActive = true): Extension => + ({ + id: name, + name, + version: '1.0.0', + path: `/home/user/.qwen/extensions/${name}`, + isActive, + installMetadata: { + type: 'git', + source: `github:user/${name}`, + }, + mcpServers: {}, + commands: [], + skills: [], + agents: [], + resolvedSettings: [], + config: {}, + contextFiles: [], + }) as unknown as Extension; + +describe('ActionSelectionStep Snapshots', () => { + const baseProps = { + onNavigateToStep: vi.fn(), + onNavigateBack: vi.fn(), + onActionSelect: vi.fn(), + }; + + it('should render for active extension without update', () => { + const { lastFrame } = render( + , + ); + + expect(lastFrame()).toMatchSnapshot(); + }); + + it('should render for disabled extension', () => { + const { lastFrame } = render( + , + ); + + expect(lastFrame()).toMatchSnapshot(); + }); + + it('should render for extension with update available', () => { + const { lastFrame } = render( + , + ); + + expect(lastFrame()).toMatchSnapshot(); + }); + + it('should render for disabled extension with update', () => { + const { lastFrame } = render( + , + ); + + expect(lastFrame()).toMatchSnapshot(); + }); + + it('should render with no extension selected', () => { + const { lastFrame } = render( + , + ); + + expect(lastFrame()).toMatchSnapshot(); + }); +}); diff --git a/packages/cli/src/ui/components/extensions/steps/ActionSelectionStep.tsx b/packages/cli/src/ui/components/extensions/steps/ActionSelectionStep.tsx new file mode 100644 index 000000000..aa4e0cf18 --- /dev/null +++ b/packages/cli/src/ui/components/extensions/steps/ActionSelectionStep.tsx @@ -0,0 +1,116 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useState, useMemo } from 'react'; +import { Box } from 'ink'; +import { RadioButtonSelect } from '../../shared/RadioButtonSelect.js'; +import { type Extension } from '@qwen-code/qwen-code-core'; +import { t } from '../../../../i18n/index.js'; +import { type ExtensionAction } from '../types.js'; + +interface ActionSelectionStepProps { + selectedExtension: Extension | null; + hasUpdateAvailable: boolean; + onNavigateToStep: (step: string) => void; + onNavigateBack: () => void; + onActionSelect: (action: ExtensionAction) => void; +} + +export const ActionSelectionStep = ({ + selectedExtension, + hasUpdateAvailable, + onNavigateBack, + onActionSelect, +}: ActionSelectionStepProps) => { + const [selectedAction, setSelectedAction] = useState( + null, + ); + + const isActive = selectedExtension?.isActive ?? false; + + // Build action list based on extension state + const actions = useMemo(() => { + const allActions = [ + { + key: 'view', + get label() { + return t('View Details'); + }, + value: 'view' as const, + }, + ...(hasUpdateAvailable + ? [ + { + key: 'update', + get label() { + return t('Update Extension'); + }, + value: 'update' as const, + }, + ] + : []), + ...(isActive + ? [ + { + key: 'disable', + get label() { + return t('Disable Extension'); + }, + value: 'disable' as const, + }, + ] + : [ + { + key: 'enable', + get label() { + return t('Enable Extension'); + }, + value: 'enable' as const, + }, + ]), + { + key: 'uninstall', + get label() { + return t('Uninstall Extension'); + }, + value: 'uninstall' as const, + }, + { + key: 'back', + get label() { + return t('Back'); + }, + value: 'back' as const, + }, + ]; + return allActions; + }, [hasUpdateAvailable, isActive]); + + const handleActionSelect = (value: ExtensionAction) => { + if (value === 'back') { + onNavigateBack(); + return; + } + + setSelectedAction(value); + onActionSelect(value); + }; + + const selectedIndex = selectedAction + ? actions.findIndex((action) => action.value === selectedAction) + : 0; + + return ( + + + + ); +}; diff --git a/packages/cli/src/ui/components/extensions/steps/ExtensionDetailStep.tsx b/packages/cli/src/ui/components/extensions/steps/ExtensionDetailStep.tsx new file mode 100644 index 000000000..0280196d8 --- /dev/null +++ b/packages/cli/src/ui/components/extensions/steps/ExtensionDetailStep.tsx @@ -0,0 +1,105 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Box, Text } from 'ink'; +import { theme } from '../../../semantic-colors.js'; +import { type Extension } from '@qwen-code/qwen-code-core'; +import { t } from '../../../../i18n/index.js'; + +interface ExtensionDetailStepProps { + selectedExtension: Extension | null; +} + +export const ExtensionDetailStep = ({ + selectedExtension, +}: ExtensionDetailStepProps) => { + if (!selectedExtension) { + return ( + + {t('No extension selected')} + + ); + } + + const ext = selectedExtension; + const isActive = ext.isActive; + const activeColor = isActive ? theme.status.success : theme.text.secondary; + const activeString = isActive ? t('active') : t('disabled'); + + return ( + + + + {`${t('Name:')} `} + {ext.name} + + + + {`${t('Version:')} `} + {ext.version} + + + + {`${t('Status:')} `} + {activeString} + + + + {`${t('Path:')} `} + {ext.path} + + + {ext.installMetadata && ( + + {`${t('Source:')} `} + {ext.installMetadata.source} + + )} + + {ext.mcpServers && Object.keys(ext.mcpServers).length > 0 && ( + + {`${t('MCP Servers:')} `} + {Object.keys(ext.mcpServers).join(', ')} + + )} + + {ext.commands && ext.commands.length > 0 && ( + + {`${t('Commands:')} `} + {ext.commands.join(', ')} + + )} + + {ext.skills && ext.skills.length > 0 && ( + + {`${t('Skills:')} `} + {ext.skills.map((s) => s.name).join(', ')} + + )} + + {ext.agents && ext.agents.length > 0 && ( + + {`${t('Agents:')} `} + {ext.agents.map((a) => a.name).join(', ')} + + )} + + {ext.resolvedSettings && ext.resolvedSettings.length > 0 && ( + + {`${t('Settings:')} `} + + {ext.resolvedSettings.map((setting) => ( + + - {setting.name}: {setting.value} + + ))} + + + )} + + + ); +}; diff --git a/packages/cli/src/ui/components/extensions/steps/ExtensionListStep.test.tsx b/packages/cli/src/ui/components/extensions/steps/ExtensionListStep.test.tsx new file mode 100644 index 000000000..b90644d84 --- /dev/null +++ b/packages/cli/src/ui/components/extensions/steps/ExtensionListStep.test.tsx @@ -0,0 +1,123 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { render } from 'ink-testing-library'; +import { describe, it, expect, vi } from 'vitest'; +import { ExtensionListStep } from './ExtensionListStep.js'; +import type { Extension } from '@qwen-code/qwen-code-core'; +import { ExtensionUpdateState } from '../../../state/extensions.js'; + +const createMockExtension = ( + name: string, + isActive = true, + version = '1.0.0', +): Extension => + ({ + id: name, + name, + version, + path: `/home/user/.qwen/extensions/${name}`, + isActive, + installMetadata: { + type: 'git', + source: `github:user/${name}`, + }, + mcpServers: {}, + commands: [], + skills: [], + agents: [], + resolvedSettings: [], + config: {}, + contextFiles: [], + }) as unknown as Extension; + +describe('ExtensionListStep Snapshots', () => { + const baseProps = { + onExtensionSelect: vi.fn(), + }; + + it('should render empty state', () => { + const { lastFrame } = render( + , + ); + + expect(lastFrame()).toMatchSnapshot(); + }); + + it('should render list with single extension', () => { + const extensions = [createMockExtension('test-extension', true)]; + const { lastFrame } = render( + , + ); + + expect(lastFrame()).toMatchSnapshot(); + }); + + it('should render list with multiple extensions', () => { + const extensions = [ + createMockExtension('active-extension', true), + createMockExtension('disabled-extension', false), + createMockExtension('update-available', true), + ]; + const updateState = new Map([ + ['active-extension', ExtensionUpdateState.UP_TO_DATE], + ['disabled-extension', ExtensionUpdateState.NOT_UPDATABLE], + ['update-available', ExtensionUpdateState.UPDATE_AVAILABLE], + ]); + + const { lastFrame } = render( + , + ); + + expect(lastFrame()).toMatchSnapshot(); + }); + + it('should render with checking status', () => { + const extensions = [createMockExtension('checking-extension', true)]; + const updateState = new Map([ + ['checking-extension', ExtensionUpdateState.CHECKING_FOR_UPDATES], + ]); + + const { lastFrame } = render( + , + ); + + expect(lastFrame()).toMatchSnapshot(); + }); + + it('should render with error status', () => { + const extensions = [createMockExtension('error-extension', true)]; + const updateState = new Map([ + ['error-extension', ExtensionUpdateState.ERROR], + ]); + + const { lastFrame } = render( + , + ); + + expect(lastFrame()).toMatchSnapshot(); + }); +}); diff --git a/packages/cli/src/ui/components/extensions/steps/ExtensionListStep.tsx b/packages/cli/src/ui/components/extensions/steps/ExtensionListStep.tsx new file mode 100644 index 000000000..7a20f2696 --- /dev/null +++ b/packages/cli/src/ui/components/extensions/steps/ExtensionListStep.tsx @@ -0,0 +1,151 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useState, useEffect } from 'react'; +import { Box, Text } from 'ink'; +import { theme } from '../../../semantic-colors.js'; +import { useKeypress } from '../../../hooks/useKeypress.js'; +import { type Extension } from '@qwen-code/qwen-code-core'; +import { t } from '../../../../i18n/index.js'; +import { ExtensionUpdateState } from '../../../state/extensions.js'; + +interface ExtensionListStepProps { + extensions: Extension[]; + extensionsUpdateState: Map; + onExtensionSelect: (extensionIndex: number) => void; +} + +export const ExtensionListStep = ({ + extensions, + extensionsUpdateState, + onExtensionSelect, +}: ExtensionListStepProps) => { + const [selectedIndex, setSelectedIndex] = useState(0); + + // Reset selection when extensions change + useEffect(() => { + if (extensions.length > 0 && selectedIndex >= extensions.length) { + setSelectedIndex(0); + } + }, [extensions, selectedIndex]); + + // Keyboard navigation + useKeypress( + (key) => { + if (key.name === 'up' || key.name === 'k') { + setSelectedIndex((prev) => + prev > 0 ? prev - 1 : extensions.length - 1, + ); + } else if (key.name === 'down' || key.name === 'j') { + setSelectedIndex((prev) => + prev < extensions.length - 1 ? prev + 1 : 0, + ); + } else if (key.name === 'return' || key.name === 'space') { + if (extensions.length > 0) { + onExtensionSelect(selectedIndex); + } + } + }, + { isActive: true }, + ); + + if (extensions.length === 0) { + return ( + + + {t('No extensions installed.')} + + + {t("Use '/extensions install' to install your first extension.")} + + + ); + } + + const getUpdateStateColor = (state: string | undefined): string => { + if (!state) return theme.text.secondary; + + switch (state) { + case ExtensionUpdateState.CHECKING_FOR_UPDATES: + case ExtensionUpdateState.UPDATING: + return theme.text.secondary; + case ExtensionUpdateState.UPDATE_AVAILABLE: + case ExtensionUpdateState.UPDATED_NEEDS_RESTART: + return theme.status.warning; + case ExtensionUpdateState.ERROR: + return theme.status.error; + case ExtensionUpdateState.UP_TO_DATE: + case ExtensionUpdateState.NOT_UPDATABLE: + case ExtensionUpdateState.UPDATED: + return theme.status.success; + default: + return theme.text.secondary; + } + }; + + const getLocalizedUpdateState = (state: string | undefined): string => { + if (!state) return ''; + // Map internal state values to translation keys + const stateMap: Record = { + 'up to date': t('up to date'), + 'update available': t('update available'), + 'checking...': t('checking...'), + 'not updatable': t('not updatable'), + error: t('error'), + }; + return stateMap[state] || state; + }; + + const renderExtensionItem = ( + extension: Extension, + index: number, + isSelected: boolean, + ) => { + const isActive = extension.isActive; + const activeColor = isActive ? theme.status.success : theme.text.secondary; + const activeString = isActive ? t('active') : t('disabled'); + + const updateState = extensionsUpdateState.get(extension.name); + const stateColor = getUpdateStateColor(updateState); + const stateText = getLocalizedUpdateState(updateState); + + return ( + + + + {isSelected ? '●' : ' '} + + + + {extension.name} + v{extension.version} + ({activeString}) + {stateText && [{stateText}]} + + + ); + }; + + return ( + + + {extensions.map((extension, index) => + renderExtensionItem(extension, index, index === selectedIndex), + )} + + + + {t('{{count}} extensions installed', { + count: extensions.length.toString(), + })} + + + + ); +}; diff --git a/packages/cli/src/ui/components/extensions/steps/ScopeSelectStep.tsx b/packages/cli/src/ui/components/extensions/steps/ScopeSelectStep.tsx new file mode 100644 index 000000000..809776a5a --- /dev/null +++ b/packages/cli/src/ui/components/extensions/steps/ScopeSelectStep.tsx @@ -0,0 +1,83 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Box, Text } from 'ink'; +import { RadioButtonSelect } from '../../shared/RadioButtonSelect.js'; +import { type Extension } from '@qwen-code/qwen-code-core'; +import { theme } from '../../../semantic-colors.js'; +import { t } from '../../../../i18n/index.js'; + +interface ScopeSelectStepProps { + selectedExtension: Extension | null; + mode: 'disable' | 'enable'; + onScopeSelect: (scope: 'user' | 'workspace') => void; + onNavigateBack: () => void; +} + +export function ScopeSelectStep({ + selectedExtension, + mode, + onScopeSelect, + onNavigateBack, +}: ScopeSelectStepProps) { + const scopeItems = [ + { + key: 'user', + get label() { + return t('User (global)'); + }, + value: 'user' as const, + }, + { + key: 'workspace', + get label() { + return t('Workspace (project-specific)'); + }, + value: 'workspace' as const, + }, + { + key: 'back', + get label() { + return t('Back'); + }, + value: 'back' as const, + }, + ]; + + const handleSelect = (value: 'user' | 'workspace' | 'back') => { + if (value === 'back') { + onNavigateBack(); + return; + } + onScopeSelect(value); + }; + + if (!selectedExtension) { + return ( + + {t('No extension selected')} + + ); + } + + const title = + mode === 'disable' + ? t('Disable "{{name}}" - Select Scope', { name: selectedExtension.name }) + : t('Enable "{{name}}" - Select Scope', { name: selectedExtension.name }); + + return ( + + {title} + + + + + ); +} diff --git a/packages/cli/src/ui/components/extensions/steps/UninstallConfirmStep.tsx b/packages/cli/src/ui/components/extensions/steps/UninstallConfirmStep.tsx new file mode 100644 index 000000000..0a48418a3 --- /dev/null +++ b/packages/cli/src/ui/components/extensions/steps/UninstallConfirmStep.tsx @@ -0,0 +1,68 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Box, Text } from 'ink'; +import { type Extension } from '@qwen-code/qwen-code-core'; +import { createDebugLogger } from '@qwen-code/qwen-code-core'; +import { theme } from '../../../semantic-colors.js'; +import { useKeypress } from '../../../hooks/useKeypress.js'; +import { t } from '../../../../i18n/index.js'; + +interface UninstallConfirmStepProps { + selectedExtension: Extension | null; + onConfirm: (extension: Extension) => Promise; + onNavigateBack: () => void; +} + +const debugLogger = createDebugLogger('EXTENSION_UNINSTALL_STEP'); + +export function UninstallConfirmStep({ + selectedExtension, + onConfirm, + onNavigateBack, +}: UninstallConfirmStepProps) { + useKeypress( + async (key) => { + if (!selectedExtension) return; + + if (key.name === 'y' || key.name === 'return') { + try { + await onConfirm(selectedExtension); + // Navigation will be handled by the parent component after successful uninstall + } catch (error) { + debugLogger.error('Failed to uninstall extension:', error); + } + } else if (key.name === 'n' || key.name === 'escape') { + onNavigateBack(); + } + }, + { isActive: true }, + ); + + if (!selectedExtension) { + return ( + + {t('No extension selected')} + + ); + } + + return ( + + + {t('Are you sure you want to uninstall extension "{{name}}"?', { + name: selectedExtension.name, + })} + + + {t('This action cannot be undone.')} + + + {t('Press Y/Enter to confirm, N/Esc to cancel')} + + + ); +} diff --git a/packages/cli/src/ui/components/extensions/steps/__snapshots__/ActionSelectionStep.test.tsx.snap b/packages/cli/src/ui/components/extensions/steps/__snapshots__/ActionSelectionStep.test.tsx.snap new file mode 100644 index 000000000..27ba1a9c7 --- /dev/null +++ b/packages/cli/src/ui/components/extensions/steps/__snapshots__/ActionSelectionStep.test.tsx.snap @@ -0,0 +1,166 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`ActionSelectionStep Snapshots > should render for active extension without update 1`] = ` +" + ERROR useKeypressContext must be used within a KeypressProvider + + src/ui/contexts/KeypressContext.tsx:77:11 + + 74: export function useKeypressContext() { + 75: const context = useContext(KeypressContext); + 76: if (!context) { + 77: throw new Error( + 78: 'useKeypressContext must be used within a KeypressProvider', + 79: ); + 80: } + + - useKeypressContext (src/ui/contexts/KeypressContext.tsx:77:11) + - useKeypress (src/ui/hooks/useKeypress.ts:24:38) + - useSelectionList (src/ui/hooks/useSelectionList.ts:287:3) + - BaseSelectionList (src/ui/components/shared/BaseSelectionList.tsx:64:27) + -Object.react-stack-bott (/Users/mochi/code/qwen-code/node_modules/react-reconciler/cjs/react-reco + m-frame nciler.development.js:15859:20) + -renderWithHoo (/Users/mochi/code/qwen-code/node_modules/react-reconciler/cjs/react-reconciler.dev + s elopment.js:3221:22) + -updateFunctionComp (/Users/mochi/code/qwen-code/node_modules/react-reconciler/cjs/react-reconcile + nent r.development.js:6475:19) + -beginWor (/Users/mochi/code/qwen-code/node_modules/react-reconciler/cjs/react-reconciler.developm + ent.js:8009:18) + -runWithFiberIn (/Users/mochi/code/qwen-code/node_modules/react-reconciler/cjs/react-reconciler.de + EV velopment.js:1738:13) + -performUnitOfW (/Users/mochi/code/qwen-code/node_modules/react-reconciler/cjs/react-reconciler.de + rk velopment.js:12834:22) +" +`; + +exports[`ActionSelectionStep Snapshots > should render for disabled extension 1`] = ` +" + ERROR useKeypressContext must be used within a KeypressProvider + + src/ui/contexts/KeypressContext.tsx:77:11 + + 74: export function useKeypressContext() { + 75: const context = useContext(KeypressContext); + 76: if (!context) { + 77: throw new Error( + 78: 'useKeypressContext must be used within a KeypressProvider', + 79: ); + 80: } + + - useKeypressContext (src/ui/contexts/KeypressContext.tsx:77:11) + - useKeypress (src/ui/hooks/useKeypress.ts:24:38) + - useSelectionList (src/ui/hooks/useSelectionList.ts:287:3) + - BaseSelectionList (src/ui/components/shared/BaseSelectionList.tsx:64:27) + -Object.react-stack-bott (/Users/mochi/code/qwen-code/node_modules/react-reconciler/cjs/react-reco + m-frame nciler.development.js:15859:20) + -renderWithHoo (/Users/mochi/code/qwen-code/node_modules/react-reconciler/cjs/react-reconciler.dev + s elopment.js:3221:22) + -updateFunctionComp (/Users/mochi/code/qwen-code/node_modules/react-reconciler/cjs/react-reconcile + nent r.development.js:6475:19) + -beginWor (/Users/mochi/code/qwen-code/node_modules/react-reconciler/cjs/react-reconciler.developm + ent.js:8009:18) + -runWithFiberIn (/Users/mochi/code/qwen-code/node_modules/react-reconciler/cjs/react-reconciler.de + EV velopment.js:1738:13) + -performUnitOfW (/Users/mochi/code/qwen-code/node_modules/react-reconciler/cjs/react-reconciler.de + rk velopment.js:12834:22) +" +`; + +exports[`ActionSelectionStep Snapshots > should render for disabled extension with update 1`] = ` +" + ERROR useKeypressContext must be used within a KeypressProvider + + src/ui/contexts/KeypressContext.tsx:77:11 + + 74: export function useKeypressContext() { + 75: const context = useContext(KeypressContext); + 76: if (!context) { + 77: throw new Error( + 78: 'useKeypressContext must be used within a KeypressProvider', + 79: ); + 80: } + + - useKeypressContext (src/ui/contexts/KeypressContext.tsx:77:11) + - useKeypress (src/ui/hooks/useKeypress.ts:24:38) + - useSelectionList (src/ui/hooks/useSelectionList.ts:287:3) + - BaseSelectionList (src/ui/components/shared/BaseSelectionList.tsx:64:27) + -Object.react-stack-bott (/Users/mochi/code/qwen-code/node_modules/react-reconciler/cjs/react-reco + m-frame nciler.development.js:15859:20) + -renderWithHoo (/Users/mochi/code/qwen-code/node_modules/react-reconciler/cjs/react-reconciler.dev + s elopment.js:3221:22) + -updateFunctionComp (/Users/mochi/code/qwen-code/node_modules/react-reconciler/cjs/react-reconcile + nent r.development.js:6475:19) + -beginWor (/Users/mochi/code/qwen-code/node_modules/react-reconciler/cjs/react-reconciler.developm + ent.js:8009:18) + -runWithFiberIn (/Users/mochi/code/qwen-code/node_modules/react-reconciler/cjs/react-reconciler.de + EV velopment.js:1738:13) + -performUnitOfW (/Users/mochi/code/qwen-code/node_modules/react-reconciler/cjs/react-reconciler.de + rk velopment.js:12834:22) +" +`; + +exports[`ActionSelectionStep Snapshots > should render for extension with update available 1`] = ` +" + ERROR useKeypressContext must be used within a KeypressProvider + + src/ui/contexts/KeypressContext.tsx:77:11 + + 74: export function useKeypressContext() { + 75: const context = useContext(KeypressContext); + 76: if (!context) { + 77: throw new Error( + 78: 'useKeypressContext must be used within a KeypressProvider', + 79: ); + 80: } + + - useKeypressContext (src/ui/contexts/KeypressContext.tsx:77:11) + - useKeypress (src/ui/hooks/useKeypress.ts:24:38) + - useSelectionList (src/ui/hooks/useSelectionList.ts:287:3) + - BaseSelectionList (src/ui/components/shared/BaseSelectionList.tsx:64:27) + -Object.react-stack-bott (/Users/mochi/code/qwen-code/node_modules/react-reconciler/cjs/react-reco + m-frame nciler.development.js:15859:20) + -renderWithHoo (/Users/mochi/code/qwen-code/node_modules/react-reconciler/cjs/react-reconciler.dev + s elopment.js:3221:22) + -updateFunctionComp (/Users/mochi/code/qwen-code/node_modules/react-reconciler/cjs/react-reconcile + nent r.development.js:6475:19) + -beginWor (/Users/mochi/code/qwen-code/node_modules/react-reconciler/cjs/react-reconciler.developm + ent.js:8009:18) + -runWithFiberIn (/Users/mochi/code/qwen-code/node_modules/react-reconciler/cjs/react-reconciler.de + EV velopment.js:1738:13) + -performUnitOfW (/Users/mochi/code/qwen-code/node_modules/react-reconciler/cjs/react-reconciler.de + rk velopment.js:12834:22) +" +`; + +exports[`ActionSelectionStep Snapshots > should render with no extension selected 1`] = ` +" + ERROR useKeypressContext must be used within a KeypressProvider + + src/ui/contexts/KeypressContext.tsx:77:11 + + 74: export function useKeypressContext() { + 75: const context = useContext(KeypressContext); + 76: if (!context) { + 77: throw new Error( + 78: 'useKeypressContext must be used within a KeypressProvider', + 79: ); + 80: } + + - useKeypressContext (src/ui/contexts/KeypressContext.tsx:77:11) + - useKeypress (src/ui/hooks/useKeypress.ts:24:38) + - useSelectionList (src/ui/hooks/useSelectionList.ts:287:3) + - BaseSelectionList (src/ui/components/shared/BaseSelectionList.tsx:64:27) + -Object.react-stack-bott (/Users/mochi/code/qwen-code/node_modules/react-reconciler/cjs/react-reco + m-frame nciler.development.js:15859:20) + -renderWithHoo (/Users/mochi/code/qwen-code/node_modules/react-reconciler/cjs/react-reconciler.dev + s elopment.js:3221:22) + -updateFunctionComp (/Users/mochi/code/qwen-code/node_modules/react-reconciler/cjs/react-reconcile + nent r.development.js:6475:19) + -beginWor (/Users/mochi/code/qwen-code/node_modules/react-reconciler/cjs/react-reconciler.developm + ent.js:8009:18) + -runWithFiberIn (/Users/mochi/code/qwen-code/node_modules/react-reconciler/cjs/react-reconciler.de + EV velopment.js:1738:13) + -performUnitOfW (/Users/mochi/code/qwen-code/node_modules/react-reconciler/cjs/react-reconciler.de + rk velopment.js:12834:22) +" +`; diff --git a/packages/cli/src/ui/components/extensions/steps/__snapshots__/ExtensionListStep.test.tsx.snap b/packages/cli/src/ui/components/extensions/steps/__snapshots__/ExtensionListStep.test.tsx.snap new file mode 100644 index 000000000..294e6a3db --- /dev/null +++ b/packages/cli/src/ui/components/extensions/steps/__snapshots__/ExtensionListStep.test.tsx.snap @@ -0,0 +1,171 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`ExtensionListStep Snapshots > should render empty state 1`] = ` +" + ERROR useKeypressContext must be used within a KeypressProvider + + src/ui/contexts/KeypressContext.tsx:77:11 + + 74: export function useKeypressContext() { + 75: const context = useContext(KeypressContext); + 76: if (!context) { + 77: throw new Error( + 78: 'useKeypressContext must be used within a KeypressProvider', + 79: ); + 80: } + + - useKeypressContext (src/ui/contexts/KeypressContext.tsx:77:11) + - useKeypress (src/ui/hooks/useKeypress.ts:24:38) + - ExtensionListStep (src/ui/components/extensions/steps/ExtensionListStep.tsx:36:3) + -Object.react-stack-bott (/Users/mochi/code/qwen-code/node_modules/react-reconciler/cjs/react-reco + m-frame nciler.development.js:15859:20) + -renderWithHoo (/Users/mochi/code/qwen-code/node_modules/react-reconciler/cjs/react-reconciler.dev + s elopment.js:3221:22) + -updateFunctionComp (/Users/mochi/code/qwen-code/node_modules/react-reconciler/cjs/react-reconcile + nent r.development.js:6475:19) + -beginWor (/Users/mochi/code/qwen-code/node_modules/react-reconciler/cjs/react-reconciler.developm + ent.js:8009:18) + -runWithFiberIn (/Users/mochi/code/qwen-code/node_modules/react-reconciler/cjs/react-reconciler.de + EV velopment.js:1738:13) + -performUnitOfW (/Users/mochi/code/qwen-code/node_modules/react-reconciler/cjs/react-reconciler.de + rk velopment.js:12834:22) + -workLoopSyn (/Users/mochi/code/qwen-code/node_modules/react-reconciler/cjs/react-reconciler.devel + opment.js:12644:41) +" +`; + +exports[`ExtensionListStep Snapshots > should render list with multiple extensions 1`] = ` +" + ERROR useKeypressContext must be used within a KeypressProvider + + src/ui/contexts/KeypressContext.tsx:77:11 + + 74: export function useKeypressContext() { + 75: const context = useContext(KeypressContext); + 76: if (!context) { + 77: throw new Error( + 78: 'useKeypressContext must be used within a KeypressProvider', + 79: ); + 80: } + + - useKeypressContext (src/ui/contexts/KeypressContext.tsx:77:11) + - useKeypress (src/ui/hooks/useKeypress.ts:24:38) + - ExtensionListStep (src/ui/components/extensions/steps/ExtensionListStep.tsx:36:3) + -Object.react-stack-bott (/Users/mochi/code/qwen-code/node_modules/react-reconciler/cjs/react-reco + m-frame nciler.development.js:15859:20) + -renderWithHoo (/Users/mochi/code/qwen-code/node_modules/react-reconciler/cjs/react-reconciler.dev + s elopment.js:3221:22) + -updateFunctionComp (/Users/mochi/code/qwen-code/node_modules/react-reconciler/cjs/react-reconcile + nent r.development.js:6475:19) + -beginWor (/Users/mochi/code/qwen-code/node_modules/react-reconciler/cjs/react-reconciler.developm + ent.js:8009:18) + -runWithFiberIn (/Users/mochi/code/qwen-code/node_modules/react-reconciler/cjs/react-reconciler.de + EV velopment.js:1738:13) + -performUnitOfW (/Users/mochi/code/qwen-code/node_modules/react-reconciler/cjs/react-reconciler.de + rk velopment.js:12834:22) + -workLoopSyn (/Users/mochi/code/qwen-code/node_modules/react-reconciler/cjs/react-reconciler.devel + opment.js:12644:41) +" +`; + +exports[`ExtensionListStep Snapshots > should render list with single extension 1`] = ` +" + ERROR useKeypressContext must be used within a KeypressProvider + + src/ui/contexts/KeypressContext.tsx:77:11 + + 74: export function useKeypressContext() { + 75: const context = useContext(KeypressContext); + 76: if (!context) { + 77: throw new Error( + 78: 'useKeypressContext must be used within a KeypressProvider', + 79: ); + 80: } + + - useKeypressContext (src/ui/contexts/KeypressContext.tsx:77:11) + - useKeypress (src/ui/hooks/useKeypress.ts:24:38) + - ExtensionListStep (src/ui/components/extensions/steps/ExtensionListStep.tsx:36:3) + -Object.react-stack-bott (/Users/mochi/code/qwen-code/node_modules/react-reconciler/cjs/react-reco + m-frame nciler.development.js:15859:20) + -renderWithHoo (/Users/mochi/code/qwen-code/node_modules/react-reconciler/cjs/react-reconciler.dev + s elopment.js:3221:22) + -updateFunctionComp (/Users/mochi/code/qwen-code/node_modules/react-reconciler/cjs/react-reconcile + nent r.development.js:6475:19) + -beginWor (/Users/mochi/code/qwen-code/node_modules/react-reconciler/cjs/react-reconciler.developm + ent.js:8009:18) + -runWithFiberIn (/Users/mochi/code/qwen-code/node_modules/react-reconciler/cjs/react-reconciler.de + EV velopment.js:1738:13) + -performUnitOfW (/Users/mochi/code/qwen-code/node_modules/react-reconciler/cjs/react-reconciler.de + rk velopment.js:12834:22) + -workLoopSyn (/Users/mochi/code/qwen-code/node_modules/react-reconciler/cjs/react-reconciler.devel + opment.js:12644:41) +" +`; + +exports[`ExtensionListStep Snapshots > should render with checking status 1`] = ` +" + ERROR useKeypressContext must be used within a KeypressProvider + + src/ui/contexts/KeypressContext.tsx:77:11 + + 74: export function useKeypressContext() { + 75: const context = useContext(KeypressContext); + 76: if (!context) { + 77: throw new Error( + 78: 'useKeypressContext must be used within a KeypressProvider', + 79: ); + 80: } + + - useKeypressContext (src/ui/contexts/KeypressContext.tsx:77:11) + - useKeypress (src/ui/hooks/useKeypress.ts:24:38) + - ExtensionListStep (src/ui/components/extensions/steps/ExtensionListStep.tsx:36:3) + -Object.react-stack-bott (/Users/mochi/code/qwen-code/node_modules/react-reconciler/cjs/react-reco + m-frame nciler.development.js:15859:20) + -renderWithHoo (/Users/mochi/code/qwen-code/node_modules/react-reconciler/cjs/react-reconciler.dev + s elopment.js:3221:22) + -updateFunctionComp (/Users/mochi/code/qwen-code/node_modules/react-reconciler/cjs/react-reconcile + nent r.development.js:6475:19) + -beginWor (/Users/mochi/code/qwen-code/node_modules/react-reconciler/cjs/react-reconciler.developm + ent.js:8009:18) + -runWithFiberIn (/Users/mochi/code/qwen-code/node_modules/react-reconciler/cjs/react-reconciler.de + EV velopment.js:1738:13) + -performUnitOfW (/Users/mochi/code/qwen-code/node_modules/react-reconciler/cjs/react-reconciler.de + rk velopment.js:12834:22) + -workLoopSyn (/Users/mochi/code/qwen-code/node_modules/react-reconciler/cjs/react-reconciler.devel + opment.js:12644:41) +" +`; + +exports[`ExtensionListStep Snapshots > should render with error status 1`] = ` +" + ERROR useKeypressContext must be used within a KeypressProvider + + src/ui/contexts/KeypressContext.tsx:77:11 + + 74: export function useKeypressContext() { + 75: const context = useContext(KeypressContext); + 76: if (!context) { + 77: throw new Error( + 78: 'useKeypressContext must be used within a KeypressProvider', + 79: ); + 80: } + + - useKeypressContext (src/ui/contexts/KeypressContext.tsx:77:11) + - useKeypress (src/ui/hooks/useKeypress.ts:24:38) + - ExtensionListStep (src/ui/components/extensions/steps/ExtensionListStep.tsx:36:3) + -Object.react-stack-bott (/Users/mochi/code/qwen-code/node_modules/react-reconciler/cjs/react-reco + m-frame nciler.development.js:15859:20) + -renderWithHoo (/Users/mochi/code/qwen-code/node_modules/react-reconciler/cjs/react-reconciler.dev + s elopment.js:3221:22) + -updateFunctionComp (/Users/mochi/code/qwen-code/node_modules/react-reconciler/cjs/react-reconcile + nent r.development.js:6475:19) + -beginWor (/Users/mochi/code/qwen-code/node_modules/react-reconciler/cjs/react-reconciler.developm + ent.js:8009:18) + -runWithFiberIn (/Users/mochi/code/qwen-code/node_modules/react-reconciler/cjs/react-reconciler.de + EV velopment.js:1738:13) + -performUnitOfW (/Users/mochi/code/qwen-code/node_modules/react-reconciler/cjs/react-reconciler.de + rk velopment.js:12834:22) + -workLoopSyn (/Users/mochi/code/qwen-code/node_modules/react-reconciler/cjs/react-reconciler.devel + opment.js:12644:41) +" +`; diff --git a/packages/cli/src/ui/components/extensions/steps/index.ts b/packages/cli/src/ui/components/extensions/steps/index.ts new file mode 100644 index 000000000..45bde6671 --- /dev/null +++ b/packages/cli/src/ui/components/extensions/steps/index.ts @@ -0,0 +1,11 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +export { ExtensionListStep } from './ExtensionListStep.js'; +export { ExtensionDetailStep } from './ExtensionDetailStep.js'; +export { ActionSelectionStep } from './ActionSelectionStep.js'; +export { UninstallConfirmStep } from './UninstallConfirmStep.js'; +export { ScopeSelectStep } from './ScopeSelectStep.js'; diff --git a/packages/cli/src/ui/components/extensions/types.ts b/packages/cli/src/ui/components/extensions/types.ts new file mode 100644 index 000000000..09a8426bd --- /dev/null +++ b/packages/cli/src/ui/components/extensions/types.ts @@ -0,0 +1,89 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { Extension, Config } from '@qwen-code/qwen-code-core'; + +/** + * Management steps for the extensions manager dialog. + */ +export const MANAGEMENT_STEPS = { + EXTENSION_LIST: 'extension-list', + ACTION_SELECTION: 'action-selection', + EXTENSION_DETAIL: 'extension-detail', + UNINSTALL_CONFIRMATION: 'uninstall-confirmation', + DISABLE_SCOPE_SELECT: 'disable-scope-select', + ENABLE_SCOPE_SELECT: 'enable-scope-select', + UPDATE_PROGRESS: 'update-progress', +} as const; + +/** + * Props for step navigation. + */ +export interface StepNavigationProps { + onNavigateToStep: (step: string) => void; + onNavigateBack: () => void; +} + +/** + * Props for the extension list step. + */ +export interface ExtensionListStepProps extends StepNavigationProps { + extensions: Extension[]; + extensionsUpdateState: Map; + onExtensionSelect: (extensionIndex: number) => void; +} + +/** + * Props for the extension detail step. + */ +export interface ExtensionDetailStepProps extends StepNavigationProps { + selectedExtension: Extension | null; +} + +/** + * Props for the action selection step. + */ +export interface ActionSelectionStepProps extends StepNavigationProps { + selectedExtension: Extension | null; + hasUpdateAvailable: boolean; + onActionSelect: (action: ExtensionAction) => void; +} + +/** + * Props for the uninstall confirmation step. + */ +export interface UninstallConfirmStepProps extends StepNavigationProps { + selectedExtension: Extension | null; + onConfirm: (extension: Extension) => Promise; +} + +/** + * Props for the scope selection step. + */ +export interface ScopeSelectStepProps extends StepNavigationProps { + selectedExtension: Extension | null; + mode: 'disable' | 'enable'; + onScopeSelect: (scope: 'user' | 'workspace') => void; +} + +/** + * Available actions for an extension. + */ +export type ExtensionAction = + | 'view' + | 'update' + | 'disable' + | 'enable' + | 'uninstall' + | 'back'; + +/** + * Props for the ExtensionsManagerDialog component. + */ +export interface ExtensionsManagerDialogProps { + onClose: () => void; + config: Config | null; +} diff --git a/packages/cli/src/ui/contexts/UIActionsContext.tsx b/packages/cli/src/ui/contexts/UIActionsContext.tsx index 7534b6d3a..1dc38787a 100644 --- a/packages/cli/src/ui/contexts/UIActionsContext.tsx +++ b/packages/cli/src/ui/contexts/UIActionsContext.tsx @@ -76,6 +76,8 @@ export interface UIActions { // Subagent dialogs closeSubagentCreateDialog: () => void; closeAgentsManagerDialog: () => void; + // Extensions manager dialog + closeExtensionsManagerDialog: () => void; // Resume session dialog openResumeDialog: () => void; closeResumeDialog: () => void; diff --git a/packages/cli/src/ui/contexts/UIStateContext.tsx b/packages/cli/src/ui/contexts/UIStateContext.tsx index f8d52faa1..963353e36 100644 --- a/packages/cli/src/ui/contexts/UIStateContext.tsx +++ b/packages/cli/src/ui/contexts/UIStateContext.tsx @@ -127,6 +127,8 @@ export interface UIState { // Subagent dialogs isSubagentCreateDialogOpen: boolean; isAgentsManagerDialogOpen: boolean; + // Extensions manager dialog + isExtensionsManagerDialogOpen: boolean; // Feedback dialog isFeedbackDialogOpen: boolean; } diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.ts index e3243a2bb..4d84bb4fb 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.ts +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.ts @@ -77,6 +77,7 @@ interface SlashCommandProcessorActions { addConfirmUpdateExtensionRequest: (request: ConfirmationRequest) => void; openSubagentCreateDialog: () => void; openAgentsManagerDialog: () => void; + openExtensionsManagerDialog: () => void; } /** @@ -430,6 +431,9 @@ export const useSlashCommandProcessor = ( case 'resume': actions.openResumeDialog(); return { type: 'handled' }; + case 'extensions_manage': + actions.openExtensionsManagerDialog(); + return { type: 'handled' }; case 'help': return { type: 'handled' }; default: { diff --git a/packages/cli/src/ui/hooks/useExtensionsManagerDialog.ts b/packages/cli/src/ui/hooks/useExtensionsManagerDialog.ts new file mode 100644 index 000000000..db6c82054 --- /dev/null +++ b/packages/cli/src/ui/hooks/useExtensionsManagerDialog.ts @@ -0,0 +1,33 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useState, useCallback } from 'react'; + +interface UseExtensionsManagerDialogReturn { + isExtensionsManagerDialogOpen: boolean; + openExtensionsManagerDialog: () => void; + closeExtensionsManagerDialog: () => void; +} + +export const useExtensionsManagerDialog = + (): UseExtensionsManagerDialogReturn => { + const [isExtensionsManagerDialogOpen, setIsExtensionsManagerDialogOpen] = + useState(false); + + const openExtensionsManagerDialog = useCallback(() => { + setIsExtensionsManagerDialogOpen(true); + }, []); + + const closeExtensionsManagerDialog = useCallback(() => { + setIsExtensionsManagerDialogOpen(false); + }, []); + + return { + isExtensionsManagerDialogOpen, + openExtensionsManagerDialog, + closeExtensionsManagerDialog, + }; + }; diff --git a/packages/core/src/extension/github.ts b/packages/core/src/extension/github.ts index 9e1d46ed4..cb193a02a 100644 --- a/packages/core/src/extension/github.ts +++ b/packages/core/src/extension/github.ts @@ -167,6 +167,7 @@ export async function checkForExtensionUpdate( } if ( !installMetadata || + installMetadata.originSource === 'Claude' || (installMetadata.type !== 'git' && installMetadata.type !== 'github-release') ) {