Merge branch 'main' into feat/context-usage

This commit is contained in:
pomelo 2026-03-18 16:45:12 +08:00 committed by GitHub
commit d4379d6ee6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
428 changed files with 36382 additions and 11297 deletions

View file

@ -102,6 +102,8 @@ import { useDialogClose } from './hooks/useDialogClose.js';
import { useInitializationAuthError } from './hooks/useInitializationAuthError.js';
import { useSubagentCreateDialog } from './hooks/useSubagentCreateDialog.js';
import { useAgentsManagerDialog } from './hooks/useAgentsManagerDialog.js';
import { useExtensionsManagerDialog } from './hooks/useExtensionsManagerDialog.js';
import { useMcpDialog } from './hooks/useMcpDialog.js';
import { useAttentionNotifications } from './hooks/useAttentionNotifications.js';
import {
requestConsentInteractive,
@ -493,6 +495,12 @@ export const AppContainer = (props: AppContainerProps) => {
openAgentsManagerDialog,
closeAgentsManagerDialog,
} = useAgentsManagerDialog();
const {
isExtensionsManagerDialogOpen,
openExtensionsManagerDialog,
closeExtensionsManagerDialog,
} = useExtensionsManagerDialog();
const { isMcpDialogOpen, openMcpDialog, closeMcpDialog } = useMcpDialog();
const slashCommandActions = useMemo(
() => ({
@ -515,6 +523,8 @@ export const AppContainer = (props: AppContainerProps) => {
addConfirmUpdateExtensionRequest,
openSubagentCreateDialog,
openAgentsManagerDialog,
openExtensionsManagerDialog,
openMcpDialog,
openResumeDialog,
}),
[
@ -530,6 +540,8 @@ export const AppContainer = (props: AppContainerProps) => {
addConfirmUpdateExtensionRequest,
openSubagentCreateDialog,
openAgentsManagerDialog,
openExtensionsManagerDialog,
openMcpDialog,
openResumeDialog,
],
);
@ -1299,8 +1311,10 @@ export const AppContainer = (props: AppContainerProps) => {
showIdeRestartPrompt ||
isSubagentCreateDialogOpen ||
isAgentsManagerDialogOpen ||
isMcpDialogOpen ||
isApprovalModeDialogOpen ||
isResumeDialogOpen;
isResumeDialogOpen ||
isExtensionsManagerDialogOpen;
const {
isFeedbackDialogOpen,
@ -1410,6 +1424,10 @@ export const AppContainer = (props: AppContainerProps) => {
// Subagent dialogs
isSubagentCreateDialogOpen,
isAgentsManagerDialogOpen,
// Extensions manager dialog
isExtensionsManagerDialogOpen,
// MCP dialog
isMcpDialogOpen,
// Feedback dialog
isFeedbackDialogOpen,
}),
@ -1500,6 +1518,10 @@ export const AppContainer = (props: AppContainerProps) => {
// Subagent dialogs
isSubagentCreateDialogOpen,
isAgentsManagerDialogOpen,
// Extensions manager dialog
isExtensionsManagerDialogOpen,
// MCP dialog
isMcpDialogOpen,
// Feedback dialog
isFeedbackDialogOpen,
],
@ -1541,6 +1563,10 @@ export const AppContainer = (props: AppContainerProps) => {
// Subagent dialogs
closeSubagentCreateDialog,
closeAgentsManagerDialog,
// Extensions manager dialog
closeExtensionsManagerDialog,
// MCP dialog
closeMcpDialog,
// Resume session dialog
openResumeDialog,
closeResumeDialog,
@ -1584,6 +1610,10 @@ export const AppContainer = (props: AppContainerProps) => {
// Subagent dialogs
closeSubagentCreateDialog,
closeAgentsManagerDialog,
// Extensions manager dialog
closeExtensionsManagerDialog,
// MCP dialog
closeMcpDialog,
// Resume session dialog
openResumeDialog,
closeResumeDialog,

View file

@ -345,7 +345,7 @@ export function AuthDialog(): React.JSX.Element {
return (
<Box
borderStyle="round"
borderStyle="single"
borderColor={theme?.border?.default}
flexDirection="column"
padding={1}

View file

@ -41,7 +41,7 @@ export function AuthInProgress({
return (
<Box
borderStyle="round"
borderStyle="single"
borderColor={theme.border.default}
flexDirection="column"
padding={1}

View file

@ -389,13 +389,24 @@ export const useAuthCommand = (
{
type: MessageType.INFO,
text: t(
'Authenticated successfully with {{region}}. API key and model configs saved to settings.json (backed up).',
'Authenticated successfully with {{region}}. API key and model configs saved to settings.json.',
{ region: t('Alibaba Cloud Coding Plan') },
),
},
Date.now(),
);
// Hint about /model command
addItem(
{
type: MessageType.INFO,
text: t(
'Tip: Use /model to switch between available Coding Plan models.',
),
},
Date.now(),
);
// Log success
const authEvent = new AuthEvent(
AuthType.USE_OPENAI,

View file

@ -150,7 +150,8 @@ Memory Usage: 100 MB`;
Runtime: Node.js v20.0.0 / npm 10.0.0
IDE Client: VSCode
OS: test-platform x64 (22.0.0)
Auth: ${AuthType.USE_OPENAI} (https://api.openai.com/v1)
Auth: API Key - ${AuthType.USE_OPENAI}
Base URL: https://api.openai.com/v1
Model: qwen3-coder-plus
Session ID: test-session-id
Sandbox: test

View file

@ -19,14 +19,14 @@ import {
} from '../utils/export/index.js';
const mockSessionServiceMocks = vi.hoisted(() => ({
loadLastSession: vi.fn(),
loadSession: vi.fn(),
}));
vi.mock('@qwen-code/qwen-code-core', () => {
class SessionService {
constructor(_cwd: string) {}
async loadLastSession() {
return mockSessionServiceMocks.loadLastSession();
async loadSession(_sessionId: string) {
return mockSessionServiceMocks.loadSession();
}
}
@ -68,13 +68,14 @@ describe('exportCommand', () => {
beforeEach(() => {
vi.clearAllMocks();
mockSessionServiceMocks.loadLastSession.mockResolvedValue(mockSessionData);
mockSessionServiceMocks.loadSession.mockResolvedValue(mockSessionData);
mockContext = createMockCommandContext({
services: {
config: {
getWorkingDir: vi.fn().mockReturnValue('/test/dir'),
getProjectRoot: vi.fn().mockReturnValue('/test/project'),
getSessionId: vi.fn().mockReturnValue('test-session-id'),
},
},
});
@ -132,7 +133,7 @@ describe('exportCommand', () => {
content: expect.stringContaining('export-2025-01-01T00-00-00-000Z.md'),
});
expect(mockSessionServiceMocks.loadLastSession).toHaveBeenCalled();
expect(mockSessionServiceMocks.loadSession).toHaveBeenCalled();
expect(collectSessionData).toHaveBeenCalledWith(
mockSessionData.conversation,
expect.anything(),
@ -191,7 +192,7 @@ describe('exportCommand', () => {
});
it('should return error when no session is found', async () => {
mockSessionServiceMocks.loadLastSession.mockResolvedValue(undefined);
mockSessionServiceMocks.loadSession.mockResolvedValue(undefined);
const mdCommand = exportCommand.subCommands?.find((c) => c.name === 'md');
if (!mdCommand?.action) {
@ -260,7 +261,7 @@ describe('exportCommand', () => {
),
});
expect(mockSessionServiceMocks.loadLastSession).toHaveBeenCalled();
expect(mockSessionServiceMocks.loadSession).toHaveBeenCalled();
expect(collectSessionData).toHaveBeenCalledWith(
mockSessionData.conversation,
expect.anything(),
@ -323,7 +324,7 @@ describe('exportCommand', () => {
});
it('should return error when no session is found', async () => {
mockSessionServiceMocks.loadLastSession.mockResolvedValue(undefined);
mockSessionServiceMocks.loadSession.mockResolvedValue(undefined);
const htmlCommand = exportCommand.subCommands?.find(
(c) => c.name === 'html',

View file

@ -22,6 +22,7 @@ import {
toJsonl,
generateExportFilename,
} from '../utils/export/index.js';
import { t } from '../../i18n/index.js';
/**
* Action for the 'md' subcommand - exports session to markdown.
@ -50,9 +51,10 @@ async function exportMarkdownAction(
}
try {
// Load the current session
// Load the current session using the current session ID
const sessionService = new SessionService(cwd);
const sessionData = await sessionService.loadLastSession();
const sessionId = config.getSessionId();
const sessionData = await sessionService.loadSession(sessionId);
if (!sessionData) {
return {
@ -122,9 +124,10 @@ async function exportHtmlAction(
}
try {
// Load the current session
// Load the current session using the current session ID
const sessionService = new SessionService(cwd);
const sessionData = await sessionService.loadLastSession();
const sessionId = config.getSessionId();
const sessionData = await sessionService.loadSession(sessionId);
if (!sessionData) {
return {
@ -194,9 +197,10 @@ async function exportJsonAction(
}
try {
// Load the current session
// Load the current session using the current session ID
const sessionService = new SessionService(cwd);
const sessionData = await sessionService.loadLastSession();
const sessionId = config.getSessionId();
const sessionData = await sessionService.loadSession(sessionId);
if (!sessionData) {
return {
@ -266,9 +270,10 @@ async function exportJsonlAction(
}
try {
// Load the current session
// Load the current session using the current session ID
const sessionService = new SessionService(cwd);
const sessionData = await sessionService.loadLastSession();
const sessionId = config.getSessionId();
const sessionData = await sessionService.loadSession(sessionId);
if (!sessionData) {
return {
@ -316,30 +321,40 @@ async function exportJsonlAction(
*/
export const exportCommand: SlashCommand = {
name: 'export',
description: 'Export current session message history to a file',
get description() {
return t('Export current session message history to a file');
},
kind: CommandKind.BUILT_IN,
subCommands: [
{
name: 'html',
description: 'Export session to HTML format',
get description() {
return t('Export session to HTML format');
},
kind: CommandKind.BUILT_IN,
action: exportHtmlAction,
},
{
name: 'md',
description: 'Export session to markdown format',
get description() {
return t('Export session to markdown format');
},
kind: CommandKind.BUILT_IN,
action: exportMarkdownAction,
},
{
name: 'json',
description: 'Export session to JSON format',
get description() {
return t('Export session to JSON format');
},
kind: CommandKind.BUILT_IN,
action: exportJsonAction,
},
{
name: 'jsonl',
description: 'Export session to JSONL format (one message per line)',
get description() {
return t('Export session to JSONL format (one message per line)');
},
kind: CommandKind.BUILT_IN,
action: exportJsonlAction,
},

View file

@ -16,9 +16,8 @@ import {
beforeEach,
type MockedFunction,
} from 'vitest';
import { ExtensionUpdateState } from '../state/extensions.js';
import {
type Extension,
ExtensionManager,
parseInstallSource,
} from '@qwen-code/qwen-code-core';
@ -33,24 +32,12 @@ vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
});
const mockGetExtensions = vi.fn();
const mockUpdateExtension = vi.fn();
const mockUpdateAllUpdatableExtensions = vi.fn();
const mockCheckForAllExtensionUpdates = vi.fn();
const mockInstallExtension = vi.fn();
const mockUninstallExtension = vi.fn();
const mockGetLoadedExtensions = vi.fn();
const mockEnableExtension = vi.fn();
const mockDisableExtension = vi.fn();
const mockInstallExtension = vi.fn();
const createMockExtensionManager = () => ({
updateExtension: mockUpdateExtension,
updateAllUpdatableExtensions: mockUpdateAllUpdatableExtensions,
checkForAllExtensionUpdates: mockCheckForAllExtensionUpdates,
installExtension: mockInstallExtension,
uninstallExtension: mockUninstallExtension,
getLoadedExtensions: mockGetLoadedExtensions,
enableExtension: mockEnableExtension,
disableExtension: mockDisableExtension,
});
describe('extensionsCommand', () => {
@ -62,7 +49,6 @@ describe('extensionsCommand', () => {
mockExtensionManager = createMockExtensionManager();
mockGetExtensions.mockReturnValue([]);
mockGetLoadedExtensions.mockReturnValue([]);
mockCheckForAllExtensionUpdates.mockResolvedValue(undefined);
mockContext = createMockCommandContext({
services: {
config: {
@ -78,334 +64,57 @@ describe('extensionsCommand', () => {
});
});
describe('list', () => {
it('should add an EXTENSIONS_LIST item to the UI when extensions exist', async () => {
describe('default action (manage)', () => {
it('should open extensions manager dialog when extensions exist', async () => {
if (!extensionsCommand.action) throw new Error('Action not defined');
mockGetExtensions.mockReturnValue([{ name: 'test-ext', isActive: true }]);
await extensionsCommand.action(mockContext, '');
const result = await extensionsCommand.action(mockContext, '');
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
{
type: MessageType.EXTENSIONS_LIST,
},
expect.any(Number),
);
expect(result).toEqual({
type: 'dialog',
dialog: 'extensions_manage',
});
});
it('should show info message when no extensions installed', async () => {
it('should open extensions manager dialog when no extensions installed', async () => {
if (!extensionsCommand.action) throw new Error('Action not defined');
mockGetExtensions.mockReturnValue([]);
await extensionsCommand.action(mockContext, '');
const result = await extensionsCommand.action(mockContext, '');
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
{
type: MessageType.INFO,
text: 'No extensions installed.',
},
expect.any(Number),
);
expect(result).toEqual({
type: 'dialog',
dialog: 'extensions_manage',
});
});
});
describe('update', () => {
const updateAction = extensionsCommand.subCommands?.find(
(cmd) => cmd.name === 'update',
describe('manage', () => {
const manageAction = extensionsCommand.subCommands?.find(
(cmd) => cmd.name === 'manage',
)?.action;
if (!updateAction) {
throw new Error('Update action not found');
if (!manageAction) {
throw new Error('Manage action not found');
}
it('should show usage if no args are provided', async () => {
await updateAction(mockContext, '');
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
{
type: MessageType.ERROR,
text: 'Usage: /extensions update <extension-names>|--all',
},
expect.any(Number),
);
});
it('should return dialog action for extensions manager', async () => {
mockGetExtensions.mockReturnValue([{ name: 'test-ext', isActive: true }]);
const result = await manageAction(mockContext, '');
it('should inform user if there are no extensions to update with --all', async () => {
mockGetExtensions.mockReturnValue([{ name: 'ext-one', isActive: true }]);
mockUpdateAllUpdatableExtensions.mockResolvedValue([]);
await updateAction(mockContext, '--all');
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
{
type: MessageType.INFO,
text: 'No extensions to update.',
},
expect.any(Number),
);
});
it('should call setPendingItem and addItem in a finally block on success', async () => {
mockGetExtensions.mockReturnValue([{ name: 'ext-one', isActive: true }]);
mockUpdateAllUpdatableExtensions.mockResolvedValue([
{
name: 'ext-one',
originalVersion: '1.0.0',
updatedVersion: '1.0.1',
},
{
name: 'ext-two',
originalVersion: '2.0.0',
updatedVersion: '2.0.1',
},
]);
await updateAction(mockContext, '--all');
expect(mockContext.ui.setPendingItem).toHaveBeenCalledWith({
type: MessageType.EXTENSIONS_LIST,
});
expect(mockContext.ui.setPendingItem).toHaveBeenCalledWith(null);
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
{
type: MessageType.EXTENSIONS_LIST,
},
expect.any(Number),
);
});
it('should call setPendingItem and addItem in a finally block on failure', async () => {
mockGetExtensions.mockReturnValue([{ name: 'ext-one', isActive: true }]);
mockUpdateAllUpdatableExtensions.mockRejectedValue(
new Error('Something went wrong'),
);
await updateAction(mockContext, '--all');
expect(mockContext.ui.setPendingItem).toHaveBeenCalledWith({
type: MessageType.EXTENSIONS_LIST,
});
expect(mockContext.ui.setPendingItem).toHaveBeenCalledWith(null);
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
{
type: MessageType.EXTENSIONS_LIST,
},
expect.any(Number),
);
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
{
type: MessageType.ERROR,
text: 'Something went wrong',
},
expect.any(Number),
);
});
it('should update a single extension by name', async () => {
const extension: Extension = {
id: 'ext-one',
name: 'ext-one',
version: '1.0.0',
isActive: true,
path: '/test/dir/ext-one',
contextFiles: [],
config: { name: 'ext-one', version: '1.0.0' },
installMetadata: {
type: 'git',
autoUpdate: false,
source: 'https://github.com/some/extension.git',
},
};
mockUpdateExtension.mockResolvedValue({
name: extension.name,
originalVersion: extension.version,
updatedVersion: '1.0.1',
});
mockGetExtensions.mockReturnValue([extension]);
mockContext.ui.extensionsUpdateState.set(extension.name, {
status: ExtensionUpdateState.UPDATE_AVAILABLE,
processed: false,
});
await updateAction(mockContext, 'ext-one');
expect(mockUpdateExtension).toHaveBeenCalledWith(
extension,
ExtensionUpdateState.UPDATE_AVAILABLE,
expect.any(Function),
);
});
it('should handle errors when updating a single extension', async () => {
// Provide at least one extension so we don't get "No extensions installed" message
const otherExtension: Extension = {
id: 'other-ext',
name: 'other-ext',
version: '1.0.0',
isActive: true,
path: '/test/dir/other-ext',
contextFiles: [],
config: { name: 'other-ext', version: '1.0.0' },
};
mockGetExtensions.mockReturnValue([otherExtension]);
await updateAction(mockContext, 'ext-one');
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
{
type: MessageType.ERROR,
text: 'Extension "ext-one" not found.',
},
expect.any(Number),
);
});
it('should update multiple extensions by name', async () => {
const extensionOne: Extension = {
id: 'ext-one',
name: 'ext-one',
version: '1.0.0',
isActive: true,
path: '/test/dir/ext-one',
contextFiles: [],
config: { name: 'ext-one', version: '1.0.0' },
installMetadata: {
type: 'git',
autoUpdate: false,
source: 'https://github.com/some/extension.git',
},
};
const extensionTwo: Extension = {
id: 'ext-two',
name: 'ext-two',
version: '1.0.0',
isActive: true,
path: '/test/dir/ext-two',
contextFiles: [],
config: { name: 'ext-two', version: '1.0.0' },
installMetadata: {
type: 'git',
autoUpdate: false,
source: 'https://github.com/some/extension.git',
},
};
mockGetExtensions.mockReturnValue([extensionOne, extensionTwo]);
mockContext.ui.extensionsUpdateState.set(extensionOne.name, {
status: ExtensionUpdateState.UPDATE_AVAILABLE,
processed: false,
});
mockContext.ui.extensionsUpdateState.set(extensionTwo.name, {
status: ExtensionUpdateState.UPDATE_AVAILABLE,
processed: false,
});
mockUpdateExtension
.mockResolvedValueOnce({
name: 'ext-one',
originalVersion: '1.0.0',
updatedVersion: '1.0.1',
})
.mockResolvedValueOnce({
name: 'ext-two',
originalVersion: '2.0.0',
updatedVersion: '2.0.1',
});
await updateAction(mockContext, 'ext-one ext-two');
expect(mockUpdateExtension).toHaveBeenCalledTimes(2);
expect(mockContext.ui.setPendingItem).toHaveBeenCalledWith({
type: MessageType.EXTENSIONS_LIST,
});
expect(mockContext.ui.setPendingItem).toHaveBeenCalledWith(null);
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
{
type: MessageType.EXTENSIONS_LIST,
},
expect.any(Number),
);
});
describe('completion', () => {
const updateCompletion = extensionsCommand.subCommands?.find(
(cmd) => cmd.name === 'update',
)?.completion;
if (!updateCompletion) {
throw new Error('Update completion not found');
}
const extensionOne: Extension = {
id: 'ext-one',
name: 'ext-one',
version: '1.0.0',
isActive: true,
path: '/test/dir/ext-one',
contextFiles: [],
config: { name: 'ext-one', version: '1.0.0' },
installMetadata: {
type: 'git',
autoUpdate: false,
source: 'https://github.com/some/extension.git',
},
};
const extensionTwo: Extension = {
id: 'another-ext',
contextFiles: [],
config: { name: 'another-ext', version: '1.0.0' },
name: 'another-ext',
version: '1.0.0',
isActive: true,
path: '/test/dir/another-ext',
installMetadata: {
type: 'git',
autoUpdate: false,
source: 'https://github.com/some/extension.git',
},
};
const allExt: Extension = {
id: 'all-ext',
name: 'all-ext',
contextFiles: [],
config: { name: 'all-ext', version: '1.0.0' },
version: '1.0.0',
isActive: true,
path: '/test/dir/all-ext',
installMetadata: {
type: 'git',
autoUpdate: false,
source: 'https://github.com/some/extension.git',
},
};
it.each([
{
description: 'should return matching extension names',
extensions: [extensionOne, extensionTwo],
partialArg: 'ext',
expected: ['ext-one'],
},
{
description: 'should return --all when partialArg matches',
extensions: [],
partialArg: '--al',
expected: ['--all'],
},
{
description:
'should return both extension names and --all when both match',
extensions: [allExt],
partialArg: 'all',
expected: ['--all', 'all-ext'],
},
{
description: 'should return an empty array if no matches',
extensions: [extensionOne],
partialArg: 'nomatch',
expected: [],
},
])('$description', async ({ extensions, partialArg, expected }) => {
mockGetExtensions.mockReturnValue(extensions);
const suggestions = await updateCompletion(mockContext, partialArg);
expect(suggestions).toEqual(expected);
expect(result).toEqual({
type: 'dialog',
dialog: 'extensions_manage',
});
});
it('should call reloadCommands in finally block', async () => {
mockGetExtensions.mockReturnValue([{ name: 'ext-one', isActive: true }]);
mockUpdateAllUpdatableExtensions.mockResolvedValue([
{
name: 'ext-one',
originalVersion: '1.0.0',
updatedVersion: '1.0.1',
},
]);
await updateAction(mockContext, '--all');
expect(mockContext.ui.reloadCommands).toHaveBeenCalled();
it('should return dialog action even when no extensions installed', async () => {
mockGetExtensions.mockReturnValue([]);
const result = await manageAction(mockContext, '');
expect(result).toEqual({
type: 'dialog',
dialog: 'extensions_manage',
});
});
});
@ -501,363 +210,4 @@ describe('extensionsCommand', () => {
);
});
});
describe('uninstall', () => {
const uninstallAction = extensionsCommand.subCommands?.find(
(cmd) => cmd.name === 'uninstall',
)?.action;
if (!uninstallAction) {
throw new Error('Uninstall action not found');
}
let realMockExtensionManager: ExtensionManager;
beforeEach(() => {
vi.resetAllMocks();
realMockExtensionManager = Object.create(ExtensionManager.prototype);
realMockExtensionManager.uninstallExtension = mockUninstallExtension;
mockContext = createMockCommandContext({
services: {
config: {
getExtensions: mockGetExtensions,
getWorkingDir: () => '/test/dir',
getExtensionManager: () => realMockExtensionManager,
},
},
ui: {
dispatchExtensionStateUpdate: vi.fn(),
},
});
});
it('should show usage if no name is provided', async () => {
await uninstallAction(mockContext, '');
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
{
type: MessageType.ERROR,
text: 'Usage: /extensions uninstall <extension-name>',
},
expect.any(Number),
);
});
it('should uninstall extension successfully', async () => {
mockUninstallExtension.mockResolvedValue(undefined);
await uninstallAction(mockContext, 'test-extension');
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
{
type: MessageType.INFO,
text: 'Uninstalling extension "test-extension"...',
},
expect.any(Number),
);
expect(mockUninstallExtension).toHaveBeenCalledWith(
'test-extension',
false,
);
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
{
type: MessageType.INFO,
text: 'Extension "test-extension" uninstalled successfully.',
},
expect.any(Number),
);
expect(mockContext.ui.reloadCommands).toHaveBeenCalled();
});
it('should handle uninstall errors', async () => {
mockUninstallExtension.mockRejectedValue(
new Error('Extension not found.'),
);
await uninstallAction(mockContext, 'nonexistent-extension');
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
{
type: MessageType.ERROR,
text: 'Failed to uninstall extension "nonexistent-extension": Extension not found.',
},
expect.any(Number),
);
});
});
describe('disable', () => {
const disableAction = extensionsCommand.subCommands?.find(
(cmd) => cmd.name === 'disable',
)?.action;
if (!disableAction) {
throw new Error('Disable action not found');
}
let realMockExtensionManager: ExtensionManager;
beforeEach(() => {
vi.resetAllMocks();
realMockExtensionManager = Object.create(ExtensionManager.prototype);
realMockExtensionManager.disableExtension = mockDisableExtension;
realMockExtensionManager.getLoadedExtensions = mockGetLoadedExtensions;
mockContext = createMockCommandContext({
invocation: {
raw: '/extensions disable',
name: 'disable',
args: '',
},
services: {
config: {
getExtensions: mockGetExtensions,
getWorkingDir: () => '/test/dir',
getExtensionManager: () => realMockExtensionManager,
},
},
ui: {
dispatchExtensionStateUpdate: vi.fn(),
},
});
});
it('should show usage if invalid args are provided', async () => {
await disableAction(mockContext, '');
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
{
type: MessageType.ERROR,
text: 'Usage: /extensions disable <extension> [--scope=<user|workspace>]',
},
expect.any(Number),
);
});
it('should disable extension at user scope', async () => {
mockDisableExtension.mockResolvedValue(undefined);
await disableAction(mockContext, 'test-extension --scope=user');
expect(mockDisableExtension).toHaveBeenCalledWith(
'test-extension',
'User',
);
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
{
type: MessageType.INFO,
text: 'Extension "test-extension" disabled for scope "User"',
},
expect.any(Number),
);
});
it('should disable extension at workspace scope', async () => {
mockDisableExtension.mockResolvedValue(undefined);
await disableAction(mockContext, 'test-extension --scope workspace');
expect(mockDisableExtension).toHaveBeenCalledWith(
'test-extension',
'Workspace',
);
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
{
type: MessageType.INFO,
text: 'Extension "test-extension" disabled for scope "Workspace"',
},
expect.any(Number),
);
});
it('should show error for invalid scope', async () => {
await disableAction(mockContext, 'test-extension --scope=invalid');
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
{
type: MessageType.ERROR,
text: 'Unsupported scope "invalid", should be one of "user" or "workspace"',
},
expect.any(Number),
);
});
});
describe('enable', () => {
const enableAction = extensionsCommand.subCommands?.find(
(cmd) => cmd.name === 'enable',
)?.action;
if (!enableAction) {
throw new Error('Enable action not found');
}
let realMockExtensionManager: ExtensionManager;
beforeEach(() => {
vi.resetAllMocks();
realMockExtensionManager = Object.create(ExtensionManager.prototype);
realMockExtensionManager.enableExtension = mockEnableExtension;
realMockExtensionManager.getLoadedExtensions = mockGetLoadedExtensions;
mockContext = createMockCommandContext({
invocation: {
raw: '/extensions enable',
name: 'enable',
args: '',
},
services: {
config: {
getExtensions: mockGetExtensions,
getWorkingDir: () => '/test/dir',
getExtensionManager: () => realMockExtensionManager,
},
},
ui: {
dispatchExtensionStateUpdate: vi.fn(),
},
});
});
it('should show usage if invalid args are provided', async () => {
await enableAction(mockContext, '');
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
{
type: MessageType.ERROR,
text: 'Usage: /extensions enable <extension> [--scope=<user|workspace>]',
},
expect.any(Number),
);
});
it('should enable extension at user scope', async () => {
mockEnableExtension.mockResolvedValue(undefined);
await enableAction(mockContext, 'test-extension --scope=user');
expect(mockEnableExtension).toHaveBeenCalledWith(
'test-extension',
'User',
);
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
{
type: MessageType.INFO,
text: 'Extension "test-extension" enabled for scope "User"',
},
expect.any(Number),
);
});
it('should enable extension at workspace scope', async () => {
mockEnableExtension.mockResolvedValue(undefined);
await enableAction(mockContext, 'test-extension --scope workspace');
expect(mockEnableExtension).toHaveBeenCalledWith(
'test-extension',
'Workspace',
);
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
{
type: MessageType.INFO,
text: 'Extension "test-extension" enabled for scope "Workspace"',
},
expect.any(Number),
);
});
it('should show error for invalid scope', async () => {
await enableAction(mockContext, 'test-extension --scope=invalid');
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
{
type: MessageType.ERROR,
text: 'Unsupported scope "invalid", should be one of "user" or "workspace"',
},
expect.any(Number),
);
});
});
describe('detail', () => {
const detailAction = extensionsCommand.subCommands?.find(
(cmd) => cmd.name === 'detail',
)?.action;
if (!detailAction) {
throw new Error('Detail action not found');
}
let realMockExtensionManager: ExtensionManager;
beforeEach(() => {
vi.resetAllMocks();
realMockExtensionManager = Object.create(ExtensionManager.prototype);
realMockExtensionManager.getLoadedExtensions = mockGetLoadedExtensions;
mockContext = createMockCommandContext({
invocation: {
raw: '/extensions detail',
name: 'detail',
args: '',
},
services: {
config: {
getExtensions: mockGetExtensions,
getWorkingDir: () => '/test/dir',
getExtensionManager: () => realMockExtensionManager,
},
},
ui: {
dispatchExtensionStateUpdate: vi.fn(),
},
});
});
it('should show usage if no name is provided', async () => {
await detailAction(mockContext, '');
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
{
type: MessageType.ERROR,
text: 'Usage: /extensions detail <extension-name>',
},
expect.any(Number),
);
});
it('should show error if extension not found', async () => {
mockGetExtensions.mockReturnValue([]);
await detailAction(mockContext, 'nonexistent-extension');
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
{
type: MessageType.ERROR,
text: 'Extension "nonexistent-extension" not found.',
},
expect.any(Number),
);
});
it('should show extension details when found', async () => {
const extension: Extension = {
id: 'test-ext',
name: 'test-ext',
version: '1.0.0',
isActive: true,
path: '/test/dir/test-ext',
contextFiles: [],
config: { name: 'test-ext', version: '1.0.0' },
};
mockGetExtensions.mockReturnValue([extension]);
realMockExtensionManager.isEnabled = vi.fn().mockReturnValue(true);
await detailAction(mockContext, 'test-ext');
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
{
type: MessageType.INFO,
text: expect.stringContaining('test-ext'),
},
expect.any(Number),
);
});
});
});

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 = {
@ -31,23 +27,6 @@ const EXTENSION_EXPLORE_URL = {
type ExtensionExploreSource = keyof typeof EXTENSION_EXPLORE_URL;
function showMessageIfNoExtensions(
context: CommandContext,
extensions: unknown[],
): boolean {
if (extensions.length === 0) {
context.ui.addItem(
{
type: MessageType.INFO,
text: t('No extensions installed.'),
},
Date.now(),
);
return true;
}
return false;
}
async function exploreAction(context: CommandContext, args: string) {
const source = args.trim();
const extensionsUrl = source
@ -113,130 +92,11 @@ async function exploreAction(context: CommandContext, args: string) {
}
}
async function listAction(context: CommandContext) {
const extensions = context.services.config
? context.services.config.getExtensions()
: [];
if (showMessageIfNoExtensions(context, extensions)) {
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);
}
async function listAction(_context: CommandContext, _args: string) {
return {
type: 'dialog' as const,
dialog: 'extensions_manage' as const,
};
}
async function installAction(context: CommandContext, args: string) {
@ -296,235 +156,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 +220,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 +238,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 +245,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

@ -0,0 +1,322 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type {
SlashCommand,
SlashCommandActionReturn,
CommandContext,
MessageActionReturn,
} from './types.js';
import { CommandKind } from './types.js';
import { t } from '../../i18n/index.js';
import type { HookRegistryEntry } from '@qwen-code/qwen-code-core';
/**
* Format hook source for display
*/
function formatHookSource(source: string): string {
switch (source) {
case 'project':
return 'Project';
case 'user':
return 'User';
case 'system':
return 'System';
case 'extensions':
return 'Extension';
default:
return source;
}
}
/**
* Format hook status for display
*/
function formatHookStatus(enabled: boolean): string {
return enabled ? '✓ Enabled' : '✗ Disabled';
}
const listCommand: SlashCommand = {
name: 'list',
get description() {
return t('List all configured hooks');
},
kind: CommandKind.BUILT_IN,
action: async (
context: CommandContext,
_args: string,
): Promise<MessageActionReturn> => {
const { config } = context.services;
if (!config) {
return {
type: 'message',
messageType: 'error',
content: t('Config not loaded.'),
};
}
const hookSystem = config.getHookSystem();
if (!hookSystem) {
return {
type: 'message',
messageType: 'info',
content: t(
'Hooks are not enabled. Enable hooks in settings to use this feature.',
),
};
}
const registry = hookSystem.getRegistry();
const allHooks = registry.getAllHooks();
if (allHooks.length === 0) {
return {
type: 'message',
messageType: 'info',
content: t(
'No hooks configured. Add hooks in your settings.json file.',
),
};
}
// Group hooks by event
const hooksByEvent = new Map<string, HookRegistryEntry[]>();
for (const hook of allHooks) {
const eventName = hook.eventName;
if (!hooksByEvent.has(eventName)) {
hooksByEvent.set(eventName, []);
}
hooksByEvent.get(eventName)!.push(hook);
}
let output = `**Configured Hooks (${allHooks.length} total)**\n\n`;
for (const [eventName, hooks] of hooksByEvent) {
output += `### ${eventName}\n`;
for (const hook of hooks) {
const name = hook.config.name || hook.config.command || 'unnamed';
const source = formatHookSource(hook.source);
const status = formatHookStatus(hook.enabled);
const matcher = hook.matcher ? ` (matcher: ${hook.matcher})` : '';
output += `- **${name}** [${source}] ${status}${matcher}\n`;
}
output += '\n';
}
return {
type: 'message',
messageType: 'info',
content: output,
};
},
};
const enableCommand: SlashCommand = {
name: 'enable',
get description() {
return t('Enable a disabled hook');
},
kind: CommandKind.BUILT_IN,
action: async (
context: CommandContext,
args: string,
): Promise<MessageActionReturn> => {
const hookName = args.trim();
if (!hookName) {
return {
type: 'message',
messageType: 'error',
content: t(
'Please specify a hook name. Usage: /hooks enable <hook-name>',
),
};
}
const { config } = context.services;
if (!config) {
return {
type: 'message',
messageType: 'error',
content: t('Config not loaded.'),
};
}
const hookSystem = config.getHookSystem();
if (!hookSystem) {
return {
type: 'message',
messageType: 'error',
content: t('Hooks are not enabled.'),
};
}
const registry = hookSystem.getRegistry();
registry.setHookEnabled(hookName, true);
return {
type: 'message',
messageType: 'info',
content: t('Hook "{{name}}" has been enabled for this session.', {
name: hookName,
}),
};
},
completion: async (context: CommandContext, partialArg: string) => {
const { config } = context.services;
if (!config) return [];
const hookSystem = config.getHookSystem();
if (!hookSystem) return [];
const registry = hookSystem.getRegistry();
const allHooks = registry.getAllHooks();
// Return disabled hooks for enable command (deduplicated by name)
const disabledHookNames = allHooks
.filter((hook) => !hook.enabled)
.map((hook) => hook.config.name || hook.config.command || '')
.filter((name) => name && name.startsWith(partialArg));
return [...new Set(disabledHookNames)];
},
};
const disableCommand: SlashCommand = {
name: 'disable',
get description() {
return t('Disable an active hook');
},
kind: CommandKind.BUILT_IN,
action: async (
context: CommandContext,
args: string,
): Promise<MessageActionReturn> => {
const hookName = args.trim();
if (!hookName) {
return {
type: 'message',
messageType: 'error',
content: t(
'Please specify a hook name. Usage: /hooks disable <hook-name>',
),
};
}
const { config } = context.services;
if (!config) {
return {
type: 'message',
messageType: 'error',
content: t('Config not loaded.'),
};
}
const hookSystem = config.getHookSystem();
if (!hookSystem) {
return {
type: 'message',
messageType: 'error',
content: t('Hooks are not enabled.'),
};
}
const registry = hookSystem.getRegistry();
registry.setHookEnabled(hookName, false);
return {
type: 'message',
messageType: 'info',
content: t('Hook "{{name}}" has been disabled for this session.', {
name: hookName,
}),
};
},
completion: async (context: CommandContext, partialArg: string) => {
const { config } = context.services;
if (!config) return [];
const hookSystem = config.getHookSystem();
if (!hookSystem) return [];
const registry = hookSystem.getRegistry();
const allHooks = registry.getAllHooks();
// Return enabled hooks for disable command (deduplicated by name)
const enabledHookNames = allHooks
.filter((hook) => hook.enabled)
.map((hook) => hook.config.name || hook.config.command || '')
.filter((name) => name && name.startsWith(partialArg));
return [...new Set(enabledHookNames)];
},
};
export const hooksCommand: SlashCommand = {
name: 'hooks',
get description() {
return t('Manage Qwen Code hooks');
},
kind: CommandKind.BUILT_IN,
subCommands: [listCommand, enableCommand, disableCommand],
action: async (
context: CommandContext,
args: string,
): Promise<SlashCommandActionReturn> => {
// If no subcommand provided, show list
if (!args.trim()) {
const result = await listCommand.action?.(context, '');
return result ?? { type: 'message', messageType: 'info', content: '' };
}
const [subcommand, ...rest] = args.trim().split(/\s+/);
const subArgs = rest.join(' ');
let result: SlashCommandActionReturn | void;
switch (subcommand.toLowerCase()) {
case 'list':
result = await listCommand.action?.(context, subArgs);
break;
case 'enable':
result = await enableCommand.action?.(context, subArgs);
break;
case 'disable':
result = await disableCommand.action?.(context, subArgs);
break;
default:
return {
type: 'message',
messageType: 'error',
content: t(
'Unknown subcommand: {{cmd}}. Available: list, enable, disable',
{
cmd: subcommand,
},
),
};
}
return result ?? { type: 'message', messageType: 'info', content: '' };
},
completion: async (context: CommandContext, partialArg: string) => {
const subcommands = ['list', 'enable', 'disable'];
const parts = partialArg.split(/\s+/);
if (parts.length <= 1) {
// Complete subcommand
return subcommands.filter((cmd) => cmd.startsWith(partialArg));
}
// Complete subcommand arguments
const [subcommand, ...rest] = parts;
const subArgs = rest.join(' ');
switch (subcommand.toLowerCase()) {
case 'enable':
return enableCommand.completion?.(context, subArgs) ?? [];
case 'disable':
return disableCommand.completion?.(context, subArgs) ?? [];
default:
return [];
}
},
};

View file

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

View file

@ -4,368 +4,18 @@
* SPDX-License-Identifier: Apache-2.0
*/
import type {
SlashCommand,
SlashCommandActionReturn,
CommandContext,
MessageActionReturn,
} from './types.js';
import type { SlashCommand, OpenDialogActionReturn } from './types.js';
import { CommandKind } from './types.js';
import type { DiscoveredMCPPrompt } from '@qwen-code/qwen-code-core';
import {
DiscoveredMCPTool,
getMCPDiscoveryState,
getMCPServerStatus,
MCPDiscoveryState,
MCPServerStatus,
getErrorMessage,
MCPOAuthTokenStorage,
MCPOAuthProvider,
} from '@qwen-code/qwen-code-core';
import { appEvents, AppEvent } from '../../utils/events.js';
import { MessageType, type HistoryItemMcpStatus } from '../types.js';
import { t } from '../../i18n/index.js';
const authCommand: SlashCommand = {
name: 'auth',
get description() {
return t('Authenticate with an OAuth-enabled MCP server');
},
kind: CommandKind.BUILT_IN,
action: async (
context: CommandContext,
args: string,
): Promise<MessageActionReturn> => {
const serverName = args.trim();
const { config } = context.services;
if (!config) {
return {
type: 'message',
messageType: 'error',
content: t('Config not loaded.'),
};
}
const mcpServers = config.getMcpServers() || {};
if (!serverName) {
// List servers that support OAuth
const oauthServers = Object.entries(mcpServers)
.filter(([_, server]) => server.oauth?.enabled)
.map(([name, _]) => name);
if (oauthServers.length === 0) {
return {
type: 'message',
messageType: 'info',
content: t('No MCP servers configured with OAuth authentication.'),
};
}
return {
type: 'message',
messageType: 'info',
content: `${t('MCP servers with OAuth authentication:')}\n${oauthServers.map((s) => ` - ${s}`).join('\n')}\n\n${t('Use /mcp auth <server-name> to authenticate.')}`,
};
}
const server = mcpServers[serverName];
if (!server) {
return {
type: 'message',
messageType: 'error',
content: t("MCP server '{{name}}' not found.", { name: serverName }),
};
}
// Always attempt OAuth authentication, even if not explicitly configured
// The authentication process will discover OAuth requirements automatically
const displayListener = (message: string) => {
context.ui.addItem({ type: 'info', text: message }, Date.now());
};
appEvents.on(AppEvent.OauthDisplayMessage, displayListener);
try {
context.ui.addItem(
{
type: 'info',
text: t(
"Starting OAuth authentication for MCP server '{{name}}'...",
{
name: serverName,
},
),
},
Date.now(),
);
let oauthConfig = server.oauth;
if (!oauthConfig) {
oauthConfig = { enabled: false };
}
const mcpServerUrl = server.httpUrl || server.url;
const authProvider = new MCPOAuthProvider(new MCPOAuthTokenStorage());
await authProvider.authenticate(
serverName,
oauthConfig,
mcpServerUrl,
appEvents,
);
context.ui.addItem(
{
type: 'info',
text: t(
"Successfully authenticated and refreshed tools for '{{name}}'.",
{
name: serverName,
},
),
},
Date.now(),
);
// Trigger tool re-discovery to pick up authenticated server
const toolRegistry = config.getToolRegistry();
if (toolRegistry) {
context.ui.addItem(
{
type: 'info',
text: t("Re-discovering tools from '{{name}}'...", {
name: serverName,
}),
},
Date.now(),
);
await toolRegistry.discoverToolsForServer(serverName);
}
// Update the client with the new tools
const geminiClient = config.getGeminiClient();
if (geminiClient) {
await geminiClient.setTools();
}
// Reload the slash commands to reflect the changes.
context.ui.reloadCommands();
return {
type: 'message',
messageType: 'info',
content: t(
"Successfully authenticated and refreshed tools for '{{name}}'.",
{
name: serverName,
},
),
};
} catch (error) {
return {
type: 'message',
messageType: 'error',
content: t(
"Failed to authenticate with MCP server '{{name}}': {{error}}",
{
name: serverName,
error: getErrorMessage(error),
},
),
};
} finally {
appEvents.removeListener(AppEvent.OauthDisplayMessage, displayListener);
}
},
completion: async (context: CommandContext, partialArg: string) => {
const { config } = context.services;
if (!config) return [];
const mcpServers = config.getMcpServers() || {};
return Object.keys(mcpServers).filter((name) =>
name.startsWith(partialArg),
);
},
};
const listCommand: SlashCommand = {
name: 'list',
get description() {
return t('List configured MCP servers and tools');
},
kind: CommandKind.BUILT_IN,
action: async (
context: CommandContext,
args: string,
): Promise<void | MessageActionReturn> => {
const { config } = context.services;
if (!config) {
return {
type: 'message',
messageType: 'error',
content: t('Config not loaded.'),
};
}
const toolRegistry = config.getToolRegistry();
if (!toolRegistry) {
return {
type: 'message',
messageType: 'error',
content: t('Could not retrieve tool registry.'),
};
}
const lowerCaseArgs = args.toLowerCase().split(/\s+/).filter(Boolean);
const hasDesc =
lowerCaseArgs.includes('desc') || lowerCaseArgs.includes('descriptions');
const hasNodesc =
lowerCaseArgs.includes('nodesc') ||
lowerCaseArgs.includes('nodescriptions');
const showSchema = lowerCaseArgs.includes('schema');
const showDescriptions = !hasNodesc && (hasDesc || showSchema);
const showTips = lowerCaseArgs.length === 0;
const mcpServers = config.getMcpServers() || {};
const serverNames = Object.keys(mcpServers);
const blockedMcpServers = config.getBlockedMcpServers() || [];
const connectingServers = serverNames.filter(
(name) => getMCPServerStatus(name) === MCPServerStatus.CONNECTING,
);
const discoveryState = getMCPDiscoveryState();
const discoveryInProgress =
discoveryState === MCPDiscoveryState.IN_PROGRESS ||
connectingServers.length > 0;
const allTools = toolRegistry.getAllTools();
const mcpTools = allTools.filter(
(tool) => tool instanceof DiscoveredMCPTool,
) as DiscoveredMCPTool[];
const promptRegistry = await config.getPromptRegistry();
const mcpPrompts = promptRegistry
.getAllPrompts()
.filter(
(prompt) =>
'serverName' in prompt &&
serverNames.includes(prompt.serverName as string),
) as DiscoveredMCPPrompt[];
const authStatus: HistoryItemMcpStatus['authStatus'] = {};
const tokenStorage = new MCPOAuthTokenStorage();
for (const serverName of serverNames) {
const server = mcpServers[serverName];
if (server.oauth?.enabled) {
const creds = await tokenStorage.getCredentials(serverName);
if (creds) {
if (creds.token.expiresAt && creds.token.expiresAt < Date.now()) {
authStatus[serverName] = 'expired';
} else {
authStatus[serverName] = 'authenticated';
}
} else {
authStatus[serverName] = 'unauthenticated';
}
} else {
authStatus[serverName] = 'not-configured';
}
}
const mcpStatusItem: HistoryItemMcpStatus = {
type: MessageType.MCP_STATUS,
servers: mcpServers,
tools: mcpTools.map((tool) => ({
serverName: tool.serverName,
name: tool.name,
description: tool.description,
schema: tool.schema,
})),
prompts: mcpPrompts.map((prompt) => ({
serverName: prompt.serverName as string,
name: prompt.name,
description: prompt.description,
})),
authStatus,
blockedServers: blockedMcpServers,
discoveryInProgress,
connectingServers,
showDescriptions,
showSchema,
showTips,
};
context.ui.addItem(mcpStatusItem, Date.now());
},
};
const refreshCommand: SlashCommand = {
name: 'refresh',
get description() {
return t('Restarts MCP servers.');
},
kind: CommandKind.BUILT_IN,
action: async (
context: CommandContext,
): Promise<void | SlashCommandActionReturn> => {
const { config } = context.services;
if (!config) {
return {
type: 'message',
messageType: 'error',
content: t('Config not loaded.'),
};
}
const toolRegistry = config.getToolRegistry();
if (!toolRegistry) {
return {
type: 'message',
messageType: 'error',
content: t('Could not retrieve tool registry.'),
};
}
context.ui.addItem(
{
type: 'info',
text: t('Restarting MCP servers...'),
},
Date.now(),
);
await toolRegistry.restartMcpServers();
// Update the client with the new tools
const geminiClient = config.getGeminiClient();
if (geminiClient) {
await geminiClient.setTools();
}
// Reload the slash commands to reflect the changes.
context.ui.reloadCommands();
return listCommand.action!(context, '');
},
};
export const mcpCommand: SlashCommand = {
name: 'mcp',
get description() {
return t(
'list configured MCP servers and tools, or authenticate with OAuth-enabled servers',
);
return t('Open MCP management dialog');
},
kind: CommandKind.BUILT_IN,
subCommands: [listCommand, authCommand, refreshCommand],
// Default action when no subcommand is provided
action: async (
context: CommandContext,
args: string,
): Promise<void | SlashCommandActionReturn> =>
// If no subcommand, run the list command
listCommand.action!(context, args),
action: async (): Promise<OpenDialogActionReturn> => ({
type: 'dialog',
dialog: 'mcp',
}),
};

View file

@ -13,6 +13,7 @@ import {
CommandKind,
} from './types.js';
import type { Config } from '@qwen-code/qwen-code-core';
import { t } from '../../i18n/index.js';
async function restoreAction(
context: CommandContext,
@ -144,8 +145,11 @@ export const restoreCommand = (config: Config | null): SlashCommand | null => {
return {
name: 'restore',
description:
'Restore a tool call. This will reset the conversation and file history to the state it was in when the tool call was suggested',
get description() {
return t(
'Restore a tool call. This will reset the conversation and file history to the state it was in when the tool call was suggested',
);
},
kind: CommandKind.BUILT_IN,
action: restoreAction,
completion,

View file

@ -148,7 +148,9 @@ export interface OpenDialogActionReturn {
| 'subagent_list'
| 'permissions'
| 'approval-mode'
| 'resume';
| 'resume'
| 'extensions_manage'
| 'mcp';
}
/**
@ -209,6 +211,7 @@ export enum CommandKind {
BUILT_IN = 'built-in',
FILE = 'file',
MCP_PROMPT = 'mcp-prompt',
SKILL = 'skill',
}
export interface CommandCompletionItem {

View file

@ -103,7 +103,9 @@ export const Composer = () => {
)}
{/* Exclusive area: only one component visible at a time */}
{/* Hide footer when a confirmation dialog (e.g. ask_user_question) is active */}
{!showSuggestions &&
uiState.streamingState !== StreamingState.WaitingForConfirmation &&
(showShortcuts ? (
<KeyboardShortcuts />
) : (

View file

@ -34,6 +34,8 @@ import { IdeTrustChangeDialog } from './IdeTrustChangeDialog.js';
import { WelcomeBackDialog } from './WelcomeBackDialog.js';
import { AgentCreationWizard } from './subagents/create/AgentCreationWizard.js';
import { AgentsManagerDialog } from './subagents/manage/AgentsManagerDialog.js';
import { ExtensionsManagerDialog } from './extensions/ExtensionsManagerDialog.js';
import { MCPManagementDialog } from './mcp/MCPManagementDialog.js';
import { SessionPicker } from './SessionPicker.js';
interface DialogManagerProps {
@ -292,6 +294,18 @@ export const DialogManager = ({
);
}
if (uiState.isExtensionsManagerDialogOpen) {
return (
<ExtensionsManagerDialog
onClose={uiActions.closeExtensionsManagerDialog}
config={config}
/>
);
}
if (uiState.isMcpDialogOpen) {
return <MCPManagementDialog onClose={uiActions.closeMcpDialog} />;
}
if (uiState.isResumeDialogOpen) {
return (
<SessionPicker

View file

@ -78,7 +78,7 @@ describe('<Header />', () => {
it('renders with border around info panel', () => {
const { lastFrame } = render(<Header {...defaultProps} />);
expect(lastFrame()).toContain('');
expect(lastFrame()).toContain('');
expect(lastFrame()).toContain('');
expect(lastFrame()).toContain('');
});
});

View file

@ -128,7 +128,7 @@ export const Header: React.FC<HeaderProps> = ({
{/* Right side: Info panel (flexible width, max 60 in two-column layout) */}
<Box
flexDirection="column"
borderStyle="round"
borderStyle="single"
borderColor={theme.border.default}
paddingX={infoPanelPaddingX}
flexGrow={showLogo ? 0 : 1}

View file

@ -8,19 +8,23 @@ import type React from 'react';
import { useMemo } from 'react';
import { escapeAnsiCtrlCodes } from '../utils/textUtils.js';
import type { HistoryItem } from '../types.js';
import { UserMessage } from './messages/UserMessage.js';
import { UserShellMessage } from './messages/UserShellMessage.js';
import { GeminiMessage } from './messages/GeminiMessage.js';
import { InfoMessage } from './messages/InfoMessage.js';
import { ErrorMessage } from './messages/ErrorMessage.js';
import {
UserMessage,
UserShellMessage,
AssistantMessage,
AssistantMessageContent,
ThinkMessage,
ThinkMessageContent,
} from './messages/ConversationMessages.js';
import { ToolGroupMessage } from './messages/ToolGroupMessage.js';
import { GeminiMessageContent } from './messages/GeminiMessageContent.js';
import { GeminiThoughtMessage } from './messages/GeminiThoughtMessage.js';
import { GeminiThoughtMessageContent } from './messages/GeminiThoughtMessageContent.js';
import { CompressionMessage } from './messages/CompressionMessage.js';
import { SummaryMessage } from './messages/SummaryMessage.js';
import { WarningMessage } from './messages/WarningMessage.js';
import { RetryCountdownMessage } from './messages/RetryCountdownMessage.js';
import {
InfoMessage,
WarningMessage,
ErrorMessage,
RetryCountdownMessage,
} from './messages/StatusMessages.js';
import { Box } from 'ink';
import { AboutBox } from './AboutBox.js';
import { StatsDisplay } from './StatsDisplay.js';
@ -62,6 +66,11 @@ const HistoryItemDisplayComponent: React.FC<HistoryItemDisplayProps> = ({
embeddedShellFocused,
availableTerminalHeightGemini,
}) => {
const marginTop =
item.type === 'gemini_content' || item.type === 'gemini_thought_content'
? 0
: 1;
const itemForDisplay = useMemo(() => escapeAnsiCtrlCodes(item), [item]);
const contentWidth = terminalWidth - 4;
const boxWidth = mainAreaWidth || contentWidth;
@ -70,6 +79,7 @@ const HistoryItemDisplayComponent: React.FC<HistoryItemDisplayProps> = ({
<Box
flexDirection="column"
key={itemForDisplay.id}
marginTop={marginTop}
marginLeft={2}
marginRight={2}
>
@ -81,7 +91,7 @@ const HistoryItemDisplayComponent: React.FC<HistoryItemDisplayProps> = ({
<UserShellMessage text={itemForDisplay.text} />
)}
{itemForDisplay.type === 'gemini' && (
<GeminiMessage
<AssistantMessage
text={itemForDisplay.text}
isPending={isPending}
availableTerminalHeight={
@ -91,7 +101,7 @@ const HistoryItemDisplayComponent: React.FC<HistoryItemDisplayProps> = ({
/>
)}
{itemForDisplay.type === 'gemini_content' && (
<GeminiMessageContent
<AssistantMessageContent
text={itemForDisplay.text}
isPending={isPending}
availableTerminalHeight={
@ -101,7 +111,7 @@ const HistoryItemDisplayComponent: React.FC<HistoryItemDisplayProps> = ({
/>
)}
{itemForDisplay.type === 'gemini_thought' && (
<GeminiThoughtMessage
<ThinkMessage
text={itemForDisplay.text}
isPending={isPending}
availableTerminalHeight={
@ -111,7 +121,7 @@ const HistoryItemDisplayComponent: React.FC<HistoryItemDisplayProps> = ({
/>
)}
{itemForDisplay.type === 'gemini_thought_content' && (
<GeminiThoughtMessageContent
<ThinkMessageContent
text={itemForDisplay.text}
isPending={isPending}
availableTerminalHeight={

View file

@ -1957,6 +1957,25 @@ describe('InputPrompt', () => {
});
describe('command search (Ctrl+R when not in shell)', () => {
it('passes newest-first user history to command search', async () => {
props.shellModeActive = false;
props.userMessages = ['oldest', 'middle', 'newest'];
const { unmount } = renderWithProviders(<InputPrompt {...props} />);
await wait();
const commandSearchCall =
mockedUseReverseSearchCompletion.mock.calls.find(
([, history]) =>
Array.isArray(history) &&
history.length === 3 &&
history.includes('newest'),
);
expect(commandSearchCall?.[1]).toEqual(['newest', 'middle', 'oldest']);
unmount();
});
it('enters command search on Ctrl+R and shows suggestions', async () => {
props.shellModeActive = false;

View file

@ -5,7 +5,7 @@
*/
import type React from 'react';
import { useCallback, useEffect, useState, useRef } from 'react';
import { useCallback, useEffect, useMemo, useState, useRef } from 'react';
import { Box, Text } from 'ink';
import { SuggestionsDisplay, MAX_WIDTH } from './SuggestionsDisplay.js';
import { theme } from '../semantic-colors.js';
@ -213,9 +213,14 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
reverseSearchActive,
);
const commandSearchHistory = useMemo(
() => [...userMessages].reverse(),
[userMessages],
);
const commandSearchCompletion = useReverseSearchCompletion(
buffer,
userMessages,
commandSearchHistory,
commandSearchActive,
);

View file

@ -21,12 +21,13 @@ export const PlanSummaryDisplay: React.FC<PlanSummaryDisplayProps> = ({
availableHeight,
childWidth,
}) => {
const { message, plan } = data;
const { message, plan, rejected } = data;
const messageColor = rejected ? Colors.AccentYellow : Colors.AccentGreen;
return (
<Box flexDirection="column">
<Box marginBottom={1}>
<Text color={Colors.AccentGreen} wrap="wrap">
<Text color={messageColor} wrap="wrap">
{message}
</Text>
</Box>

View file

@ -17,18 +17,6 @@ vi.mock('../hooks/useKeypress.js', () => ({
useKeypress: vi.fn(),
}));
// Mock qrcode-terminal module
vi.mock('qrcode-terminal', () => ({
default: {
generate: vi.fn(),
},
}));
// Mock ink-spinner
vi.mock('ink-spinner', () => ({
default: ({ type }: { type: string }) => `MockSpinner(${type})`,
}));
// Mock ink-link
vi.mock('ink-link', () => ({
default: ({ children }: { children: React.ReactNode; url: string }) =>
@ -95,19 +83,17 @@ describe('QwenOAuthProgress', () => {
const { lastFrame } = renderComponent();
const output = lastFrame();
expect(output).toContain('MockSpinner(dots)');
expect(output).toContain('Waiting for Qwen OAuth authentication...');
expect(output).toContain('(Press ESC or CTRL+C to cancel)');
expect(output).toContain('Esc to cancel');
});
it('should render loading state with gray border', () => {
it('should render loading state with single border', () => {
const { lastFrame } = renderComponent();
const output = lastFrame();
// Should not contain auth flow elements
expect(output).not.toContain('Qwen OAuth Authentication');
expect(output).not.toContain('Please visit this URL to authorize:');
// Loading state still shows time remaining with default timeout
// Should contain the auth title even in loading state
expect(output).toContain('Qwen OAuth Authentication');
// Loading state shows time remaining with default timeout
expect(output).toContain('Time remaining:');
});
});
@ -117,44 +103,20 @@ describe('QwenOAuthProgress', () => {
const { lastFrame } = renderComponent({ deviceAuth: mockDeviceAuth });
const output = lastFrame();
// Initially no QR code shown until it's generated, but the status area should be visible
expect(output).toContain('MockSpinner(dots)');
expect(output).toContain('Waiting for authorization');
expect(output).toContain('Time remaining: 5:00');
expect(output).toContain('(Press ESC or CTRL+C to cancel)');
expect(output).toContain('Esc to cancel');
});
it('should display correct URL in Static component when QR code is generated', async () => {
const qrcode = await import('qrcode-terminal');
const mockGenerate = vi.mocked(qrcode.default.generate);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let qrCallback: any = null;
mockGenerate.mockImplementation((url, options, callback) => {
qrCallback = callback;
});
it('should display correct URL in auth URL display', () => {
const customAuth = createMockDeviceAuth({
verification_uri_complete: 'https://custom.com/auth?code=XYZ789',
});
const { lastFrame, rerender } = renderComponent({
const { lastFrame } = renderComponent({
deviceAuth: customAuth,
});
// Manually trigger the QR code callback
if (qrCallback && typeof qrCallback === 'function') {
qrCallback('Mock QR Code Data');
}
rerender(
<QwenOAuthProgress
onTimeout={mockOnTimeout}
onCancel={mockOnCancel}
deviceAuth={customAuth}
/>,
);
expect(lastFrame()).toContain('https://custom.com/auth?code=XYZ789');
});
@ -282,10 +244,11 @@ describe('QwenOAuthProgress', () => {
/>,
);
// Initial state should have no dots
expect(lastFrame()).toContain('Waiting for authorization');
// Initial state should show '...' (default value)
const initialOutput = lastFrame();
expect(initialOutput).toContain('Waiting for authorization');
// Advance by 500ms to add first dot
// Advance by 500ms to cycle animation
vi.advanceTimersByTime(500);
rerender(
<QwenOAuthProgress
@ -294,9 +257,10 @@ describe('QwenOAuthProgress', () => {
deviceAuth={mockDeviceAuth}
/>,
);
expect(lastFrame()).toContain('Waiting for authorization.');
const after500ms = lastFrame();
expect(after500ms).toContain('Waiting for authorization');
// Advance by another 500ms to add second dot
// Advance by another 500ms to continue animation
vi.advanceTimersByTime(500);
rerender(
<QwenOAuthProgress
@ -305,9 +269,10 @@ describe('QwenOAuthProgress', () => {
deviceAuth={mockDeviceAuth}
/>,
);
expect(lastFrame()).toContain('Waiting for authorization..');
const after1000ms = lastFrame();
expect(after1000ms).toContain('Waiting for authorization');
// Advance by another 500ms to add third dot
// Advance by another 500ms to complete cycle
vi.advanceTimersByTime(500);
rerender(
<QwenOAuthProgress
@ -316,110 +281,8 @@ describe('QwenOAuthProgress', () => {
deviceAuth={mockDeviceAuth}
/>,
);
expect(lastFrame()).toContain('Waiting for authorization...');
// Advance by another 500ms to reset dots
vi.advanceTimersByTime(500);
rerender(
<QwenOAuthProgress
onTimeout={mockOnTimeout}
onCancel={mockOnCancel}
deviceAuth={mockDeviceAuth}
/>,
);
expect(lastFrame()).toContain('Waiting for authorization');
});
});
describe('QR Code functionality', () => {
it('should generate QR code when deviceAuth is provided', async () => {
const qrcode = await import('qrcode-terminal');
const mockGenerate = vi.mocked(qrcode.default.generate);
mockGenerate.mockImplementation((url, options, callback) => {
callback!('Mock QR Code Data');
});
render(
<QwenOAuthProgress
onTimeout={mockOnTimeout}
onCancel={mockOnCancel}
deviceAuth={mockDeviceAuth}
/>,
);
expect(mockGenerate).toHaveBeenCalledWith(
mockDeviceAuth.verification_uri_complete,
{ small: true },
expect.any(Function),
);
});
it('should display QR code in Static component when available', async () => {
const qrcode = await import('qrcode-terminal');
const mockGenerate = vi.mocked(qrcode.default.generate);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let qrCallback: any = null;
mockGenerate.mockImplementation((url, options, callback) => {
qrCallback = callback;
});
const { lastFrame, rerender } = render(
<QwenOAuthProgress
onTimeout={mockOnTimeout}
onCancel={mockOnCancel}
deviceAuth={mockDeviceAuth}
/>,
);
// Manually trigger the QR code callback
if (qrCallback && typeof qrCallback === 'function') {
qrCallback('Mock QR Code Data');
}
rerender(
<QwenOAuthProgress
onTimeout={mockOnTimeout}
onCancel={mockOnCancel}
deviceAuth={mockDeviceAuth}
/>,
);
const output = lastFrame();
expect(output).toContain('Or scan the QR code below:');
expect(output).toContain('Mock QR Code Data');
});
it('should handle QR code generation errors gracefully', async () => {
const qrcode = await import('qrcode-terminal');
const mockGenerate = vi.mocked(qrcode.default.generate);
mockGenerate.mockImplementation(() => {
throw new Error('QR Code generation failed');
});
const { lastFrame } = render(
<QwenOAuthProgress
onTimeout={mockOnTimeout}
onCancel={mockOnCancel}
deviceAuth={mockDeviceAuth}
/>,
);
// Should not crash and should not show QR code section since QR generation failed
const output = lastFrame();
expect(output).not.toContain('Or scan the QR code below:');
});
it('should not generate QR code when deviceAuth is null', async () => {
const qrcode = await import('qrcode-terminal');
const mockGenerate = vi.mocked(qrcode.default.generate);
render(
<QwenOAuthProgress onTimeout={mockOnTimeout} onCancel={mockOnCancel} />,
);
expect(mockGenerate).not.toHaveBeenCalled();
const after1500ms = lastFrame();
expect(after1500ms).toContain('Waiting for authorization');
});
});

View file

@ -5,14 +5,11 @@
*/
import type React from 'react';
import { useState, useEffect, useMemo } from 'react';
import { useState, useEffect } from 'react';
import { Box, Text } from 'ink';
import Spinner from 'ink-spinner';
import Link from 'ink-link';
import qrcode from 'qrcode-terminal';
import { Colors } from '../colors.js';
import { theme } from '../semantic-colors.js';
import type { DeviceAuthorizationData } from '@qwen-code/qwen-code-core';
import { createDebugLogger } from '@qwen-code/qwen-code-core';
import { useKeypress } from '../hooks/useKeypress.js';
import { t } from '../../i18n/index.js';
@ -30,98 +27,10 @@ interface QwenOAuthProgressProps {
authMessage?: string | null;
}
const debugLogger = createDebugLogger('QWEN_OAUTH_PROGRESS');
/**
* Static QR Code Display Component
* Renders the QR code and URL once and doesn't re-render unless the URL changes
*/
function QrCodeDisplay({
verificationUrl,
qrCodeData,
}: {
verificationUrl: string;
qrCodeData: string | null;
}): React.JSX.Element | null {
if (!qrCodeData) {
return null;
}
return (
<Box
borderStyle="round"
borderColor={Colors.AccentBlue}
flexDirection="column"
padding={1}
width="100%"
>
<Text bold color={Colors.AccentBlue}>
{t('Qwen OAuth Authentication')}
</Text>
<Box marginTop={1}>
<Text>{t('Please visit this URL to authorize:')}</Text>
</Box>
<Link url={verificationUrl} fallback={false}>
<Text color={Colors.AccentGreen} bold>
{verificationUrl}
</Text>
</Link>
<Box marginTop={1}>
<Text>{t('Or scan the QR code below:')}</Text>
</Box>
<Box marginTop={1}>
<Text>{qrCodeData}</Text>
</Box>
</Box>
);
}
/**
* Dynamic Status Display Component
* Shows the loading spinner, timer, and status messages
*/
function StatusDisplay({
timeRemaining,
dots,
}: {
timeRemaining: number;
dots: string;
}): React.JSX.Element {
const formatTime = (seconds: number): string => {
const minutes = Math.floor(seconds / 60);
const remainingSeconds = seconds % 60;
return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`;
};
return (
<Box
borderStyle="round"
borderColor={Colors.AccentBlue}
flexDirection="column"
padding={1}
width="100%"
>
<Box marginTop={1}>
<Text>
<Spinner type="dots" /> {t('Waiting for authorization')}
{dots}
</Text>
</Box>
<Box marginTop={1} justifyContent="space-between">
<Text color={Colors.Gray}>
{t('Time remaining:')} {formatTime(timeRemaining)}
</Text>
<Text color={Colors.AccentPurple}>
{t('(Press ESC or CTRL+C to cancel)')}
</Text>
</Box>
</Box>
);
function formatTime(seconds: number): string {
const minutes = Math.floor(seconds / 60);
const remainingSeconds = seconds % 60;
return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`;
}
export function QwenOAuthProgress({
@ -133,13 +42,11 @@ export function QwenOAuthProgress({
}: QwenOAuthProgressProps): React.JSX.Element {
const defaultTimeout = deviceAuth?.expires_in || 300; // Default 5 minutes
const [timeRemaining, setTimeRemaining] = useState<number>(defaultTimeout);
const [dots, setDots] = useState<string>('');
const [qrCodeData, setQrCodeData] = useState<string | null>(null);
const [dots, setDots] = useState<string>('...');
useKeypress(
(key) => {
if (authStatus === 'timeout' || authStatus === 'error') {
// Any key press in timeout or error state should trigger cancel to return to auth dialog
onCancel();
} else if (key.name === 'escape' || (key.ctrl && key.name === 'c')) {
onCancel();
@ -148,30 +55,6 @@ export function QwenOAuthProgress({
{ isActive: true },
);
// Generate QR code once when device auth is available
useEffect(() => {
if (!deviceAuth?.verification_uri_complete) {
return;
}
const generateQR = () => {
try {
qrcode.generate(
deviceAuth.verification_uri_complete,
{ small: true },
(qrcode: string) => {
setQrCodeData(qrcode);
},
);
} catch (error) {
debugLogger.error('Failed to generate QR code:', error);
setQrCodeData(null);
}
};
generateQR();
}, [deviceAuth?.verification_uri_complete]);
// Countdown timer
useEffect(() => {
const timer = setInterval(() => {
@ -187,41 +70,29 @@ export function QwenOAuthProgress({
return () => clearInterval(timer);
}, [onTimeout]);
// Animated dots
// Animated dots — cycle through fixed-width patterns to avoid layout shift
useEffect(() => {
const dotFrames = ['. ', '.. ', '...'];
let frameIndex = 0;
const dotsTimer = setInterval(() => {
setDots((prev) => {
if (prev.length >= 3) return '';
return prev + '.';
});
frameIndex = (frameIndex + 1) % dotFrames.length;
setDots(dotFrames[frameIndex]!);
}, 500);
return () => clearInterval(dotsTimer);
}, []);
// Memoize the QR code display to prevent unnecessary re-renders
const qrCodeDisplay = useMemo(() => {
if (!deviceAuth?.verification_uri_complete) return null;
return (
<QrCodeDisplay
verificationUrl={deviceAuth.verification_uri_complete}
qrCodeData={qrCodeData}
/>
);
}, [deviceAuth?.verification_uri_complete, qrCodeData]);
// Handle timeout state
if (authStatus === 'timeout') {
return (
<Box
borderStyle="round"
borderColor={Colors.AccentRed}
borderStyle="single"
borderColor={theme.border.default}
flexDirection="column"
padding={1}
width="100%"
>
<Text bold color={Colors.AccentRed}>
<Text bold color={theme.status.error}>
{t('Qwen OAuth Authentication Timeout')}
</Text>
@ -238,7 +109,7 @@ export function QwenOAuthProgress({
</Box>
<Box marginTop={1}>
<Text color={Colors.Gray}>
<Text color={theme.text.secondary}>
{t('Press any key to return to authentication type selection.')}
</Text>
</Box>
@ -249,26 +120,26 @@ export function QwenOAuthProgress({
if (authStatus === 'error') {
return (
<Box
borderStyle="round"
borderColor={Colors.AccentRed}
borderStyle="single"
borderColor={theme.border.default}
flexDirection="column"
padding={1}
width="100%"
>
<Text bold color={Colors.AccentRed}>
Qwen OAuth Authentication Error
<Text bold color={theme.status.error}>
{t('Qwen OAuth Authentication Error')}
</Text>
<Box marginTop={1}>
<Text>
{authMessage ||
'An error occurred during authentication. Please try again.'}
t('An error occurred during authentication. Please try again.')}
</Text>
</Box>
<Box marginTop={1}>
<Text color={Colors.Gray}>
Press any key to return to authentication type selection.
<Text color={theme.text.secondary}>
{t('Press any key to return to authentication type selection.')}
</Text>
</Box>
</Box>
@ -279,38 +150,61 @@ export function QwenOAuthProgress({
if (!deviceAuth) {
return (
<Box
borderStyle="round"
borderColor={Colors.Gray}
borderStyle="single"
borderColor={theme.border.default}
flexDirection="column"
padding={1}
width="100%"
>
<Box>
<Text bold>{t('Qwen OAuth Authentication')}</Text>
<Box marginTop={1} flexDirection="column">
<Text>{t('Waiting for Qwen OAuth authentication...')}</Text>
<Text>
<Spinner type="dots" />
{t('Waiting for Qwen OAuth authentication...')}
{t('Time remaining:')} {formatTime(timeRemaining)}
</Text>
</Box>
<Box marginTop={1} justifyContent="space-between">
<Text color={Colors.Gray}>
{t('Time remaining:')} {Math.floor(timeRemaining / 60)}:
{(timeRemaining % 60).toString().padStart(2, '0')}
</Text>
<Text color={Colors.AccentPurple}>
{t('(Press ESC or CTRL+C to cancel)')}
</Text>
<Box marginTop={1}>
<Text color={theme.text.secondary}>{t('Esc to cancel')}</Text>
</Box>
</Box>
);
}
return (
<Box flexDirection="column" width="100%">
{/* Static QR Code Display */}
{qrCodeDisplay}
<Box
borderStyle="single"
borderColor={theme.border.default}
flexDirection="column"
padding={1}
width="100%"
>
<Text bold>{t('Qwen OAuth Authentication')}</Text>
{/* Dynamic Status Display */}
<StatusDisplay timeRemaining={timeRemaining} dots={dots} />
<Box marginTop={1}>
<Text>{t('Please visit this URL to authorize:')}</Text>
</Box>
<Link url={deviceAuth.verification_uri_complete || ''} fallback={false}>
<Text color={theme.text.link} bold>
{deviceAuth.verification_uri_complete}
</Text>
</Link>
<Box marginTop={1} flexDirection="column">
<Text>
{t('Waiting for authorization')}
{dots}
</Text>
<Text>
{t('Time remaining:')} {formatTime(timeRemaining)}
</Text>
</Box>
<Box marginTop={1}>
<Text color={theme.text.secondary}>{t('Esc to cancel')}</Text>
</Box>
</Box>
);
}

View file

@ -9,6 +9,7 @@ import type React from 'react';
import { useKeypress } from '../hooks/useKeypress.js';
import { ShellExecutionService } from '@qwen-code/qwen-code-core';
import { keyToAnsi, type Key } from '../hooks/keyToAnsi.js';
import { keyMatchers, Command } from '../keyMatchers.js';
export interface ShellInputPromptProps {
activeShellPtyId: number | null;
@ -33,6 +34,11 @@ export const ShellInputPrompt: React.FC<ShellInputPromptProps> = ({
if (!focus || !activeShellPtyId) {
return;
}
// Don't forward Ctrl+F to the PTY — it's used to toggle shell focus.
// Without this, the raw ^F control character gets written to the shell.
if (keyMatchers[Command.TOGGLE_SHELL_INPUT_FOCUS](key)) {
return;
}
if (key.ctrl && key.shift && key.name === 'up') {
ShellExecutionService.scrollPty(activeShellPtyId, -1);
return;

View file

@ -1,7 +1,8 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`<HistoryItemDisplay /> > should render a full gemini item when using availableTerminalHeightGemini 1`] = `
" ✦ Example code block:
"
✦ Example code block:
1 Line 1
2 Line 2
3 Line 3
@ -109,7 +110,8 @@ exports[`<HistoryItemDisplay /> > should render a full gemini_content item when
`;
exports[`<HistoryItemDisplay /> > should render a truncated gemini item 1`] = `
" ✦ Example code block:
"
✦ Example code block:
... first 41 lines hidden ...
42 Line 42
43 Line 43

View file

@ -11,7 +11,7 @@ exports[`SettingsDialog > Snapshot Tests > should render default state correctly
│ Language: Model auto │
│ Theme Qwen Dark │
│ Vim Mode false │
│ Interactive Shell (PTY) false │
│ Interactive Shell (PTY) true │
│ Preferred Editor │
│ Auto-connect to IDE false │
│ ▼ │
@ -32,7 +32,7 @@ exports[`SettingsDialog > Snapshot Tests > should render focused on scope select
│ Language: Model auto │
│ Theme Qwen Dark │
│ Vim Mode false │
│ Interactive Shell (PTY) false │
│ Interactive Shell (PTY) true │
│ Preferred Editor │
│ Auto-connect to IDE false │
│ ▼ │
@ -53,7 +53,7 @@ exports[`SettingsDialog > Snapshot Tests > should render with accessibility sett
│ Language: Model auto │
│ Theme Qwen Dark │
│ Vim Mode true* │
│ Interactive Shell (PTY) false │
│ Interactive Shell (PTY) true │
│ Preferred Editor │
│ Auto-connect to IDE false │
│ ▼ │
@ -74,7 +74,7 @@ exports[`SettingsDialog > Snapshot Tests > should render with all boolean settin
│ Language: Model auto │
│ Theme Qwen Dark │
│ Vim Mode false* │
│ Interactive Shell (PTY) false │
│ Interactive Shell (PTY) true │
│ Preferred Editor │
│ Auto-connect to IDE false* │
│ ▼ │
@ -95,7 +95,7 @@ exports[`SettingsDialog > Snapshot Tests > should render with different scope se
│ Language: Model auto │
│ Theme Qwen Dark │
│ Vim Mode (Modified in System) false │
│ Interactive Shell (PTY) false │
│ Interactive Shell (PTY) true │
│ Preferred Editor │
│ Auto-connect to IDE false │
│ ▼ │
@ -116,7 +116,7 @@ exports[`SettingsDialog > Snapshot Tests > should render with different scope se
│ Language: Model auto │
│ Theme Qwen Dark │
│ Vim Mode (Modified in Workspace) false │
│ Interactive Shell (PTY) false │
│ Interactive Shell (PTY) true │
│ Preferred Editor │
│ Auto-connect to IDE false │
│ ▼ │
@ -137,7 +137,7 @@ exports[`SettingsDialog > Snapshot Tests > should render with file filtering set
│ Language: Model auto │
│ Theme Qwen Dark │
│ Vim Mode false │
│ Interactive Shell (PTY) false │
│ Interactive Shell (PTY) true │
│ Preferred Editor │
│ Auto-connect to IDE false │
│ ▼ │
@ -158,7 +158,7 @@ exports[`SettingsDialog > Snapshot Tests > should render with mixed boolean and
│ Language: Model auto │
│ Theme Qwen Dark │
│ Vim Mode false* │
│ Interactive Shell (PTY) false │
│ Interactive Shell (PTY) true │
│ Preferred Editor │
│ Auto-connect to IDE false │
│ ▼ │
@ -179,7 +179,7 @@ exports[`SettingsDialog > Snapshot Tests > should render with tools and security
│ Language: Model auto │
│ Theme Qwen Dark │
│ Vim Mode false │
│ Interactive Shell (PTY) false │
│ Interactive Shell (PTY) true │
│ Preferred Editor │
│ Auto-connect to IDE false │
│ ▼ │
@ -200,7 +200,7 @@ exports[`SettingsDialog > Snapshot Tests > should render with various boolean se
│ Language: Model auto │
│ Theme Qwen Dark │
│ Vim Mode true* │
│ Interactive Shell (PTY) false │
│ Interactive Shell (PTY) true │
│ Preferred Editor │
│ Auto-connect to IDE true* │
│ ▼ │

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,527 @@
/**
* @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';
import { getErrorMessage } from '../../../utils/errors.js';
import { useTerminalSize } from '../../hooks/useTerminalSize.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);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const { columns } = useTerminalSize();
const boxWidth = columns - 4;
// 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
setErrorMessage(null); // Clear error 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);
});
// Clear messages when navigating back
setErrorMessage(null);
}, []);
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],
);
// Unified handler for toggling extension state (enable/disable)
const handleToggleExtensionState = useCallback(
async (scope: 'user' | 'workspace', newState: boolean) => {
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;
if (newState) {
await extensionManager.enableExtension(
selectedExtension.name,
settingScope,
);
} else {
await extensionManager.disableExtension(
selectedExtension.name,
settingScope,
);
}
// Update local state
setExtensions((prev) =>
prev.map((ext) =>
ext.name === selectedExtension.name
? { ...ext, isActive: newState }
: ext,
),
);
// Show success message
const actionKey = newState ? 'enabled' : 'disabled';
setSuccessMessage(
t(`Extension "{{name}}" ${actionKey} successfully.`, {
name: selectedExtension.name,
}),
);
setErrorMessage(null);
// Go back to extension list to show success message
setNavigationStack([MANAGEMENT_STEPS.EXTENSION_LIST]);
} catch (error) {
debugLogger.error(
`Failed to ${newState ? 'enable' : 'disable'} extension:`,
error,
);
setErrorMessage(
t('Failed to {{action}} extension "{{name}}": {{error}}', {
action: newState ? 'enable' : 'disable',
name: selectedExtension.name,
error: getErrorMessage(error),
}),
);
setSuccessMessage(null);
}
},
[config, selectedExtension],
);
const handleDisableExtension = useCallback(
async (scope: 'user' | 'workspace') => {
await handleToggleExtensionState(scope, false);
},
[handleToggleExtensionState],
);
const handleEnableExtension = useCallback(
async (scope: 'user' | 'workspace') => {
await handleToggleExtensionState(scope, true);
},
[handleToggleExtensionState],
);
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 color={theme.text.accent} bold>
{getStepHeaderText()}
</Text>
</Box>
);
}, [getCurrentStep, selectedExtension]);
const renderStepFooter = useCallback(() => {
const currentStep = getCurrentStep();
const getNavigationInstructions = () => {
if (currentStep === MANAGEMENT_STEPS.EXTENSION_LIST) {
if (extensions.length === 0 || successMessage) {
return t('Esc to close');
}
return t('↑↓ to navigate · Enter to select · 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('↑↓ to navigate · Enter to select · Esc to go back');
};
return (
<Box>
<Text color={theme.text.secondary}>{getNavigationInstructions()}</Text>
</Box>
);
}, [getCurrentStep, extensions.length, updateInProgress, successMessage]);
const renderStepContent = useCallback(() => {
const currentStep = getCurrentStep();
// Show error message if present (only on extension list step)
if (errorMessage && currentStep === MANAGEMENT_STEPS.EXTENSION_LIST) {
return (
<Box flexDirection="column" gap={1}>
<Text color={theme.status.error}>{errorMessage}</Text>
</Box>
);
}
// 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}
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}
/>
);
case MANAGEMENT_STEPS.ENABLE_SCOPE_SELECT:
return (
<ScopeSelectStep
selectedExtension={selectedExtension}
mode="enable"
onScopeSelect={handleEnableExtension}
/>
);
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,
errorMessage,
handleSelectExtension,
handleNavigateToStep,
handleNavigateBack,
handleActionSelect,
handleDisableExtension,
handleEnableExtension,
handleUninstallExtension,
]);
return (
<Box flexDirection="column" width={boxWidth}>
<Box
borderStyle="single"
borderColor={theme.border.default}
flexDirection="column"
paddingLeft={1}
paddingRight={1}
width={boxWidth}
gap={1}
>
{renderStepHeader()}
{renderStepContent()}
{renderStepFooter()}
</Box>
</Box>
);
}

View file

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

View file

@ -0,0 +1,102 @@
/**
* @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;
onActionSelect: (action: ExtensionAction) => void;
}
export const ActionSelectionStep = ({
selectedExtension,
hasUpdateAvailable,
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,
},
];
return allActions;
}, [hasUpdateAvailable, isActive]);
const handleActionSelect = (value: ExtensionAction) => {
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,128 @@
/**
* @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');
// Fixed width for labels to ensure alignment
const LABEL_WIDTH = 12;
return (
<Box flexDirection="column" gap={1}>
<Box flexDirection="column">
<Box>
<Box width={LABEL_WIDTH} flexShrink={0}>
<Text color={theme.text.primary}>{t('Name:')}</Text>
</Box>
<Text>{ext.name}</Text>
</Box>
<Box>
<Box width={LABEL_WIDTH} flexShrink={0}>
<Text color={theme.text.primary}>{t('Version:')}</Text>
</Box>
<Text>{ext.version}</Text>
</Box>
<Box>
<Box width={LABEL_WIDTH} flexShrink={0}>
<Text color={theme.text.primary}>{t('Status:')}</Text>
</Box>
<Text color={activeColor}>{activeString}</Text>
</Box>
<Box>
<Box width={LABEL_WIDTH} flexShrink={0}>
<Text color={theme.text.primary}>{t('Path:')}</Text>
</Box>
<Text>{ext.path}</Text>
</Box>
{ext.installMetadata && (
<Box>
<Box width={LABEL_WIDTH} flexShrink={0}>
<Text color={theme.text.primary}>{t('Source:')}</Text>
</Box>
<Text>{ext.installMetadata.source}</Text>
</Box>
)}
{ext.mcpServers && Object.keys(ext.mcpServers).length > 0 && (
<Box>
<Box width={LABEL_WIDTH} flexShrink={0}>
<Text color={theme.text.primary}>{t('MCP Servers:')}</Text>
</Box>
<Text>{Object.keys(ext.mcpServers).join(', ')}</Text>
</Box>
)}
{ext.commands && ext.commands.length > 0 && (
<Box>
<Box width={LABEL_WIDTH} flexShrink={0}>
<Text color={theme.text.primary}>{t('Commands:')}</Text>
</Box>
<Text>{ext.commands.join(', ')}</Text>
</Box>
)}
{ext.skills && ext.skills.length > 0 && (
<Box>
<Box width={LABEL_WIDTH} flexShrink={0}>
<Text color={theme.text.primary}>{t('Skills:')}</Text>
</Box>
<Text>{ext.skills.map((s) => s.name).join(', ')}</Text>
</Box>
)}
{ext.agents && ext.agents.length > 0 && (
<Box>
<Box width={LABEL_WIDTH} flexShrink={0}>
<Text color={theme.text.primary}>{t('Agents:')}</Text>
</Box>
<Text>{ext.agents.map((a) => a.name).join(', ')}</Text>
</Box>
)}
{ext.resolvedSettings && ext.resolvedSettings.length > 0 && (
<Box flexDirection="column" marginTop={1}>
<Box width={LABEL_WIDTH} flexShrink={0}>
<Text color={theme.text.primary}>{t('Settings:')}</Text>
</Box>
<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,134 @@
/**
* @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 { KeypressProvider } from '../../../contexts/KeypressContext.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(
<KeypressProvider kittyProtocolEnabled={false}>
<ExtensionListStep
extensions={[]}
extensionsUpdateState={new Map()}
{...baseProps}
/>
</KeypressProvider>,
);
expect(lastFrame()).toMatchSnapshot();
});
it('should render list with single extension', () => {
const extensions = [createMockExtension('test-extension', true)];
const { lastFrame } = render(
<KeypressProvider kittyProtocolEnabled={false}>
<ExtensionListStep
extensions={extensions}
extensionsUpdateState={new Map()}
{...baseProps}
/>
</KeypressProvider>,
);
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(
<KeypressProvider kittyProtocolEnabled={false}>
<ExtensionListStep
extensions={extensions}
extensionsUpdateState={updateState}
{...baseProps}
/>
</KeypressProvider>,
);
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(
<KeypressProvider kittyProtocolEnabled={false}>
<ExtensionListStep
extensions={extensions}
extensionsUpdateState={updateState}
{...baseProps}
/>
</KeypressProvider>,
);
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(
<KeypressProvider kittyProtocolEnabled={false}>
<ExtensionListStep
extensions={extensions}
extensionsUpdateState={updateState}
{...baseProps}
/>
</KeypressProvider>,
);
expect(lastFrame()).toMatchSnapshot();
});
});

View file

@ -0,0 +1,177 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import { useState, useEffect, useMemo } 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);
// Calculate max widths for each column for alignment
const { maxNameWidth, maxVersionWidth, maxStatusWidth } = useMemo(() => {
let maxName = 0;
let maxVersion = 0;
let maxStatus = 0;
for (const ext of extensions) {
maxName = Math.max(maxName, ext.name.length);
maxVersion = Math.max(maxVersion, ext.version.length);
const statusLength = ext.isActive
? t('active').length
: t('disabled').length;
maxStatus = Math.max(maxStatus, statusLength);
}
return {
maxNameWidth: maxName,
maxVersionWidth: maxVersion,
maxStatusWidth: maxStatus,
};
}, [extensions]);
// 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>
<Box width={maxNameWidth} flexShrink={0}>
<Text
color={isSelected ? theme.text.accent : theme.text.primary}
wrap="truncate"
>
{extension.name}
</Text>
</Box>
<Box width={maxVersionWidth + 8} flexShrink={0}>
<Text color={theme.text.secondary}> v{extension.version}</Text>
</Box>
<Box width={maxStatusWidth + 8} flexShrink={0}>
<Text color={activeColor}>({activeString})</Text>
</Box>
{stateText && <Text color={stateColor}>[{stateText}]</Text>}
</Box>
);
};
return (
<Box flexDirection="column">
<Box marginBottom={1}>
<Text color={theme.text.secondary}>
{t('{{count}} extensions installed', {
count: extensions.length.toString(),
})}
</Text>
</Box>
<Box flexDirection="column">
{extensions.map((extension, index) =>
renderExtensionItem(extension, index, index === selectedIndex),
)}
</Box>
</Box>
);
};

View file

@ -0,0 +1,70 @@
/**
* @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;
}
export function ScopeSelectStep({
selectedExtension,
mode,
onScopeSelect,
}: 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,
},
];
const handleSelect = (value: 'user' | 'workspace') => {
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>
<RadioButtonSelect
items={scopeItems}
onSelect={handleSelect}
showNumbers={false}
/>
</Box>
</Box>
);
}

View file

@ -0,0 +1,65 @@
/**
* @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>
);
}

View file

@ -0,0 +1,33 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`ActionSelectionStep Snapshots > should render for active extension without update 1`] = `
"● View Details
Disable Extension
Uninstall Extension"
`;
exports[`ActionSelectionStep Snapshots > should render for disabled extension 1`] = `
"● View Details
Enable Extension
Uninstall Extension"
`;
exports[`ActionSelectionStep Snapshots > should render for disabled extension with update 1`] = `
"● View Details
Update Extension
Enable Extension
Uninstall Extension"
`;
exports[`ActionSelectionStep Snapshots > should render for extension with update available 1`] = `
"● View Details
Update Extension
Disable Extension
Uninstall Extension"
`;
exports[`ActionSelectionStep Snapshots > should render with no extension selected 1`] = `
"● View Details
Enable Extension
Uninstall Extension"
`;

View file

@ -0,0 +1,32 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`ExtensionListStep Snapshots > should render empty state 1`] = `
"No extensions installed.
Use '/extensions install' to install your first extension."
`;
exports[`ExtensionListStep Snapshots > should render list with multiple extensions 1`] = `
"3 extensions installed
● active-extension v1.0.0 (active) [up to date]
disabled-extension v1.0.0 (disabled) [not updatable]
update-available v1.0.0 (active) [update available]"
`;
exports[`ExtensionListStep Snapshots > should render list with single extension 1`] = `
"1 extensions installed
● test-extension v1.0.0 (active)"
`;
exports[`ExtensionListStep Snapshots > should render with checking status 1`] = `
"1 extensions installed
● checking-extension v1.0.0 (active) [checking for updates]"
`;
exports[`ExtensionListStep Snapshots > should render with error status 1`] = `
"1 extensions installed
● error-extension v1.0.0 (active) [error]"
`;

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

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

View file

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

View file

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

View file

@ -0,0 +1,197 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import { useState, useCallback, useRef, useEffect } from 'react';
import { Box, Text } from 'ink';
import { theme } from '../../../semantic-colors.js';
import { useKeypress } from '../../../hooks/useKeypress.js';
import { t } from '../../../../i18n/index.js';
import type { AuthenticateStepProps } from '../types.js';
import { useConfig } from '../../../contexts/ConfigContext.js';
import {
MCPOAuthProvider,
MCPOAuthTokenStorage,
getErrorMessage,
} from '@qwen-code/qwen-code-core';
import type { OAuthDisplayPayload } from '@qwen-code/qwen-code-core';
import { appEvents, AppEvent } from '../../../../utils/events.js';
type AuthState = 'idle' | 'authenticating' | 'success' | 'error';
const AUTO_BACK_DELAY_MS = 2000;
export const AuthenticateStep: React.FC<AuthenticateStepProps> = ({
server,
onBack,
}) => {
const config = useConfig();
const [authState, setAuthState] = useState<AuthState>('idle');
const [messages, setMessages] = useState<string[]>([]);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const isRunning = useRef(false);
const runAuthentication = useCallback(async () => {
if (!server || !config || isRunning.current) return;
isRunning.current = true;
setAuthState('authenticating');
setMessages([]);
setErrorMessage(null);
// Listen for OAuth display messages - supports both plain strings and
// structured i18n messages ({ key, params }) emitted by the core layer.
const displayListener = (message: OAuthDisplayPayload) => {
const text =
typeof message === 'string' ? message : t(message.key, message.params);
setMessages((prev) => [...prev, text]);
};
appEvents.on(AppEvent.OauthDisplayMessage, displayListener);
try {
setMessages([
t("Starting OAuth authentication for MCP server '{{name}}'...", {
name: server.name,
}),
]);
let oauthConfig = server.config.oauth;
if (!oauthConfig) {
oauthConfig = { enabled: false };
}
const mcpServerUrl = server.config.httpUrl || server.config.url;
const authProvider = new MCPOAuthProvider(new MCPOAuthTokenStorage());
await authProvider.authenticate(
server.name,
oauthConfig,
mcpServerUrl,
appEvents,
);
setMessages((prev) => [
...prev,
t("Successfully authenticated and refreshed tools for '{{name}}'.", {
name: server.name,
}),
]);
// Trigger tool re-discovery to pick up authenticated server
const toolRegistry = config.getToolRegistry();
if (toolRegistry) {
setMessages((prev) => [
...prev,
t("Re-discovering tools from '{{name}}'...", {
name: server.name,
}),
]);
await toolRegistry.discoverToolsForServer(server.name);
// Show discovered tool count
const discoveredTools = toolRegistry.getToolsByServer(server.name);
setMessages((prev) => [
...prev,
t("Discovered {{count}} tool(s) from '{{name}}'.", {
count: String(discoveredTools.length),
name: server.name,
}),
]);
}
// Update the client with the new tools
const geminiClient = config.getGeminiClient();
if (geminiClient) {
await geminiClient.setTools();
}
setMessages((prev) => [
...prev,
t('Authentication complete. Returning to server details...'),
]);
setAuthState('success');
} catch (error) {
setErrorMessage(getErrorMessage(error));
setAuthState('error');
} finally {
isRunning.current = false;
appEvents.removeListener(AppEvent.OauthDisplayMessage, displayListener);
}
}, [server, config]);
useEffect(() => {
runAuthentication();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Auto-navigate back after authentication succeeds
useEffect(() => {
if (authState !== 'success') return;
const timer = setTimeout(() => {
onBack();
}, AUTO_BACK_DELAY_MS);
return () => clearTimeout(timer);
}, [authState, onBack]);
useKeypress(
(key) => {
if (key.name === 'escape') {
onBack();
}
},
{ isActive: true },
);
if (!server) {
return (
<Box>
<Text color={theme.status.error}>{t('No server selected')}</Text>
</Box>
);
}
return (
<Box flexDirection="column" gap={1}>
{/* Server info */}
<Box>
<Text color={theme.text.secondary}>
{t('Server:')} {server.name}
</Text>
</Box>
{/* Progress messages */}
{messages.length > 0 && (
<Box flexDirection="column">
{messages.map((msg, i) => (
<Text key={i} color={theme.text.secondary}>
{msg}
</Text>
))}
</Box>
)}
{/* Error message */}
{authState === 'error' && errorMessage && (
<Box>
<Text color={theme.status.error}>{errorMessage}</Text>
</Box>
)}
{/* Action hints */}
<Box>
{authState === 'authenticating' && (
<Text color={theme.text.secondary}>
{t('Authenticating... Please complete the login in your browser.')}
</Text>
)}
{authState === 'success' && (
<Text color={theme.status.success}>
{t('Authentication successful.')}
</Text>
)}
</Box>
</Box>
);
};

View file

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

View file

@ -0,0 +1,247 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import { useMemo } from 'react';
import { Box, Text } from 'ink';
import { theme } from '../../../semantic-colors.js';
import { useKeypress } from '../../../hooks/useKeypress.js';
import { RadioButtonSelect } from '../../shared/RadioButtonSelect.js';
import { t } from '../../../../i18n/index.js';
import type { ServerDetailStepProps } from '../types.js';
import {
getStatusColor,
getStatusIcon,
formatServerCommand,
} from '../utils.js';
// 标签列宽度
const LABEL_WIDTH = 15;
type ServerAction =
| 'view-tools'
| 'reconnect'
| 'toggle-disable'
| 'authenticate'
| 'clear-auth';
export const ServerDetailStep: React.FC<ServerDetailStepProps> = ({
server,
onViewTools,
onReconnect,
onDisable,
onAuthenticate,
onClearAuth,
onBack,
}) => {
const statusColor = server
? server.isDisabled
? 'yellow'
: getStatusColor(server.status)
: 'gray';
// 根据服务器状态动态生成可用操作
const actions = useMemo(() => {
const result: Array<{
key: string;
label: string;
value: ServerAction;
}> = [];
if (!server) {
return result;
}
// 只在服务器未禁用且有工具时显示"查看工具"选项
if (!server.isDisabled && (server.toolCount ?? 0) > 0) {
result.push({
key: 'view-tools',
label: t('View tools'),
value: 'view-tools',
});
}
// 只在服务器未禁用且已断开连接时显示"重新连接"选项
if (!server.isDisabled && server.status === 'disconnected') {
result.push({
key: 'reconnect',
label: t('Reconnect'),
value: 'reconnect',
});
}
// 始终显示启用/禁用选项
result.push({
key: 'toggle-disable',
label: server?.isDisabled ? t('Enable') : t('Disable'),
value: 'toggle-disable',
});
// 已认证的服务器显示"重新认证",未认证的显示"认证"
if (!server.isDisabled) {
result.push({
key: 'authenticate',
label: server.hasOAuthTokens ? t('Re-authenticate') : t('Authenticate'),
value: 'authenticate',
});
}
// 只在存储有 OAuth 认证信息时显示“清空认证”选项
if (!server.isDisabled && server.hasOAuthTokens) {
result.push({
key: 'clear-auth',
label: t('Clear Authentication'),
value: 'clear-auth',
});
}
return result;
}, [server]);
useKeypress(
(key) => {
if (key.name === 'escape') {
onBack();
}
},
{ isActive: true },
);
if (!server) {
return (
<Box>
<Text color={theme.status.error}>{t('No server selected')}</Text>
</Box>
);
}
return (
<Box flexDirection="column" gap={1}>
{/* 服务器详情 */}
<Box flexDirection="column">
<Box>
<Box width={LABEL_WIDTH}>
<Text color={theme.text.primary}>{t('Status:')}</Text>
</Box>
<Box>
<Text
color={
statusColor === 'green'
? theme.status.success
: statusColor === 'yellow'
? theme.status.warning
: theme.status.error
}
>
{getStatusIcon(server.status)}{' '}
{server.isDisabled ? t('disabled') : t(server.status)}
</Text>
</Box>
</Box>
<Box>
<Box width={LABEL_WIDTH}>
<Text color={theme.text.primary}>{t('Source:')}</Text>
</Box>
<Box>
<Text color={theme.text.primary}>
{server.source === 'user'
? t('User Settings')
: server.source === 'project'
? t('Workspace Settings')
: t('Extension')}
</Text>
</Box>
</Box>
<Box>
<Box width={LABEL_WIDTH}>
<Text color={theme.text.primary}>{t('Command:')}</Text>
</Box>
<Box>
<Text wrap="truncate">{formatServerCommand(server)}</Text>
</Box>
</Box>
{server.config.cwd && (
<Box>
<Box width={LABEL_WIDTH}>
<Text color={theme.text.primary}>{t('Working Directory:')}</Text>
</Box>
<Box>
<Text wrap="truncate">{server.config.cwd}</Text>
</Box>
</Box>
)}
{!server.isDisabled && (
<Box>
<Box width={LABEL_WIDTH}>
<Text color={theme.text.primary}>{t('Tools:')}</Text>
</Box>
<Box>
<Text>
{server.toolCount}{' '}
{server.toolCount === 1 ? t('tool') : t('tools')}
{!!server.invalidToolCount && server.invalidToolCount > 0 && (
<Text color={theme.status.warning}>
{' '}
({server.invalidToolCount}{' '}
{server.invalidToolCount === 1
? t('invalid')
: t('invalid')}
)
</Text>
)}
</Text>
</Box>
</Box>
)}
{server.errorMessage && (
<Box>
<Box width={LABEL_WIDTH}>
<Text color={theme.status.error}>{t('Error:')}</Text>
</Box>
<Box>
<Text color={theme.status.error} wrap="wrap">
{server.errorMessage}
</Text>
</Box>
</Box>
)}
</Box>
{/* 操作列表 */}
<Box>
<RadioButtonSelect<ServerAction>
items={actions}
showNumbers={false}
onSelect={(value: ServerAction) => {
switch (value) {
case 'view-tools':
onViewTools();
break;
case 'reconnect':
onReconnect?.();
break;
case 'toggle-disable':
onDisable?.();
break;
case 'authenticate':
onAuthenticate?.();
break;
case 'clear-auth':
onClearAuth?.();
break;
default:
break;
}
}}
/>
</Box>
</Box>
);
};

View file

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

View file

@ -0,0 +1,139 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import { Box, Text } from 'ink';
import { theme } from '../../../semantic-colors.js';
import { useKeypress } from '../../../hooks/useKeypress.js';
import { t } from '../../../../i18n/index.js';
import type { ToolDetailStepProps } from '../types.js';
/**
*
*/
const renderParameter = (
name: string,
param: Record<string, unknown>,
isRequired: boolean,
): React.ReactNode => {
const type = (param['type'] as string) || 'any';
const description = (param['description'] as string) || '';
// const defaultValue = param['default'];
// const enumValues = param['enum'] as string[] | undefined;
const text = `${name}${isRequired ? t('required') : ''}: ${type} ${description ? `- ${description}` : ''}`;
return (
<Box key={name}>
<Text color={theme.text.secondary} wrap="wrap">
{text}
</Text>
</Box>
);
};
/**
*
*/
const ParametersList: React.FC<{
properties: Record<string, unknown>;
required: string[];
}> = ({ properties, required }) => {
const requiredSet = new Set(required);
return (
<Box flexDirection="column">
<Text color={theme.text.primary} bold>
{t('Parameters')}:
</Text>
<Box flexDirection="column" marginLeft={1}>
{Object.entries(properties).map(([name, param]) =>
renderParameter(
name,
param as Record<string, unknown>,
requiredSet.has(name),
),
)}
</Box>
</Box>
);
};
/**
* schema的关键信息使
*/
const SchemaSummary: React.FC<{ schema: object }> = ({ schema }) => {
const obj = schema as Record<string, unknown>;
const properties = obj['properties'] as Record<string, unknown> | undefined;
const required = (obj['required'] as string[]) || [];
return (
<Box flexDirection="column">
{/* 参数列表 */}
{properties && Object.keys(properties).length > 0 && (
<ParametersList properties={properties} required={required} />
)}
</Box>
);
};
export const ToolDetailStep: React.FC<ToolDetailStepProps> = ({
tool,
onBack,
}) => {
useKeypress(
(key) => {
if (key.name === 'escape') {
onBack();
}
},
{ isActive: true },
);
if (!tool) {
return (
<Box>
<Text color={theme.status.error}>{t('No tool selected')}</Text>
</Box>
);
}
return (
<Box flexDirection="column" gap={1}>
{/* 无效工具警告 */}
{!tool.isValid && (
<Box flexDirection="column" marginBottom={1}>
<Text color={theme.status.error} bold>
{t('Warning: This tool cannot be called by the LLM')}
</Text>
<Text color={theme.status.error}>
{t('Reason')}: {tool.invalidReason || t('unknown')}
</Text>
<Text color={theme.text.secondary}>
{t(
'Tools must have both name and description to be used by the LLM.',
)}
</Text>
</Box>
)}
{/* 工具描述 */}
{tool.description && (
<Box flexDirection="column">
<Text color={theme.text.primary} bold>
{t('Description')}:
</Text>
<Text wrap="wrap">{tool.description}</Text>
</Box>
)}
{/* Schema */}
{tool.schema && (
<Box flexDirection="column">
<SchemaSummary schema={tool.schema} />
</Box>
)}
</Box>
);
};

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,331 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi } from 'vitest';
import { AskUserQuestionDialog } from './AskUserQuestionDialog.js';
import type { ToolAskUserQuestionConfirmationDetails } from '@qwen-code/qwen-code-core';
import { ToolConfirmationOutcome } from '@qwen-code/qwen-code-core';
import { renderWithProviders } from '../../../test-utils/render.js';
const wait = (ms = 50) => new Promise((resolve) => setTimeout(resolve, ms));
const createSingleQuestion = (
overrides: Partial<
ToolAskUserQuestionConfirmationDetails['questions'][0]
> = {},
): ToolAskUserQuestionConfirmationDetails['questions'][0] => ({
question: 'What is your favorite color?',
header: 'Color',
options: [
{ label: 'Red', description: 'A warm color' },
{ label: 'Blue', description: 'A cool color' },
{ label: 'Green', description: '' },
],
multiSelect: false,
...overrides,
});
const createConfirmationDetails = (
overrides: Partial<ToolAskUserQuestionConfirmationDetails> = {},
): ToolAskUserQuestionConfirmationDetails => ({
type: 'ask_user_question',
title: 'Question',
questions: [createSingleQuestion()],
onConfirm: vi.fn(),
...overrides,
});
describe('<AskUserQuestionDialog />', () => {
describe('rendering', () => {
it('renders single question with options', () => {
const details = createConfirmationDetails();
const onConfirm = vi.fn();
const { lastFrame } = renderWithProviders(
<AskUserQuestionDialog
confirmationDetails={details}
onConfirm={onConfirm}
/>,
);
const output = lastFrame();
expect(output).toContain('What is your favorite color?');
expect(output).toContain('Red');
expect(output).toContain('Blue');
expect(output).toContain('Green');
expect(output).toContain('A warm color');
expect(output).toContain('A cool color');
});
it('renders header for single question', () => {
const details = createConfirmationDetails();
const onConfirm = vi.fn();
const { lastFrame } = renderWithProviders(
<AskUserQuestionDialog
confirmationDetails={details}
onConfirm={onConfirm}
/>,
);
expect(lastFrame()).toContain('Color');
});
it('renders "Type something..." custom input option', () => {
const details = createConfirmationDetails();
const onConfirm = vi.fn();
const { lastFrame } = renderWithProviders(
<AskUserQuestionDialog
confirmationDetails={details}
onConfirm={onConfirm}
/>,
);
expect(lastFrame()).toContain('Type something...');
});
it('renders help text for single select', () => {
const details = createConfirmationDetails();
const onConfirm = vi.fn();
const { lastFrame } = renderWithProviders(
<AskUserQuestionDialog
confirmationDetails={details}
onConfirm={onConfirm}
/>,
);
expect(lastFrame()).toContain('Enter: Select');
expect(lastFrame()).toContain('Esc: Cancel');
expect(lastFrame()).not.toContain('Switch tabs');
});
it('renders tabs for multiple questions', () => {
const details = createConfirmationDetails({
questions: [
createSingleQuestion({ header: 'Q1' }),
createSingleQuestion({
header: 'Q2',
question: 'Second question?',
}),
],
});
const onConfirm = vi.fn();
const { lastFrame } = renderWithProviders(
<AskUserQuestionDialog
confirmationDetails={details}
onConfirm={onConfirm}
/>,
);
const output = lastFrame();
expect(output).toContain('Q1');
expect(output).toContain('Q2');
expect(output).toContain('Submit');
expect(output).toContain('Switch tabs');
});
it('renders multi-select with checkboxes', () => {
const details = createConfirmationDetails({
questions: [createSingleQuestion({ multiSelect: true })],
});
const onConfirm = vi.fn();
const { lastFrame } = renderWithProviders(
<AskUserQuestionDialog
confirmationDetails={details}
onConfirm={onConfirm}
/>,
);
const output = lastFrame();
expect(output).toContain('[ ]');
expect(output).toContain('Space: Toggle');
expect(output).toContain('Enter: Confirm');
});
});
describe('single-select interaction', () => {
it('selects an option with Enter and submits immediately for single question', async () => {
const onConfirm = vi.fn();
const details = createConfirmationDetails();
const { stdin, unmount } = renderWithProviders(
<AskUserQuestionDialog
confirmationDetails={details}
onConfirm={onConfirm}
/>,
);
await wait();
// Press Enter to select the first option (Red)
stdin.write('\r');
await wait();
expect(onConfirm).toHaveBeenCalledWith(
ToolConfirmationOutcome.ProceedOnce,
{ answers: { 0: 'Red' } },
);
unmount();
});
it('navigates with number keys', async () => {
const onConfirm = vi.fn();
const details = createConfirmationDetails();
const { stdin, unmount } = renderWithProviders(
<AskUserQuestionDialog
confirmationDetails={details}
onConfirm={onConfirm}
/>,
);
await wait();
// Press '2' to select Blue
stdin.write('2');
await wait();
// Press Enter
stdin.write('\r');
await wait();
expect(onConfirm).toHaveBeenCalledWith(
ToolConfirmationOutcome.ProceedOnce,
{ answers: { 0: 'Blue' } },
);
unmount();
});
it('cancels with Escape', async () => {
const onConfirm = vi.fn();
const details = createConfirmationDetails();
const { stdin, unmount } = renderWithProviders(
<AskUserQuestionDialog
confirmationDetails={details}
onConfirm={onConfirm}
/>,
);
await wait();
stdin.write('\u001B'); // Escape
await wait();
expect(onConfirm).toHaveBeenCalledWith(ToolConfirmationOutcome.Cancel);
unmount();
});
});
describe('multi-select interaction', () => {
it('toggles options with Space', async () => {
const onConfirm = vi.fn();
const details = createConfirmationDetails({
questions: [createSingleQuestion({ multiSelect: true })],
});
const { stdin, lastFrame, unmount } = renderWithProviders(
<AskUserQuestionDialog
confirmationDetails={details}
onConfirm={onConfirm}
/>,
);
await wait();
// Space to toggle first option
stdin.write(' ');
await wait();
// Should show checked state
expect(lastFrame()).toContain('[✓]');
unmount();
});
});
describe('multiple questions', () => {
it('shows Submit tab for multiple questions', async () => {
const onConfirm = vi.fn();
const details = createConfirmationDetails({
questions: [
createSingleQuestion({ header: 'Q1' }),
createSingleQuestion({ header: 'Q2' }),
],
});
const { stdin, lastFrame, unmount } = renderWithProviders(
<AskUserQuestionDialog
confirmationDetails={details}
onConfirm={onConfirm}
/>,
);
await wait();
// Navigate to submit tab (right arrow twice: Q1 -> Q2 -> Submit)
stdin.write('\u001B[C'); // Right
await wait();
stdin.write('\u001B[C'); // Right
await wait();
const output = lastFrame();
expect(output).toContain('Submit answers');
expect(output).toContain('Cancel');
expect(output).toContain('Your answers');
unmount();
});
it('shows unanswered questions as (not answered) in Submit tab', async () => {
const onConfirm = vi.fn();
const details = createConfirmationDetails({
questions: [
createSingleQuestion({ header: 'Q1' }),
createSingleQuestion({ header: 'Q2' }),
],
});
const { stdin, lastFrame, unmount } = renderWithProviders(
<AskUserQuestionDialog
confirmationDetails={details}
onConfirm={onConfirm}
/>,
);
await wait();
// Navigate directly to submit tab without answering anything
stdin.write('\u001B[C'); // Right
await wait();
stdin.write('\u001B[C'); // Right
await wait();
expect(lastFrame()).toContain('(not answered)');
unmount();
});
});
describe('focus behavior', () => {
it('does not respond to keys when isFocused is false', async () => {
const onConfirm = vi.fn();
const details = createConfirmationDetails();
const { stdin, unmount } = renderWithProviders(
<AskUserQuestionDialog
confirmationDetails={details}
isFocused={false}
onConfirm={onConfirm}
/>,
);
await wait();
stdin.write('\r'); // Enter
await wait();
stdin.write('\u001B'); // Escape
await wait();
expect(onConfirm).not.toHaveBeenCalled();
unmount();
});
});
});

View file

@ -0,0 +1,572 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { useState } from 'react';
import { Box, Text } from 'ink';
import {
type ToolAskUserQuestionConfirmationDetails,
ToolConfirmationOutcome,
type ToolConfirmationPayload,
} from '@qwen-code/qwen-code-core';
import { theme } from '../../semantic-colors.js';
import { useKeypress } from '../../hooks/useKeypress.js';
import { TextInput } from '../shared/TextInput.js';
import { t } from '../../../i18n/index.js';
interface AskUserQuestionDialogProps {
confirmationDetails: ToolAskUserQuestionConfirmationDetails;
isFocused?: boolean;
onConfirm: (
outcome: ToolConfirmationOutcome,
payload?: ToolConfirmationPayload,
) => Promise<void>;
}
export const AskUserQuestionDialog: React.FC<AskUserQuestionDialogProps> = ({
confirmationDetails,
isFocused = true,
onConfirm,
}) => {
const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0);
const [selectedOptions, setSelectedOptions] = useState<
Record<number, string>
>({});
const [customInputValues, setCustomInputValues] = useState<
Record<number, string>
>({});
const [selectedIndex, setSelectedIndex] = useState(0);
const [multiSelectedOptions, setMultiSelectedOptions] = useState<
Record<number, string[]>
>({});
const [customInputChecked, setCustomInputChecked] = useState<
Record<number, boolean>
>({});
const hasMultipleQuestions = confirmationDetails.questions.length > 1;
const totalTabs = hasMultipleQuestions
? confirmationDetails.questions.length + 1
: confirmationDetails.questions.length; // +1 for Submit tab
const isSubmitTab =
hasMultipleQuestions && currentQuestionIndex === totalTabs - 1;
const currentQuestion = isSubmitTab
? null
: confirmationDetails.questions[currentQuestionIndex];
const isMultiSelect = currentQuestion?.multiSelect ?? false;
// Options + custom input ("Other")
const totalOptions = currentQuestion ? currentQuestion.options.length + 1 : 2;
// Check if the custom input option is selected
const isCustomInputSelected =
!isSubmitTab &&
currentQuestion &&
selectedIndex === currentQuestion.options.length;
const currentCustomInputValue = customInputValues[currentQuestionIndex] ?? '';
const isCustomInputAnswer =
!isSubmitTab &&
currentQuestion &&
!isMultiSelect &&
selectedOptions[currentQuestionIndex] !== undefined &&
!currentQuestion.options.some(
(opt) => opt.label === selectedOptions[currentQuestionIndex],
);
// Compute the current answer for a question, considering multi-select state
const getAnswerForQuestion = (idx: number): string | undefined => {
const q = confirmationDetails.questions[idx];
if (q?.multiSelect) {
const selections = [...(multiSelectedOptions[idx] ?? [])];
const customValue = (customInputValues[idx] ?? '').trim();
if (customInputChecked[idx] && customValue) {
selections.push(customValue);
}
return selections.length > 0 ? selections.join(', ') : undefined;
}
return selectedOptions[idx];
};
const handleSubmit = async () => {
const answers: Record<string, string> = {};
confirmationDetails.questions.forEach((_, idx) => {
const answer = getAnswerForQuestion(idx);
if (answer !== undefined) {
answers[idx] = answer;
}
});
await onConfirm(ToolConfirmationOutcome.ProceedOnce, { answers });
};
const handleMultiSelectSubmit = () => {
if (!currentQuestion) return;
const selections = [...(multiSelectedOptions[currentQuestionIndex] ?? [])];
const customValue = currentCustomInputValue.trim();
if (customInputChecked[currentQuestionIndex] && customValue) {
selections.push(customValue);
}
if (selections.length === 0) return;
const value = selections.join(', ');
const updated = { ...selectedOptions, [currentQuestionIndex]: value };
setSelectedOptions(updated);
if (!hasMultipleQuestions) {
void onConfirm(ToolConfirmationOutcome.ProceedOnce, {
answers: { [currentQuestionIndex]: value },
});
} else {
if (currentQuestionIndex < totalTabs - 1) {
setTimeout(() => {
setCurrentQuestionIndex((prev) => Math.min(prev + 1, totalTabs - 1));
setSelectedIndex(0);
}, 150);
}
}
};
const handleCustomInputSubmit = () => {
const trimmedValue = currentCustomInputValue.trim();
if (isMultiSelect) {
// Toggle custom input checked state
if (!trimmedValue) return;
setCustomInputChecked((prev) => ({
...prev,
[currentQuestionIndex]: !prev[currentQuestionIndex],
}));
return;
}
if (!trimmedValue) return;
const updated = {
...selectedOptions,
[currentQuestionIndex]: trimmedValue,
};
setSelectedOptions(updated);
// If single question, submit immediately
if (!hasMultipleQuestions) {
void onConfirm(ToolConfirmationOutcome.ProceedOnce, {
answers: {
[currentQuestionIndex]: trimmedValue,
},
});
} else {
// Auto-advance to next tab
if (currentQuestionIndex < totalTabs - 1) {
setTimeout(() => {
setCurrentQuestionIndex((prev) => Math.min(prev + 1, totalTabs - 1));
setSelectedIndex(0);
}, 150);
}
}
};
// Handle navigation and selection
useKeypress(
(key) => {
if (!isFocused) return;
// When custom input is focused, still allow up/down navigation, tab switch and escape
if (isCustomInputSelected) {
if (key.name === 'up') {
setSelectedIndex(Math.max(0, selectedIndex - 1));
return;
}
if (key.name === 'down') {
setSelectedIndex(Math.min(totalOptions - 1, selectedIndex + 1));
return;
}
if (key.name === 'escape' || (key.ctrl && key.name === 'c')) {
void onConfirm(ToolConfirmationOutcome.Cancel);
return;
}
return;
}
const input = key.sequence;
// Tab navigation (left/right arrows)
if (key.name === 'left' && hasMultipleQuestions) {
if (currentQuestionIndex > 0) {
setCurrentQuestionIndex(currentQuestionIndex - 1);
setSelectedIndex(0);
}
return;
}
if (key.name === 'right' && hasMultipleQuestions) {
if (currentQuestionIndex < totalTabs - 1) {
setCurrentQuestionIndex(currentQuestionIndex + 1);
setSelectedIndex(0);
}
return;
}
// Option navigation (up/down arrows)
if (key.name === 'up') {
setSelectedIndex(Math.max(0, selectedIndex - 1));
return;
}
if (key.name === 'down') {
setSelectedIndex(Math.min(totalOptions - 1, selectedIndex + 1));
return;
}
// Number key selection
const numKey = parseInt(input || '', 10);
if (!isNaN(numKey) && numKey >= 1 && numKey <= totalOptions) {
setSelectedIndex(numKey - 1);
return;
}
// Space to toggle multi-select
if (key.name === 'space' && isMultiSelect && currentQuestion) {
if (selectedIndex < currentQuestion.options.length) {
const option = currentQuestion.options[selectedIndex];
if (option) {
const current = multiSelectedOptions[currentQuestionIndex] ?? [];
const isChecked = current.includes(option.label);
const updated = isChecked
? current.filter((l) => l !== option.label)
: [...current, option.label];
setMultiSelectedOptions((prev) => ({
...prev,
[currentQuestionIndex]: updated,
}));
}
}
return;
}
// Enter to select
if (key.name === 'return') {
// Handle Submit tab
if (isSubmitTab) {
if (selectedIndex === 0) {
// Submit
void handleSubmit();
} else {
// Cancel
void onConfirm(ToolConfirmationOutcome.Cancel);
}
return;
}
// Handle multi-select: Enter advances to next question / submits
if (isMultiSelect && currentQuestion) {
// Custom input is handled by TextInput's onSubmit
if (selectedIndex === currentQuestion.options.length) {
return;
}
handleMultiSelectSubmit();
return;
}
// Handle question options (not custom input - that's handled by TextInput)
if (currentQuestion && selectedIndex < currentQuestion.options.length) {
const option = currentQuestion.options[selectedIndex];
if (option) {
const updated = {
...selectedOptions,
[currentQuestionIndex]: option.label,
};
setSelectedOptions(updated);
// If single question, submit immediately
if (!hasMultipleQuestions) {
void onConfirm(ToolConfirmationOutcome.ProceedOnce, {
answers: { [currentQuestionIndex]: option.label },
});
} else {
// Auto-advance to next tab after selection
if (currentQuestionIndex < totalTabs - 1) {
setTimeout(() => {
setCurrentQuestionIndex((prev) =>
Math.min(prev + 1, totalTabs - 1),
);
setSelectedIndex(0);
}, 150);
}
}
}
}
return;
}
// Cancel
if (key.name === 'escape' || (key.ctrl && key.name === 'c')) {
void onConfirm(ToolConfirmationOutcome.Cancel);
return;
}
},
{ isActive: isFocused },
);
// Submit tab (for multiple questions)
if (isSubmitTab) {
return (
<Box flexDirection="column" padding={1}>
{/* Tabs */}
<Box marginBottom={1} flexDirection="row" gap={1}>
{confirmationDetails.questions.map((q, idx) => {
const isAnswered = getAnswerForQuestion(idx) !== undefined;
return (
<Box key={idx}>
<Text dimColor>
{isAnswered ? ' ' : ' '}
{q.header}
{isAnswered ? ' ✓' : ''}
</Text>
</Box>
);
})}
<Box>
<Text color={theme.text.accent} bold>
{t('Submit')}
</Text>
</Box>
</Box>
{/* Show selected answers */}
<Box flexDirection="column" marginBottom={1}>
<Text bold>{t('Your answers:')}</Text>
{confirmationDetails.questions.map((q, idx) => {
const answer = getAnswerForQuestion(idx);
return (
<Box key={idx} marginLeft={2}>
<Text>
{q.header}:{' '}
{answer ? (
<Text color={theme.text.accent}>{answer}</Text>
) : (
<Text dimColor>{t('(not answered)')}</Text>
)}
</Text>
</Box>
);
})}
</Box>
<Box marginTop={1} marginBottom={1}>
<Text>{t('Ready to submit your answers?')}</Text>
</Box>
{/* Submit/Cancel options */}
<Box flexDirection="column">
<Box>
<Text
color={
selectedIndex === 0 ? theme.text.accent : theme.text.primary
}
bold={selectedIndex === 0}
>
{selectedIndex === 0 ? ' ' : ' '}1. {t('Submit answers')}
</Text>
</Box>
<Box>
<Text
color={
selectedIndex === 1 ? theme.text.accent : theme.text.primary
}
bold={selectedIndex === 1}
>
{selectedIndex === 1 ? ' ' : ' '}2. {t('Cancel')}
</Text>
</Box>
</Box>
<Box marginTop={1}>
<Text dimColor>
{t('↑/↓: Navigate | ←/→: Switch tabs | Enter: Select')}
</Text>
</Box>
</Box>
);
}
// Question tab
return (
<Box flexDirection="column" padding={1}>
{/* Tabs for multiple questions */}
{hasMultipleQuestions && (
<Box marginBottom={1} flexDirection="row" gap={1}>
{confirmationDetails.questions.map((q, idx) => {
const isAnswered = getAnswerForQuestion(idx) !== undefined;
return (
<Box key={idx}>
<Text
color={
idx === currentQuestionIndex
? theme.text.accent
: theme.text.primary
}
bold={idx === currentQuestionIndex}
dimColor={idx !== currentQuestionIndex}
>
{idx === currentQuestionIndex ? '▸ ' : ' '}
{q.header}
{isAnswered ? ' ✓' : ''}
</Text>
</Box>
);
})}
<Box>
<Text dimColor> {t('Submit')}</Text>
</Box>
</Box>
)}
{/* Question */}
<Box flexDirection="column" marginBottom={1}>
{!hasMultipleQuestions && (
<Box marginBottom={1}>
<Text color={theme.text.accent} bold>
{currentQuestion!.header}
</Text>
</Box>
)}
<Text>{currentQuestion!.question}</Text>
</Box>
{/* Options */}
<Box flexDirection="column" marginBottom={1}>
{currentQuestion!.options.map((opt, index) => {
const isSelected = selectedIndex === index;
const isMultiChecked =
isMultiSelect &&
(multiSelectedOptions[currentQuestionIndex] ?? []).includes(
opt.label,
);
const isAnswered =
!isMultiSelect &&
selectedOptions[currentQuestionIndex] === opt.label;
const isHighlighted = isSelected || isAnswered || isMultiChecked;
// Calculate prefix width for description alignment:
// 2 (cursor) + checkbox (4 if multi) + number + ". " (2)
const prefixWidth =
2 + (isMultiSelect ? 4 : 0) + String(index + 1).length + 2;
return (
<Box key={index} flexDirection="column">
<Box>
<Text
color={isHighlighted ? theme.text.accent : theme.text.primary}
bold={isHighlighted}
>
{isSelected ? ' ' : ' '}
{isMultiSelect ? (isMultiChecked ? '[✓] ' : '[ ] ') : ''}
{index + 1}. {opt.label}
{isAnswered ? ' ✓' : ''}
</Text>
</Box>
{opt.description && (
<Box marginLeft={prefixWidth}>
<Text dimColor>{opt.description}</Text>
</Box>
)}
</Box>
);
})}
{/* Type something option/input */}
<Box flexDirection="column">
{isCustomInputSelected ? (
// Inline TextInput replaces the option text
<Box>
<Text color={theme.text.accent} bold>
{' '}
{isMultiSelect
? customInputChecked[currentQuestionIndex]
? '[✓] '
: '[ ] '
: ''}
{currentQuestion!.options.length + 1}.{' '}
</Text>
<TextInput
value={currentCustomInputValue}
initialCursorOffset={currentCustomInputValue.length}
onChange={(value: string) => {
const oldValue =
customInputValues[currentQuestionIndex] ?? '';
if (isMultiSelect && value !== oldValue) {
setCustomInputChecked((prevChecked) => ({
...prevChecked,
[currentQuestionIndex]: value.trim().length > 0,
}));
}
setCustomInputValues((prev) => ({
...prev,
[currentQuestionIndex]: value,
}));
}}
onSubmit={handleCustomInputSubmit}
placeholder={t('Type something...')}
isActive={true}
inputWidth={50}
/>
</Box>
) : (
// Show typed value or placeholder when not selected
<Box>
<Text
color={
isCustomInputAnswer ||
customInputChecked[currentQuestionIndex]
? theme.text.accent
: theme.text.primary
}
bold={
!!(
isCustomInputAnswer ||
customInputChecked[currentQuestionIndex]
)
}
dimColor={
!currentCustomInputValue &&
!isCustomInputAnswer &&
!customInputChecked[currentQuestionIndex]
}
>
{' '}
{isMultiSelect
? customInputChecked[currentQuestionIndex]
? '[✓] '
: '[ ] '
: ''}
{currentQuestion!.options.length + 1}.{' '}
{currentCustomInputValue || t('Type something...')}
{isCustomInputAnswer ? ' ✓' : ''}
</Text>
</Box>
)}
</Box>
</Box>
{/* Help text */}
<Box flexDirection="column" marginTop={1}>
<Box>
<Text dimColor>
{hasMultipleQuestions
? isMultiSelect
? t(
'↑/↓: Navigate | ←/→: Switch tabs | Space: Toggle | Enter: Confirm | Esc: Cancel',
)
: t(
'↑/↓: Navigate | ←/→: Switch tabs | Enter: Select | Esc: Cancel',
)
: isMultiSelect
? t(
'↑/↓: Navigate | Space: Toggle | Enter: Confirm | Esc: Cancel',
)
: t('↑/↓: Navigate | Enter: Select | Esc: Cancel')}
</Text>
</Box>
</Box>
</Box>
);
};

View file

@ -0,0 +1,261 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { Box, Text } from 'ink';
import stringWidth from 'string-width';
import { MarkdownDisplay } from '../../utils/MarkdownDisplay.js';
import { theme } from '../../semantic-colors.js';
import {
SCREEN_READER_MODEL_PREFIX,
SCREEN_READER_USER_PREFIX,
} from '../../textConstants.js';
interface UserMessageProps {
text: string;
}
interface UserShellMessageProps {
text: string;
}
interface AssistantMessageProps {
text: string;
isPending: boolean;
availableTerminalHeight?: number;
contentWidth: number;
}
interface AssistantMessageContentProps {
text: string;
isPending: boolean;
availableTerminalHeight?: number;
contentWidth: number;
}
interface ThinkMessageProps {
text: string;
isPending: boolean;
availableTerminalHeight?: number;
contentWidth: number;
}
interface ThinkMessageContentProps {
text: string;
isPending: boolean;
availableTerminalHeight?: number;
contentWidth: number;
}
interface PrefixedTextMessageProps {
text: string;
prefix: string;
prefixColor: string;
textColor: string;
ariaLabel?: string;
marginTop?: number;
alignSelf?: 'auto' | 'flex-start' | 'center' | 'flex-end';
}
interface PrefixedMarkdownMessageProps {
text: string;
prefix: string;
prefixColor: string;
isPending: boolean;
availableTerminalHeight?: number;
contentWidth: number;
ariaLabel?: string;
textColor?: string;
}
interface ContinuationMarkdownMessageProps {
text: string;
isPending: boolean;
availableTerminalHeight?: number;
contentWidth: number;
basePrefix: string;
textColor?: string;
}
function getPrefixWidth(prefix: string): number {
// Reserve one extra column so text never touches the prefix glyph.
return stringWidth(prefix) + 1;
}
const PrefixedTextMessage: React.FC<PrefixedTextMessageProps> = ({
text,
prefix,
prefixColor,
textColor,
ariaLabel,
marginTop = 0,
alignSelf,
}) => {
const prefixWidth = getPrefixWidth(prefix);
return (
<Box
flexDirection="row"
paddingY={0}
marginTop={marginTop}
alignSelf={alignSelf}
>
<Box width={prefixWidth}>
<Text color={prefixColor} aria-label={ariaLabel}>
{prefix}
</Text>
</Box>
<Box flexGrow={1}>
<Text wrap="wrap" color={textColor}>
{text}
</Text>
</Box>
</Box>
);
};
const PrefixedMarkdownMessage: React.FC<PrefixedMarkdownMessageProps> = ({
text,
prefix,
prefixColor,
isPending,
availableTerminalHeight,
contentWidth,
ariaLabel,
textColor,
}) => {
const prefixWidth = getPrefixWidth(prefix);
return (
<Box flexDirection="row">
<Box width={prefixWidth}>
<Text color={prefixColor} aria-label={ariaLabel}>
{prefix}
</Text>
</Box>
<Box flexGrow={1} flexDirection="column">
<MarkdownDisplay
text={text}
isPending={isPending}
availableTerminalHeight={availableTerminalHeight}
contentWidth={contentWidth - prefixWidth}
textColor={textColor}
/>
</Box>
</Box>
);
};
const ContinuationMarkdownMessage: React.FC<
ContinuationMarkdownMessageProps
> = ({
text,
isPending,
availableTerminalHeight,
contentWidth,
basePrefix,
textColor,
}) => {
const prefixWidth = getPrefixWidth(basePrefix);
return (
<Box flexDirection="column" paddingLeft={prefixWidth}>
<MarkdownDisplay
text={text}
isPending={isPending}
availableTerminalHeight={availableTerminalHeight}
contentWidth={contentWidth - prefixWidth}
textColor={textColor}
/>
</Box>
);
};
export const UserMessage: React.FC<UserMessageProps> = ({ text }) => (
<PrefixedTextMessage
text={text}
prefix=">"
prefixColor={theme.text.accent}
textColor={theme.text.accent}
ariaLabel={SCREEN_READER_USER_PREFIX}
alignSelf="flex-start"
/>
);
export const UserShellMessage: React.FC<UserShellMessageProps> = ({ text }) => {
const commandToDisplay = text.startsWith('!') ? text.substring(1) : text;
return (
<PrefixedTextMessage
text={commandToDisplay}
prefix="$"
prefixColor={theme.text.link}
textColor={theme.text.primary}
/>
);
};
export const AssistantMessage: React.FC<AssistantMessageProps> = ({
text,
isPending,
availableTerminalHeight,
contentWidth,
}) => (
<PrefixedMarkdownMessage
text={text}
prefix="✦"
prefixColor={theme.text.accent}
ariaLabel={SCREEN_READER_MODEL_PREFIX}
isPending={isPending}
availableTerminalHeight={availableTerminalHeight}
contentWidth={contentWidth}
/>
);
export const AssistantMessageContent: React.FC<
AssistantMessageContentProps
> = ({ text, isPending, availableTerminalHeight, contentWidth }) => (
<ContinuationMarkdownMessage
text={text}
isPending={isPending}
availableTerminalHeight={availableTerminalHeight}
contentWidth={contentWidth}
basePrefix="✦"
/>
);
export const ThinkMessage: React.FC<ThinkMessageProps> = ({
text,
isPending,
availableTerminalHeight,
contentWidth,
}) => (
<PrefixedMarkdownMessage
text={text}
prefix="✦"
prefixColor={theme.text.secondary}
isPending={isPending}
availableTerminalHeight={availableTerminalHeight}
contentWidth={contentWidth}
textColor={theme.text.secondary}
/>
);
export const ThinkMessageContent: React.FC<ThinkMessageContentProps> = ({
text,
isPending,
availableTerminalHeight,
contentWidth,
}) => (
<ContinuationMarkdownMessage
text={text}
isPending={isPending}
availableTerminalHeight={availableTerminalHeight}
contentWidth={contentWidth}
basePrefix="✦"
textColor={theme.text.secondary}
/>
);

View file

@ -1,38 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { Text, Box } from 'ink';
import { theme } from '../../semantic-colors.js';
interface ErrorMessageProps {
text: string;
/** Optional inline hint displayed after the error text in secondary/dimmed color */
hint?: string;
}
/**
* Renders an error message with a "✕" prefix.
* When a hint is provided (e.g., retry countdown), it is displayed inline
* in parentheses with a dimmed secondary color, similar to the ESC hint
* style used in LoadingIndicator.
*/
export const ErrorMessage: React.FC<ErrorMessageProps> = ({ text, hint }) => {
const prefix = '✕ ';
const prefixWidth = prefix.length;
return (
<Box flexDirection="row">
<Box width={prefixWidth}>
<Text color={theme.status.error}>{prefix}</Text>
</Box>
<Box flexGrow={1} flexWrap="wrap" flexDirection="row">
<Text color={theme.status.error}>{text}</Text>
{hint && <Text color={theme.text.secondary}> ({hint})</Text>}
</Box>
</Box>
);
};

View file

@ -1,46 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { Text, Box } from 'ink';
import { MarkdownDisplay } from '../../utils/MarkdownDisplay.js';
import { theme } from '../../semantic-colors.js';
import { SCREEN_READER_MODEL_PREFIX } from '../../textConstants.js';
interface GeminiMessageProps {
text: string;
isPending: boolean;
availableTerminalHeight?: number;
contentWidth: number;
}
export const GeminiMessage: React.FC<GeminiMessageProps> = ({
text,
isPending,
availableTerminalHeight,
contentWidth,
}) => {
const prefix = '✦ ';
const prefixWidth = prefix.length;
return (
<Box flexDirection="row">
<Box width={prefixWidth}>
<Text color={theme.text.accent} aria-label={SCREEN_READER_MODEL_PREFIX}>
{prefix}
</Text>
</Box>
<Box flexGrow={1} flexDirection="column">
<MarkdownDisplay
text={text}
isPending={isPending}
availableTerminalHeight={availableTerminalHeight}
contentWidth={contentWidth - prefixWidth}
/>
</Box>
</Box>
);
};

View file

@ -1,43 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { Box } from 'ink';
import { MarkdownDisplay } from '../../utils/MarkdownDisplay.js';
interface GeminiMessageContentProps {
text: string;
isPending: boolean;
availableTerminalHeight?: number;
contentWidth: number;
}
/*
* Gemini message content is a semi-hacked component. The intention is to represent a partial
* of GeminiMessage and is only used when a response gets too long. In that instance messages
* are split into multiple GeminiMessageContent's to enable the root <Static> component in
* App.tsx to be as performant as humanly possible.
*/
export const GeminiMessageContent: React.FC<GeminiMessageContentProps> = ({
text,
isPending,
availableTerminalHeight,
contentWidth,
}) => {
const originalPrefix = '✦ ';
const prefixWidth = originalPrefix.length;
return (
<Box flexDirection="column" paddingLeft={prefixWidth}>
<MarkdownDisplay
text={text}
isPending={isPending}
availableTerminalHeight={availableTerminalHeight}
contentWidth={contentWidth - prefixWidth}
/>
</Box>
);
};

View file

@ -1,48 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { Text, Box } from 'ink';
import { MarkdownDisplay } from '../../utils/MarkdownDisplay.js';
import { theme } from '../../semantic-colors.js';
interface GeminiThoughtMessageProps {
text: string;
isPending: boolean;
availableTerminalHeight?: number;
contentWidth: number;
}
/**
* Displays model thinking/reasoning text with a softer, dimmed style
* to visually distinguish it from regular content output.
*/
export const GeminiThoughtMessage: React.FC<GeminiThoughtMessageProps> = ({
text,
isPending,
availableTerminalHeight,
contentWidth,
}) => {
const prefix = '✦ ';
const prefixWidth = prefix.length;
return (
<Box flexDirection="row">
<Box width={prefixWidth}>
<Text color={theme.text.secondary}>{prefix}</Text>
</Box>
<Box flexGrow={1} flexDirection="column">
<MarkdownDisplay
text={text}
isPending={isPending}
availableTerminalHeight={availableTerminalHeight}
contentWidth={contentWidth - prefixWidth}
textColor={theme.text.secondary}
/>
</Box>
</Box>
);
};

View file

@ -1,40 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { Box } from 'ink';
import { MarkdownDisplay } from '../../utils/MarkdownDisplay.js';
import { theme } from '../../semantic-colors.js';
interface GeminiThoughtMessageContentProps {
text: string;
isPending: boolean;
availableTerminalHeight?: number;
contentWidth: number;
}
/**
* Continuation component for thought messages, similar to GeminiMessageContent.
* Used when a thought response gets too long and needs to be split for performance.
*/
export const GeminiThoughtMessageContent: React.FC<
GeminiThoughtMessageContentProps
> = ({ text, isPending, availableTerminalHeight, contentWidth }) => {
const originalPrefix = '✦ ';
const prefixWidth = originalPrefix.length;
return (
<Box flexDirection="column" paddingLeft={prefixWidth}>
<MarkdownDisplay
text={text}
isPending={isPending}
availableTerminalHeight={availableTerminalHeight}
contentWidth={contentWidth - prefixWidth}
textColor={theme.text.secondary}
/>
</Box>
);
};

View file

@ -1,37 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { Text, Box } from 'ink';
import { theme } from '../../semantic-colors.js';
import { RenderInline } from '../../utils/InlineMarkdownRenderer.js';
interface InfoMessageProps {
text: string;
}
export const InfoMessage: React.FC<InfoMessageProps> = ({ text }) => {
// Don't render anything if text is empty
if (!text || text.trim() === '') {
return null;
}
const prefix = ' ';
const prefixWidth = prefix.length;
return (
<Box flexDirection="row">
<Box width={prefixWidth}>
<Text color={theme.status.warning}>{prefix}</Text>
</Box>
<Box flexGrow={1}>
<Text wrap="wrap" color={theme.status.warning}>
<RenderInline text={text} textColor={theme.status.warning} />
</Text>
</Box>
</Box>
);
};

View file

@ -1,41 +0,0 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { Text, Box } from 'ink';
import { theme } from '../../semantic-colors.js';
interface RetryCountdownMessageProps {
text: string;
}
/**
* Displays a retry countdown message in a dimmed/secondary style
* to visually distinguish it from error messages.
*/
export const RetryCountdownMessage: React.FC<RetryCountdownMessageProps> = ({
text,
}) => {
if (!text || text.trim() === '') {
return null;
}
const prefix = '↻ ';
const prefixWidth = prefix.length;
return (
<Box flexDirection="row">
<Box width={prefixWidth}>
<Text color={theme.text.secondary}>{prefix}</Text>
</Box>
<Box flexGrow={1}>
<Text wrap="wrap" color={theme.text.secondary}>
{text}
</Text>
</Box>
</Box>
);
};

View file

@ -0,0 +1,105 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { Box, Text } from 'ink';
import stringWidth from 'string-width';
import { theme } from '../../semantic-colors.js';
import { RenderInline } from '../../utils/InlineMarkdownRenderer.js';
interface StatusMessageProps {
text: string;
prefix: string;
prefixColor: string;
textColor: string;
children?: React.ReactNode;
}
interface StatusTextProps {
text: string;
}
/**
* Shared renderer for status-like history messages (info/warning/error/retry).
* Keeps prefix spacing and wrapping behavior consistent across variants.
*/
export const StatusMessage: React.FC<StatusMessageProps> = ({
text,
prefix,
prefixColor,
textColor,
children,
}) => {
if (!text || text.trim() === '') {
return null;
}
const prefixWidth = stringWidth(prefix) + 1;
return (
<Box flexDirection="row">
<Box width={prefixWidth} flexShrink={0}>
<Text color={prefixColor}>{prefix}</Text>
</Box>
<Box flexGrow={1}>
<Text wrap="wrap" color={textColor}>
<RenderInline text={text} />
{children}
</Text>
</Box>
</Box>
);
};
export const InfoMessage: React.FC<StatusTextProps> = ({ text }) => (
<StatusMessage
text={text}
prefix="●"
prefixColor={theme.text.primary}
textColor={theme.text.primary}
/>
);
export const SuccessMessage: React.FC<StatusTextProps> = ({ text }) => (
<StatusMessage
text={text}
prefix="✓"
prefixColor={theme.status.success}
textColor={theme.status.success}
/>
);
export const WarningMessage: React.FC<StatusTextProps> = ({ text }) => (
<StatusMessage
text={text}
prefix="⚠"
prefixColor={theme.status.warning}
textColor={theme.status.warning}
/>
);
export const ErrorMessage: React.FC<StatusTextProps & { hint?: string }> = ({
text,
hint,
}) => (
<StatusMessage
text={text}
prefix="✕"
prefixColor={theme.status.error}
textColor={theme.status.error}
>
{hint && <Text color={theme.text.secondary}> ({hint})</Text>}
</StatusMessage>
);
export const RetryCountdownMessage: React.FC<StatusTextProps> = ({ text }) => (
<StatusMessage
text={text}
prefix="↻"
prefixColor={theme.text.secondary}
textColor={theme.text.secondary}
/>
);

View file

@ -25,6 +25,7 @@ import { useKeypress } from '../../hooks/useKeypress.js';
import { useSettings } from '../../contexts/SettingsContext.js';
import { theme } from '../../semantic-colors.js';
import { t } from '../../../i18n/index.js';
import { AskUserQuestionDialog } from './AskUserQuestionDialog.js';
export interface ToolConfirmationMessageProps {
confirmationDetails: ToolCallConfirmationDetails;
@ -345,6 +346,15 @@ export const ToolConfirmationMessage: React.FC<
)}
</Box>
);
} else if (confirmationDetails.type === 'ask_user_question') {
// Use dedicated dialog for ask_user_question type
return (
<AskUserQuestionDialog
confirmationDetails={confirmationDetails}
isFocused={isFocused}
onConfirm={onConfirm}
/>
);
} else {
// mcp tool confirmation
const mcpProps = confirmationDetails as ToolMcpConfirmationDetails;

View file

@ -6,7 +6,7 @@
import type React from 'react';
import { useMemo } from 'react';
import { Box, Text } from 'ink';
import { Box } from 'ink';
import type { IndividualToolCallDisplay } from '../../types.js';
import { ToolCallStatus } from '../../types.js';
import { ToolMessage } from './ToolMessage.js';
@ -136,13 +136,6 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
contentWidth={innerWidth}
/>
)}
{tool.outputFile && (
<Box marginX={1}>
<Text color={theme.text.primary}>
Output too long and was saved to: {tool.outputFile}
</Text>
</Box>
)}
</Box>
);
})}

View file

@ -300,4 +300,55 @@ describe('<ToolMessage />', () => {
);
expect(lastFrame()).toContain('MockAnsiOutput:hello');
});
it('renders rejected plan content with plan text still visible', () => {
const planResultDisplay = {
type: 'plan_summary' as const,
message: 'Plan was rejected. Remaining in plan mode.',
plan: '# My Plan\n- Step 1: Do something\n- Step 2: Do another thing',
rejected: true,
};
const { lastFrame } = renderWithContext(
<ToolMessage
{...baseProps}
name="ExitPlanMode"
description="Plan:"
status={ToolCallStatus.Canceled}
resultDisplay={planResultDisplay}
/>,
StreamingState.Idle,
);
const output = lastFrame();
expect(output).toContain('Plan was rejected. Remaining in plan mode.');
expect(output).toContain('MockMarkdown:# My Plan');
expect(output).toContain('- Step 1: Do something');
expect(output).toContain('- Step 2: Do another thing');
});
it('renders approved plan content with approval message', () => {
const planResultDisplay = {
type: 'plan_summary' as const,
message: 'User approved the plan.',
plan: '# My Plan\n- Step 1\n- Step 2',
};
const { lastFrame } = renderWithContext(
<ToolMessage
{...baseProps}
name="ExitPlanMode"
description="Plan:"
status={ToolCallStatus.Success}
resultDisplay={planResultDisplay}
/>,
StreamingState.Idle,
);
const output = lastFrame();
expect(output).toContain('User approved the plan.');
expect(output).toContain('MockMarkdown:# My Plan');
expect(output).toContain('- Step 1');
expect(output).toContain('- Step 2');
});
});

View file

@ -1,38 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { Text, Box } from 'ink';
import { theme } from '../../semantic-colors.js';
import { SCREEN_READER_USER_PREFIX } from '../../textConstants.js';
import { isSlashCommand as checkIsSlashCommand } from '../../utils/commandUtils.js';
interface UserMessageProps {
text: string;
}
export const UserMessage: React.FC<UserMessageProps> = ({ text }) => {
const prefix = '> ';
const prefixWidth = prefix.length;
const isSlashCommand = checkIsSlashCommand(text);
const textColor = isSlashCommand ? theme.text.accent : theme.text.secondary;
return (
<Box flexDirection="row" paddingY={0} marginY={1} alignSelf="flex-start">
<Box width={prefixWidth}>
<Text color={theme.text.accent} aria-label={SCREEN_READER_USER_PREFIX}>
{prefix}
</Text>
</Box>
<Box flexGrow={1}>
<Text wrap="wrap" color={textColor}>
{text}
</Text>
</Box>
</Box>
);
};

View file

@ -1,25 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { Box, Text } from 'ink';
import { theme } from '../../semantic-colors.js';
interface UserShellMessageProps {
text: string;
}
export const UserShellMessage: React.FC<UserShellMessageProps> = ({ text }) => {
// Remove leading '!' if present, as App.tsx adds it for the processor.
const commandToDisplay = text.startsWith('!') ? text.substring(1) : text;
return (
<Box>
<Text color={theme.text.link}>$ </Text>
<Text color={theme.text.primary}>{commandToDisplay}</Text>
</Box>
);
};

View file

@ -1,33 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type React from 'react';
import { Box, Text } from 'ink';
import { Colors } from '../../colors.js';
import { RenderInline } from '../../utils/InlineMarkdownRenderer.js';
import { theme } from '../../semantic-colors.js';
interface WarningMessageProps {
text: string;
}
export const WarningMessage: React.FC<WarningMessageProps> = ({ text }) => {
const prefix = '⚠ ';
const prefixWidth = 3;
return (
<Box flexDirection="row">
<Box width={prefixWidth}>
<Text color={Colors.AccentYellow}>{prefix}</Text>
</Box>
<Box flexGrow={1}>
<Text wrap="wrap" color={Colors.AccentYellow}>
<RenderInline text={text} textColor={theme.status.warning} />
</Text>
</Box>
</Box>
);
};

View file

@ -26,6 +26,7 @@ export interface TextInputProps {
isActive?: boolean; // when false, ignore keypresses
validationErrors?: string[];
inputWidth?: number;
initialCursorOffset?: number;
}
export function TextInput({
@ -37,6 +38,7 @@ export function TextInput({
isActive = true,
validationErrors = [],
inputWidth = 80,
initialCursorOffset,
}: TextInputProps) {
const allowMultiline = height > 1;
@ -51,6 +53,7 @@ export function TextInput({
const buffer = useTextBuffer({
initialText: value || '',
initialCursorOffset,
viewport: { height, width: inputWidth },
isValidPath: () => false,
onChange: stableOnChange,

View file

@ -1840,7 +1840,7 @@ export function useTextBuffer({
process.env['VISUAL'] ??
process.env['EDITOR'] ??
(process.platform === 'win32' ? 'notepad' : 'vi');
const tmpDir = fs.mkdtempSync(pathMod.join(os.tmpdir(), 'gemini-edit-'));
const tmpDir = fs.mkdtempSync(pathMod.join(os.tmpdir(), 'qwen-edit-'));
const filePath = pathMod.join(tmpDir, 'buffer.txt');
fs.writeFileSync(filePath, text, 'utf8');

View file

@ -120,45 +120,6 @@ export function AgentCreationWizard({
);
}, [state.currentStep, state.generationMethod]);
const renderDebugContent = useCallback(() => {
if (process.env['NODE_ENV'] !== 'development') {
return null;
}
return (
<Box borderStyle="single" borderColor={theme.status.warning} padding={1}>
<Box flexDirection="column">
<Text color={theme.status.warning} bold>
Debug Info:
</Text>
<Text color={theme.text.secondary}>Step: {state.currentStep}</Text>
<Text color={theme.text.secondary}>
Can Proceed: {state.canProceed ? 'Yes' : 'No'}
</Text>
<Text color={theme.text.secondary}>
Generating: {state.isGenerating ? 'Yes' : 'No'}
</Text>
<Text color={theme.text.secondary}>Location: {state.location}</Text>
<Text color={theme.text.secondary}>
Method: {state.generationMethod}
</Text>
{state.validationErrors.length > 0 && (
<Text color={theme.status.error}>
Errors: {state.validationErrors.join(', ')}
</Text>
)}
</Box>
</Box>
);
}, [
state.currentStep,
state.canProceed,
state.isGenerating,
state.location,
state.generationMethod,
state.validationErrors,
]);
const renderStepFooter = useCallback(() => {
const getNavigationInstructions = () => {
// Special case: During generation in description input step, only show cancel option
@ -331,7 +292,6 @@ export function AgentCreationWizard({
>
{renderStepHeader()}
{renderStepContent()}
{renderDebugContent()}
{renderStepFooter()}
</Box>
</Box>

View file

@ -94,7 +94,7 @@ export function CreationSummary({
}
// Check length warnings
if (state.generatedDescription.length > 300) {
if (state.generatedDescription.length > 1000) {
allWarnings.push(
t('Description is over {{length}} characters', {
length: state.generatedDescription.length.toString(),

View file

@ -1335,6 +1335,208 @@ describe('KeypressContext - Kitty Protocol', () => {
);
});
describe('Printable CSI-u keys', () => {
it('parses kitty CSI-u space as a space key with literal sequence', () => {
const keyHandler = vi.fn();
const { result } = renderHook(() => useKeypressContext(), { wrapper });
act(() => result.current.subscribe(keyHandler));
act(() => stdin.sendKittySequence(`\x1b[32u`));
expect(keyHandler).toHaveBeenCalledWith(
expect.objectContaining({
name: 'space',
sequence: ' ',
kittyProtocol: true,
}),
);
});
it('parses kitty CSI-u printable letters as literal input', () => {
const keyHandler = vi.fn();
const { result } = renderHook(() => useKeypressContext(), { wrapper });
act(() => result.current.subscribe(keyHandler));
act(() => stdin.sendKittySequence(`\x1b[100u`)); // 'd'
expect(keyHandler).toHaveBeenCalledWith(
expect.objectContaining({
name: 'd',
sequence: 'd',
kittyProtocol: true,
}),
);
});
it('drops unsupported Kitty CSI-u keys without blocking later input', () => {
const keyHandler = vi.fn();
const { result } = renderHook(() => useKeypressContext(), { wrapper });
act(() => result.current.subscribe(keyHandler));
act(() => stdin.sendKittySequence(`\x1b[57358u`)); // CAPS_LOCK
act(() =>
stdin.pressKey({
name: 'a',
ctrl: false,
meta: false,
shift: false,
paste: false,
sequence: 'a',
}),
);
expect(keyHandler).toHaveBeenCalledTimes(1);
expect(keyHandler).toHaveBeenCalledWith(
expect.objectContaining({
name: 'a',
sequence: 'a',
}),
);
});
it('recovers plain text that arrives in the same chunk after an unsupported CSI-u key', () => {
const keyHandler = vi.fn();
const { result } = renderHook(() => useKeypressContext(), { wrapper });
act(() => result.current.subscribe(keyHandler));
act(() =>
stdin.pressKey({
name: '',
ctrl: false,
meta: false,
shift: false,
paste: false,
sequence: '\x1b[57358ua',
}),
);
expect(keyHandler).toHaveBeenCalledTimes(1);
expect(keyHandler).toHaveBeenCalledWith(
expect.objectContaining({
name: 'a',
sequence: 'a',
kittyProtocol: true,
}),
);
});
it('drops unsupported CSI-u variants with event metadata and keeps parsing', () => {
const keyHandler = vi.fn();
const { result } = renderHook(() => useKeypressContext(), { wrapper });
act(() => result.current.subscribe(keyHandler));
act(() => stdin.sendKittySequence(`\x1b[57358;1:1u\x1b[100u`));
expect(keyHandler).toHaveBeenCalledTimes(1);
expect(keyHandler).toHaveBeenCalledWith(
expect.objectContaining({
name: 'd',
sequence: 'd',
kittyProtocol: true,
}),
);
});
});
describe('Kitty keypad private-use keys', () => {
it.each([
{ keyCode: 57399, digit: '0' },
{ keyCode: 57400, digit: '1' },
{ keyCode: 57401, digit: '2' },
{ keyCode: 57402, digit: '3' },
{ keyCode: 57403, digit: '4' },
{ keyCode: 57404, digit: '5' },
{ keyCode: 57405, digit: '6' },
{ keyCode: 57406, digit: '7' },
{ keyCode: 57407, digit: '8' },
{ keyCode: 57408, digit: '9' },
])(
'parses kitty keypad digit keyCode $keyCode as "$digit"',
({ keyCode, digit }) => {
const keyHandler = vi.fn();
const { result } = renderHook(() => useKeypressContext(), { wrapper });
act(() => result.current.subscribe(keyHandler));
act(() => stdin.sendKittySequence(`\x1b[${keyCode}u`));
expect(keyHandler).toHaveBeenCalledWith(
expect.objectContaining({
name: digit,
sequence: digit,
kittyProtocol: true,
}),
);
},
);
it.each([
{ keyCode: 57409, char: '.' },
{ keyCode: 57410, char: '/' },
{ keyCode: 57411, char: '*' },
{ keyCode: 57412, char: '-' },
{ keyCode: 57413, char: '+' },
{ keyCode: 57415, char: '=' },
{ keyCode: 57416, char: ',' },
])(
'parses kitty keypad printable keyCode $keyCode as "$char"',
({ keyCode, char }) => {
const keyHandler = vi.fn();
const { result } = renderHook(() => useKeypressContext(), { wrapper });
act(() => result.current.subscribe(keyHandler));
act(() => stdin.sendKittySequence(`\x1b[${keyCode}u`));
expect(keyHandler).toHaveBeenCalledWith(
expect.objectContaining({
name: char,
sequence: char,
kittyProtocol: true,
}),
);
},
);
it.each([
{ keyCode: 57417, name: 'left' },
{ keyCode: 57418, name: 'right' },
{ keyCode: 57419, name: 'up' },
{ keyCode: 57420, name: 'down' },
{ keyCode: 57421, name: 'pageup' },
{ keyCode: 57422, name: 'pagedown' },
{ keyCode: 57423, name: 'home' },
{ keyCode: 57424, name: 'end' },
{ keyCode: 57425, name: 'insert' },
{ keyCode: 57426, name: 'delete' },
])(
'parses kitty keypad functional keyCode $keyCode as $name',
({ keyCode, name }) => {
const keyHandler = vi.fn();
const { result } = renderHook(() => useKeypressContext(), { wrapper });
act(() => result.current.subscribe(keyHandler));
act(() => stdin.sendKittySequence(`\x1b[${keyCode};5u`));
expect(keyHandler).toHaveBeenCalledWith(
expect.objectContaining({
name,
ctrl: true,
kittyProtocol: true,
}),
);
},
);
it('does not emit a placeholder for unmapped private-use keyCodes', () => {
const keyHandler = vi.fn();
const { result } = renderHook(() => useKeypressContext(), { wrapper });
act(() => result.current.subscribe(keyHandler));
act(() => stdin.sendKittySequence(`\x1b[57398u`));
expect(keyHandler).not.toHaveBeenCalled();
});
});
describe('Shift+Tab forms', () => {
it.each([
{ sequence: `\x1b[Z`, description: 'legacy reverse Tab' },

View file

@ -47,6 +47,42 @@ export const DRAG_COMPLETION_TIMEOUT_MS = 100; // Broadcast full path after 100m
export const SINGLE_QUOTE = "'";
export const DOUBLE_QUOTE = '"';
// Kitty keypad private-use keycodes (0xE000-0xE026)
// Reference: https://sw.kovidgoyal.net/kitty/keyboard-protocol/#functional-key-definitions
const KITTY_KEYPAD_PRINTABLE_KEYCODE_TO_CHAR: Record<number, string> = {
57399: '0',
57400: '1',
57401: '2',
57402: '3',
57403: '4',
57404: '5',
57405: '6',
57406: '7',
57407: '8',
57408: '9',
57409: '.',
57410: '/',
57411: '*',
57412: '-',
57413: '+',
// 57414 is keypad Enter - handled separately via CSI~ sequence
57415: '=',
57416: ',',
};
const KITTY_KEYPAD_FUNCTIONAL_KEYCODE_TO_NAME: Record<number, string> = {
57417: 'left',
57418: 'right',
57419: 'up',
57420: 'down',
57421: 'pageup',
57422: 'pagedown',
57423: 'home',
57424: 'end',
57425: 'insert',
57426: 'delete',
};
export interface Key {
name: string;
ctrl: boolean;
@ -142,6 +178,25 @@ export function KeypressProvider({
let rawDataBuffer = Buffer.alloc(0);
let rawFlushTimeout: NodeJS.Timeout | null = null;
const createPrintableKey = (char: string): Key => {
const printableName =
char === ' '
? 'space'
: /^[A-Za-z]$/.test(char)
? char.toLowerCase()
: char;
return {
name: printableName,
ctrl: false,
meta: false,
shift: false,
paste: false,
sequence: char,
kittyProtocol: true,
};
};
// Parse a single complete kitty sequence from the start (prefix) of the
// buffer and return both the Key and the number of characters consumed.
// This lets us "peel off" one complete event when multiple sequences arrive
@ -332,6 +387,63 @@ export function KeypressProvider({
};
}
if (!ctrl) {
const keypadChar = KITTY_KEYPAD_PRINTABLE_KEYCODE_TO_CHAR[keyCode];
if (keypadChar) {
return {
key: {
name: keypadChar,
ctrl: false,
meta: alt,
shift,
paste: false,
sequence: keypadChar,
kittyProtocol: true,
},
length: m[0].length,
};
}
}
const keypadName = KITTY_KEYPAD_FUNCTIONAL_KEYCODE_TO_NAME[keyCode];
if (keypadName) {
return {
key: {
name: keypadName,
ctrl,
meta: alt,
shift,
paste: false,
sequence: buffer.slice(0, m[0].length),
kittyProtocol: true,
},
length: m[0].length,
};
}
// Printable CSI-u keys (including space) should behave like regular
// character input so downstream text inputs receive the literal char.
// Kitty uses the Unicode private use area for some functional keys
// such as keypad events, so exclude that range from generic printable
// conversion and handle mapped keys explicitly above.
if (
terminator === 'u' &&
!ctrl &&
keyCode >= 32 &&
keyCode !== 127 &&
keyCode <= 0x10ffff &&
!(keyCode >= 0xe000 && keyCode <= 0xf8ff)
) {
return {
key: {
...createPrintableKey(String.fromCodePoint(keyCode)),
meta: alt,
shift,
},
length: m[0].length,
};
}
// Ctrl+letters
if (
ctrl &&
@ -386,6 +498,42 @@ export function KeypressProvider({
return null;
};
const getCompleteCsiSequenceLength = (buffer: string): number | null => {
if (!buffer.startsWith(`${ESC}[`)) {
return null;
}
for (let i = 2; i < buffer.length; i++) {
const code = buffer.charCodeAt(i);
if (code >= 0x40 && code <= 0x7e) {
return i + 1;
}
if (code < 0x20 || code > 0x3f) {
return 0;
}
}
return null;
};
const parsePlainTextPrefix = (
buffer: string,
): { key: Key; length: number } | null => {
if (!buffer || buffer.startsWith(ESC)) {
return null;
}
const [char] = Array.from(buffer);
if (!char) {
return null;
}
return {
key: createPrintableKey(char),
length: char.length,
};
};
const broadcast = (key: Key) => {
for (const handler of subscribers) {
handler(key);
@ -549,47 +697,82 @@ export function KeypressProvider({
// start of the buffer. This handles batched inputs cleanly. If the
// prefix is incomplete or invalid, skip to the next CSI introducer
// (ESC[) so that a following valid sequence can still be parsed.
let parsedAny = false;
let bufferedInputHandled = false;
while (kittySequenceBuffer) {
const parsed = parseKittyPrefix(kittySequenceBuffer);
if (!parsed) {
// Look for the next potential CSI start beyond index 0
const nextStart = kittySequenceBuffer.indexOf(`${ESC}[`, 1);
if (nextStart > 0) {
if (debugKeystrokeLogging) {
if (parsed) {
if (debugKeystrokeLogging) {
const parsedSequence = kittySequenceBuffer.slice(
0,
parsed.length,
);
if (kittySequenceBuffer.length > parsed.length) {
debugLogger.debug(
'[DEBUG] Skipping incomplete/invalid CSI prefix:',
kittySequenceBuffer.slice(0, nextStart),
'[DEBUG] Kitty sequence parsed successfully (prefix):',
parsedSequence,
);
} else {
debugLogger.debug(
'[DEBUG] Kitty sequence parsed successfully:',
parsedSequence,
);
}
kittySequenceBuffer = kittySequenceBuffer.slice(nextStart);
continue;
}
break;
// Consume the parsed prefix and broadcast it.
kittySequenceBuffer = kittySequenceBuffer.slice(parsed.length);
broadcast(parsed.key);
bufferedInputHandled = true;
continue;
}
if (debugKeystrokeLogging) {
const parsedSequence = kittySequenceBuffer.slice(
0,
parsed.length,
const completeUnsupportedCsiLength =
getCompleteCsiSequenceLength(kittySequenceBuffer);
if (completeUnsupportedCsiLength) {
if (debugKeystrokeLogging) {
debugLogger.debug(
'[DEBUG] Dropping unsupported complete CSI sequence:',
kittySequenceBuffer.slice(0, completeUnsupportedCsiLength),
);
}
kittySequenceBuffer = kittySequenceBuffer.slice(
completeUnsupportedCsiLength,
);
if (kittySequenceBuffer.length > parsed.length) {
bufferedInputHandled = true;
continue;
}
const plainTextPrefix = parsePlainTextPrefix(kittySequenceBuffer);
if (plainTextPrefix) {
if (debugKeystrokeLogging) {
debugLogger.debug(
'[DEBUG] Kitty sequence parsed successfully (prefix):',
parsedSequence,
);
} else {
debugLogger.debug(
'[DEBUG] Kitty sequence parsed successfully:',
parsedSequence,
'[DEBUG] Recovered plain text after kitty sequence:',
plainTextPrefix.key.sequence,
);
}
kittySequenceBuffer = kittySequenceBuffer.slice(
plainTextPrefix.length,
);
broadcast(plainTextPrefix.key);
bufferedInputHandled = true;
continue;
}
// Consume the parsed prefix and broadcast it.
kittySequenceBuffer = kittySequenceBuffer.slice(parsed.length);
broadcast(parsed.key);
parsedAny = true;
// Look for the next potential CSI start beyond index 0
const nextStart = kittySequenceBuffer.indexOf(`${ESC}[`, 1);
if (nextStart > 0) {
if (debugKeystrokeLogging) {
debugLogger.debug(
'[DEBUG] Skipping incomplete/invalid CSI prefix:',
kittySequenceBuffer.slice(0, nextStart),
);
}
kittySequenceBuffer = kittySequenceBuffer.slice(nextStart);
bufferedInputHandled = true;
continue;
}
break;
}
if (parsedAny) return;
if (bufferedInputHandled) return;
if (config?.getDebugMode() || debugKeystrokeLogging) {
const codes = Array.from(kittySequenceBuffer).map((ch) =>

View file

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

View file

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

View file

@ -31,6 +31,7 @@ import type { LoadedSettings } from '../../config/settings.js';
import { type CommandContext, type SlashCommand } from '../commands/types.js';
import { CommandService } from '../../services/CommandService.js';
import { BuiltinCommandLoader } from '../../services/BuiltinCommandLoader.js';
import { BundledSkillLoader } from '../../services/BundledSkillLoader.js';
import { FileCommandLoader } from '../../services/FileCommandLoader.js';
import { McpPromptLoader } from '../../services/McpPromptLoader.js';
import { parseSlashCommand } from '../../utils/commands.js';
@ -78,6 +79,8 @@ interface SlashCommandProcessorActions {
addConfirmUpdateExtensionRequest: (request: ConfirmationRequest) => void;
openSubagentCreateDialog: () => void;
openAgentsManagerDialog: () => void;
openExtensionsManagerDialog: () => void;
openMcpDialog: () => void;
}
/**
@ -309,6 +312,7 @@ export const useSlashCommandProcessor = (
const loaders = [
new McpPromptLoader(config),
new BuiltinCommandLoader(config),
new BundledSkillLoader(config),
new FileCommandLoader(config),
];
const commandService = await CommandService.create(
@ -476,12 +480,18 @@ export const useSlashCommandProcessor = (
case 'subagent_list':
actions.openAgentsManagerDialog();
return { type: 'handled' };
case 'mcp':
actions.openMcpDialog();
return { type: 'handled' };
case 'approval-mode':
actions.openApprovalModeDialog();
return { type: 'handled' };
case 'resume':
actions.openResumeDialog();
return { type: 'handled' };
case 'extensions_manage':
actions.openExtensionsManagerDialog();
return { type: 'handled' };
case 'help':
return { type: 'handled' };
default: {

View file

@ -481,6 +481,111 @@ describe('useCodingPlanUpdates', () => {
).toBe(true);
});
it('should show "model preserved" message when current model exists in new template', async () => {
mockSettings.merged.codingPlan = {
region: CodingPlanRegion.CHINA,
version: 'old-version-hash',
};
mockSettings.merged.modelProviders = {
[AuthType.USE_OPENAI]: [
{
id: 'qwen3.5-plus',
baseUrl: chinaConfig.baseUrl,
envKey: CODING_PLAN_ENV_KEY,
},
],
};
// Simulate the user's current model being one that exists in the new template
mockConfig.getModel.mockReturnValue('qwen3.5-plus');
mockConfig.refreshAuth.mockResolvedValue(undefined);
const { result } = renderHook(() =>
useCodingPlanUpdates(
mockSettings as never,
mockConfig as never,
mockAddItem,
),
);
await waitFor(() => {
expect(result.current.codingPlanUpdateRequest).toBeDefined();
});
await result.current.codingPlanUpdateRequest!.onConfirm(true);
await waitFor(() => {
expect(mockSettings.setValue).toHaveBeenCalled();
});
// Should show plain success message without "switched"
expect(mockAddItem).toHaveBeenCalledWith(
expect.objectContaining({
type: 'info',
text: expect.stringContaining('updated successfully'),
}),
expect.any(Number),
);
expect(mockAddItem).not.toHaveBeenCalledWith(
expect.objectContaining({
type: 'info',
text: expect.stringContaining('switched'),
}),
expect.any(Number),
);
// Reset mock
mockConfig.getModel.mockReturnValue('qwen-max');
});
it('should show "model switched" message when current model is not in new template', async () => {
mockSettings.merged.codingPlan = {
region: CodingPlanRegion.CHINA,
version: 'old-version-hash',
};
mockSettings.merged.modelProviders = {
[AuthType.USE_OPENAI]: [
{
id: 'removed-model',
baseUrl: chinaConfig.baseUrl,
envKey: CODING_PLAN_ENV_KEY,
},
],
};
// The user's current model no longer exists in the new template
mockConfig.getModel.mockReturnValue('removed-model');
mockConfig.refreshAuth.mockResolvedValue(undefined);
const { result } = renderHook(() =>
useCodingPlanUpdates(
mockSettings as never,
mockConfig as never,
mockAddItem,
),
);
await waitFor(() => {
expect(result.current.codingPlanUpdateRequest).toBeDefined();
});
await result.current.codingPlanUpdateRequest!.onConfirm(true);
await waitFor(() => {
expect(mockSettings.setValue).toHaveBeenCalled();
});
// Should show "model switched" message
expect(mockAddItem).toHaveBeenCalledWith(
expect.objectContaining({
type: 'info',
text: expect.stringContaining('switched'),
}),
expect.any(Number),
);
// Reset mock
mockConfig.getModel.mockReturnValue('qwen-max');
});
it('should handle update errors gracefully', async () => {
mockSettings.merged.codingPlan = {
region: CodingPlanRegion.CHINA,

View file

@ -42,6 +42,7 @@ export function useCodingPlanUpdates(
/**
* Execute the Coding Plan configuration update.
* Removes old Coding Plan configs and replaces them with new ones from the template.
* Preserves the user's current model selection if it still exists in the new template.
* Uses the region from settings.codingPlan.region (defaults to CHINA).
*/
const executeUpdate = useCallback(
@ -82,6 +83,12 @@ export function useCodingPlanUpdates(
...(nonCodingPlanConfigs as Array<Record<string, unknown>>),
] as Array<Record<string, unknown>>;
// Record the user's current model before the update
const previousModel = config.getModel();
const previousModelStillAvailable = newConfigs.some(
(cfg) => cfg.id === previousModel,
);
// Hot-reload model providers configuration first (in-memory only)
const updatedModelProviders = {
...(settings.merged.modelProviders as
@ -112,12 +119,34 @@ export function useCodingPlanUpdates(
const activeModel = config.getModel();
if (previousModelStillAvailable && activeModel === previousModel) {
addItem(
{
type: 'info',
text: t('{{region}} configuration updated successfully.', {
region: t('Alibaba Cloud Coding Plan'),
}),
},
Date.now(),
);
} else {
addItem(
{
type: 'info',
text: t(
'{{region}} configuration updated successfully. Model switched to "{{model}}".',
{ region: t('Alibaba Cloud Coding Plan'), model: activeModel },
),
},
Date.now(),
);
}
addItem(
{
type: 'info',
text: t(
'{{region}} configuration updated successfully. Model switched to "{{model}}".',
{ region: t('Alibaba Cloud Coding Plan'), model: activeModel },
'Tip: Use /model to switch between available Coding Plan models.',
),
},
Date.now(),

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

View file

@ -28,6 +28,7 @@ import {
ApprovalMode,
AuthType,
GeminiEventType as ServerGeminiEventType,
SendMessageType,
ToolErrorType,
ToolConfirmationOutcome,
} from '@qwen-code/qwen-code-core';
@ -482,7 +483,7 @@ describe('useGeminiStream', () => {
expectedMergedResponse,
expect.any(AbortSignal),
'prompt-id-2',
{ isContinuation: true },
{ type: SendMessageType.ToolResult },
);
});
@ -806,7 +807,7 @@ describe('useGeminiStream', () => {
toolCallResponseParts,
expect.any(AbortSignal),
'prompt-id-4',
{ isContinuation: true },
{ type: SendMessageType.ToolResult },
);
});
@ -1122,7 +1123,7 @@ describe('useGeminiStream', () => {
'This is the actual prompt from the command file.',
expect.any(AbortSignal),
expect.any(String),
undefined,
{ type: SendMessageType.UserQuery },
);
expect(mockScheduleToolCalls).not.toHaveBeenCalled();
@ -1149,7 +1150,7 @@ describe('useGeminiStream', () => {
'',
expect.any(AbortSignal),
expect.any(String),
undefined,
{ type: SendMessageType.UserQuery },
);
});
});
@ -1168,7 +1169,7 @@ describe('useGeminiStream', () => {
'// This is a line comment',
expect.any(AbortSignal),
expect.any(String),
undefined,
{ type: SendMessageType.UserQuery },
);
});
});
@ -1187,7 +1188,7 @@ describe('useGeminiStream', () => {
'/* This is a block comment */',
expect.any(AbortSignal),
expect.any(String),
undefined,
{ type: SendMessageType.UserQuery },
);
});
});
@ -2091,7 +2092,7 @@ describe('useGeminiStream', () => {
processedQueryParts, // Argument 1: The parts array directly
expect.any(AbortSignal), // Argument 2: An AbortSignal
expect.any(String), // Argument 3: The prompt_id string
undefined, // Argument 4: Options (undefined for normal prompts)
{ type: SendMessageType.UserQuery }, // Argument 4: The options
);
});
@ -2244,6 +2245,7 @@ describe('useGeminiStream', () => {
it('should show a retry countdown and update pending history over time', async () => {
vi.useFakeTimers();
try {
let continueToRetryAttempt: (() => void) | undefined;
let resolveStream: (() => void) | undefined;
mockSendMessageStream.mockReturnValue(
(async function* () {
@ -2256,6 +2258,9 @@ describe('useGeminiStream', () => {
delayMs: 3000,
},
};
await new Promise<void>((resolve) => {
continueToRetryAttempt = resolve;
});
yield {
type: ServerGeminiEventType.Retry,
};
@ -2330,6 +2335,12 @@ describe('useGeminiStream', () => {
'2s',
);
continueToRetryAttempt?.();
await act(async () => {
await Promise.resolve();
});
resolveStream?.();
await act(async () => {
@ -2347,6 +2358,103 @@ describe('useGeminiStream', () => {
}
});
it('should clear retry errors after auto-retry succeeds once the countdown has elapsed', async () => {
vi.useFakeTimers();
try {
let continueAfterCountdown: (() => void) | undefined;
mockSendMessageStream.mockReturnValue(
(async function* () {
yield {
type: ServerGeminiEventType.Retry,
retryInfo: {
message: '[API Error: Rate limit exceeded]',
attempt: 1,
maxRetries: 3,
delayMs: 1000,
},
};
await new Promise<void>((resolve) => {
continueAfterCountdown = resolve;
});
yield {
type: ServerGeminiEventType.Retry,
};
yield {
type: ServerGeminiEventType.Text,
value: 'Success after retry',
};
yield {
type: ServerGeminiEventType.Finished,
value: { reason: 'STOP', usageMetadata: undefined },
};
})(),
);
const { result } = renderHook(() =>
useGeminiStream(
new MockedGeminiClientClass(mockConfig),
[],
mockAddItem,
mockConfig,
mockLoadedSettings,
mockOnDebugMessage,
mockHandleSlashCommand,
false,
() => 'vscode' as EditorType,
() => {},
() => Promise.resolve(),
false,
() => {},
() => {},
() => {},
() => {},
80,
24,
),
);
act(() => {
void result.current.submitQuery('Trigger retry after countdown');
});
let errorItem = result.current.pendingHistoryItems.find(
(item) => item.type === MessageType.ERROR,
) as { hint?: string } | undefined;
for (let attempts = 0; attempts < 5 && !errorItem; attempts++) {
await act(async () => {
await Promise.resolve();
});
errorItem = result.current.pendingHistoryItems.find(
(item) => item.type === MessageType.ERROR,
) as { hint?: string } | undefined;
}
expect(errorItem?.hint).toContain('1s');
await act(async () => {
await vi.advanceTimersByTimeAsync(1000);
});
const staleErrorBeforeRetryCompletes =
result.current.pendingHistoryItems.find(
(item) => item.type === MessageType.ERROR,
) as { hint?: string } | undefined;
expect(staleErrorBeforeRetryCompletes?.hint).toContain('0s');
await act(async () => {
continueAfterCountdown?.();
await Promise.resolve();
await Promise.resolve();
});
const remainingError = result.current.pendingHistoryItems.find(
(item) => item.type === MessageType.ERROR,
);
expect(remainingError).toBeUndefined();
} finally {
vi.useRealTimers();
}
});
it('should memoize pendingHistoryItems', () => {
mockUseReactToolScheduler.mockReturnValue([
[],
@ -2526,6 +2634,77 @@ describe('useGeminiStream', () => {
expect.any(String),
);
});
it('should clear static error when starting a new query', async () => {
// First, mock a stream that yields an error (static error without countdown)
mockSendMessageStream.mockReturnValueOnce(
(async function* () {
yield {
type: ServerGeminiEventType.Error,
value: { error: { message: 'First error' } },
};
})(),
);
const { result } = renderHook(() =>
useGeminiStream(
new MockedGeminiClientClass(mockConfig),
[],
mockAddItem,
mockConfig,
mockLoadedSettings,
mockOnDebugMessage,
mockHandleSlashCommand,
false,
() => 'vscode' as EditorType,
() => {},
() => Promise.resolve(),
false,
() => {},
() => {},
() => {},
() => {},
80,
24,
),
);
// Submit first query that will fail
await act(async () => {
await result.current.submitQuery('First query');
});
// Verify error appears in pending history items
await waitFor(() => {
const errorItem = result.current.pendingHistoryItems.find(
(item) => item.type === 'error',
);
expect(errorItem).toBeDefined();
});
// Now mock a successful stream for the second query
mockSendMessageStream.mockReturnValueOnce(
(async function* () {
yield {
type: ServerGeminiEventType.Text,
value: 'Success response',
};
})(),
);
// Submit second query
await act(async () => {
await result.current.submitQuery('Second query');
});
// Verify the error is cleared (no longer in pending history items)
await waitFor(() => {
const errorItem = result.current.pendingHistoryItems.find(
(item) => item.type === 'error',
);
expect(errorItem).toBeUndefined();
});
});
});
describe('Concurrent Execution Prevention', () => {
@ -2598,7 +2777,7 @@ describe('useGeminiStream', () => {
'First query',
expect.any(AbortSignal),
expect.any(String),
undefined,
{ type: SendMessageType.UserQuery },
);
// Verify only the first query was added to history
@ -2650,14 +2829,14 @@ describe('useGeminiStream', () => {
'First query',
expect.any(AbortSignal),
expect.any(String),
undefined,
{ type: SendMessageType.UserQuery },
);
expect(mockSendMessageStream).toHaveBeenNthCalledWith(
2,
'Second query',
expect.any(AbortSignal),
expect.any(String),
undefined,
{ type: SendMessageType.UserQuery },
);
});
@ -2680,7 +2859,7 @@ describe('useGeminiStream', () => {
'Second query',
expect.any(AbortSignal),
expect.any(String),
undefined,
{ type: SendMessageType.UserQuery },
);
});
});

View file

@ -19,14 +19,17 @@ import type {
} from '@qwen-code/qwen-code-core';
import {
GeminiEventType as ServerGeminiEventType,
SendMessageType,
createDebugLogger,
getErrorMessage,
isNodeError,
MessageSenderType,
logUserPrompt,
logUserRetry,
GitService,
UnauthorizedError,
UserPromptEvent,
UserRetryEvent,
logConversationFinishedEvent,
ConversationFinishedEvent,
ApprovalMode,
@ -59,6 +62,7 @@ import {
type TrackedToolCall,
type TrackedCompletedToolCall,
type TrackedCancelledToolCall,
type TrackedExecutingToolCall,
type TrackedWaitingToolCall,
} from './useReactToolScheduler.js';
import { promises as fs } from 'node:fs';
@ -358,6 +362,23 @@ export const useGeminiStream = (
if (toolCalls.some((tc) => tc.status === 'awaiting_approval')) {
return StreamingState.WaitingForConfirmation;
}
// Check if any executing subagent task has a pending confirmation
if (
toolCalls.some((tc) => {
if (tc.status !== 'executing') return false;
const liveOutput = (tc as TrackedExecutingToolCall).liveOutput;
return (
typeof liveOutput === 'object' &&
liveOutput !== null &&
'type' in liveOutput &&
liveOutput.type === 'task_execution' &&
'pendingConfirmation' in liveOutput &&
liveOutput.pendingConfirmation != null
);
})
) {
return StreamingState.WaitingForConfirmation;
}
if (
isResponding ||
toolCalls.some(
@ -1016,10 +1037,20 @@ export const useGeminiStream = (
// Show retry info if available (rate-limit / throttling errors)
if (event.retryInfo) {
startRetryCountdown(event.retryInfo);
} else if (!pendingRetryCountdownItemRef.current) {
} else {
// The retry attempt is starting now, so any prior retry UI is stale.
clearRetryCountdown();
}
break;
case ServerGeminiEventType.HookSystemMessage:
// Display system message from hooks (e.g., Ralph Loop iteration info)
// This is handled as a content event to show in the UI
geminiMessageBuffer = handleContentEvent(
event.value + '\n',
geminiMessageBuffer,
userMessageTimestamp,
);
break;
default: {
// enforces exhaustive switch-case
const unreachable: never = event;
@ -1048,26 +1079,28 @@ export const useGeminiStream = (
setThought,
pendingHistoryItemRef,
setPendingHistoryItem,
pendingRetryCountdownItemRef,
],
);
const submitQuery = useCallback(
async (
query: PartListUnion,
options?: { isContinuation: boolean; skipPreparation?: boolean },
submitType: SendMessageType = SendMessageType.UserQuery,
prompt_id?: string,
) => {
// Prevent concurrent executions of submitQuery, but allow continuations
// which are part of the same logical flow (tool responses)
if (isSubmittingQueryRef.current && !options?.isContinuation) {
if (
isSubmittingQueryRef.current &&
submitType !== SendMessageType.ToolResult
) {
return;
}
if (
(streamingState === StreamingState.Responding ||
streamingState === StreamingState.WaitingForConfirmation) &&
!options?.isContinuation
submitType !== SendMessageType.ToolResult
)
return;
@ -1077,11 +1110,16 @@ export const useGeminiStream = (
const userMessageTimestamp = Date.now();
// Reset quota error flag when starting a new query (not a continuation)
if (!options?.isContinuation) {
if (submitType !== SendMessageType.ToolResult) {
setModelSwitchedFromQuotaError(false);
// Commit any pending retry error to history (without hint) since the
// user is starting a new conversation turn
if (pendingRetryCountdownItemRef.current) {
// user is starting a new conversation turn.
// Clear both countdown-based errors AND static errors (those without
// an active countdown timer, e.g. "Press Ctrl+Y to retry").
if (
pendingRetryCountdownItemRef.current ||
pendingRetryErrorItemRef.current
) {
clearRetryCountdown();
}
}
@ -1095,14 +1133,15 @@ export const useGeminiStream = (
}
return promptIdContext.run(prompt_id, async () => {
const { queryToSend, shouldProceed } = options?.skipPreparation
? { queryToSend: query, shouldProceed: true }
: await prepareQueryForGemini(
query,
userMessageTimestamp,
abortSignal,
prompt_id!,
);
const { queryToSend, shouldProceed } =
submitType === SendMessageType.Retry
? { queryToSend: query, shouldProceed: true }
: await prepareQueryForGemini(
query,
userMessageTimestamp,
abortSignal,
prompt_id!,
);
if (!shouldProceed || queryToSend === null) {
isSubmittingQueryRef.current = false;
@ -1110,7 +1149,7 @@ export const useGeminiStream = (
}
// Check image format support for non-continuations
if (!options?.isContinuation) {
if (submitType === SendMessageType.UserQuery) {
const formatCheck = checkImageFormatsSupport(queryToSend);
if (formatCheck.hasUnsupportedFormats) {
addItem(
@ -1127,7 +1166,7 @@ export const useGeminiStream = (
lastPromptRef.current = finalQueryToSend;
lastPromptErroredRef.current = false;
if (!options?.isContinuation) {
if (submitType === SendMessageType.UserQuery) {
// trigger new prompt event for session stats in CLI
startNewPrompt();
@ -1148,6 +1187,10 @@ export const useGeminiStream = (
setThought(null);
}
if (submitType === SendMessageType.Retry) {
logUserRetry(config, new UserRetryEvent(prompt_id));
}
setIsResponding(true);
setInitError(null);
@ -1156,7 +1199,7 @@ export const useGeminiStream = (
finalQueryToSend,
abortSignal,
prompt_id!,
options,
{ type: submitType },
);
const processingStatus = await processGeminiStreamEvents(
@ -1176,7 +1219,8 @@ export const useGeminiStream = (
}
// Only clear auto-retry countdown errors (those with an active timer).
// Do NOT clear static error+hint from handleErrorEvent — those should
// remain visible until the user presses Ctrl+Y to retry.
// remain visible until the user presses Ctrl+Y to retry or starts
// a new conversation turn (cleared in submitQuery).
if (retryCountdownTimerRef.current) {
clearRetryCountdown();
}
@ -1223,6 +1267,7 @@ export const useGeminiStream = (
handleLoopDetectedEvent,
clearRetryCountdown,
pendingRetryCountdownItemRef,
pendingRetryErrorItemRef,
setPendingRetryErrorItem,
],
);
@ -1242,7 +1287,7 @@ export const useGeminiStream = (
*
* When conditions are met:
* - Clears any pending auto-retry countdown to avoid duplicate retries
* - Re-submits the last query with skipPreparation: true for faster retry
* - Re-submits the last query with isRetry: true, reusing the same prompt_id
*
* This function is exposed via UIActionsContext and triggered by InputPrompt
* when the user presses Ctrl+Y (bound to Command.RETRY_LAST in keyBindings.ts).
@ -1267,24 +1312,10 @@ export const useGeminiStream = (
return;
}
// Commit the error to history (without hint) before clearing
const errorItem = pendingRetryErrorItemRef.current;
if (errorItem) {
addItem({ type: errorItem.type, text: errorItem.text }, Date.now());
}
clearRetryCountdown();
await submitQuery(lastPrompt, {
isContinuation: false,
skipPreparation: true,
});
}, [
streamingState,
addItem,
clearRetryCountdown,
submitQuery,
pendingRetryErrorItemRef,
]);
await submitQuery(lastPrompt, SendMessageType.Retry);
}, [streamingState, addItem, clearRetryCountdown, submitQuery]);
const handleApprovalModeChange = useCallback(
async (newApprovalMode: ApprovalMode) => {
@ -1429,13 +1460,7 @@ export const useGeminiStream = (
return;
}
submitQuery(
responsesToSend,
{
isContinuation: true,
},
prompt_ids[0],
);
submitQuery(responsesToSend, SendMessageType.ToolResult, prompt_ids[0]);
},
[
isResponding,

View file

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

View file

@ -252,7 +252,6 @@ export function mapToDisplay(
status: mapCoreStatusToDisplayStatus(trackedCall.status),
resultDisplay: trackedCall.response.resultDisplay,
confirmationDetails: undefined,
outputFile: trackedCall.response.outputFile,
};
case 'error':
return {

View file

@ -33,6 +33,7 @@ const noColorSemanticColors: SemanticColors = {
secondary: '',
link: '',
accent: '',
code: '',
},
background: {
primary: '',

View file

@ -12,6 +12,7 @@ export interface SemanticColors {
secondary: string;
link: string;
accent: string;
code: string;
};
background: {
primary: string;
@ -45,6 +46,7 @@ export const lightSemanticColors: SemanticColors = {
secondary: lightTheme.Gray,
link: lightTheme.AccentBlue,
accent: lightTheme.AccentPurple,
code: lightTheme.LightBlue,
},
background: {
primary: lightTheme.Background,
@ -77,6 +79,7 @@ export const darkSemanticColors: SemanticColors = {
secondary: darkTheme.Gray,
link: darkTheme.AccentBlue,
accent: darkTheme.AccentPurple,
code: darkTheme.LightBlue,
},
background: {
primary: darkTheme.Background,
@ -109,6 +112,7 @@ export const ansiSemanticColors: SemanticColors = {
secondary: ansiTheme.Gray,
link: ansiTheme.AccentBlue,
accent: ansiTheme.AccentPurple,
code: ansiTheme.LightBlue,
},
background: {
primary: ansiTheme.Background,

View file

@ -40,6 +40,7 @@ export interface CustomTheme {
secondary?: string;
link?: string;
accent?: string;
code?: string;
};
background?: {
primary?: string;
@ -174,6 +175,7 @@ export class Theme {
secondary: this.colors.Gray,
link: this.colors.AccentBlue,
accent: this.colors.AccentPurple,
code: this.colors.LightBlue,
},
background: {
primary: this.colors.Background,
@ -269,7 +271,7 @@ export function createCustomTheme(customTheme: CustomTheme): Theme {
type: 'custom',
Background: customTheme.background?.primary ?? customTheme.Background ?? '',
Foreground: customTheme.text?.primary ?? customTheme.Foreground ?? '',
LightBlue: customTheme.text?.link ?? customTheme.LightBlue ?? '',
LightBlue: customTheme.text?.code ?? customTheme.LightBlue ?? '',
AccentBlue: customTheme.text?.link ?? customTheme.AccentBlue ?? '',
AccentPurple: customTheme.text?.accent ?? customTheme.AccentPurple ?? '',
AccentCyan: customTheme.text?.link ?? customTheme.AccentCyan ?? '',
@ -433,6 +435,7 @@ export function createCustomTheme(customTheme: CustomTheme): Theme {
secondary: customTheme.text?.secondary ?? colors.Gray,
link: customTheme.text?.link ?? colors.AccentBlue,
accent: customTheme.text?.accent ?? colors.AccentPurple,
code: customTheme.text?.code ?? colors.LightBlue,
},
background: {
primary: customTheme.background?.primary ?? colors.Background,

View file

@ -68,7 +68,6 @@ export interface IndividualToolCallDisplay {
confirmationDetails: ToolCallConfirmationDetails | undefined;
renderOutputAsMarkdown?: boolean;
ptyId?: number;
outputFile?: string;
}
export interface CompressionProps {

View file

@ -7,7 +7,7 @@
import { randomUUID } from 'node:crypto';
import type { Config, ChatRecord } from '@qwen-code/qwen-code-core';
import type { SessionContext } from '../../../acp-integration/session/types.js';
import type * as acp from '../../../acp-integration/acp.js';
import type { SessionUpdate, ToolCall } from '@agentclientprotocol/sdk';
import { HistoryReplayer } from '../../../acp-integration/session/HistoryReplayer.js';
import type { ExportMessage, ExportSessionData } from './types.js';
@ -34,7 +34,7 @@ class ExportSessionContext implements SessionContext {
this.config = config;
}
async sendUpdate(update: acp.SessionUpdate): Promise<void> {
async sendUpdate(update: SessionUpdate): Promise<void> {
switch (update.sessionUpdate) {
case 'user_message_chunk':
this.handleMessageChunk('user', update.content);
@ -108,7 +108,7 @@ class ExportSessionContext implements SessionContext {
}
}
private handleToolCallStart(update: acp.ToolCall): void {
private handleToolCallStart(update: ToolCall): void {
const toolCall: ExportMessage['toolCall'] = {
toolCallId: update.toolCallId,
kind: update.kind || 'other',