mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-05-05 23:42:03 +00:00
merge main
This commit is contained in:
commit
98fb607ad1
73 changed files with 5287 additions and 1246 deletions
|
|
@ -4,186 +4,12 @@
|
|||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type {
|
||||
SlashCommand,
|
||||
CommandContext,
|
||||
MessageActionReturn,
|
||||
OpenDialogActionReturn,
|
||||
} from './types.js';
|
||||
import type { SlashCommand, OpenDialogActionReturn } from './types.js';
|
||||
import { CommandKind } from './types.js';
|
||||
import {
|
||||
getErrorMessage,
|
||||
MCPOAuthTokenStorage,
|
||||
MCPOAuthProvider,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import { appEvents, AppEvent } from '../../utils/events.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 manageCommand: SlashCommand = {
|
||||
name: 'manage',
|
||||
export const mcpCommand: SlashCommand = {
|
||||
name: 'mcp',
|
||||
get description() {
|
||||
return t('Open MCP management dialog');
|
||||
},
|
||||
|
|
@ -193,19 +19,3 @@ const manageCommand: SlashCommand = {
|
|||
dialog: 'mcp',
|
||||
}),
|
||||
};
|
||||
|
||||
export const mcpCommand: SlashCommand = {
|
||||
name: 'mcp',
|
||||
get description() {
|
||||
return t(
|
||||
'Open MCP management dialog, or authenticate with OAuth-enabled servers',
|
||||
);
|
||||
},
|
||||
kind: CommandKind.BUILT_IN,
|
||||
subCommands: [manageCommand, authCommand],
|
||||
// Default action when no subcommand is provided - open dialog
|
||||
action: async (): Promise<OpenDialogActionReturn> => ({
|
||||
type: 'dialog',
|
||||
dialog: 'mcp',
|
||||
}),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ 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;
|
||||
|
|
@ -46,6 +47,8 @@ export function ExtensionsManagerDialog({
|
|||
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 () => {
|
||||
|
|
@ -362,10 +365,10 @@ export function ExtensionsManagerDialog({
|
|||
const currentStep = getCurrentStep();
|
||||
const getNavigationInstructions = () => {
|
||||
if (currentStep === MANAGEMENT_STEPS.EXTENSION_LIST) {
|
||||
if (extensions.length === 0) {
|
||||
if (extensions.length === 0 || successMessage) {
|
||||
return t('Esc to close');
|
||||
}
|
||||
return t('Enter to select, ↑↓ to navigate, Esc to close');
|
||||
return t('↑↓ to navigate · Enter to select · Esc to close');
|
||||
}
|
||||
|
||||
if (currentStep === MANAGEMENT_STEPS.EXTENSION_DETAIL) {
|
||||
|
|
@ -373,14 +376,14 @@ export function ExtensionsManagerDialog({
|
|||
}
|
||||
|
||||
if (currentStep === MANAGEMENT_STEPS.UNINSTALL_CONFIRMATION) {
|
||||
return t('Y/Enter to confirm, N/Esc to cancel');
|
||||
return t('Y/Enter to confirm · N/Esc to cancel');
|
||||
}
|
||||
|
||||
if (currentStep === MANAGEMENT_STEPS.UPDATE_PROGRESS) {
|
||||
return updateInProgress ? t('Updating...') : '';
|
||||
}
|
||||
|
||||
return t('Enter to select, ↑↓ to navigate, Esc to go back');
|
||||
return t('↑↓ to navigate · Enter to select · Esc to go back');
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
@ -388,7 +391,7 @@ export function ExtensionsManagerDialog({
|
|||
<Text color={theme.text.secondary}>{getNavigationInstructions()}</Text>
|
||||
</Box>
|
||||
);
|
||||
}, [getCurrentStep, extensions.length, updateInProgress]);
|
||||
}, [getCurrentStep, extensions.length, updateInProgress, successMessage]);
|
||||
|
||||
const renderStepContent = useCallback(() => {
|
||||
const currentStep = getCurrentStep();
|
||||
|
|
@ -435,7 +438,6 @@ export function ExtensionsManagerDialog({
|
|||
selectedExtension={selectedExtension}
|
||||
hasUpdateAvailable={hasUpdateAvailable}
|
||||
onNavigateToStep={handleNavigateToStep}
|
||||
onNavigateBack={handleNavigateBack}
|
||||
onActionSelect={handleActionSelect}
|
||||
/>
|
||||
);
|
||||
|
|
@ -447,7 +449,6 @@ export function ExtensionsManagerDialog({
|
|||
selectedExtension={selectedExtension}
|
||||
mode="disable"
|
||||
onScopeSelect={handleDisableExtension}
|
||||
onNavigateBack={handleNavigateBack}
|
||||
/>
|
||||
);
|
||||
case MANAGEMENT_STEPS.ENABLE_SCOPE_SELECT:
|
||||
|
|
@ -456,7 +457,6 @@ export function ExtensionsManagerDialog({
|
|||
selectedExtension={selectedExtension}
|
||||
mode="enable"
|
||||
onScopeSelect={handleEnableExtension}
|
||||
onNavigateBack={handleNavigateBack}
|
||||
/>
|
||||
);
|
||||
case MANAGEMENT_STEPS.UNINSTALL_CONFIRMATION:
|
||||
|
|
@ -508,13 +508,14 @@ export function ExtensionsManagerDialog({
|
|||
]);
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Box flexDirection="column" width={boxWidth}>
|
||||
<Box
|
||||
borderStyle="single"
|
||||
borderColor={theme.border.default}
|
||||
flexDirection="column"
|
||||
padding={1}
|
||||
width="100%"
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
width={boxWidth}
|
||||
gap={1}
|
||||
>
|
||||
{renderStepHeader()}
|
||||
|
|
|
|||
|
|
@ -1,53 +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 │
|
||||
│ │
|
||||
└──────────────────────────────────────────────────────────────────────────────────────────────────┘"
|
||||
"┌──────────────────────────────────────────────────────────────────────────┐
|
||||
│ 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 │
|
||||
│ │
|
||||
└──────────────────────────────────────────────────────────────────────────────────────────────────┘"
|
||||
"┌──────────────────────────────────────────────────────────────────────────┐
|
||||
│ 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 │
|
||||
│ │
|
||||
└──────────────────────────────────────────────────────────────────────────────────────────────────┘"
|
||||
"┌──────────────────────────────────────────────────────────────────────────┐
|
||||
│ 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 │
|
||||
│ │
|
||||
└──────────────────────────────────────────────────────────────────────────────────────────────────┘"
|
||||
"┌──────────────────────────────────────────────────────────────────────────┐
|
||||
│ Manage Extensions │
|
||||
│ │
|
||||
│ No extensions installed. │
|
||||
│ Use '/extensions install' to install your first extension. │
|
||||
│ │
|
||||
│ Esc to close │
|
||||
└──────────────────────────────────────────────────────────────────────────┘"
|
||||
`;
|
||||
|
|
|
|||
|
|
@ -15,14 +15,12 @@ interface ActionSelectionStepProps {
|
|||
selectedExtension: Extension | null;
|
||||
hasUpdateAvailable: boolean;
|
||||
onNavigateToStep: (step: string) => void;
|
||||
onNavigateBack: () => void;
|
||||
onActionSelect: (action: ExtensionAction) => void;
|
||||
}
|
||||
|
||||
export const ActionSelectionStep = ({
|
||||
selectedExtension,
|
||||
hasUpdateAvailable,
|
||||
onNavigateBack,
|
||||
onActionSelect,
|
||||
}: ActionSelectionStepProps) => {
|
||||
const [selectedAction, setSelectedAction] = useState<ExtensionAction | null>(
|
||||
|
|
@ -78,23 +76,11 @@ export const ActionSelectionStep = ({
|
|||
},
|
||||
value: 'uninstall' as const,
|
||||
},
|
||||
{
|
||||
key: 'back',
|
||||
get label() {
|
||||
return t('Back');
|
||||
},
|
||||
value: 'back' as const,
|
||||
},
|
||||
];
|
||||
return allActions;
|
||||
}, [hasUpdateAvailable, isActive]);
|
||||
|
||||
const handleActionSelect = (value: ExtensionAction) => {
|
||||
if (value === 'back') {
|
||||
onNavigateBack();
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedAction(value);
|
||||
onActionSelect(value);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -160,18 +160,18 @@ export const ExtensionListStep = ({
|
|||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
{extensions.map((extension, index) =>
|
||||
renderExtensionItem(extension, index, index === selectedIndex),
|
||||
)}
|
||||
</Box>
|
||||
<Box marginTop={1}>
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -14,14 +14,12 @@ interface ScopeSelectStepProps {
|
|||
selectedExtension: Extension | null;
|
||||
mode: 'disable' | 'enable';
|
||||
onScopeSelect: (scope: 'user' | 'workspace') => void;
|
||||
onNavigateBack: () => void;
|
||||
}
|
||||
|
||||
export function ScopeSelectStep({
|
||||
selectedExtension,
|
||||
mode,
|
||||
onScopeSelect,
|
||||
onNavigateBack,
|
||||
}: ScopeSelectStepProps) {
|
||||
const scopeItems = [
|
||||
{
|
||||
|
|
@ -38,20 +36,9 @@ export function ScopeSelectStep({
|
|||
},
|
||||
value: 'workspace' as const,
|
||||
},
|
||||
{
|
||||
key: 'back',
|
||||
get label() {
|
||||
return t('Back');
|
||||
},
|
||||
value: 'back' as const,
|
||||
},
|
||||
];
|
||||
|
||||
const handleSelect = (value: 'user' | 'workspace' | 'back') => {
|
||||
if (value === 'back') {
|
||||
onNavigateBack();
|
||||
return;
|
||||
}
|
||||
const handleSelect = (value: 'user' | 'workspace') => {
|
||||
onScopeSelect(value);
|
||||
};
|
||||
|
||||
|
|
@ -71,7 +58,7 @@ export function ScopeSelectStep({
|
|||
return (
|
||||
<Box flexDirection="column" gap={1}>
|
||||
<Text color={theme.text.primary}>{title}</Text>
|
||||
<Box marginTop={1}>
|
||||
<Box>
|
||||
<RadioButtonSelect
|
||||
items={scopeItems}
|
||||
onSelect={handleSelect}
|
||||
|
|
|
|||
|
|
@ -60,9 +60,6 @@ export function UninstallConfirmStep({
|
|||
<Text color={theme.text.secondary}>
|
||||
{t('This action cannot be undone.')}
|
||||
</Text>
|
||||
<Box marginTop={1}>
|
||||
<Text>{t('Press Y/Enter to confirm, N/Esc to cancel')}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,36 +3,31 @@
|
|||
exports[`ActionSelectionStep Snapshots > should render for active extension without update 1`] = `
|
||||
"● View Details
|
||||
Disable Extension
|
||||
Uninstall Extension
|
||||
Back"
|
||||
Uninstall Extension"
|
||||
`;
|
||||
|
||||
exports[`ActionSelectionStep Snapshots > should render for disabled extension 1`] = `
|
||||
"● View Details
|
||||
Enable Extension
|
||||
Uninstall Extension
|
||||
Back"
|
||||
Uninstall Extension"
|
||||
`;
|
||||
|
||||
exports[`ActionSelectionStep Snapshots > should render for disabled extension with update 1`] = `
|
||||
"● View Details
|
||||
Update Extension
|
||||
Enable Extension
|
||||
Uninstall Extension
|
||||
Back"
|
||||
Uninstall Extension"
|
||||
`;
|
||||
|
||||
exports[`ActionSelectionStep Snapshots > should render for extension with update available 1`] = `
|
||||
"● View Details
|
||||
Update Extension
|
||||
Disable Extension
|
||||
Uninstall Extension
|
||||
Back"
|
||||
Uninstall Extension"
|
||||
`;
|
||||
|
||||
exports[`ActionSelectionStep Snapshots > should render with no extension selected 1`] = `
|
||||
"● View Details
|
||||
Enable Extension
|
||||
Uninstall Extension
|
||||
Back"
|
||||
Uninstall Extension"
|
||||
`;
|
||||
|
|
|
|||
|
|
@ -6,31 +6,27 @@ Use '/extensions install' to install your first extension."
|
|||
`;
|
||||
|
||||
exports[`ExtensionListStep Snapshots > should render list with multiple extensions 1`] = `
|
||||
"● active-extension v1.0.0 (active) [up to date]
|
||||
"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]
|
||||
|
||||
|
||||
3 extensions installed"
|
||||
update-available v1.0.0 (active) [update available]"
|
||||
`;
|
||||
|
||||
exports[`ExtensionListStep Snapshots > should render list with single extension 1`] = `
|
||||
"● test-extension v1.0.0 (active)
|
||||
"1 extensions installed
|
||||
|
||||
|
||||
1 extensions installed"
|
||||
● test-extension v1.0.0 (active)"
|
||||
`;
|
||||
|
||||
exports[`ExtensionListStep Snapshots > should render with checking status 1`] = `
|
||||
"● checking-extension v1.0.0 (active) [checking for updates]
|
||||
"1 extensions installed
|
||||
|
||||
|
||||
1 extensions installed"
|
||||
● checking-extension v1.0.0 (active) [checking for updates]"
|
||||
`;
|
||||
|
||||
exports[`ExtensionListStep Snapshots > should render with error status 1`] = `
|
||||
"● error-extension v1.0.0 (active) [error]
|
||||
"1 extensions installed
|
||||
|
||||
|
||||
1 extensions installed"
|
||||
● error-extension v1.0.0 (active) [error]"
|
||||
`;
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ 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,
|
||||
|
|
@ -31,6 +32,7 @@ import {
|
|||
} 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');
|
||||
|
||||
|
|
@ -38,6 +40,8 @@ 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);
|
||||
|
|
@ -91,16 +95,10 @@ export const MCPManagementDialog: React.FC<MCPManagementDialogProps> = ({
|
|||
let source: 'user' | 'project' | 'extension' = 'user';
|
||||
if (serverConfig.extensionName) {
|
||||
source = 'extension';
|
||||
}
|
||||
|
||||
// Determine the scope of the configuration
|
||||
let scope: 'user' | 'workspace' | 'extension' = 'user';
|
||||
if (serverConfig.extensionName) {
|
||||
scope = 'extension';
|
||||
} else if (workspaceSettings.mcpServers?.[name]) {
|
||||
scope = 'workspace';
|
||||
source = 'project';
|
||||
} else if (userSettings.mcpServers?.[name]) {
|
||||
scope = 'user';
|
||||
source = 'user';
|
||||
}
|
||||
|
||||
// Use config.isMcpServerDisabled() to check if server is disabled
|
||||
|
|
@ -115,7 +113,6 @@ export const MCPManagementDialog: React.FC<MCPManagementDialogProps> = ({
|
|||
name,
|
||||
status,
|
||||
source,
|
||||
scope,
|
||||
config: serverConfig,
|
||||
toolCount: serverTools.length,
|
||||
invalidToolCount,
|
||||
|
|
@ -225,6 +222,11 @@ export const MCPManagementDialog: React.FC<MCPManagementDialogProps> = ({
|
|||
handleNavigateToStep(MCP_MANAGEMENT_STEPS.TOOL_LIST);
|
||||
}, [handleNavigateToStep]);
|
||||
|
||||
// Authenticate
|
||||
const handleAuthenticate = useCallback(() => {
|
||||
handleNavigateToStep(MCP_MANAGEMENT_STEPS.AUTHENTICATE);
|
||||
}, [handleNavigateToStep]);
|
||||
|
||||
// Select tool
|
||||
const handleSelectTool = useCallback(
|
||||
(tool: MCPToolDisplayInfo) => {
|
||||
|
|
@ -318,17 +320,68 @@ export const MCPManagementDialog: React.FC<MCPManagementDialogProps> = ({
|
|||
}, [config, selectedServer, reloadServers]);
|
||||
|
||||
// Handle disable/enable action
|
||||
const handleDisable = useCallback(() => {
|
||||
const handleDisable = useCallback(async () => {
|
||||
if (!selectedServer) return;
|
||||
|
||||
// If server is already disabled, enable it directly
|
||||
if (selectedServer.isDisabled) {
|
||||
void handleEnableServer();
|
||||
} else {
|
||||
// Otherwise navigate to disable scope selection
|
||||
handleNavigateToStep(MCP_MANAGEMENT_STEPS.DISABLE_SCOPE_SELECT);
|
||||
// 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, handleNavigateToStep]);
|
||||
}, [selectedServer, handleEnableServer, config, reloadServers]);
|
||||
|
||||
// Execute disable after selecting scope
|
||||
const handleSelectDisableScope = useCallback(
|
||||
|
|
@ -383,36 +436,84 @@ export const MCPManagementDialog: React.FC<MCPManagementDialogProps> = ({
|
|||
// Render step header
|
||||
const renderStepHeader = useCallback(() => {
|
||||
const currentStep = getCurrentStep();
|
||||
let headerText = '';
|
||||
|
||||
switch (currentStep) {
|
||||
case MCP_MANAGEMENT_STEPS.SERVER_LIST:
|
||||
headerText = t('Manage MCP servers');
|
||||
break;
|
||||
case MCP_MANAGEMENT_STEPS.SERVER_DETAIL:
|
||||
headerText = selectedServer?.name || t('Server Detail');
|
||||
break;
|
||||
case MCP_MANAGEMENT_STEPS.DISABLE_SCOPE_SELECT:
|
||||
headerText = t('Disable Server');
|
||||
break;
|
||||
case MCP_MANAGEMENT_STEPS.TOOL_LIST:
|
||||
headerText = t('Tools');
|
||||
break;
|
||||
case MCP_MANAGEMENT_STEPS.TOOL_DETAIL:
|
||||
headerText = selectedTool?.name || t('Tool Detail');
|
||||
break;
|
||||
default:
|
||||
headerText = t('MCP Management');
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
let headerText = (
|
||||
<Box flexDirection="column">
|
||||
<Text color={theme.text.accent} bold>
|
||||
{headerText}
|
||||
{t('Manage MCP servers')}
|
||||
</Text>
|
||||
<Text color={theme.text.secondary}>
|
||||
{servers.length} {servers.length === 1 ? t('server') : t('servers')}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}, [getCurrentStep, selectedServer, selectedTool]);
|
||||
|
||||
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(() => {
|
||||
|
|
@ -435,6 +536,7 @@ export const MCPManagementDialog: React.FC<MCPManagementDialogProps> = ({
|
|||
onViewTools={handleViewTools}
|
||||
onReconnect={handleReconnect}
|
||||
onDisable={handleDisable}
|
||||
onAuthenticate={handleAuthenticate}
|
||||
onBack={handleNavigateBack}
|
||||
/>
|
||||
);
|
||||
|
|
@ -463,6 +565,17 @@ export const MCPManagementDialog: React.FC<MCPManagementDialogProps> = ({
|
|||
<ToolDetailStep tool={selectedTool} onBack={handleNavigateBack} />
|
||||
);
|
||||
|
||||
case MCP_MANAGEMENT_STEPS.AUTHENTICATE:
|
||||
return (
|
||||
<AuthenticateStep
|
||||
server={selectedServer}
|
||||
onSuccess={() => {
|
||||
void reloadServers();
|
||||
}}
|
||||
onBack={handleNavigateBack}
|
||||
/>
|
||||
);
|
||||
|
||||
default:
|
||||
return (
|
||||
<Box>
|
||||
|
|
@ -480,10 +593,12 @@ export const MCPManagementDialog: React.FC<MCPManagementDialogProps> = ({
|
|||
handleViewTools,
|
||||
handleReconnect,
|
||||
handleDisable,
|
||||
handleAuthenticate,
|
||||
handleNavigateBack,
|
||||
handleSelectTool,
|
||||
handleSelectDisableScope,
|
||||
getServerTools,
|
||||
reloadServers,
|
||||
]);
|
||||
|
||||
// Render step footer
|
||||
|
|
@ -511,6 +626,9 @@ export const MCPManagementDialog: React.FC<MCPManagementDialogProps> = ({
|
|||
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');
|
||||
}
|
||||
|
|
@ -536,14 +654,15 @@ export const MCPManagementDialog: React.FC<MCPManagementDialogProps> = ({
|
|||
);
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Box flexDirection="column" width={boxWidth}>
|
||||
<Box
|
||||
borderStyle="single"
|
||||
borderColor={theme.border.default}
|
||||
flexDirection="column"
|
||||
padding={1}
|
||||
width="100%"
|
||||
width={boxWidth}
|
||||
gap={1}
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
>
|
||||
{renderStepHeader()}
|
||||
{renderStepContent()}
|
||||
|
|
|
|||
164
packages/cli/src/ui/components/mcp/steps/AuthenticateStep.tsx
Normal file
164
packages/cli/src/ui/components/mcp/steps/AuthenticateStep.tsx
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
/**
|
||||
* @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 { appEvents, AppEvent } from '../../../../utils/events.js';
|
||||
|
||||
type AuthState = 'idle' | 'authenticating' | 'success' | 'error';
|
||||
|
||||
export const AuthenticateStep: React.FC<AuthenticateStepProps> = ({
|
||||
server,
|
||||
onSuccess,
|
||||
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 (same as mcpCommand.ts)
|
||||
const displayListener = (message: string) => {
|
||||
setMessages((prev) => [...prev, message]);
|
||||
};
|
||||
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);
|
||||
}
|
||||
|
||||
// Update the client with the new tools
|
||||
const geminiClient = config.getGeminiClient();
|
||||
if (geminiClient) {
|
||||
await geminiClient.setTools();
|
||||
}
|
||||
|
||||
setAuthState('success');
|
||||
onSuccess?.();
|
||||
} catch (error) {
|
||||
setErrorMessage(getErrorMessage(error));
|
||||
setAuthState('error');
|
||||
} finally {
|
||||
isRunning.current = false;
|
||||
appEvents.removeListener(AppEvent.OauthDisplayMessage, displayListener);
|
||||
}
|
||||
}, [server, config, onSuccess]);
|
||||
|
||||
useEffect(() => {
|
||||
runAuthentication();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
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>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
|
@ -4,7 +4,7 @@
|
|||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useMemo } from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { theme } from '../../../semantic-colors.js';
|
||||
import { useKeypress } from '../../../hooks/useKeypress.js';
|
||||
|
|
@ -20,62 +20,79 @@ import {
|
|||
// 标签列宽度
|
||||
const LABEL_WIDTH = 15;
|
||||
|
||||
type ServerAction = 'view-tools' | 'reconnect' | 'toggle-disable';
|
||||
type ServerAction =
|
||||
| 'view-tools'
|
||||
| 'reconnect'
|
||||
| 'toggle-disable'
|
||||
| 'authenticate';
|
||||
|
||||
export const ServerDetailStep: React.FC<ServerDetailStepProps> = ({
|
||||
server,
|
||||
onViewTools,
|
||||
onReconnect,
|
||||
onDisable,
|
||||
onAuthenticate,
|
||||
onBack,
|
||||
}) => {
|
||||
const [selectedAction, setSelectedAction] =
|
||||
useState<ServerAction>('view-tools');
|
||||
const statusColor = server
|
||||
? server.isDisabled
|
||||
? 'yellow'
|
||||
: getStatusColor(server.status)
|
||||
: 'gray';
|
||||
|
||||
const statusColor = server ? getStatusColor(server.status) : 'gray';
|
||||
// 根据服务器状态动态生成可用操作
|
||||
const actions = useMemo(() => {
|
||||
const result: Array<{
|
||||
key: string;
|
||||
label: string;
|
||||
value: ServerAction;
|
||||
}> = [];
|
||||
|
||||
const actions = [
|
||||
{
|
||||
key: 'view-tools',
|
||||
get label() {
|
||||
return t('View tools');
|
||||
},
|
||||
value: 'view-tools' as const,
|
||||
},
|
||||
{
|
||||
key: 'reconnect',
|
||||
get label() {
|
||||
return t('Reconnect');
|
||||
},
|
||||
value: 'reconnect' as const,
|
||||
},
|
||||
{
|
||||
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',
|
||||
get label() {
|
||||
return server?.isDisabled ? t('Enable') : t('Disable');
|
||||
},
|
||||
value: 'toggle-disable' as const,
|
||||
},
|
||||
];
|
||||
label: server?.isDisabled ? t('Enable') : t('Disable'),
|
||||
value: 'toggle-disable',
|
||||
});
|
||||
|
||||
// 待补充准确的认证判断方案,暂时全部开放
|
||||
if (!server.isDisabled) {
|
||||
result.push({
|
||||
key: 'authenticate',
|
||||
label: t('Authenticate'),
|
||||
value: 'authenticate',
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [server]);
|
||||
|
||||
useKeypress(
|
||||
(key) => {
|
||||
if (key.name === 'escape') {
|
||||
onBack();
|
||||
} else if (key.name === 'return') {
|
||||
switch (selectedAction) {
|
||||
case 'view-tools':
|
||||
onViewTools();
|
||||
break;
|
||||
case 'reconnect':
|
||||
onReconnect?.();
|
||||
break;
|
||||
case 'toggle-disable':
|
||||
onDisable?.();
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
},
|
||||
{ isActive: true },
|
||||
|
|
@ -107,10 +124,8 @@ export const ServerDetailStep: React.FC<ServerDetailStepProps> = ({
|
|||
: theme.status.error
|
||||
}
|
||||
>
|
||||
{getStatusIcon(server.status)} {t(server.status)}
|
||||
{server.isDisabled && (
|
||||
<Text color={theme.status.warning}> {t('(disabled)')}</Text>
|
||||
)}
|
||||
{getStatusIcon(server.status)}{' '}
|
||||
{server.isDisabled ? t('disabled') : t(server.status)}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
|
|
@ -120,10 +135,10 @@ export const ServerDetailStep: React.FC<ServerDetailStepProps> = ({
|
|||
<Text color={theme.text.primary}>{t('Source:')}</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text color={theme.text.secondary}>
|
||||
{server.scope === 'user'
|
||||
<Text color={theme.text.primary}>
|
||||
{server.source === 'user'
|
||||
? t('User Settings')
|
||||
: server.scope === 'workspace'
|
||||
: server.source === 'project'
|
||||
? t('Workspace Settings')
|
||||
: t('Extension')}
|
||||
</Text>
|
||||
|
|
@ -150,37 +165,29 @@ export const ServerDetailStep: React.FC<ServerDetailStepProps> = ({
|
|||
</Box>
|
||||
)}
|
||||
|
||||
<Box>
|
||||
<Box width={LABEL_WIDTH}>
|
||||
<Text color={theme.text.primary}>{t('Capabilities:')}</Text>
|
||||
</Box>
|
||||
{!server.isDisabled && (
|
||||
<Box>
|
||||
<Text>
|
||||
{server.toolCount > 0 ? t('tools') : ''}
|
||||
{server.toolCount > 0 && server.promptCount > 0 ? ', ' : ''}
|
||||
{server.promptCount > 0 ? t('prompts') : ''}
|
||||
</Text>
|
||||
<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>
|
||||
</Box>
|
||||
|
||||
<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>
|
||||
|
|
@ -200,7 +207,7 @@ export const ServerDetailStep: React.FC<ServerDetailStepProps> = ({
|
|||
<Box>
|
||||
<RadioButtonSelect<ServerAction>
|
||||
items={actions}
|
||||
onHighlight={(value: ServerAction) => setSelectedAction(value)}
|
||||
showNumbers={false}
|
||||
onSelect={(value: ServerAction) => {
|
||||
switch (value) {
|
||||
case 'view-tools':
|
||||
|
|
@ -212,6 +219,9 @@ export const ServerDetailStep: React.FC<ServerDetailStepProps> = ({
|
|||
case 'toggle-disable':
|
||||
onDisable?.();
|
||||
break;
|
||||
case 'authenticate':
|
||||
onAuthenticate?.();
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,7 +27,6 @@ export const ServerListStep: React.FC<ServerListStepProps> = ({
|
|||
[servers],
|
||||
);
|
||||
|
||||
// 动态计算服务器名称列的最大宽度(基于实际内容)
|
||||
const serverNameWidth = useMemo(() => {
|
||||
if (servers.length === 0) return 20;
|
||||
const maxLength = Math.max(...servers.map((s) => s.name.length));
|
||||
|
|
@ -35,7 +34,6 @@ export const ServerListStep: React.FC<ServerListStepProps> = ({
|
|||
return Math.min(Math.max(maxLength + 2, 20), 35);
|
||||
}, [servers]);
|
||||
|
||||
// 计算扁平化的服务器列表用于导航
|
||||
const flatServers = useMemo(() => {
|
||||
const result: MCPServerDisplayInfo[] = [];
|
||||
for (const group of groupedServers) {
|
||||
|
|
@ -44,7 +42,6 @@ export const ServerListStep: React.FC<ServerListStepProps> = ({
|
|||
return result;
|
||||
}, [groupedServers]);
|
||||
|
||||
// 键盘导航
|
||||
useKeypress(
|
||||
(key) => {
|
||||
if (key.name === 'up') {
|
||||
|
|
@ -71,7 +68,6 @@ export const ServerListStep: React.FC<ServerListStepProps> = ({
|
|||
);
|
||||
}
|
||||
|
||||
// 计算当前选中项在分组中的位置
|
||||
const getSelectionPosition = (globalIndex: number) => {
|
||||
let currentIndex = 0;
|
||||
for (const group of groupedServers) {
|
||||
|
|
@ -90,18 +86,15 @@ export const ServerListStep: React.FC<ServerListStepProps> = ({
|
|||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
{/* 服务器统计 */}
|
||||
<Box marginBottom={1}>
|
||||
<Text color={theme.text.secondary}>
|
||||
{servers.length} {servers.length === 1 ? t('server') : t('servers')}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{/* 分组服务器列表 */}
|
||||
{groupedServers.map((group, groupIndex) => (
|
||||
<Box key={group.source} flexDirection="column" marginBottom={1}>
|
||||
<Box
|
||||
key={group.source}
|
||||
flexDirection="column"
|
||||
marginBottom={groupIndex === groupedServers.length - 1 ? 0 : 1}
|
||||
>
|
||||
<Text bold color={theme.text.primary}>
|
||||
{group.displayName}
|
||||
{` ${group.displayName}`}
|
||||
{group.servers[0]?.configPath && (
|
||||
<Text color={theme.text.secondary}>
|
||||
{' '}
|
||||
|
|
@ -109,12 +102,14 @@ export const ServerListStep: React.FC<ServerListStepProps> = ({
|
|||
</Text>
|
||||
)}
|
||||
</Text>
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<Box flexDirection="column">
|
||||
{group.servers.map((server, itemIndex) => {
|
||||
const isSelected =
|
||||
groupIndex === currentPosition.groupIndex &&
|
||||
itemIndex === currentPosition.itemIndex;
|
||||
const statusColor = getStatusColor(server.status);
|
||||
const statusColor = server.isDisabled
|
||||
? 'yellow'
|
||||
: getStatusColor(server.status);
|
||||
|
||||
return (
|
||||
<Box key={server.name}>
|
||||
|
|
@ -149,13 +144,9 @@ export const ServerListStep: React.FC<ServerListStepProps> = ({
|
|||
: theme.status.error
|
||||
}
|
||||
>
|
||||
{getStatusIcon(server.status)} {t(server.status)}
|
||||
{getStatusIcon(server.status)}{' '}
|
||||
{server.isDisabled ? t('disabled') : t(server.status)}
|
||||
</Text>
|
||||
{/* 显示 Scope 和禁用状态 */}
|
||||
<Text color={theme.text.secondary}> [{server.scope}]</Text>
|
||||
{server.isDisabled && (
|
||||
<Text color={theme.status.warning}> {t('(disabled)')}</Text>
|
||||
)}
|
||||
{/* 显示无效工具警告 */}
|
||||
{!!server.invalidToolCount && server.invalidToolCount > 0 && (
|
||||
<Text color={theme.status.warning}>
|
||||
|
|
@ -173,8 +164,8 @@ export const ServerListStep: React.FC<ServerListStepProps> = ({
|
|||
))}
|
||||
|
||||
{/* 提示信息 */}
|
||||
{servers.some((s) => s.status === 'disconnected') && (
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -10,14 +10,6 @@ import { useKeypress } from '../../../hooks/useKeypress.js';
|
|||
import { t } from '../../../../i18n/index.js';
|
||||
import type { ToolDetailStepProps } from '../types.js';
|
||||
|
||||
/**
|
||||
* 截断过长的字符串
|
||||
*/
|
||||
const truncate = (str: string, maxLen: number = 50): string => {
|
||||
if (str.length <= maxLen) return str;
|
||||
return str.substring(0, maxLen - 3) + '...';
|
||||
};
|
||||
|
||||
/**
|
||||
* 渲染单个参数
|
||||
*/
|
||||
|
|
@ -28,45 +20,15 @@ const renderParameter = (
|
|||
): 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 defaultValue = param['default'];
|
||||
// const enumValues = param['enum'] as string[] | undefined;
|
||||
const text = `• ${name}${isRequired ? t('required') : ''}: ${type} ${description ? `- ${description}` : ''}`;
|
||||
|
||||
return (
|
||||
<Box key={name} flexDirection="column" marginTop={1}>
|
||||
<Box>
|
||||
<Text color={theme.text.primary}>• {name}</Text>
|
||||
{isRequired && (
|
||||
<Text color={theme.status.error}> ({t('required')})</Text>
|
||||
)}
|
||||
</Box>
|
||||
<Box marginLeft={2}>
|
||||
<Text color={theme.text.secondary}>{t('Type')}: </Text>
|
||||
<Text color={theme.status.success}>{type}</Text>
|
||||
</Box>
|
||||
{description && (
|
||||
<Box marginLeft={2}>
|
||||
<Text color={theme.text.secondary} wrap="wrap">
|
||||
{truncate(description, 80)}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
{enumValues && enumValues.length > 0 && (
|
||||
<Box marginLeft={2}>
|
||||
<Text color={theme.text.secondary}>
|
||||
{t('Enum')}: {enumValues.join(', ')}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
{defaultValue !== undefined && (
|
||||
<Box marginLeft={2}>
|
||||
<Text color={theme.text.secondary}>
|
||||
{t('Default')}:{' '}
|
||||
{typeof defaultValue === 'string'
|
||||
? `"${truncate(defaultValue, 30)}"`
|
||||
: String(defaultValue)}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
<Box key={name}>
|
||||
<Text color={theme.text.secondary} wrap="wrap">
|
||||
{text}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
|
@ -82,8 +44,10 @@ const ParametersList: React.FC<{
|
|||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Text color={theme.text.secondary}>{t('Parameters')}:</Text>
|
||||
<Box marginLeft={2} flexDirection="column">
|
||||
<Text color={theme.text.primary} bold>
|
||||
{t('Parameters')}:
|
||||
</Text>
|
||||
<Box flexDirection="column" marginLeft={1}>
|
||||
{Object.entries(properties).map(([name, param]) =>
|
||||
renderParameter(
|
||||
name,
|
||||
|
|
@ -156,62 +120,20 @@ export const ToolDetailStep: React.FC<ToolDetailStepProps> = ({
|
|||
|
||||
{/* 工具描述 */}
|
||||
{tool.description && (
|
||||
<Box>
|
||||
<Box flexDirection="column">
|
||||
<Text color={theme.text.primary} bold>
|
||||
{t('Description')}:
|
||||
</Text>
|
||||
<Text wrap="wrap">{tool.description}</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* 工具注解 */}
|
||||
{tool.annotations && (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<Text color={theme.text.secondary}>{t('Annotations')}:</Text>
|
||||
<Box marginLeft={2} flexDirection="column">
|
||||
{tool.annotations.title && (
|
||||
<Text color={theme.text.secondary}>
|
||||
• {t('Title')}: {tool.annotations.title}
|
||||
</Text>
|
||||
)}
|
||||
{tool.annotations.readOnlyHint !== undefined && (
|
||||
<Text color={theme.text.secondary}>
|
||||
• {t('Read Only')}:{' '}
|
||||
{tool.annotations.readOnlyHint ? t('Yes') : t('No')}
|
||||
</Text>
|
||||
)}
|
||||
{tool.annotations.destructiveHint !== undefined && (
|
||||
<Text color={theme.text.secondary}>
|
||||
• {t('Destructive')}:{' '}
|
||||
{tool.annotations.destructiveHint ? t('Yes') : t('No')}
|
||||
</Text>
|
||||
)}
|
||||
{tool.annotations.idempotentHint !== undefined && (
|
||||
<Text color={theme.text.secondary}>
|
||||
• {t('Idempotent')}:{' '}
|
||||
{tool.annotations.idempotentHint ? t('Yes') : t('No')}
|
||||
</Text>
|
||||
)}
|
||||
{tool.annotations.openWorldHint !== undefined && (
|
||||
<Text color={theme.text.secondary}>
|
||||
• {t('Open World')}:{' '}
|
||||
{tool.annotations.openWorldHint ? t('Yes') : t('No')}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Schema */}
|
||||
{tool.schema && (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<Box flexDirection="column">
|
||||
<SchemaSummary schema={tool.schema} />
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* 所属服务器 */}
|
||||
<Box marginTop={1}>
|
||||
<Text color={theme.text.secondary}>
|
||||
{t('Server')}: {tool.serverName}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -14,7 +14,6 @@ import { VISIBLE_TOOLS_COUNT } from '../constants.js';
|
|||
|
||||
export const ToolListStep: React.FC<ToolListStepProps> = ({
|
||||
tools,
|
||||
serverName,
|
||||
onSelect,
|
||||
onBack,
|
||||
}) => {
|
||||
|
|
@ -78,24 +77,15 @@ export const ToolListStep: React.FC<ToolListStepProps> = ({
|
|||
|
||||
const getToolAnnotations = (tool: MCPToolDisplayInfo): string => {
|
||||
const hints: string[] = [];
|
||||
if (tool.annotations?.destructiveHint) hints.push(t('destructive'));
|
||||
if (tool.annotations?.readOnlyHint) hints.push(t('read-only'));
|
||||
if (tool.annotations?.openWorldHint) hints.push(t('open-world'));
|
||||
if (tool.annotations?.idempotentHint) hints.push(t('idempotent'));
|
||||
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 marginBottom={1}>
|
||||
<Text bold>{t('Tools for {{name}}', { name: serverName })}</Text>
|
||||
<Text color={theme.text.secondary}>
|
||||
{' '}
|
||||
({tools.length} {tools.length === 1 ? t('tool') : t('tools')})
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{/* 工具列表 */}
|
||||
<Box flexDirection="column">
|
||||
{displayTools.map((tool, index) => {
|
||||
|
|
@ -105,14 +95,13 @@ export const ToolListStep: React.FC<ToolListStepProps> = ({
|
|||
|
||||
return (
|
||||
<Box key={tool.name}>
|
||||
{/* 选择器和序号 */}
|
||||
<Box minWidth={4}>
|
||||
{/* 选择器 */}
|
||||
<Box minWidth={2}>
|
||||
<Text
|
||||
color={isSelected ? theme.text.accent : theme.text.primary}
|
||||
>
|
||||
{isSelected ? '❯' : ' '}
|
||||
</Text>
|
||||
<Text color={theme.text.secondary}>{actualIndex + 1}.</Text>
|
||||
</Box>
|
||||
{/* 工具名称 - 固定宽度 */}
|
||||
<Box width={toolNameWidth}>
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ export const MCP_MANAGEMENT_STEPS = {
|
|||
DISABLE_SCOPE_SELECT: 'disable-scope-select',
|
||||
TOOL_LIST: 'tool-list',
|
||||
TOOL_DETAIL: 'tool-detail',
|
||||
AUTHENTICATE: 'authenticate', // OAuth 认证步骤
|
||||
} as const;
|
||||
|
||||
export type MCPManagementStep =
|
||||
|
|
@ -33,8 +34,6 @@ export interface MCPServerDisplayInfo {
|
|||
status: MCPServerStatus;
|
||||
/** 来源类型 */
|
||||
source: 'user' | 'project' | 'extension';
|
||||
/** 配置所在的 scope */
|
||||
scope: 'user' | 'workspace' | 'extension';
|
||||
/** 配置文件路径 */
|
||||
configPath?: string;
|
||||
/** 服务器配置 */
|
||||
|
|
@ -120,7 +119,7 @@ export interface ServerListStepProps {
|
|||
}
|
||||
|
||||
/**
|
||||
* ServerDetailStep组件属性
|
||||
* ServerDetailStep 组件属性
|
||||
*/
|
||||
export interface ServerDetailStepProps {
|
||||
/** 选中的服务器 */
|
||||
|
|
@ -131,6 +130,8 @@ export interface ServerDetailStepProps {
|
|||
onReconnect?: () => void;
|
||||
/** 禁用服务器回调 */
|
||||
onDisable?: () => void;
|
||||
/** OAuth 认证回调 */
|
||||
onAuthenticate?: () => void;
|
||||
/** 返回回调 */
|
||||
onBack: () => void;
|
||||
}
|
||||
|
|
@ -162,7 +163,7 @@ export interface ToolListStepProps {
|
|||
}
|
||||
|
||||
/**
|
||||
* ToolDetailStep组件属性
|
||||
* ToolDetailStep 组件属性
|
||||
*/
|
||||
export interface ToolDetailStepProps {
|
||||
/** 工具信息 */
|
||||
|
|
@ -171,6 +172,18 @@ export interface ToolDetailStepProps {
|
|||
onBack: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* AuthenticateStep 组件属性
|
||||
*/
|
||||
export interface AuthenticateStepProps {
|
||||
/** 服务器信息 */
|
||||
server: MCPServerDisplayInfo | null;
|
||||
/** 认证成功回调 */
|
||||
onSuccess?: () => void;
|
||||
/** 返回回调 */
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* MCP管理对话框属性
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -25,7 +25,6 @@ describe('MCP utils', () => {
|
|||
name: 'server1',
|
||||
status: MCPServerStatus.CONNECTED,
|
||||
source: 'user',
|
||||
scope: 'user',
|
||||
config: { command: 'cmd1' },
|
||||
toolCount: 1,
|
||||
promptCount: 0,
|
||||
|
|
@ -35,7 +34,6 @@ describe('MCP utils', () => {
|
|||
name: 'server2',
|
||||
status: MCPServerStatus.CONNECTED,
|
||||
source: 'extension',
|
||||
scope: 'extension',
|
||||
config: { command: 'cmd2' },
|
||||
toolCount: 2,
|
||||
promptCount: 0,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,456 @@
|
|||
/**
|
||||
* @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 down with arrow key and selects', async () => {
|
||||
const onConfirm = vi.fn();
|
||||
const details = createConfirmationDetails();
|
||||
|
||||
const { stdin, unmount } = renderWithProviders(
|
||||
<AskUserQuestionDialog
|
||||
confirmationDetails={details}
|
||||
onConfirm={onConfirm}
|
||||
/>,
|
||||
);
|
||||
await wait();
|
||||
|
||||
// Navigate down to "Blue"
|
||||
stdin.write('\u001B[B'); // Down arrow
|
||||
await wait();
|
||||
|
||||
// Press Enter
|
||||
stdin.write('\r');
|
||||
await wait();
|
||||
|
||||
expect(onConfirm).toHaveBeenCalledWith(
|
||||
ToolConfirmationOutcome.ProceedOnce,
|
||||
{ answers: { 0: 'Blue' } },
|
||||
);
|
||||
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();
|
||||
});
|
||||
|
||||
it('submits multi-select with Space to toggle then Enter to confirm', async () => {
|
||||
const onConfirm = vi.fn();
|
||||
const details = createConfirmationDetails({
|
||||
questions: [createSingleQuestion({ multiSelect: true })],
|
||||
});
|
||||
|
||||
const { stdin, unmount } = renderWithProviders(
|
||||
<AskUserQuestionDialog
|
||||
confirmationDetails={details}
|
||||
onConfirm={onConfirm}
|
||||
/>,
|
||||
);
|
||||
await wait();
|
||||
|
||||
// Space to toggle first option
|
||||
stdin.write(' ');
|
||||
await wait();
|
||||
|
||||
// Enter to confirm and submit
|
||||
stdin.write('\r');
|
||||
await wait();
|
||||
|
||||
expect(onConfirm).toHaveBeenCalledWith(
|
||||
ToolConfirmationOutcome.ProceedOnce,
|
||||
{ answers: { 0: 'Red' } },
|
||||
);
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe('multiple questions', () => {
|
||||
it('navigates between tabs with left/right arrows', async () => {
|
||||
const onConfirm = vi.fn();
|
||||
const details = createConfirmationDetails({
|
||||
questions: [
|
||||
createSingleQuestion({ header: 'Q1' }),
|
||||
createSingleQuestion({
|
||||
header: 'Q2',
|
||||
question: 'Second question?',
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const { stdin, lastFrame, unmount } = renderWithProviders(
|
||||
<AskUserQuestionDialog
|
||||
confirmationDetails={details}
|
||||
onConfirm={onConfirm}
|
||||
/>,
|
||||
);
|
||||
await wait();
|
||||
|
||||
// Navigate right to Q2
|
||||
stdin.write('\u001B[C'); // Right arrow
|
||||
await wait();
|
||||
|
||||
expect(lastFrame()).toContain('Second question?');
|
||||
|
||||
// Navigate left back to Q1
|
||||
stdin.write('\u001B[D'); // Left arrow
|
||||
await wait();
|
||||
|
||||
expect(lastFrame()).toContain('What is your favorite color?');
|
||||
unmount();
|
||||
});
|
||||
|
||||
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('cancels from Submit tab', async () => {
|
||||
const onConfirm = vi.fn();
|
||||
const details = createConfirmationDetails({
|
||||
questions: [
|
||||
createSingleQuestion({ header: 'Q1' }),
|
||||
createSingleQuestion({ header: 'Q2' }),
|
||||
],
|
||||
});
|
||||
|
||||
const { stdin, unmount } = renderWithProviders(
|
||||
<AskUserQuestionDialog
|
||||
confirmationDetails={details}
|
||||
onConfirm={onConfirm}
|
||||
/>,
|
||||
);
|
||||
await wait();
|
||||
|
||||
// Navigate to submit tab
|
||||
stdin.write('\u001B[C'); // Right
|
||||
await wait();
|
||||
stdin.write('\u001B[C'); // Right
|
||||
await wait();
|
||||
|
||||
// Navigate down to Cancel option
|
||||
stdin.write('\u001B[B'); // Down
|
||||
await wait();
|
||||
|
||||
// Press Enter
|
||||
stdin.write('\r');
|
||||
await wait();
|
||||
|
||||
expect(onConfirm).toHaveBeenCalledWith(ToolConfirmationOutcome.Cancel);
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -2526,6 +2526,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', () => {
|
||||
|
|
|
|||
|
|
@ -1107,8 +1107,13 @@ export const useGeminiStream = (
|
|||
if (!options?.isContinuation) {
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
|
@ -1203,7 +1208,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();
|
||||
}
|
||||
|
|
@ -1250,6 +1256,7 @@ export const useGeminiStream = (
|
|||
handleLoopDetectedEvent,
|
||||
clearRetryCountdown,
|
||||
pendingRetryCountdownItemRef,
|
||||
pendingRetryErrorItemRef,
|
||||
setPendingRetryErrorItem,
|
||||
],
|
||||
);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue