Files
qwen-code/packages/cli/src/ui/opentui/dialogs-mcp.tsx
T
ChiGao 49dfc87906 fix(cli): close the remaining OpenTUI parity gaps against ink (#12309)
The OpenTUI renderer is meant to stand in for ink, so a divergence is a
defect in the port rather than a difference of taste. This sweep drives
both renderers through the same scripted sessions and closes what the
frame-by-frame comparison surfaced.

The ink theme was never applied at all, so ui.theme, /theme and the
light/dark probe were inert here; the session list behind /resume and
/delete was one hand-rolled line per row with no checkbox column and no
preview, so a bulk delete could only ever remove the row under the
cursor; several dialogs still read the cursor and the text buffer of the
previous render, which loses keys whenever one stdin read carries more
than one; tool cards dropped their inline arguments, folded the status
glyph into the title and ignored the settings that collapse them; and a
few settings had no read site in this renderer at all.

ink's own behaviour is untouched — the ink-side edits only export what
the port now shares instead of duplicating.
2026-09-22 07:18:27 +00:00

906 lines
28 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* eslint-disable react/no-unknown-property */
/** @jsxImportSource @opentui/react */
/**
* @license
* Copyright 2026 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
/**
* OpenTUI parity of the ink `/mcp` dialog
* (ui/components/mcp/MCPManagementDialog.tsx): the server-list → detail →
* tool/resource step navigation stack, per-step headers and footers, the
* source-grouped server list with status icons and approval/auth states,
* the tool and resource lists with their scroll hints. Server actions and
* mutations are reported to the backend via callbacks.
*/
import { useState } from 'react';
import { useKeyboard } from '@opentui/react';
import { C } from './theme.js';
import { t } from '../../i18n/index.js';
import { MCPServerStatus } from '@qwen-code/qwen-code-core/tools/mcp-status.js';
import { ICON } from '../constants.js';
import { toOriginalKey } from './key-map.js';
import { useBatchSafeCursor } from './batch-cursor.js';
import { keyMatchers, Command } from '../keyMatchers.js';
import { DialogFrame, FooterHint } from './dialogs-shared.js';
import { findNextEnabledIndex } from './dialogs-core.js';
export const MCP_MANAGEMENT_STEPS = {
SERVER_LIST: 'server-list',
SERVER_DETAIL: 'server-detail',
TOOL_LIST: 'tool-list',
TOOL_DETAIL: 'tool-detail',
RESOURCE_LIST: 'resource-list',
RESOURCE_DETAIL: 'resource-detail',
AUTHENTICATE: 'authenticate',
} as const;
export type McpManagementStep =
(typeof MCP_MANAGEMENT_STEPS)[keyof typeof MCP_MANAGEMENT_STEPS];
export type McpServerSource =
| 'user'
| 'project'
| 'workspace'
| 'system'
| 'extension';
export const MCP_SOURCE_ORDER: readonly McpServerSource[] = [
'user',
'project',
'workspace',
'system',
'extension',
];
export interface McpServerInfo {
name: string;
status: MCPServerStatus;
source: McpServerSource;
configPath?: string;
toolCount: number;
invalidToolCount: number;
promptCount: number;
resourceCount: number;
isDisabled: boolean;
hasOAuthTokens: boolean;
requiresAuth: boolean;
approvalState?: 'pending' | 'rejected';
command?: string;
workingDirectory?: string;
error?: string;
}
export interface McpToolInfo {
name: string;
description?: string;
isValid: boolean;
invalidReason?: string;
annotations?: {
destructiveHint?: boolean;
idempotentHint?: boolean;
readOnlyHint?: boolean;
openWorldHint?: boolean;
};
}
export interface McpResourceInfo {
uri: string;
name?: string;
title?: string;
}
/** Parity of getStatusIcon in mcp/utils.ts. */
export function mcpStatusIcon(status: string): string {
switch (status) {
case 'connected':
return '✓';
case 'connecting':
return '…';
case 'disconnected':
return '✗';
default:
return '?';
}
}
/** Parity of getStatusColor in mcp/utils.ts. */
export function mcpStatusColor(
status: string,
): 'green' | 'yellow' | 'red' | 'gray' {
switch (status) {
case 'connected':
return 'green';
case 'connecting':
return 'yellow';
case 'disconnected':
return 'red';
default:
return 'gray';
}
}
/** Parity of getSourceDisplayName in mcp/utils.ts. */
export function mcpSourceDisplayName(source: string): string {
switch (source) {
case 'user':
return t('User MCPs');
case 'project':
return t('Project MCPs');
case 'workspace':
return t('Workspace Settings');
case 'system':
return t('System Settings');
case 'extension':
return t('Extension MCPs');
default:
return source;
}
}
export interface McpServerGroup {
source: McpServerSource;
displayName: string;
servers: McpServerInfo[];
}
/** Parity of groupServersBySource: SOURCE_ORDER grouping. */
export function groupMcpServersBySource(
servers: readonly McpServerInfo[],
): McpServerGroup[] {
const groups = new Map<McpServerSource, McpServerInfo[]>();
for (const server of servers) {
const existing = groups.get(server.source);
if (existing) existing.push(server);
else groups.set(server.source, [server]);
}
const result: McpServerGroup[] = [];
for (const source of MCP_SOURCE_ORDER) {
const groupServers = groups.get(source);
if (groupServers && groupServers.length > 0) {
result.push({
source,
displayName: mcpSourceDisplayName(source),
servers: groupServers,
});
}
}
return result;
}
/** Parity of the server-row status text (approval/auth overrides first). */
export function mcpServerStatusText(server: McpServerInfo): string {
const awaitingApproval = !server.isDisabled && !!server.approvalState;
const needsAuth =
!server.isDisabled &&
!awaitingApproval &&
!!server.requiresAuth &&
server.status !== MCPServerStatus.CONNECTED;
if (server.isDisabled) return t('disabled');
if (awaitingApproval) {
return server.approvalState === 'rejected'
? t('rejected — edit config to re-approve')
: t('needs approval');
}
if (needsAuth) return t('needs authentication');
return t(server.status);
}
/** Parity of the server-row status color rules. */
export function mcpServerRowColor(
server: McpServerInfo,
): 'green' | 'yellow' | 'red' | 'gray' {
const awaitingApproval = !server.isDisabled && !!server.approvalState;
const needsAuth =
!server.isDisabled &&
!awaitingApproval &&
!!server.requiresAuth &&
server.status !== MCPServerStatus.CONNECTED;
if (server.isDisabled || awaitingApproval || needsAuth) return 'yellow';
return mcpStatusColor(server.status);
}
export type McpServerAction =
| 'view-tools'
| 'view-resources'
| 'reconnect'
| 'approve'
| 'toggle-disable'
| 'authenticate'
| 'clear-auth';
/** Parity of ServerDetailStep's conditional action list. */
export function buildMcpServerActions(
server: McpServerInfo,
options: { resourcesSupported?: boolean; approveSupported?: boolean } = {},
): Array<{ key: string; label: string; action: McpServerAction }> {
const result: Array<{ key: string; label: string; action: McpServerAction }> =
[];
const awaitingApproval = !server.isDisabled && !!server.approvalState;
if (!server.isDisabled && server.toolCount > 0) {
result.push({
key: 'view-tools',
label: t('View tools'),
action: 'view-tools',
});
}
if (
options.resourcesSupported &&
!server.isDisabled &&
server.resourceCount > 0
) {
result.push({
key: 'view-resources',
label: t('View resources'),
action: 'view-resources',
});
}
if (
!server.isDisabled &&
!awaitingApproval &&
server.status === 'disconnected'
) {
result.push({
key: 'reconnect',
label: t('Reconnect'),
action: 'reconnect',
});
}
if (awaitingApproval && options.approveSupported) {
result.push({ key: 'approve', label: t('Approve'), action: 'approve' });
}
result.push({
key: 'toggle-disable',
label: server.isDisabled ? t('Enable') : t('Disable'),
action: 'toggle-disable',
});
if (!server.isDisabled && !awaitingApproval) {
result.push({
key: 'authenticate',
label: server.hasOAuthTokens ? t('Re-authenticate') : t('Authenticate'),
action: 'authenticate',
});
}
if (!server.isDisabled && server.hasOAuthTokens) {
result.push({
key: 'clear-auth',
label: t('Clear Authentication'),
action: 'clear-auth',
});
}
return result;
}
/** Parity of the per-step footer hints in MCPManagementDialog. */
export function mcpStepFooter(
step: McpManagementStep,
serverCount: number,
): string {
switch (step) {
case MCP_MANAGEMENT_STEPS.SERVER_LIST:
return serverCount === 0
? t('Esc to close')
: t('↑↓ to navigate · Enter to select · Esc to close');
case MCP_MANAGEMENT_STEPS.SERVER_DETAIL:
case MCP_MANAGEMENT_STEPS.TOOL_LIST:
case MCP_MANAGEMENT_STEPS.RESOURCE_LIST:
return t('↑↓ to navigate · Enter to select · Esc to back');
case MCP_MANAGEMENT_STEPS.TOOL_DETAIL:
case MCP_MANAGEMENT_STEPS.RESOURCE_DETAIL:
return t('Esc to back');
case MCP_MANAGEMENT_STEPS.AUTHENTICATE:
return t('Esc to go back');
default:
return t('Esc to close');
}
}
/**
* Clamp-style navigation — ink's server/tool/resource steps clamp here; the
* server-detail action list is a radio list and wraps instead.
*/
export function clampNavIndex(
current: number,
count: number,
direction: 'up' | 'down',
): number {
return direction === 'down'
? Math.min(count - 1, current + 1)
: Math.max(0, current - 1);
}
export interface OpenTuiMcpDialogProps {
servers: readonly McpServerInfo[];
/** Backend feeds the selected server's tools/resources on demand. */
getServerTools?: (server: McpServerInfo) => readonly McpToolInfo[];
getServerResources?: (server: McpServerInfo) => readonly McpResourceInfo[];
onClose: () => void;
onServerAction?: (server: McpServerInfo, action: McpServerAction) => void;
}
export function OpenTuiMcpDialog(props: OpenTuiMcpDialogProps) {
const {
servers,
getServerTools,
getServerResources,
onClose,
onServerAction,
} = props;
const [navigationStack, setNavigationStack] = useState<string[]>([
MCP_MANAGEMENT_STEPS.SERVER_LIST,
]);
// ink derives the selected server from the live list (useMemo on
// [servers, selectedServerIndex]) so a reload after an action refreshes
// the detail view; keying by name survives the host rebuilding the array.
const [selectedServerName, setSelectedServerName] = useState<string | null>(
null,
);
const selectedServer = selectedServerName
? (servers.find((server) => server.name === selectedServerName) ?? null)
: null;
const [selectedTool, setSelectedTool] = useState<McpToolInfo | null>(null);
const [selectedResource, setSelectedResource] =
useState<McpResourceInfo | null>(null);
const {
cursor: serverCursor,
cursorRef: serverCursorRef,
setCursor: setServerCursor,
} = useBatchSafeCursor();
const {
cursor: actionCursor,
cursorRef: actionCursorRef,
setCursor: setActionCursor,
} = useBatchSafeCursor();
const {
cursor: toolCursor,
cursorRef: toolCursorRef,
setCursor: setToolCursor,
} = useBatchSafeCursor();
const {
cursor: resourceCursor,
cursorRef: resourceCursorRef,
setCursor: setResourceCursor,
} = useBatchSafeCursor();
const currentStep = (navigationStack[navigationStack.length - 1] ??
MCP_MANAGEMENT_STEPS.SERVER_LIST) as McpManagementStep;
const navigateToStep = (step: string) =>
setNavigationStack((prev) => [...prev, step]);
const navigateBack = () =>
setNavigationStack((prev) => (prev.length <= 1 ? prev : prev.slice(0, -1)));
const groupedServers = groupMcpServersBySource(servers);
// Derive the flat navigation list from the grouped render order, not the
// raw prop order: groupMcpServersBySource reorders by source (user first),
// so indexing the raw prop would open a different server than highlighted.
const flatServers = groupedServers.flatMap((group) => group.servers);
const serverTools = selectedServer
? (getServerTools?.(selectedServer) ?? [])
: [];
const serverResources = selectedServer
? (getServerResources?.(selectedServer) ?? [])
: [];
const detailActions = selectedServer
? buildMcpServerActions(selectedServer, {
resourcesSupported: !!getServerResources,
approveSupported: !!onServerAction,
})
: [];
useKeyboard((key) => {
const original = toOriginalKey(key);
const { name } = original;
if (currentStep === MCP_MANAGEMENT_STEPS.SERVER_LIST) {
if (name === 'escape') {
onClose();
return;
}
if (keyMatchers[Command.SELECTION_UP](original)) {
setServerCursor(
clampNavIndex(serverCursorRef.current, flatServers.length, 'up'),
);
} else if (keyMatchers[Command.SELECTION_DOWN](original)) {
setServerCursor(
clampNavIndex(serverCursorRef.current, flatServers.length, 'down'),
);
} else if (name === 'return') {
const server = flatServers[serverCursorRef.current];
if (server) {
setSelectedServerName(server.name);
setActionCursor(0);
navigateToStep(MCP_MANAGEMENT_STEPS.SERVER_DETAIL);
}
}
return;
}
if (name === 'escape') {
navigateBack();
return;
}
if (currentStep === MCP_MANAGEMENT_STEPS.SERVER_DETAIL) {
if (keyMatchers[Command.SELECTION_UP](original)) {
setActionCursor(
findNextEnabledIndex(detailActions, actionCursorRef.current, 'up'),
);
} else if (keyMatchers[Command.SELECTION_DOWN](original)) {
setActionCursor(
findNextEnabledIndex(detailActions, actionCursorRef.current, 'down'),
);
} else if (name === 'return') {
const action = detailActions[actionCursorRef.current];
if (!action || !selectedServer) return;
switch (action.action) {
case 'view-tools':
setToolCursor(0);
navigateToStep(MCP_MANAGEMENT_STEPS.TOOL_LIST);
break;
case 'view-resources':
setResourceCursor(0);
navigateToStep(MCP_MANAGEMENT_STEPS.RESOURCE_LIST);
break;
default:
onServerAction?.(selectedServer, action.action);
}
}
return;
}
if (currentStep === MCP_MANAGEMENT_STEPS.TOOL_LIST) {
if (keyMatchers[Command.SELECTION_UP](original)) {
setToolCursor(
clampNavIndex(toolCursorRef.current, serverTools.length, 'up'),
);
} else if (keyMatchers[Command.SELECTION_DOWN](original)) {
setToolCursor(
clampNavIndex(toolCursorRef.current, serverTools.length, 'down'),
);
} else if (name === 'return') {
const tool = serverTools[toolCursorRef.current];
if (tool) {
setSelectedTool(tool);
navigateToStep(MCP_MANAGEMENT_STEPS.TOOL_DETAIL);
}
}
return;
}
if (currentStep === MCP_MANAGEMENT_STEPS.RESOURCE_LIST) {
if (keyMatchers[Command.SELECTION_UP](original)) {
setResourceCursor(
clampNavIndex(
resourceCursorRef.current,
serverResources.length,
'up',
),
);
} else if (keyMatchers[Command.SELECTION_DOWN](original)) {
setResourceCursor(
clampNavIndex(
resourceCursorRef.current,
serverResources.length,
'down',
),
);
} else if (name === 'return') {
const resource = serverResources[resourceCursorRef.current];
if (resource) {
setSelectedResource(resource);
navigateToStep(MCP_MANAGEMENT_STEPS.RESOURCE_DETAIL);
}
}
}
});
const statusTextColor = (color: 'green' | 'yellow' | 'red' | 'gray') =>
color === 'green'
? C.green
: color === 'yellow'
? C.yellow
: color === 'red'
? C.red
: C.dim;
// --- Header ---
const header = (() => {
switch (currentStep) {
case MCP_MANAGEMENT_STEPS.SERVER_DETAIL:
return (
<text fg={C.accent} attributes={1}>
{selectedServer?.name || t('Server Detail')}
</text>
);
case MCP_MANAGEMENT_STEPS.TOOL_LIST:
return (
<box flexDirection="column">
<text fg={C.accent} attributes={1}>
{t('Tools for {{serverName}}', {
serverName: selectedServer?.name || 'Server',
})}
</text>
<text fg={C.dim}>
({serverTools.length}{' '}
{serverTools.length === 1 ? t('tool') : t('tools')})
</text>
</box>
);
case MCP_MANAGEMENT_STEPS.TOOL_DETAIL:
return (
<box flexDirection="column">
<box flexDirection="row">
<text fg={C.accent} attributes={1}>
{selectedTool?.name || t('Tool Detail')}
</text>
{selectedTool?.annotations?.destructiveHint && (
<text fg={C.red}> [{t('destructive')}]</text>
)}
{selectedTool?.annotations?.idempotentHint && (
<text fg={C.yellow}> [{t('idempotent')}]</text>
)}
{selectedTool?.annotations?.readOnlyHint && (
<text fg={C.green}> [{t('read-only')}]</text>
)}
{selectedTool?.annotations?.openWorldHint && (
<text fg={C.text}> [{t('open-world')}]</text>
)}
</box>
<text fg={C.dim}>{t('Server')}</text>
</box>
);
case MCP_MANAGEMENT_STEPS.RESOURCE_LIST:
return (
<box flexDirection="column">
<text fg={C.accent} attributes={1}>
{t('Resources for {{serverName}}', {
serverName: selectedServer?.name || 'Server',
})}
</text>
<text fg={C.dim}>
({serverResources.length}{' '}
{serverResources.length === 1 ? t('resource') : t('resources')})
</text>
</box>
);
case MCP_MANAGEMENT_STEPS.RESOURCE_DETAIL:
return (
<box flexDirection="column">
<text fg={C.accent} attributes={1}>
{selectedResource?.uri || t('Resource Detail')}
</text>
<text fg={C.dim}>{t('Server')}</text>
</box>
);
case MCP_MANAGEMENT_STEPS.AUTHENTICATE:
return (
<text fg={C.accent} attributes={1}>
{t('OAuth Authentication')}
</text>
);
default:
return (
<box flexDirection="column">
<text fg={C.accent} attributes={1}>
{t('Manage MCP servers')}
</text>
<text fg={C.dim}>
{servers.length}{' '}
{servers.length === 1 ? t('server') : t('servers')}
</text>
</box>
);
}
})();
// --- Content ---
const renderServerList = () => {
if (servers.length === 0) {
return (
<box flexDirection="column">
<text fg={C.dim}>{t('No MCP servers configured.')}</text>
<text fg={C.dim}>
{t('Add MCP servers to your settings to get started.')}
</text>
</box>
);
}
let flatIndex = 0;
return (
<box flexDirection="column">
{groupedServers.map((group, groupIndex) => {
const startIndex = flatIndex;
flatIndex += group.servers.length;
return (
<box
key={group.source}
flexDirection="column"
marginBottom={groupIndex === groupedServers.length - 1 ? 0 : 1}
>
<box flexDirection="row">
<text fg={C.text} attributes={1}>
{` ${group.displayName}`}
</text>
{group.servers[0]?.configPath ? (
<text fg={C.dim}>{` (${group.servers[0].configPath})`}</text>
) : null}
</box>
{group.servers.map((server, itemIndex) => {
const globalIndex = startIndex + itemIndex;
const isSelected = globalIndex === serverCursor;
const color = mcpServerRowColor(server);
return (
<box
key={server.name}
flexDirection="row"
onMouseOver={() => setServerCursor(globalIndex)}
onMouseUp={() => {
setServerCursor(globalIndex);
setSelectedServerName(server.name);
setActionCursor(0);
navigateToStep(MCP_MANAGEMENT_STEPS.SERVER_DETAIL);
}}
>
<box width={2} flexShrink={0}>
<text fg={isSelected ? C.accent : C.text}>
{isSelected ? '❯' : ' '}
</text>
</box>
<box width={30} flexShrink={0}>
<text fg={isSelected ? C.accent : C.text}>
{server.name}
</text>
</box>
<text fg={C.dim}> · </text>
<text fg={statusTextColor(color)}>
{mcpStatusIcon(server.status)}{' '}
{mcpServerStatusText(server)}
</text>
{server.invalidToolCount > 0 && (
<text fg={C.yellow}>
{' '}
{t('{{count}} invalid tools', {
count: String(server.invalidToolCount),
})}
</text>
)}
</box>
);
})}
</box>
);
})}
{servers.some(
(s) =>
s.status === 'disconnected' && !s.isDisabled && !s.approvalState,
) && (
<box marginTop={1}>
<text fg={C.yellow}>
{ICON.REFERENCE} {t('Run qwen --debug to see error logs')}
</text>
</box>
)}
</box>
);
};
const renderServerDetail = () => {
if (!selectedServer) {
return <text fg={C.red}>{t('No server selected')}</text>;
}
const rows: Array<{ label: string; value: string }> = [
{ label: t('Status:'), value: mcpServerStatusText(selectedServer) },
{
label: t('Source:'),
value: mcpSourceDisplayName(selectedServer.source),
},
{
label: t('Tools:'),
value: `${selectedServer.toolCount} ${selectedServer.toolCount === 1 ? t('tool') : t('tools')}`,
},
{
label: t('Prompts:'),
value: String(selectedServer.promptCount),
},
{
label: t('Resources:'),
value: String(selectedServer.resourceCount),
},
];
if (selectedServer.command) {
rows.splice(2, 0, {
label: t('Command:'),
value: selectedServer.command,
});
}
return (
<box flexDirection="column">
{rows.map((row) => (
<box key={row.label} flexDirection="row">
<box width={20} flexShrink={0}>
<text fg={C.text}>{row.label}</text>
</box>
<text fg={C.text}>{row.value}</text>
</box>
))}
{selectedServer.error && (
<box flexDirection="row">
<box width={20} flexShrink={0}>
<text fg={C.red}>{t('Error:')}</text>
</box>
<text fg={C.red}>{selectedServer.error}</text>
</box>
)}
<box height={1} />
{detailActions.map((action, index) => {
const isSelected = index === actionCursor;
return (
<box
key={action.key}
flexDirection="row"
onMouseOver={() => setActionCursor(index)}
onMouseUp={() => {
setActionCursor(index);
if (!selectedServer) return;
switch (action.action) {
case 'view-tools':
setToolCursor(0);
navigateToStep(MCP_MANAGEMENT_STEPS.TOOL_LIST);
break;
case 'view-resources':
setResourceCursor(0);
navigateToStep(MCP_MANAGEMENT_STEPS.RESOURCE_LIST);
break;
default:
onServerAction?.(selectedServer, action.action);
}
}}
>
<box width={2} flexShrink={0}>
<text fg={isSelected ? C.green : C.text}>
{isSelected ? '›' : ' '}
</text>
</box>
<text fg={isSelected ? C.green : C.text}>{action.label}</text>
</box>
);
})}
</box>
);
};
const renderToolList = () => {
if (serverTools.length === 0) {
return <text fg={C.dim}>{t('No tools available for this server.')}</text>;
}
return (
<box flexDirection="column">
{serverTools.map((tool, index) => {
const isSelected = index === toolCursor;
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'));
return (
<box
key={tool.name}
flexDirection="row"
onMouseOver={() => setToolCursor(index)}
onMouseUp={() => {
setToolCursor(index);
setSelectedTool(tool);
navigateToStep(MCP_MANAGEMENT_STEPS.TOOL_DETAIL);
}}
>
<box width={2} flexShrink={0}>
<text fg={isSelected ? C.accent : C.text}>
{isSelected ? '❯' : ' '}
</text>
</box>
<box width={40} flexShrink={0}>
<text fg={isSelected ? C.accent : C.text}>{tool.name}</text>
</box>
{!tool.isValid ? (
<text fg={C.yellow}>
{t('invalid: {{reason}}', {
reason: tool.invalidReason || t('unknown'),
})}
</text>
) : hints.length > 0 ? (
<text fg={C.dim}>{hints.join(', ')}</text>
) : null}
</box>
);
})}
</box>
);
};
const renderToolDetail = () => (
<box flexDirection="column">
{selectedTool?.description ? (
<text fg={C.text}>{selectedTool.description}</text>
) : (
<text fg={C.dim}>{t('(no description)')}</text>
)}
</box>
);
const renderResourceList = () => {
if (serverResources.length === 0) {
return (
<text fg={C.dim}>{t('No resources available for this server.')}</text>
);
}
return (
<box flexDirection="column">
{serverResources.map((resource, index) => {
const isSelected = index === resourceCursor;
const friendly =
resource.title && resource.title !== resource.uri
? resource.title
: resource.name && resource.name !== resource.uri
? resource.name
: '';
return (
<box
key={resource.uri}
flexDirection="row"
onMouseOver={() => setResourceCursor(index)}
onMouseUp={() => {
setResourceCursor(index);
setSelectedResource(resource);
navigateToStep(MCP_MANAGEMENT_STEPS.RESOURCE_DETAIL);
}}
>
<box width={2} flexShrink={0}>
<text fg={isSelected ? C.accent : C.text}>
{isSelected ? '❯' : ' '}
</text>
</box>
<text fg={isSelected ? C.accent : C.text}>{resource.uri}</text>
{friendly ? <text fg={C.dim}> {friendly}</text> : null}
</box>
);
})}
</box>
);
};
const renderResourceDetail = () => (
<box flexDirection="column">
<text fg={C.text}>{selectedResource?.uri}</text>
{selectedResource?.name ? (
<text fg={C.dim}>{selectedResource.name}</text>
) : null}
</box>
);
return (
<DialogFrame>
{header}
<box marginTop={1}>
{currentStep === MCP_MANAGEMENT_STEPS.SERVER_LIST && renderServerList()}
{currentStep === MCP_MANAGEMENT_STEPS.SERVER_DETAIL &&
renderServerDetail()}
{currentStep === MCP_MANAGEMENT_STEPS.TOOL_LIST && renderToolList()}
{currentStep === MCP_MANAGEMENT_STEPS.TOOL_DETAIL && renderToolDetail()}
{currentStep === MCP_MANAGEMENT_STEPS.RESOURCE_LIST &&
renderResourceList()}
{currentStep === MCP_MANAGEMENT_STEPS.RESOURCE_DETAIL &&
renderResourceDetail()}
{currentStep === MCP_MANAGEMENT_STEPS.AUTHENTICATE && (
<text fg={C.dim}>{t('Loading...')}</text>
)}
</box>
<FooterHint text={mcpStepFooter(currentStep, servers.length)} />
</DialogFrame>
);
}