feat: Add interactive TUI for extension management

This commit is contained in:
LaZzyMan 2026-02-28 16:06:34 +08:00
parent d7ebd815b3
commit 4d27950a95
27 changed files with 2132 additions and 425 deletions

View file

@ -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,

View file

@ -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 <extension-names>|--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 <extension-name>'),
},
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=<scope>
(parts.length === 3 && parts[1] === '--scope') // --scope <scope>
)
) {
context.ui.addItem(
{
type: MessageType.ERROR,
text: t(
'Usage: /extensions {{command}} <extension> [--scope=<user|workspace>]',
{
command: context.invocation?.name ?? '',
},
),
},
Date.now(),
);
return null;
}
let scope: SettingScope;
// Transform `--scope=<scope>` to `--scope <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 <extension-name>'),
},
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 <extension-names>|--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),
};

View file

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

View file

@ -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 (
<ExtensionsManagerDialog
onClose={uiActions.closeExtensionsManagerDialog}
config={config}
/>
);
}
if (uiState.isResumeDialogOpen) {
return (
<SessionPicker

View file

@ -0,0 +1,153 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import { render } from 'ink-testing-library';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { ExtensionsManagerDialog } from './ExtensionsManagerDialog.js';
import { UIStateContext } from '../../contexts/UIStateContext.js';
import { KeypressProvider } from '../../contexts/KeypressContext.js';
import type { UIState } from '../../contexts/UIStateContext.js';
import type { Config, 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;
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<string, ExtensionUpdateState>(),
): 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(
<UIStateContext.Provider value={uiState}>
<KeypressProvider kittyProtocolEnabled={false}>
<ExtensionsManagerDialog {...baseProps} />
</KeypressProvider>
</UIStateContext.Provider>,
);
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(
<UIStateContext.Provider value={uiState}>
<KeypressProvider kittyProtocolEnabled={false}>
<ExtensionsManagerDialog
{...baseProps}
config={createMockConfig(extensions)}
/>
</KeypressProvider>
</UIStateContext.Provider>,
);
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(
<UIStateContext.Provider value={uiState}>
<KeypressProvider kittyProtocolEnabled={false}>
<ExtensionsManagerDialog
{...baseProps}
config={createMockConfig(extensions)}
/>
</KeypressProvider>
</UIStateContext.Provider>,
);
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(
<UIStateContext.Provider value={uiState}>
<KeypressProvider kittyProtocolEnabled={false}>
<ExtensionsManagerDialog
{...baseProps}
config={createMockConfig(extensions)}
/>
</KeypressProvider>
</UIStateContext.Provider>,
);
expect(lastFrame()).toMatchSnapshot();
});
});

View file

