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

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