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

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