@ -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<Extension[]>([]);
const [selectedExtensionIndex, setSelectedExtensionIndex] =
useState<number>(-1);
const [navigationStack, setNavigationStack] = useState<string[]>([
MANAGEMENT_STEPS.EXTENSION_LIST,
]);
const [updateInProgress, setUpdateInProgress] = useState(false);
const [updateError, setUpdateError] = useState<string | null>(null);
const [successMessage, setSuccessMessage] = useState<string | null>(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 (
<Box>
<Text bold>{getStepHeaderText()}</Text>
</Box>
);
}, [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 (
<Box>
<Text color={theme.text.secondary}>{getNavigationInstructions()}</Text>
</Box>
);
}, [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 (
<Box flexDirection="column" gap={1}>
<Text color={theme.status.success}>{successMessage}</Text>
</Box>
);
}
if (updateError && currentStep === MANAGEMENT_STEPS.UPDATE_PROGRESS) {
return (
<Box flexDirection="column" gap={1}>
<Text color={theme.status.error}>{t('Update failed:')}</Text>
<Text>{updateError}</Text>
</Box>
);
}
switch (currentStep) {
case MANAGEMENT_STEPS.EXTENSION_LIST:
return (
<ExtensionListStep
extensions={extensions}
extensionsUpdateState={extensionsUpdateState}
onExtensionSelect={handleSelectExtension}
/>
);
case MANAGEMENT_STEPS.ACTION_SELECTION:
return (
<ActionSelectionStep
selectedExtension={selectedExtension}
hasUpdateAvailable={hasUpdateAvailable}
onNavigateToStep={handleNavigateToStep}
onNavigateBack={handleNavigateBack}
onActionSelect={handleActionSelect}
/>
);
case MANAGEMENT_STEPS.EXTENSION_DETAIL:
return <ExtensionDetailStep selectedExtension={selectedExtension} />;
case MANAGEMENT_STEPS.DISABLE_SCOPE_SELECT:
return (
<ScopeSelectStep
selectedExtension={selectedExtension}
mode="disable"
onScopeSelect={handleDisableExtension}
onNavigateBack={handleNavigateBack}
/>
);
case MANAGEMENT_STEPS.ENABLE_SCOPE_SELECT:
return (
<ScopeSelectStep
selectedExtension={selectedExtension}
mode="enable"
onScopeSelect={handleEnableExtension}
onNavigateBack={handleNavigateBack}
/>
);
case MANAGEMENT_STEPS.UNINSTALL_CONFIRMATION:
return (
<UninstallConfirmStep
selectedExtension={selectedExtension}
onConfirm={handleUninstallExtension}
onNavigateBack={handleNavigateBack}
/>
);
case MANAGEMENT_STEPS.UPDATE_PROGRESS:
return (
<Box flexDirection="column" gap={1}>
<Text>
{updateInProgress
? t('Updating {{name}}...', {
name: selectedExtension?.name || '',
})
: t('Update complete!')}
</Text>
</Box>
);
default:
return (
<Box>
<Text color={theme.status.error}>
{t('Invalid step: {{step}}', { step: currentStep })}
</Text>
</Box>
);
}
}, [
getCurrentStep,
extensions,
extensionsUpdateState,
selectedExtension,
hasUpdateAvailable,
updateInProgress,
updateError,
successMessage,
handleSelectExtension,
handleNavigateToStep,
handleNavigateBack,
handleActionSelect,
handleDisableExtension,
handleEnableExtension,
handleUninstallExtension,
]);
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,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 │
│ │
└──────────────────────────────────────────────────────────────────────────────────────────────────┘"
`;

View file

@ -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';

View file

@ -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(
<ActionSelectionStep
selectedExtension={createMockExtension('active-ext', true)}
hasUpdateAvailable={false}
{...baseProps}
/>,
);
expect(lastFrame()).toMatchSnapshot();
});
it('should render for disabled extension', () => {
const { lastFrame } = render(
<ActionSelectionStep
selectedExtension={createMockExtension('disabled-ext', false)}
hasUpdateAvailable={false}
{...baseProps}
/>,
);
expect(lastFrame()).toMatchSnapshot();
});
it('should render for extension with update available', () => {
const { lastFrame } = render(
<ActionSelectionStep
selectedExtension={createMockExtension('update-ext', true)}
hasUpdateAvailable={true}
{...baseProps}
/>,
);
expect(lastFrame()).toMatchSnapshot();
});
it('should render for disabled extension with update', () => {
const { lastFrame } = render(
<ActionSelectionStep
selectedExtension={createMockExtension('disabled-update-ext', false)}
hasUpdateAvailable={true}
{...baseProps}
/>,
);
expect(lastFrame()).toMatchSnapshot();
});
it('should render with no extension selected', () => {
const { lastFrame } = render(
<ActionSelectionStep
selectedExtension={null}
hasUpdateAvailable={false}
{...baseProps}
/>,
);
expect(lastFrame()).toMatchSnapshot();
});
});

View file

@ -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<ExtensionAction | null>(
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 (
<Box flexDirection="column">
<RadioButtonSelect
items={actions}
initialIndex={selectedIndex}
onSelect={handleActionSelect}
showNumbers={false}
/>
</Box>
);
};

View file

@ -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 (
<Box>
<Text color={theme.status.error}>{t('No extension selected')}</Text>
</Box>
);
}
const ext = selectedExtension;
const isActive = ext.isActive;
const activeColor = isActive ? theme.status.success : theme.text.secondary;
const activeString = isActive ? t('active') : t('disabled');
return (
<Box flexDirection="column" gap={1}>
<Box flexDirection="column">
<Box>
<Text color={theme.text.primary}>{`${t('Name:')} `}</Text>
<Text>{ext.name}</Text>
</Box>
<Box>
<Text color={theme.text.primary}>{`${t('Version:')} `}</Text>
<Text>{ext.version}</Text>
</Box>
<Box>
<Text color={theme.text.primary}>{`${t('Status:')} `}</Text>
<Text color={activeColor}>{activeString}</Text>
</Box>
<Box>
<Text color={theme.text.primary}>{`${t('Path:')} `}</Text>
<Text>{ext.path}</Text>
</Box>
{ext.installMetadata && (
<Box>
<Text color={theme.text.primary}>{`${t('Source:')} `}</Text>
<Text>{ext.installMetadata.source}</Text>
</Box>
)}
{ext.mcpServers && Object.keys(ext.mcpServers).length > 0 && (
<Box>
<Text color={theme.text.primary}>{`${t('MCP Servers:')} `}</Text>
<Text>{Object.keys(ext.mcpServers).join(', ')}</Text>
</Box>
)}
{ext.commands && ext.commands.length > 0 && (
<Box>
<Text color={theme.text.primary}>{`${t('Commands:')} `}</Text>
<Text>{ext.commands.join(', ')}</Text>
</Box>
)}
{ext.skills && ext.skills.length > 0 && (
<Box>
<Text color={theme.text.primary}>{`${t('Skills:')} `}</Text>
<Text>{ext.skills.map((s) => s.name).join(', ')}</Text>
</Box>
)}
{ext.agents && ext.agents.length > 0 && (
<Box>
<Text color={theme.text.primary}>{`${t('Agents:')} `}</Text>
<Text>{ext.agents.map((a) => a.name).join(', ')}</Text>
</Box>
)}
{ext.resolvedSettings && ext.resolvedSettings.length > 0 && (
<Box flexDirection="column" marginTop={1}>
<Text color={theme.text.primary}>{`${t('Settings:')} `}</Text>
<Box flexDirection="column" paddingLeft={2}>
{ext.resolvedSettings.map((setting) => (
<Text key={setting.name}>
- {setting.name}: {setting.value}
</Text>
))}
</Box>
</Box>
)}
</Box>
</Box>
);
};

View file

@ -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(
<ExtensionListStep
extensions={[]}
extensionsUpdateState={new Map()}
{...baseProps}
/>,
);
expect(lastFrame()).toMatchSnapshot();
});
it('should render list with single extension', () => {
const extensions = [createMockExtension('test-extension', true)];
const { lastFrame } = render(
<ExtensionListStep
extensions={extensions}
extensionsUpdateState={new Map()}
{...baseProps}
/>,
);
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(
<ExtensionListStep
extensions={extensions}
extensionsUpdateState={updateState}
{...baseProps}
/>,
);
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(
<ExtensionListStep
extensions={extensions}
extensionsUpdateState={updateState}
{...baseProps}
/>,
);
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(
<ExtensionListStep
extensions={extensions}
extensionsUpdateState={updateState}
{...baseProps}
/>,
);
expect(lastFrame()).toMatchSnapshot();
});
});

View file

@ -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<string, string>;
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 (
<Box flexDirection="column">
<Text color={theme.text.secondary}>
{t('No extensions installed.')}
</Text>
<Text color={theme.text.secondary}>
{t("Use '/extensions install' to install your first extension.")}
</Text>
</Box>
);
}
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<string, string> = {
'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 (
<Box key={extension.name} alignItems="center">
<Box minWidth={2} flexShrink={0}>
<Text color={isSelected ? theme.text.accent : theme.text.primary}>
{isSelected ? '●' : ' '}
</Text>
</Box>
<Text
color={isSelected ? theme.text.accent : theme.text.primary}
wrap="truncate"
>
{extension.name}
<Text color={theme.text.secondary}> v{extension.version}</Text>
<Text color={activeColor}> ({activeString})</Text>
{stateText && <Text color={stateColor}> [{stateText}]</Text>}
</Text>
</Box>
);
};
return (
<Box flexDirection="column">
<Box flexDirection="column" marginBottom={1}>
{extensions.map((extension, index) =>
renderExtensionItem(extension, index, index === selectedIndex),
)}
</Box>
<Box marginTop={1}>
<Text color={theme.text.secondary}>
{t('{{count}} extensions installed', {
count: extensions.length.toString(),
})}
</Text>
</Box>
</Box>
);
};

View file

@ -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 (
<Box>
<Text color={theme.status.error}>{t('No extension selected')}</Text>
</Box>
);
}
const title =
mode === 'disable'
? t('Disable "{{name}}" - Select Scope', { name: selectedExtension.name })
: t('Enable "{{name}}" - Select Scope', { name: selectedExtension.name });
return (
<Box flexDirection="column" gap={1}>
<Text color={theme.text.primary}>{title}</Text>
<Box marginTop={1}>
<RadioButtonSelect
items={scopeItems}
onSelect={handleSelect}
showNumbers={false}
/>
</Box>
</Box>
);
}

View file

@ -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<void>;
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 (
<Box>
<Text color={theme.status.error}>{t('No extension selected')}</Text>
</Box>
);
}
return (
<Box flexDirection="column" gap={1}>
<Text color={theme.status.error}>
{t('Are you sure you want to uninstall extension "{{name}}"?', {
name: selectedExtension.name,
})}
</Text>
<Text color={theme.text.secondary}>
{t('This action cannot be undone.')}
</Text>
<Box marginTop={1}>
<Text>{t('Press Y/Enter to confirm, N/Esc to cancel')}</Text>
</Box>
</Box>
);
}

View file

@ -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)
"
`;

View file

@ -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)
"
`;

View file

@ -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';

View file

@ -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<string, string>;
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<void>;
}
/**
* 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;
}

View file

@ -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;

View file

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

View file

@ -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: {

View file

@ -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,
};
};