feat: port CLI, TUI, and launcher from v1.5 into v2 (#1452)
Ports the CLI, TUI, and launcher from v1.5 into v2 behind a single `mcp-inspector` entrypoint (launcher → web/cli/tui). Includes the `mcp-inspector --web --dev` launcher fix (Vite alias resolution + prod-server double-start), the root `postinstall` install cascade with simplified CI, and spec/AGENTS docs (no-workspaces rationale, Development setup). Closes #1246. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
53dc4549be
commit
2488ca5e38
23 changed files with 25594 additions and 236 deletions
1878
clients/tui/src/App.tsx
Normal file
1878
clients/tui/src/App.tsx
Normal file
File diff suppressed because it is too large
Load diff
432
clients/tui/src/components/AuthTab.tsx
Normal file
432
clients/tui/src/components/AuthTab.tsx
Normal file
|
|
@ -0,0 +1,432 @@
|
|||
import React, { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { Box, Text, useInput, type Key } from "ink";
|
||||
import { ScrollView, type ScrollViewRef } from "ink-scroll-view";
|
||||
import { SelectableItem } from "./SelectableItem.js";
|
||||
import type {
|
||||
MCPServerConfig,
|
||||
InspectorClient,
|
||||
} from "@inspector/core/mcp/index.js";
|
||||
import type {
|
||||
AuthGuidedState,
|
||||
OAuthStep,
|
||||
} from "@inspector/core/auth/index.js";
|
||||
|
||||
const STEP_LABELS: Record<OAuthStep, string> = {
|
||||
metadata_discovery: "Metadata Discovery",
|
||||
client_registration: "Client Registration",
|
||||
authorization_redirect: "Preparing Authorization",
|
||||
authorization_code: "Request Authorization Code",
|
||||
token_request: "Token Request",
|
||||
complete: "Authentication Complete",
|
||||
};
|
||||
|
||||
const STEP_ORDER: OAuthStep[] = [
|
||||
"metadata_discovery",
|
||||
"client_registration",
|
||||
"authorization_redirect",
|
||||
"authorization_code",
|
||||
"token_request",
|
||||
"complete",
|
||||
];
|
||||
|
||||
function stepIndex(step: OAuthStep): number {
|
||||
const i = STEP_ORDER.indexOf(step);
|
||||
return i >= 0 ? i : 0;
|
||||
}
|
||||
|
||||
interface AuthTabProps {
|
||||
serverName: string | null;
|
||||
serverConfig: MCPServerConfig | null;
|
||||
inspectorClient: InspectorClient | null;
|
||||
oauthStatus: "idle" | "authenticating" | "success" | "error";
|
||||
oauthMessage: string | null;
|
||||
width: number;
|
||||
height: number;
|
||||
focused?: boolean;
|
||||
selectedAction: "guided" | "quick" | "clear";
|
||||
onSelectedActionChange: (action: "guided" | "quick" | "clear") => void;
|
||||
onQuickAuth: () => Promise<void>;
|
||||
onGuidedStart: () => Promise<void>;
|
||||
onGuidedAdvance: () => Promise<void>;
|
||||
onRunGuidedToCompletion: () => Promise<void>;
|
||||
onClearOAuth: () => void;
|
||||
isOAuthCapable: boolean;
|
||||
}
|
||||
|
||||
export function AuthTab({
|
||||
serverName,
|
||||
inspectorClient,
|
||||
oauthStatus,
|
||||
oauthMessage,
|
||||
width,
|
||||
height,
|
||||
focused = false,
|
||||
selectedAction,
|
||||
onSelectedActionChange,
|
||||
onQuickAuth,
|
||||
onGuidedStart,
|
||||
onGuidedAdvance,
|
||||
onRunGuidedToCompletion,
|
||||
onClearOAuth,
|
||||
isOAuthCapable,
|
||||
}: AuthTabProps) {
|
||||
const scrollViewRef = useRef<ScrollViewRef>(null);
|
||||
const [oauthState, setOauthState] = useState<AuthGuidedState | undefined>(
|
||||
undefined,
|
||||
);
|
||||
const [guidedStarted, setGuidedStarted] = useState(false);
|
||||
const [clearedConfirmation, setClearedConfirmation] = useState(false);
|
||||
|
||||
// Sync oauthState from InspectorClient
|
||||
useEffect(() => {
|
||||
if (!inspectorClient) {
|
||||
setOauthState(undefined);
|
||||
setGuidedStarted(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const update = () => setOauthState(inspectorClient.getOAuthState());
|
||||
update();
|
||||
|
||||
const onStepChange = () => update();
|
||||
inspectorClient.addEventListener("oauthStepChange", onStepChange);
|
||||
inspectorClient.addEventListener("oauthComplete", onStepChange);
|
||||
return () => {
|
||||
inspectorClient.removeEventListener("oauthStepChange", onStepChange);
|
||||
inspectorClient.removeEventListener("oauthComplete", onStepChange);
|
||||
};
|
||||
}, [inspectorClient]);
|
||||
|
||||
// Reset guided state when switching servers
|
||||
useEffect(() => {
|
||||
setGuidedStarted(false);
|
||||
}, [serverName]);
|
||||
|
||||
// Clear confirmation when switching away from Clear menu item
|
||||
useEffect(() => {
|
||||
if (selectedAction !== "clear") {
|
||||
setClearedConfirmation(false);
|
||||
}
|
||||
}, [selectedAction]);
|
||||
|
||||
const guidedFlowStarted = !!oauthState?.oauthStep;
|
||||
const currentStep = oauthState?.oauthStep ?? "metadata_discovery";
|
||||
const needsAuthCode =
|
||||
currentStep === "authorization_code" && oauthState?.authorizationUrl;
|
||||
const isComplete = currentStep === "complete";
|
||||
|
||||
const handleContinue = useCallback(async () => {
|
||||
if (!guidedStarted) {
|
||||
await onGuidedStart();
|
||||
setGuidedStarted(true);
|
||||
} else if (!needsAuthCode && !isComplete) {
|
||||
await onGuidedAdvance();
|
||||
}
|
||||
}, [
|
||||
guidedStarted,
|
||||
needsAuthCode,
|
||||
isComplete,
|
||||
onGuidedStart,
|
||||
onGuidedAdvance,
|
||||
]);
|
||||
|
||||
// Keyboard: G/Q/S select menu item (handled by App when not focused),
|
||||
// left/right select, Enter run, up/down scroll
|
||||
useInput(
|
||||
(input: string, key: Key) => {
|
||||
if (!focused || !isOAuthCapable) return;
|
||||
|
||||
const lower = input.toLowerCase();
|
||||
if (lower === "g") {
|
||||
onSelectedActionChange("guided");
|
||||
return;
|
||||
}
|
||||
if (lower === "q") {
|
||||
onSelectedActionChange("quick");
|
||||
return;
|
||||
}
|
||||
if (lower === "s") {
|
||||
onSelectedActionChange("clear");
|
||||
return;
|
||||
}
|
||||
|
||||
if (key.leftArrow) {
|
||||
onSelectedActionChange(
|
||||
selectedAction === "guided"
|
||||
? "clear"
|
||||
: selectedAction === "quick"
|
||||
? "guided"
|
||||
: "quick",
|
||||
);
|
||||
} else if (key.rightArrow) {
|
||||
onSelectedActionChange(
|
||||
selectedAction === "guided"
|
||||
? "quick"
|
||||
: selectedAction === "quick"
|
||||
? "clear"
|
||||
: "guided",
|
||||
);
|
||||
} else if (key.upArrow && scrollViewRef.current) {
|
||||
scrollViewRef.current.scrollBy(-1);
|
||||
} else if (key.downArrow && scrollViewRef.current) {
|
||||
scrollViewRef.current.scrollBy(1);
|
||||
} else if (key.pageUp && scrollViewRef.current) {
|
||||
const h = scrollViewRef.current.getViewportHeight() || 1;
|
||||
scrollViewRef.current.scrollBy(-h);
|
||||
} else if (key.pageDown && scrollViewRef.current) {
|
||||
const h = scrollViewRef.current.getViewportHeight() || 1;
|
||||
scrollViewRef.current.scrollBy(h);
|
||||
} else if (key.return) {
|
||||
if (selectedAction === "guided") onRunGuidedToCompletion();
|
||||
else if (selectedAction === "quick") onQuickAuth();
|
||||
else if (selectedAction === "clear") {
|
||||
onClearOAuth();
|
||||
setClearedConfirmation(true);
|
||||
}
|
||||
} else if (input === " " && selectedAction === "guided") {
|
||||
handleContinue();
|
||||
}
|
||||
},
|
||||
{
|
||||
isActive: focused,
|
||||
},
|
||||
);
|
||||
|
||||
if (!serverName || !isOAuthCapable) {
|
||||
return (
|
||||
<Box width={width} height={height} paddingX={1} paddingY={1}>
|
||||
<Text dimColor>
|
||||
Select an OAuth-capable server (SSE or Streamable HTTP) to configure
|
||||
authentication.
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box width={width} height={height} flexDirection="column" paddingX={1}>
|
||||
<Box paddingY={1} flexShrink={0}>
|
||||
<Text bold backgroundColor={focused ? "yellow" : undefined}>
|
||||
Authentication
|
||||
</Text>
|
||||
</Box>
|
||||
<Box
|
||||
flexGrow={0}
|
||||
overflow="hidden"
|
||||
flexDirection="column"
|
||||
gap={0}
|
||||
paddingY={0}
|
||||
>
|
||||
{/* Action bar and hint - single container for tight spacing */}
|
||||
<Box flexShrink={0} flexDirection="column" gap={0} paddingBottom={1}>
|
||||
<Box flexDirection="row" gap={2}>
|
||||
<SelectableItem
|
||||
isSelected={selectedAction === "guided"}
|
||||
bold={selectedAction === "guided"}
|
||||
>
|
||||
<Text underline>G</Text>uided Auth
|
||||
</SelectableItem>
|
||||
<SelectableItem
|
||||
isSelected={selectedAction === "quick"}
|
||||
bold={selectedAction === "quick"}
|
||||
>
|
||||
<Text underline>Q</Text>uick Auth
|
||||
</SelectableItem>
|
||||
<SelectableItem
|
||||
isSelected={selectedAction === "clear"}
|
||||
bold={selectedAction === "clear"}
|
||||
>
|
||||
Clear OAuth <Text underline>S</Text>tate
|
||||
</SelectableItem>
|
||||
</Box>
|
||||
<Box flexDirection="column">
|
||||
{selectedAction === "guided" && (
|
||||
<>
|
||||
<Text dimColor>
|
||||
Press [Space] to advance one step through guided auth.
|
||||
</Text>
|
||||
<Text dimColor>
|
||||
Press [Enter] to run guided auth to completion.
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
{selectedAction === "quick" && (
|
||||
<Text dimColor>Press [Enter] to run quick auth.</Text>
|
||||
)}
|
||||
{selectedAction === "clear" && (
|
||||
<Text dimColor>Press [Enter] to clear OAuth state.</Text>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
<ScrollView ref={scrollViewRef} height={height - 10}>
|
||||
{selectedAction === "guided" && (
|
||||
<Box key="guided" flexShrink={0} flexDirection="column">
|
||||
<Text bold>Guided OAuth Flow Progress</Text>
|
||||
{STEP_ORDER.map((step) => {
|
||||
const stepIdx = stepIndex(step);
|
||||
const currentIdx = stepIndex(currentStep);
|
||||
const completed =
|
||||
guidedFlowStarted &&
|
||||
(stepIdx < currentIdx ||
|
||||
(step === currentStep && isComplete));
|
||||
const inProgress =
|
||||
guidedFlowStarted && step === currentStep && !isComplete;
|
||||
const details = oauthState
|
||||
? getStepDetails(oauthState, step)
|
||||
: null;
|
||||
|
||||
const icon = completed ? "✓" : inProgress ? "→" : "○";
|
||||
const color = completed
|
||||
? "green"
|
||||
: inProgress
|
||||
? "cyan"
|
||||
: "gray";
|
||||
|
||||
return (
|
||||
<Box
|
||||
key={step}
|
||||
marginTop={1}
|
||||
flexDirection="column"
|
||||
paddingLeft={2}
|
||||
>
|
||||
<Text color={color}>
|
||||
{icon} {STEP_LABELS[step]}
|
||||
{inProgress && " (in progress)"}
|
||||
</Text>
|
||||
{completed && details && (
|
||||
<Box marginTop={1} paddingLeft={2} flexDirection="column">
|
||||
<Text dimColor>{details}</Text>
|
||||
</Box>
|
||||
)}
|
||||
{inProgress && details && (
|
||||
<Box marginTop={1} paddingLeft={2} flexDirection="column">
|
||||
<Text dimColor>{details}</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Waiting for auth - URL was opened when we reached this step */}
|
||||
{oauthState && needsAuthCode && oauthState?.authorizationUrl && (
|
||||
<Box marginTop={2} flexDirection="column">
|
||||
<Text bold>Authorization URL opened in browser</Text>
|
||||
<Box marginTop={1}>
|
||||
<Text dimColor>
|
||||
{oauthState.authorizationUrl.toString()}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box marginTop={1}>
|
||||
<Text dimColor>
|
||||
Complete authorization in the browser. You will be
|
||||
redirected and the flow will complete automatically.
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{selectedAction === "quick" && (
|
||||
<Box key="quick" flexShrink={0} flexDirection="column">
|
||||
{oauthStatus === "authenticating" && (
|
||||
<Text dimColor>Authenticating...</Text>
|
||||
)}
|
||||
{oauthStatus === "error" && oauthMessage && (
|
||||
<Text color="red">{oauthMessage}</Text>
|
||||
)}
|
||||
{oauthStatus === "success" &&
|
||||
oauthState &&
|
||||
oauthState.authType === "normal" &&
|
||||
(oauthState.oauthTokens || oauthState.oauthClientInfo) && (
|
||||
<>
|
||||
<Text bold>Quick Auth Results</Text>
|
||||
{oauthState.oauthClientInfo && (
|
||||
<Box marginTop={1} flexDirection="column" paddingLeft={2}>
|
||||
<Text dimColor>
|
||||
Client:{" "}
|
||||
{JSON.stringify(oauthState.oauthClientInfo, null, 2)}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
{oauthState.oauthTokens && (
|
||||
<Box marginTop={1} flexDirection="column" paddingLeft={2}>
|
||||
<Text dimColor>
|
||||
Access Token:{" "}
|
||||
{oauthState.oauthTokens.access_token?.slice(0, 20)}...
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{selectedAction === "clear" && clearedConfirmation && (
|
||||
<Box key="clear" flexShrink={0} flexDirection="column">
|
||||
<Text color="green">OAuth state cleared.</Text>
|
||||
</Box>
|
||||
)}
|
||||
</ScrollView>
|
||||
</Box>
|
||||
|
||||
{focused && (
|
||||
<Box
|
||||
flexShrink={0}
|
||||
height={1}
|
||||
justifyContent="center"
|
||||
backgroundColor="gray"
|
||||
>
|
||||
<Text bold color="white">
|
||||
←/→ select, G/Q/S or Enter run, ↑/↓ scroll
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function getStepDetails(
|
||||
state: AuthGuidedState,
|
||||
step: OAuthStep,
|
||||
): string | null {
|
||||
switch (step) {
|
||||
case "metadata_discovery":
|
||||
if (state.resourceMetadata || state.oauthMetadata) {
|
||||
const parts: string[] = [];
|
||||
if (state.resourceMetadata) {
|
||||
parts.push(
|
||||
`Resource: ${JSON.stringify(state.resourceMetadata, null, 2)}`,
|
||||
);
|
||||
}
|
||||
if (state.oauthMetadata) {
|
||||
parts.push(`OAuth: ${JSON.stringify(state.oauthMetadata, null, 2)}`);
|
||||
}
|
||||
return parts.join("\n");
|
||||
}
|
||||
return null;
|
||||
case "client_registration":
|
||||
if (state.oauthClientInfo) {
|
||||
return JSON.stringify(state.oauthClientInfo, null, 2);
|
||||
}
|
||||
return null;
|
||||
case "authorization_redirect":
|
||||
if (state.authorizationUrl) {
|
||||
return `URL: ${state.authorizationUrl.toString()}`;
|
||||
}
|
||||
return null;
|
||||
case "authorization_code":
|
||||
return state.authorizationCode
|
||||
? `Code received: ${state.authorizationCode.slice(0, 10)}...`
|
||||
: null;
|
||||
case "token_request":
|
||||
return "Exchanging code for tokens...";
|
||||
case "complete":
|
||||
if (state.oauthTokens) {
|
||||
return `Tokens: access_token=${state.oauthTokens.access_token?.slice(0, 15)}...`;
|
||||
}
|
||||
return null;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
102
clients/tui/src/components/DetailsModal.tsx
Normal file
102
clients/tui/src/components/DetailsModal.tsx
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
import React, { useRef } from "react";
|
||||
import { Box, Text, useInput, type Key } from "ink";
|
||||
import { ScrollView, type ScrollViewRef } from "ink-scroll-view";
|
||||
|
||||
interface DetailsModalProps {
|
||||
title: string;
|
||||
content: React.ReactNode;
|
||||
width: number;
|
||||
height: number;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function DetailsModal({
|
||||
title,
|
||||
content,
|
||||
width,
|
||||
height,
|
||||
onClose,
|
||||
}: DetailsModalProps) {
|
||||
const scrollViewRef = useRef<ScrollViewRef>(null);
|
||||
|
||||
// Use full terminal dimensions
|
||||
const [terminalDimensions, setTerminalDimensions] = React.useState({
|
||||
width: process.stdout.columns || width,
|
||||
height: process.stdout.rows || height,
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
const updateDimensions = () => {
|
||||
setTerminalDimensions({
|
||||
width: process.stdout.columns || width,
|
||||
height: process.stdout.rows || height,
|
||||
});
|
||||
};
|
||||
process.stdout.on("resize", updateDimensions);
|
||||
updateDimensions();
|
||||
return () => {
|
||||
process.stdout.off("resize", updateDimensions);
|
||||
};
|
||||
}, [width, height]);
|
||||
|
||||
// Handle escape to close and scrolling
|
||||
useInput(
|
||||
(input: string, key: Key) => {
|
||||
if (key.escape) {
|
||||
onClose();
|
||||
} else if (key.downArrow) {
|
||||
scrollViewRef.current?.scrollBy(1);
|
||||
} else if (key.upArrow) {
|
||||
scrollViewRef.current?.scrollBy(-1);
|
||||
} else if (key.pageDown) {
|
||||
const viewportHeight = scrollViewRef.current?.getViewportHeight() || 1;
|
||||
scrollViewRef.current?.scrollBy(viewportHeight);
|
||||
} else if (key.pageUp) {
|
||||
const viewportHeight = scrollViewRef.current?.getViewportHeight() || 1;
|
||||
scrollViewRef.current?.scrollBy(-viewportHeight);
|
||||
}
|
||||
},
|
||||
{ isActive: true },
|
||||
);
|
||||
|
||||
// Calculate modal dimensions - use almost full screen
|
||||
const modalWidth = terminalDimensions.width - 2;
|
||||
const modalHeight = terminalDimensions.height - 2;
|
||||
|
||||
return (
|
||||
<Box
|
||||
position="absolute"
|
||||
width={terminalDimensions.width}
|
||||
height={terminalDimensions.height}
|
||||
flexDirection="column"
|
||||
justifyContent="center"
|
||||
alignItems="center"
|
||||
>
|
||||
{/* Modal Content */}
|
||||
<Box
|
||||
width={modalWidth}
|
||||
height={modalHeight}
|
||||
borderStyle="single"
|
||||
borderColor="cyan"
|
||||
flexDirection="column"
|
||||
paddingX={1}
|
||||
paddingY={1}
|
||||
backgroundColor="black"
|
||||
>
|
||||
{/* Header */}
|
||||
<Box flexShrink={0} marginBottom={1}>
|
||||
<Text bold color="cyan">
|
||||
{title}
|
||||
</Text>
|
||||
<Text> </Text>
|
||||
<Text dimColor>(Press ESC to close)</Text>
|
||||
</Box>
|
||||
|
||||
{/* Content Area */}
|
||||
<Box flexGrow={1} flexDirection="column" overflow="hidden">
|
||||
<ScrollView ref={scrollViewRef}>{content}</ScrollView>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
326
clients/tui/src/components/HistoryTab.tsx
Normal file
326
clients/tui/src/components/HistoryTab.tsx
Normal file
|
|
@ -0,0 +1,326 @@
|
|||
import React, { useEffect, useRef } from "react";
|
||||
import { Box, Text, useInput, type Key } from "ink";
|
||||
import { ScrollView, type ScrollViewRef } from "ink-scroll-view";
|
||||
import type { MessageEntry } from "@inspector/core/mcp/index.js";
|
||||
import { useSelectableList } from "../hooks/useSelectableList.js";
|
||||
|
||||
interface HistoryTabProps {
|
||||
serverName: string | null;
|
||||
messages: MessageEntry[];
|
||||
width: number;
|
||||
height: number;
|
||||
onCountChange?: (count: number) => void;
|
||||
focusedPane?: "messages" | "details" | null;
|
||||
onViewDetails?: (message: MessageEntry) => void;
|
||||
modalOpen?: boolean;
|
||||
}
|
||||
|
||||
export function HistoryTab({
|
||||
messages,
|
||||
width,
|
||||
height,
|
||||
onCountChange,
|
||||
focusedPane = null,
|
||||
onViewDetails,
|
||||
modalOpen = false,
|
||||
}: HistoryTabProps) {
|
||||
const visibleCount = Math.max(1, height - 7);
|
||||
const { selectedIndex, firstVisible, setSelection } = useSelectableList(
|
||||
messages.length,
|
||||
visibleCount,
|
||||
);
|
||||
const scrollViewRef = useRef<ScrollViewRef>(null);
|
||||
const selectedMessage = messages[selectedIndex] || null;
|
||||
|
||||
// Handle arrow key navigation and scrolling when focused
|
||||
useInput(
|
||||
(input: string, key: Key) => {
|
||||
if (focusedPane === "messages") {
|
||||
if (key.upArrow && selectedIndex > 0) {
|
||||
setSelection(selectedIndex - 1);
|
||||
} else if (key.downArrow && selectedIndex < messages.length - 1) {
|
||||
setSelection(selectedIndex + 1);
|
||||
} else if (key.pageUp) {
|
||||
setSelection(Math.max(0, selectedIndex - visibleCount));
|
||||
} else if (key.pageDown) {
|
||||
setSelection(
|
||||
Math.min(messages.length - 1, selectedIndex + visibleCount),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// details scrolling (only when details pane is focused)
|
||||
if (focusedPane === "details") {
|
||||
// Handle '+' key to view in full screen modal
|
||||
if (input === "+" && selectedMessage && onViewDetails) {
|
||||
onViewDetails(selectedMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
if (key.upArrow) {
|
||||
scrollViewRef.current?.scrollBy(-1);
|
||||
} else if (key.downArrow) {
|
||||
scrollViewRef.current?.scrollBy(1);
|
||||
} else if (key.pageUp) {
|
||||
const viewportHeight =
|
||||
scrollViewRef.current?.getViewportHeight() || 1;
|
||||
scrollViewRef.current?.scrollBy(-viewportHeight);
|
||||
} else if (key.pageDown) {
|
||||
const viewportHeight =
|
||||
scrollViewRef.current?.getViewportHeight() || 1;
|
||||
scrollViewRef.current?.scrollBy(viewportHeight);
|
||||
}
|
||||
}
|
||||
},
|
||||
{ isActive: !modalOpen && focusedPane !== undefined },
|
||||
);
|
||||
|
||||
// Update count when messages change
|
||||
React.useEffect(() => {
|
||||
onCountChange?.(messages.length);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [messages.length]);
|
||||
|
||||
// Reset details scroll when message selection changes
|
||||
useEffect(() => {
|
||||
scrollViewRef.current?.scrollTo(0);
|
||||
}, [selectedIndex]);
|
||||
|
||||
const listWidth = Math.floor(width * 0.4);
|
||||
const detailWidth = width - listWidth;
|
||||
|
||||
return (
|
||||
<Box flexDirection="row" width={width} height={height}>
|
||||
{/* Left column - Messages list */}
|
||||
<Box
|
||||
width={listWidth}
|
||||
height={height}
|
||||
borderStyle="single"
|
||||
borderTop={false}
|
||||
borderBottom={false}
|
||||
borderLeft={false}
|
||||
borderRight={true}
|
||||
flexDirection="column"
|
||||
paddingX={1}
|
||||
>
|
||||
<Box paddingY={1} flexShrink={0}>
|
||||
<Text
|
||||
bold
|
||||
backgroundColor={focusedPane === "messages" ? "yellow" : undefined}
|
||||
>
|
||||
Messages ({messages.length})
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{/* Messages list */}
|
||||
{messages.length === 0 ? (
|
||||
<Box paddingY={1}>
|
||||
<Text dimColor>No messages</Text>
|
||||
</Box>
|
||||
) : (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
height={visibleCount}
|
||||
overflow="hidden"
|
||||
flexShrink={0}
|
||||
>
|
||||
{messages
|
||||
.slice(firstVisible, firstVisible + visibleCount)
|
||||
.map((msg, i) => {
|
||||
const index = firstVisible + i;
|
||||
const isSelected = index === selectedIndex;
|
||||
let label: string;
|
||||
if (msg.direction === "request" && "method" in msg.message) {
|
||||
label = msg.message.method;
|
||||
} else if (msg.direction === "response") {
|
||||
if ("result" in msg.message) {
|
||||
label = "Response (result)";
|
||||
} else if ("error" in msg.message) {
|
||||
label = `Response (error: ${msg.message.error.code})`;
|
||||
} else {
|
||||
label = "Response";
|
||||
}
|
||||
} else if (
|
||||
msg.direction === "notification" &&
|
||||
"method" in msg.message
|
||||
) {
|
||||
label = msg.message.method;
|
||||
} else {
|
||||
label = "Unknown";
|
||||
}
|
||||
const direction =
|
||||
msg.direction === "request"
|
||||
? "→"
|
||||
: msg.direction === "response"
|
||||
? "←"
|
||||
: "•";
|
||||
const hasResponse = msg.response !== undefined;
|
||||
|
||||
return (
|
||||
<Box key={msg.id} paddingY={0} flexShrink={0}>
|
||||
<Text color={isSelected ? "white" : "white"}>
|
||||
{isSelected ? "▶ " : " "}
|
||||
{direction} {label}
|
||||
{hasResponse
|
||||
? " ✓"
|
||||
: msg.direction === "request"
|
||||
? " ..."
|
||||
: ""}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Right column - Message details */}
|
||||
<Box
|
||||
width={detailWidth}
|
||||
height={height}
|
||||
paddingX={1}
|
||||
flexDirection="column"
|
||||
flexShrink={0}
|
||||
borderStyle="single"
|
||||
borderTop={false}
|
||||
borderBottom={false}
|
||||
borderLeft={false}
|
||||
borderRight={false}
|
||||
>
|
||||
{selectedMessage ? (
|
||||
<>
|
||||
{/* Fixed method caption only */}
|
||||
<Box flexShrink={0} paddingTop={1}>
|
||||
<Text
|
||||
bold
|
||||
backgroundColor={
|
||||
focusedPane === "details" ? "yellow" : undefined
|
||||
}
|
||||
{...(focusedPane === "details" ? {} : { color: "cyan" })}
|
||||
>
|
||||
{selectedMessage.direction === "request" &&
|
||||
"method" in selectedMessage.message
|
||||
? selectedMessage.message.method
|
||||
: selectedMessage.direction === "response"
|
||||
? "Response"
|
||||
: selectedMessage.direction === "notification" &&
|
||||
"method" in selectedMessage.message
|
||||
? selectedMessage.message.method
|
||||
: "Message"}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{/* Scrollable content area */}
|
||||
<ScrollView ref={scrollViewRef} height={height - 5}>
|
||||
{/* Metadata */}
|
||||
<Box marginTop={1} flexDirection="column" flexShrink={0}>
|
||||
<Text bold>Direction: {selectedMessage.direction}</Text>
|
||||
<Box marginTop={1}>
|
||||
<Text dimColor>
|
||||
{selectedMessage.timestamp.toLocaleTimeString()}
|
||||
{selectedMessage.duration !== undefined &&
|
||||
` (${selectedMessage.duration}ms)`}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{selectedMessage.direction === "request" ? (
|
||||
<>
|
||||
{/* Request label */}
|
||||
<Box marginTop={1} flexShrink={0}>
|
||||
<Text bold>Request:</Text>
|
||||
</Box>
|
||||
|
||||
{/* Request content */}
|
||||
{JSON.stringify(selectedMessage.message, null, 2)
|
||||
.split("\n")
|
||||
.map((line: string, idx: number) => (
|
||||
<Box
|
||||
key={`req-${idx}`}
|
||||
marginTop={idx === 0 ? 1 : 0}
|
||||
paddingLeft={2}
|
||||
flexShrink={0}
|
||||
>
|
||||
<Text dimColor>{line}</Text>
|
||||
</Box>
|
||||
))}
|
||||
|
||||
{/* Response section */}
|
||||
{selectedMessage.response ? (
|
||||
<>
|
||||
<Box marginTop={1} flexShrink={0}>
|
||||
<Text bold>Response:</Text>
|
||||
</Box>
|
||||
{JSON.stringify(selectedMessage.response, null, 2)
|
||||
.split("\n")
|
||||
.map((line: string, idx: number) => (
|
||||
<Box
|
||||
key={`resp-${idx}`}
|
||||
marginTop={idx === 0 ? 1 : 0}
|
||||
paddingLeft={2}
|
||||
flexShrink={0}
|
||||
>
|
||||
<Text dimColor>{line}</Text>
|
||||
</Box>
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
<Box marginTop={1} flexShrink={0}>
|
||||
<Text dimColor italic>
|
||||
Waiting for response...
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{/* Response or notification label */}
|
||||
<Box marginTop={1} flexShrink={0}>
|
||||
<Text bold>
|
||||
{selectedMessage.direction === "response"
|
||||
? "Response:"
|
||||
: "Notification:"}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{/* Message content */}
|
||||
{JSON.stringify(selectedMessage.message, null, 2)
|
||||
.split("\n")
|
||||
.map((line: string, idx: number) => (
|
||||
<Box
|
||||
key={`msg-${idx}`}
|
||||
marginTop={idx === 0 ? 1 : 0}
|
||||
paddingLeft={2}
|
||||
flexShrink={0}
|
||||
>
|
||||
<Text dimColor>{line}</Text>
|
||||
</Box>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</ScrollView>
|
||||
|
||||
{/* Fixed footer - only show when details pane is focused */}
|
||||
{focusedPane === "details" && (
|
||||
<Box
|
||||
flexShrink={0}
|
||||
height={1}
|
||||
justifyContent="center"
|
||||
backgroundColor="gray"
|
||||
>
|
||||
<Text bold color="white">
|
||||
↑/↓ to scroll, + to zoom
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<Box paddingY={1} flexShrink={0}>
|
||||
<Text dimColor>Select a message to view details</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
226
clients/tui/src/components/InfoTab.tsx
Normal file
226
clients/tui/src/components/InfoTab.tsx
Normal file
|
|
@ -0,0 +1,226 @@
|
|||
import React, { useRef } from "react";
|
||||
import { Box, Text, useInput, type Key } from "ink";
|
||||
import { ScrollView, type ScrollViewRef } from "ink-scroll-view";
|
||||
import type {
|
||||
MCPServerConfig,
|
||||
ServerState,
|
||||
} from "@inspector/core/mcp/index.js";
|
||||
|
||||
interface InfoTabProps {
|
||||
serverName: string | null;
|
||||
serverConfig: MCPServerConfig | null;
|
||||
serverState: ServerState | null;
|
||||
width: number;
|
||||
height: number;
|
||||
focused?: boolean;
|
||||
}
|
||||
|
||||
export function InfoTab({
|
||||
serverName,
|
||||
serverConfig,
|
||||
serverState,
|
||||
width,
|
||||
height,
|
||||
focused = false,
|
||||
}: InfoTabProps) {
|
||||
const scrollViewRef = useRef<ScrollViewRef>(null);
|
||||
|
||||
// Handle keyboard input for scrolling
|
||||
useInput(
|
||||
(input: string, key: Key) => {
|
||||
if (focused) {
|
||||
if (key.upArrow) {
|
||||
scrollViewRef.current?.scrollBy(-1);
|
||||
} else if (key.downArrow) {
|
||||
scrollViewRef.current?.scrollBy(1);
|
||||
} else if (key.pageUp) {
|
||||
const viewportHeight =
|
||||
scrollViewRef.current?.getViewportHeight() || 1;
|
||||
scrollViewRef.current?.scrollBy(-viewportHeight);
|
||||
} else if (key.pageDown) {
|
||||
const viewportHeight =
|
||||
scrollViewRef.current?.getViewportHeight() || 1;
|
||||
scrollViewRef.current?.scrollBy(viewportHeight);
|
||||
}
|
||||
}
|
||||
},
|
||||
{ isActive: focused },
|
||||
);
|
||||
|
||||
return (
|
||||
<Box width={width} height={height} flexDirection="column" paddingX={1}>
|
||||
<Box paddingY={1} flexShrink={0}>
|
||||
<Text bold backgroundColor={focused ? "yellow" : undefined}>
|
||||
Info
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{serverName ? (
|
||||
<>
|
||||
{/* Scrollable content area - takes remaining space */}
|
||||
<Box height={height - 4} overflow="hidden" paddingTop={1}>
|
||||
<ScrollView ref={scrollViewRef} height={height - 4}>
|
||||
{/* Server Configuration */}
|
||||
<Box flexShrink={0} marginTop={1}>
|
||||
<Text bold>Server Configuration</Text>
|
||||
</Box>
|
||||
{serverConfig ? (
|
||||
<Box
|
||||
flexShrink={0}
|
||||
marginTop={1}
|
||||
paddingLeft={2}
|
||||
flexDirection="column"
|
||||
>
|
||||
{serverConfig.type === undefined ||
|
||||
serverConfig.type === "stdio" ? (
|
||||
<>
|
||||
<Text dimColor>Type: stdio</Text>
|
||||
<Text dimColor>Command: {serverConfig.command}</Text>
|
||||
{serverConfig.args && serverConfig.args.length > 0 && (
|
||||
<Box marginTop={1} flexDirection="column">
|
||||
<Text dimColor>Args:</Text>
|
||||
{serverConfig.args.map((arg: string, idx: number) => (
|
||||
<Box
|
||||
key={`arg-${idx}`}
|
||||
paddingLeft={2}
|
||||
marginTop={idx === 0 ? 0 : 0}
|
||||
>
|
||||
<Text dimColor>{arg}</Text>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
{serverConfig.env &&
|
||||
Object.keys(serverConfig.env).length > 0 && (
|
||||
<Box marginTop={1}>
|
||||
<Text dimColor>
|
||||
Env:{" "}
|
||||
{Object.entries(serverConfig.env)
|
||||
.map(([k, v]) => `${k}=${v}`)
|
||||
.join(", ")}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
{serverConfig.cwd && (
|
||||
<Box marginTop={1}>
|
||||
<Text dimColor>CWD: {serverConfig.cwd}</Text>
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
) : serverConfig.type === "sse" ? (
|
||||
<>
|
||||
<Text dimColor>Type: sse</Text>
|
||||
<Text dimColor>URL: {serverConfig.url}</Text>
|
||||
{serverConfig.headers &&
|
||||
Object.keys(serverConfig.headers).length > 0 && (
|
||||
<Box marginTop={1}>
|
||||
<Text dimColor>
|
||||
Headers:{" "}
|
||||
{Object.entries(serverConfig.headers)
|
||||
.map(([k, v]) => `${k}=${v}`)
|
||||
.join(", ")}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
) : serverConfig.type === "streamable-http" ? (
|
||||
<>
|
||||
<Text dimColor>Type: streamable-http</Text>
|
||||
<Text dimColor>URL: {serverConfig.url}</Text>
|
||||
{serverConfig.headers &&
|
||||
Object.keys(serverConfig.headers).length > 0 && (
|
||||
<Box marginTop={1}>
|
||||
<Text dimColor>
|
||||
Headers:{" "}
|
||||
{Object.entries(serverConfig.headers)
|
||||
.map(([k, v]) => `${k}=${v}`)
|
||||
.join(", ")}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
) : null}
|
||||
</Box>
|
||||
) : (
|
||||
<Box marginTop={1} paddingLeft={2}>
|
||||
<Text dimColor>No configuration available</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Server Info */}
|
||||
{serverState &&
|
||||
serverState.status === "connected" &&
|
||||
serverState.serverInfo && (
|
||||
<>
|
||||
<Box flexShrink={0} marginTop={2}>
|
||||
<Text bold>Server Information</Text>
|
||||
</Box>
|
||||
<Box
|
||||
flexShrink={0}
|
||||
marginTop={1}
|
||||
paddingLeft={2}
|
||||
flexDirection="column"
|
||||
>
|
||||
{serverState.serverInfo.name && (
|
||||
<Text dimColor>
|
||||
Name: {serverState.serverInfo.name}
|
||||
</Text>
|
||||
)}
|
||||
{serverState.serverInfo.version && (
|
||||
<Box marginTop={1}>
|
||||
<Text dimColor>
|
||||
Version: {serverState.serverInfo.version}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
{serverState.instructions && (
|
||||
<Box marginTop={1} flexDirection="column">
|
||||
<Text dimColor>Instructions:</Text>
|
||||
<Box paddingLeft={2} marginTop={1}>
|
||||
<Text dimColor>{serverState.instructions}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
|
||||
{serverState && serverState.status === "error" && (
|
||||
<Box flexShrink={0} marginTop={2}>
|
||||
<Text bold color="red">
|
||||
Error
|
||||
</Text>
|
||||
{serverState.error && (
|
||||
<Box marginTop={1} paddingLeft={2}>
|
||||
<Text color="red">{serverState.error}</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{serverState && serverState.status === "disconnected" && (
|
||||
<Box flexShrink={0} marginTop={2}>
|
||||
<Text dimColor>Server not connected</Text>
|
||||
</Box>
|
||||
)}
|
||||
</ScrollView>
|
||||
</Box>
|
||||
|
||||
{/* Fixed keyboard help footer at bottom - only show when focused */}
|
||||
{focused && (
|
||||
<Box
|
||||
flexShrink={0}
|
||||
height={1}
|
||||
justifyContent="center"
|
||||
backgroundColor="gray"
|
||||
>
|
||||
<Text bold color="white">
|
||||
↑/↓ to scroll, + to zoom
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
) : null}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
83
clients/tui/src/components/NotificationsTab.tsx
Normal file
83
clients/tui/src/components/NotificationsTab.tsx
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
import React, { useEffect, useRef } from "react";
|
||||
import { Box, Text, useInput, type Key } from "ink";
|
||||
import { ScrollView, type ScrollViewRef } from "ink-scroll-view";
|
||||
import type { StderrLogEntry } from "@inspector/core/mcp/index.js";
|
||||
|
||||
interface NotificationsTabProps {
|
||||
stderrLogs: StderrLogEntry[];
|
||||
width: number;
|
||||
height: number;
|
||||
onCountChange?: (count: number) => void;
|
||||
focused?: boolean;
|
||||
}
|
||||
|
||||
export function NotificationsTab({
|
||||
stderrLogs,
|
||||
width,
|
||||
height,
|
||||
onCountChange,
|
||||
focused = false,
|
||||
}: NotificationsTabProps) {
|
||||
const scrollViewRef = useRef<ScrollViewRef>(null);
|
||||
const onCountChangeRef = useRef(onCountChange);
|
||||
|
||||
// Update ref when callback changes
|
||||
useEffect(() => {
|
||||
onCountChangeRef.current = onCountChange;
|
||||
}, [onCountChange]);
|
||||
|
||||
useEffect(() => {
|
||||
onCountChangeRef.current?.(stderrLogs.length);
|
||||
}, [stderrLogs.length]);
|
||||
|
||||
// Handle keyboard input for scrolling
|
||||
useInput(
|
||||
(input: string, key: Key) => {
|
||||
if (focused) {
|
||||
if (key.upArrow) {
|
||||
scrollViewRef.current?.scrollBy(-1);
|
||||
} else if (key.downArrow) {
|
||||
scrollViewRef.current?.scrollBy(1);
|
||||
} else if (key.pageUp) {
|
||||
const viewportHeight =
|
||||
scrollViewRef.current?.getViewportHeight() || 1;
|
||||
scrollViewRef.current?.scrollBy(-viewportHeight);
|
||||
} else if (key.pageDown) {
|
||||
const viewportHeight =
|
||||
scrollViewRef.current?.getViewportHeight() || 1;
|
||||
scrollViewRef.current?.scrollBy(viewportHeight);
|
||||
}
|
||||
}
|
||||
},
|
||||
{ isActive: focused },
|
||||
);
|
||||
|
||||
return (
|
||||
<Box width={width} height={height} flexDirection="column" paddingX={1}>
|
||||
<Box paddingY={1} flexShrink={0}>
|
||||
<Text bold backgroundColor={focused ? "yellow" : undefined}>
|
||||
Logging ({stderrLogs.length})
|
||||
</Text>
|
||||
</Box>
|
||||
{stderrLogs.length === 0 ? (
|
||||
<Box paddingY={1}>
|
||||
<Text dimColor>No stderr output yet</Text>
|
||||
</Box>
|
||||
) : (
|
||||
<ScrollView ref={scrollViewRef} height={height - 3}>
|
||||
{stderrLogs.map((log, index) => (
|
||||
<Box
|
||||
key={`log-${log.timestamp.getTime()}-${index}`}
|
||||
paddingY={0}
|
||||
flexDirection="row"
|
||||
flexShrink={0}
|
||||
>
|
||||
<Text dimColor>[{log.timestamp.toLocaleTimeString()}] </Text>
|
||||
<Text color="red">{log.message}</Text>
|
||||
</Box>
|
||||
))}
|
||||
</ScrollView>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
301
clients/tui/src/components/PromptTestModal.tsx
Normal file
301
clients/tui/src/components/PromptTestModal.tsx
Normal file
|
|
@ -0,0 +1,301 @@
|
|||
import React, { useState } from "react";
|
||||
import { Box, Text, useInput, type Key } from "ink";
|
||||
import { Form } from "ink-form";
|
||||
import { InspectorClient } from "@inspector/core/mcp/index.js";
|
||||
import type {
|
||||
Prompt,
|
||||
GetPromptResult,
|
||||
} from "@modelcontextprotocol/sdk/types.js";
|
||||
import { promptArgsToForm } from "../utils/promptArgsToForm.js";
|
||||
import { ScrollView, type ScrollViewRef } from "ink-scroll-view";
|
||||
|
||||
// Helper to extract error message from various error types
|
||||
function getErrorMessage(error: unknown): string {
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
if (typeof error === "string") {
|
||||
return error;
|
||||
}
|
||||
if (error && typeof error === "object" && "message" in error) {
|
||||
return String(error.message);
|
||||
}
|
||||
return "Unknown error";
|
||||
}
|
||||
|
||||
interface PromptTestModalProps {
|
||||
prompt: Prompt;
|
||||
inspectorClient: InspectorClient | null;
|
||||
width: number;
|
||||
height: number;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
type ModalState = "form" | "loading" | "results";
|
||||
|
||||
interface PromptResult {
|
||||
input: Record<string, string>;
|
||||
output: GetPromptResult | null;
|
||||
error?: string;
|
||||
errorDetails?: unknown;
|
||||
duration: number;
|
||||
}
|
||||
|
||||
export function PromptTestModal({
|
||||
prompt,
|
||||
inspectorClient,
|
||||
width,
|
||||
height,
|
||||
onClose,
|
||||
}: PromptTestModalProps) {
|
||||
const [state, setState] = useState<ModalState>("form");
|
||||
const [result, setResult] = useState<PromptResult | null>(null);
|
||||
const scrollViewRef = React.useRef<ScrollViewRef>(null);
|
||||
|
||||
// Use full terminal dimensions instead of passed dimensions
|
||||
const [terminalDimensions, setTerminalDimensions] = React.useState({
|
||||
width: process.stdout.columns || width,
|
||||
height: process.stdout.rows || height,
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
const updateDimensions = () => {
|
||||
setTerminalDimensions({
|
||||
width: process.stdout.columns || width,
|
||||
height: process.stdout.rows || height,
|
||||
});
|
||||
};
|
||||
process.stdout.on("resize", updateDimensions);
|
||||
updateDimensions();
|
||||
return () => {
|
||||
process.stdout.off("resize", updateDimensions);
|
||||
};
|
||||
}, [width, height]);
|
||||
|
||||
const formStructure = promptArgsToForm(
|
||||
prompt.arguments || [],
|
||||
prompt.name || "Unknown Prompt",
|
||||
);
|
||||
|
||||
// Reset state when modal closes
|
||||
React.useEffect(() => {
|
||||
return () => {
|
||||
// Cleanup: reset state when component unmounts
|
||||
setState("form");
|
||||
setResult(null);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Handle all input when modal is open - prevents input from reaching underlying components
|
||||
// When in form mode, only handle escape (form handles its own input)
|
||||
// When in results mode, handle scrolling keys
|
||||
useInput(
|
||||
(input: string, key: Key) => {
|
||||
// Always handle escape to close modal
|
||||
if (key.escape) {
|
||||
setState("form");
|
||||
setResult(null);
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
|
||||
if (state === "form") {
|
||||
// In form mode, let the form handle all other input
|
||||
// Don't process anything else - this prevents input from reaching underlying components
|
||||
return;
|
||||
}
|
||||
|
||||
if (state === "results") {
|
||||
// Allow scrolling in results view
|
||||
if (key.downArrow) {
|
||||
scrollViewRef.current?.scrollBy(1);
|
||||
} else if (key.upArrow) {
|
||||
scrollViewRef.current?.scrollBy(-1);
|
||||
} else if (key.pageDown) {
|
||||
const viewportHeight =
|
||||
scrollViewRef.current?.getViewportHeight() || 1;
|
||||
scrollViewRef.current?.scrollBy(viewportHeight);
|
||||
} else if (key.pageUp) {
|
||||
const viewportHeight =
|
||||
scrollViewRef.current?.getViewportHeight() || 1;
|
||||
scrollViewRef.current?.scrollBy(-viewportHeight);
|
||||
}
|
||||
}
|
||||
},
|
||||
{ isActive: true },
|
||||
);
|
||||
|
||||
const handleFormSubmit = async (values: Record<string, string>) => {
|
||||
if (!inspectorClient || !prompt) return;
|
||||
|
||||
setState("loading");
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
// Get the prompt using the provided arguments
|
||||
const invocation = await inspectorClient.getPrompt(prompt.name, values);
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
setResult({
|
||||
input: values,
|
||||
output: invocation.result,
|
||||
duration,
|
||||
});
|
||||
setState("results");
|
||||
} catch (error) {
|
||||
const duration = Date.now() - startTime;
|
||||
const errorMessage = getErrorMessage(error);
|
||||
|
||||
// Extract detailed error information
|
||||
const errorObj: Record<string, unknown> = {
|
||||
message: errorMessage,
|
||||
};
|
||||
if (error instanceof Error) {
|
||||
errorObj.name = error.name;
|
||||
errorObj.stack = error.stack;
|
||||
} else if (error && typeof error === "object") {
|
||||
// Try to extract more details from error object
|
||||
Object.assign(errorObj, error);
|
||||
} else {
|
||||
errorObj.error = String(error);
|
||||
}
|
||||
|
||||
setResult({
|
||||
input: values,
|
||||
output: null,
|
||||
error: errorMessage,
|
||||
errorDetails: errorObj,
|
||||
duration,
|
||||
});
|
||||
setState("results");
|
||||
}
|
||||
};
|
||||
|
||||
// Calculate modal dimensions - use almost full screen
|
||||
const modalWidth = terminalDimensions.width - 2;
|
||||
const modalHeight = terminalDimensions.height - 2;
|
||||
|
||||
return (
|
||||
<Box
|
||||
position="absolute"
|
||||
width={terminalDimensions.width}
|
||||
height={terminalDimensions.height}
|
||||
flexDirection="column"
|
||||
justifyContent="center"
|
||||
alignItems="center"
|
||||
>
|
||||
{/* Modal Content */}
|
||||
<Box
|
||||
width={modalWidth}
|
||||
height={modalHeight}
|
||||
borderStyle="single"
|
||||
borderColor="cyan"
|
||||
flexDirection="column"
|
||||
paddingX={1}
|
||||
paddingY={1}
|
||||
backgroundColor="black"
|
||||
>
|
||||
{/* Header */}
|
||||
<Box flexShrink={0} marginBottom={1}>
|
||||
<Text bold color="cyan">
|
||||
{formStructure.title}
|
||||
</Text>
|
||||
<Text> </Text>
|
||||
<Text dimColor>(Press ESC to close)</Text>
|
||||
</Box>
|
||||
|
||||
{/* Content Area */}
|
||||
<Box flexGrow={1} flexDirection="column" overflow="hidden">
|
||||
{state === "form" && (
|
||||
<Box flexGrow={1} flexDirection="column">
|
||||
{prompt.description && (
|
||||
<Box marginBottom={1} flexShrink={0}>
|
||||
<Text dimColor>{prompt.description}</Text>
|
||||
</Box>
|
||||
)}
|
||||
<Form
|
||||
form={formStructure}
|
||||
onSubmit={(values: object) =>
|
||||
handleFormSubmit(values as Record<string, string>)
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{state === "loading" && (
|
||||
<Box flexGrow={1} justifyContent="center" alignItems="center">
|
||||
<Text color="yellow">Getting prompt...</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{state === "results" && result && (
|
||||
<Box flexGrow={1} flexDirection="column" overflow="hidden">
|
||||
<ScrollView ref={scrollViewRef}>
|
||||
{/* Timing */}
|
||||
<Box marginBottom={1} flexShrink={0}>
|
||||
<Text bold color="green">
|
||||
Duration: {result.duration}ms
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{/* Input */}
|
||||
{Object.keys(result.input).length > 0 && (
|
||||
<Box marginBottom={1} flexShrink={0} flexDirection="column">
|
||||
<Text bold color="cyan">
|
||||
Arguments:
|
||||
</Text>
|
||||
<Box paddingLeft={2}>
|
||||
<Text dimColor>
|
||||
{JSON.stringify(result.input, null, 2)}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Output or Error */}
|
||||
{result.error ? (
|
||||
<Box flexShrink={0} flexDirection="column">
|
||||
<Box marginTop={1} flexShrink={0}>
|
||||
<Text bold color="red">
|
||||
Error:
|
||||
</Text>
|
||||
</Box>
|
||||
<Box marginTop={1} paddingLeft={2} flexShrink={0}>
|
||||
<Text color="red">{String(result.error)}</Text>
|
||||
</Box>
|
||||
{result.errorDetails != null ? (
|
||||
<>
|
||||
<Box marginTop={1} flexShrink={0}>
|
||||
<Text bold color="red" dimColor>
|
||||
Error Details:
|
||||
</Text>
|
||||
</Box>
|
||||
<Box marginTop={1} paddingLeft={2} flexShrink={0}>
|
||||
<Text dimColor>
|
||||
{JSON.stringify(result.errorDetails, null, 2)}
|
||||
</Text>
|
||||
</Box>
|
||||
</>
|
||||
) : null}
|
||||
</Box>
|
||||
) : (
|
||||
<Box flexShrink={0} flexDirection="column">
|
||||
<Text bold color="green">
|
||||
Prompt Messages:
|
||||
</Text>
|
||||
<Box paddingLeft={2}>
|
||||
<Text dimColor>
|
||||
{JSON.stringify(result.output, null, 2)}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</ScrollView>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
276
clients/tui/src/components/PromptsTab.tsx
Normal file
276
clients/tui/src/components/PromptsTab.tsx
Normal file
|
|
@ -0,0 +1,276 @@
|
|||
import React, { useState, useEffect, useRef } from "react";
|
||||
import { Box, Text, useInput, type Key } from "ink";
|
||||
import { ScrollView, type ScrollViewRef } from "ink-scroll-view";
|
||||
import type { InspectorClient } from "@inspector/core/mcp/index.js";
|
||||
import type {
|
||||
Prompt,
|
||||
PromptArgument,
|
||||
GetPromptResult,
|
||||
} from "@modelcontextprotocol/sdk/types.js";
|
||||
import { useSelectableList } from "../hooks/useSelectableList.js";
|
||||
|
||||
interface PromptsTabProps {
|
||||
prompts: Prompt[];
|
||||
inspectorClient: InspectorClient | null;
|
||||
width: number;
|
||||
height: number;
|
||||
onCountChange?: (count: number) => void;
|
||||
focusedPane?: "list" | "details" | null;
|
||||
onViewDetails?: (prompt: Prompt & { result?: GetPromptResult }) => void;
|
||||
onFetchPrompt?: (prompt: Prompt) => void;
|
||||
modalOpen?: boolean;
|
||||
}
|
||||
|
||||
export function PromptsTab({
|
||||
prompts,
|
||||
inspectorClient,
|
||||
width,
|
||||
height,
|
||||
focusedPane = null,
|
||||
onViewDetails,
|
||||
onFetchPrompt,
|
||||
modalOpen = false,
|
||||
}: PromptsTabProps) {
|
||||
const visibleCount = Math.max(1, height - 7);
|
||||
const { selectedIndex, firstVisible, setSelection } = useSelectableList(
|
||||
prompts.length,
|
||||
visibleCount,
|
||||
{ resetWhen: prompts },
|
||||
);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const scrollViewRef = useRef<ScrollViewRef>(null);
|
||||
|
||||
// Handle arrow key navigation when focused
|
||||
useInput(
|
||||
(input: string, key: Key) => {
|
||||
// Handle Enter key to fetch prompt (works from both list and details)
|
||||
if (key.return && selectedPrompt && inspectorClient && onFetchPrompt) {
|
||||
// If prompt has arguments, open modal to collect them
|
||||
// Otherwise, fetch directly
|
||||
if (selectedPrompt.arguments && selectedPrompt.arguments.length > 0) {
|
||||
onFetchPrompt(selectedPrompt);
|
||||
} else {
|
||||
// No arguments, fetch directly
|
||||
(async () => {
|
||||
try {
|
||||
const invocation = await inspectorClient.getPrompt(
|
||||
selectedPrompt.name,
|
||||
);
|
||||
// Show result in details modal
|
||||
if (onViewDetails) {
|
||||
onViewDetails({
|
||||
...selectedPrompt,
|
||||
result: invocation.result,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
setError(
|
||||
error instanceof Error ? error.message : "Failed to get prompt",
|
||||
);
|
||||
}
|
||||
})();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (focusedPane === "list") {
|
||||
if (key.upArrow && selectedIndex > 0) {
|
||||
setSelection(selectedIndex - 1);
|
||||
} else if (key.downArrow && selectedIndex < prompts.length - 1) {
|
||||
setSelection(selectedIndex + 1);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (focusedPane === "details") {
|
||||
// Handle '+' key to view in full screen modal
|
||||
if (input === "+" && selectedPrompt && onViewDetails) {
|
||||
onViewDetails(selectedPrompt);
|
||||
return;
|
||||
}
|
||||
|
||||
// Scroll the details pane using ink-scroll-view
|
||||
if (key.upArrow) {
|
||||
scrollViewRef.current?.scrollBy(-1);
|
||||
} else if (key.downArrow) {
|
||||
scrollViewRef.current?.scrollBy(1);
|
||||
} else if (key.pageUp) {
|
||||
const viewportHeight =
|
||||
scrollViewRef.current?.getViewportHeight() || 1;
|
||||
scrollViewRef.current?.scrollBy(-viewportHeight);
|
||||
} else if (key.pageDown) {
|
||||
const viewportHeight =
|
||||
scrollViewRef.current?.getViewportHeight() || 1;
|
||||
scrollViewRef.current?.scrollBy(viewportHeight);
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
isActive:
|
||||
!modalOpen && (focusedPane === "list" || focusedPane === "details"),
|
||||
},
|
||||
);
|
||||
|
||||
// Reset scroll when selection changes
|
||||
useEffect(() => {
|
||||
scrollViewRef.current?.scrollTo(0);
|
||||
}, [selectedIndex]);
|
||||
|
||||
const selectedPrompt = prompts[selectedIndex] || null;
|
||||
|
||||
const listWidth = Math.floor(width * 0.4);
|
||||
const detailWidth = width - listWidth;
|
||||
|
||||
return (
|
||||
<Box flexDirection="row" width={width} height={height}>
|
||||
{/* Prompts List */}
|
||||
<Box
|
||||
width={listWidth}
|
||||
height={height}
|
||||
borderStyle="single"
|
||||
borderTop={false}
|
||||
borderBottom={false}
|
||||
borderLeft={false}
|
||||
borderRight={true}
|
||||
flexDirection="column"
|
||||
paddingX={1}
|
||||
>
|
||||
<Box paddingY={1}>
|
||||
<Text
|
||||
bold
|
||||
backgroundColor={focusedPane === "list" ? "yellow" : undefined}
|
||||
>
|
||||
Prompts ({prompts.length})
|
||||
</Text>
|
||||
</Box>
|
||||
{error ? (
|
||||
<Box paddingY={1}>
|
||||
<Text color="red">{error}</Text>
|
||||
</Box>
|
||||
) : prompts.length === 0 ? (
|
||||
<Box paddingY={1}>
|
||||
<Text dimColor>No prompts available</Text>
|
||||
</Box>
|
||||
) : (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
height={visibleCount}
|
||||
overflow="hidden"
|
||||
flexShrink={0}
|
||||
>
|
||||
{prompts
|
||||
.slice(firstVisible, firstVisible + visibleCount)
|
||||
.map((prompt, i) => {
|
||||
const index = firstVisible + i;
|
||||
const isSelected = index === selectedIndex;
|
||||
return (
|
||||
<Box key={prompt.name || index} paddingY={0} flexShrink={0}>
|
||||
<Text>
|
||||
{isSelected ? "▶ " : " "}
|
||||
{prompt.name || `Prompt ${index + 1}`}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Prompt Details */}
|
||||
<Box
|
||||
width={detailWidth}
|
||||
height={height}
|
||||
paddingX={1}
|
||||
flexDirection="column"
|
||||
overflow="hidden"
|
||||
>
|
||||
{selectedPrompt ? (
|
||||
<>
|
||||
{/* Fixed header */}
|
||||
<Box flexShrink={0} paddingTop={1}>
|
||||
<Text
|
||||
bold
|
||||
backgroundColor={
|
||||
focusedPane === "details" ? "yellow" : undefined
|
||||
}
|
||||
{...(focusedPane === "details" ? {} : { color: "cyan" })}
|
||||
>
|
||||
{selectedPrompt.name}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{/* Scrollable content area - direct ScrollView with height prop like NotificationsTab */}
|
||||
<ScrollView ref={scrollViewRef} height={height - 5}>
|
||||
{/* Description */}
|
||||
{selectedPrompt.description && (
|
||||
<>
|
||||
{selectedPrompt.description
|
||||
.split("\n")
|
||||
.map((line: string, idx: number) => (
|
||||
<Box
|
||||
key={`desc-${idx}`}
|
||||
marginTop={idx === 0 ? 1 : 0}
|
||||
flexShrink={0}
|
||||
>
|
||||
<Text dimColor>{line}</Text>
|
||||
</Box>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Arguments */}
|
||||
{selectedPrompt.arguments &&
|
||||
selectedPrompt.arguments.length > 0 && (
|
||||
<>
|
||||
<Box marginTop={1} flexShrink={0}>
|
||||
<Text bold>Arguments:</Text>
|
||||
</Box>
|
||||
{selectedPrompt.arguments.map(
|
||||
(arg: PromptArgument, idx: number) => (
|
||||
<Box
|
||||
key={`arg-${idx}`}
|
||||
marginTop={1}
|
||||
paddingLeft={2}
|
||||
flexShrink={0}
|
||||
>
|
||||
<Text dimColor>
|
||||
- {arg.name}:{" "}
|
||||
{arg.description ??
|
||||
(arg as { type?: string }).type ??
|
||||
"string"}
|
||||
</Text>
|
||||
</Box>
|
||||
),
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Enter to Get Prompt message */}
|
||||
<Box marginTop={1} flexShrink={0}>
|
||||
<Text dimColor>[Enter to Get Prompt]</Text>
|
||||
</Box>
|
||||
</ScrollView>
|
||||
|
||||
{/* Fixed footer - only show when details pane is focused */}
|
||||
{focusedPane === "details" && (
|
||||
<Box
|
||||
flexShrink={0}
|
||||
height={1}
|
||||
justifyContent="center"
|
||||
backgroundColor="gray"
|
||||
>
|
||||
<Text bold color="white">
|
||||
↑/↓ to scroll, + to zoom
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<Box paddingY={1} flexShrink={0}>
|
||||
<Text dimColor>Select a prompt to view details</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
370
clients/tui/src/components/RequestsTab.tsx
Normal file
370
clients/tui/src/components/RequestsTab.tsx
Normal file
|
|
@ -0,0 +1,370 @@
|
|||
import React, { useEffect, useRef } from "react";
|
||||
import { Box, Text, useInput, type Key } from "ink";
|
||||
import { ScrollView, type ScrollViewRef } from "ink-scroll-view";
|
||||
import type { FetchRequestEntry } from "@inspector/core/mcp/index.js";
|
||||
import { useSelectableList } from "../hooks/useSelectableList.js";
|
||||
|
||||
interface RequestsTabProps {
|
||||
serverName: string | null;
|
||||
requests: FetchRequestEntry[];
|
||||
width: number;
|
||||
height: number;
|
||||
onCountChange?: (count: number) => void;
|
||||
focusedPane?: "requests" | "details" | null;
|
||||
onViewDetails?: (request: FetchRequestEntry) => void;
|
||||
modalOpen?: boolean;
|
||||
}
|
||||
|
||||
export function RequestsTab({
|
||||
requests,
|
||||
width,
|
||||
height,
|
||||
onCountChange,
|
||||
focusedPane = null,
|
||||
onViewDetails,
|
||||
modalOpen = false,
|
||||
}: RequestsTabProps) {
|
||||
const visibleCount = Math.max(1, height - 7);
|
||||
const { selectedIndex, firstVisible, setSelection } = useSelectableList(
|
||||
requests.length,
|
||||
visibleCount,
|
||||
);
|
||||
const scrollViewRef = useRef<ScrollViewRef>(null);
|
||||
const selectedRequest = requests[selectedIndex] || null;
|
||||
|
||||
// Handle arrow key navigation and scrolling when focused
|
||||
useInput(
|
||||
(input: string, key: Key) => {
|
||||
if (focusedPane === "requests") {
|
||||
if (key.upArrow && selectedIndex > 0) {
|
||||
setSelection(selectedIndex - 1);
|
||||
} else if (key.downArrow && selectedIndex < requests.length - 1) {
|
||||
setSelection(selectedIndex + 1);
|
||||
} else if (key.pageUp) {
|
||||
setSelection(Math.max(0, selectedIndex - visibleCount));
|
||||
} else if (key.pageDown) {
|
||||
setSelection(
|
||||
Math.min(requests.length - 1, selectedIndex + visibleCount),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// details scrolling (only when details pane is focused)
|
||||
if (focusedPane === "details") {
|
||||
// Handle '+' key to view in full screen modal
|
||||
if (input === "+" && selectedRequest && onViewDetails) {
|
||||
onViewDetails(selectedRequest);
|
||||
return;
|
||||
}
|
||||
|
||||
if (key.upArrow) {
|
||||
scrollViewRef.current?.scrollBy(-1);
|
||||
} else if (key.downArrow) {
|
||||
scrollViewRef.current?.scrollBy(1);
|
||||
} else if (key.pageUp) {
|
||||
const viewportHeight =
|
||||
scrollViewRef.current?.getViewportHeight() || 1;
|
||||
scrollViewRef.current?.scrollBy(-viewportHeight);
|
||||
} else if (key.pageDown) {
|
||||
const viewportHeight =
|
||||
scrollViewRef.current?.getViewportHeight() || 1;
|
||||
scrollViewRef.current?.scrollBy(viewportHeight);
|
||||
}
|
||||
}
|
||||
},
|
||||
{ isActive: !modalOpen && focusedPane !== undefined },
|
||||
);
|
||||
|
||||
// Update count when requests change
|
||||
React.useEffect(() => {
|
||||
onCountChange?.(requests.length);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [requests.length]);
|
||||
|
||||
// Reset details scroll when request selection changes
|
||||
useEffect(() => {
|
||||
scrollViewRef.current?.scrollTo(0);
|
||||
}, [selectedIndex]);
|
||||
|
||||
const listWidth = Math.floor(width * 0.4);
|
||||
const detailWidth = width - listWidth;
|
||||
|
||||
const getStatusColor = (status?: number): string => {
|
||||
if (!status) return "gray";
|
||||
if (status >= 200 && status < 300) return "green";
|
||||
if (status >= 300 && status < 400) return "yellow";
|
||||
if (status >= 400) return "red";
|
||||
return "gray";
|
||||
};
|
||||
|
||||
return (
|
||||
<Box flexDirection="row" width={width} height={height}>
|
||||
{/* Left column - Requests list */}
|
||||
<Box
|
||||
width={listWidth}
|
||||
height={height}
|
||||
borderStyle="single"
|
||||
borderTop={false}
|
||||
borderBottom={false}
|
||||
borderLeft={false}
|
||||
borderRight={true}
|
||||
flexDirection="column"
|
||||
paddingX={1}
|
||||
>
|
||||
<Box paddingY={1} flexShrink={0}>
|
||||
<Text
|
||||
bold
|
||||
backgroundColor={focusedPane === "requests" ? "yellow" : undefined}
|
||||
>
|
||||
Requests ({requests.length})
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{/* Requests list */}
|
||||
{requests.length === 0 ? (
|
||||
<Box paddingY={1}>
|
||||
<Text dimColor>No requests</Text>
|
||||
</Box>
|
||||
) : (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
height={visibleCount}
|
||||
overflow="hidden"
|
||||
flexShrink={0}
|
||||
>
|
||||
{requests
|
||||
.slice(firstVisible, firstVisible + visibleCount)
|
||||
.map((req, i) => {
|
||||
const index = firstVisible + i;
|
||||
const isSelected = index === selectedIndex;
|
||||
const statusColor = getStatusColor(req.responseStatus);
|
||||
const statusText = req.responseStatus
|
||||
? `${req.responseStatus}`
|
||||
: req.error
|
||||
? "ERROR"
|
||||
: "...";
|
||||
|
||||
const categoryLabel = req.category === "auth" ? "AUTH" : "MCP ";
|
||||
const methodPadded = req.method === "GET" ? "GET " : req.method;
|
||||
return (
|
||||
<Box key={req.id} paddingY={0} flexShrink={0}>
|
||||
<Text color={isSelected ? "white" : "white"}>
|
||||
{isSelected ? "▶ " : " "}
|
||||
<Text>{categoryLabel}</Text>{" "}
|
||||
<Text color={statusColor}>{methodPadded}</Text>{" "}
|
||||
<Text dimColor>{statusText}</Text>
|
||||
{req.duration !== undefined && (
|
||||
<Text dimColor> {req.duration}ms</Text>
|
||||
)}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Right column - Request details */}
|
||||
<Box
|
||||
width={detailWidth}
|
||||
height={height}
|
||||
paddingX={1}
|
||||
flexDirection="column"
|
||||
flexShrink={0}
|
||||
borderStyle="single"
|
||||
borderTop={false}
|
||||
borderBottom={false}
|
||||
borderLeft={false}
|
||||
borderRight={false}
|
||||
>
|
||||
{selectedRequest ? (
|
||||
<>
|
||||
{/* Fixed header */}
|
||||
<Box flexShrink={0} paddingTop={1}>
|
||||
<Text
|
||||
bold
|
||||
backgroundColor={
|
||||
focusedPane === "details" ? "yellow" : undefined
|
||||
}
|
||||
{...(focusedPane === "details" ? {} : { color: "cyan" })}
|
||||
>
|
||||
{selectedRequest.method} {selectedRequest.url}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{/* Scrollable content area */}
|
||||
<ScrollView ref={scrollViewRef} height={height - 5}>
|
||||
{/* Category */}
|
||||
<Box marginTop={1} flexShrink={0}>
|
||||
<Text bold>
|
||||
Category:{" "}
|
||||
<Text>
|
||||
{selectedRequest.category === "auth" ? "auth" : "transport"}
|
||||
</Text>
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{/* Status */}
|
||||
{selectedRequest.responseStatus !== undefined ? (
|
||||
<Box marginTop={1} flexShrink={0}>
|
||||
<Text bold>
|
||||
Status:{" "}
|
||||
<Text
|
||||
color={getStatusColor(selectedRequest.responseStatus)}
|
||||
>
|
||||
{selectedRequest.responseStatus}{" "}
|
||||
{selectedRequest.responseStatusText || ""}
|
||||
</Text>
|
||||
</Text>
|
||||
</Box>
|
||||
) : selectedRequest.error ? (
|
||||
<Box marginTop={1} flexShrink={0}>
|
||||
<Text bold color="red">
|
||||
Error: {selectedRequest.error}
|
||||
</Text>
|
||||
</Box>
|
||||
) : (
|
||||
<Box marginTop={1} flexShrink={0}>
|
||||
<Text dimColor italic>
|
||||
Request in progress...
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Duration */}
|
||||
{selectedRequest.duration !== undefined && (
|
||||
<Box marginTop={1} flexShrink={0}>
|
||||
<Text dimColor>
|
||||
{selectedRequest.timestamp.toLocaleTimeString()} (
|
||||
{selectedRequest.duration}ms)
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Request Headers */}
|
||||
<Box marginTop={1} flexShrink={0}>
|
||||
<Text bold>Request Headers:</Text>
|
||||
</Box>
|
||||
{Object.entries(selectedRequest.requestHeaders).map(
|
||||
([key, value]) => (
|
||||
<Box key={key} marginTop={0} paddingLeft={2} flexShrink={0}>
|
||||
<Text dimColor>
|
||||
{key}: {value}
|
||||
</Text>
|
||||
</Box>
|
||||
),
|
||||
)}
|
||||
|
||||
{/* Request Body */}
|
||||
{selectedRequest.requestBody && (
|
||||
<>
|
||||
<Box marginTop={1} flexShrink={0}>
|
||||
<Text bold>Request Body:</Text>
|
||||
</Box>
|
||||
{(() => {
|
||||
try {
|
||||
const parsed = JSON.parse(selectedRequest.requestBody);
|
||||
return JSON.stringify(parsed, null, 2)
|
||||
.split("\n")
|
||||
.map((line: string, idx: number) => (
|
||||
<Box
|
||||
key={`req-body-${idx}`}
|
||||
marginTop={idx === 0 ? 1 : 0}
|
||||
paddingLeft={2}
|
||||
flexShrink={0}
|
||||
>
|
||||
<Text dimColor>{line}</Text>
|
||||
</Box>
|
||||
));
|
||||
} catch {
|
||||
return (
|
||||
<Box marginTop={1} paddingLeft={2} flexShrink={0}>
|
||||
<Text dimColor>{selectedRequest.requestBody}</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
})()}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Response Headers */}
|
||||
{selectedRequest.responseHeaders &&
|
||||
Object.keys(selectedRequest.responseHeaders).length > 0 && (
|
||||
<>
|
||||
<Box marginTop={1} flexShrink={0}>
|
||||
<Text bold>Response Headers:</Text>
|
||||
</Box>
|
||||
{Object.entries(selectedRequest.responseHeaders).map(
|
||||
([key, value]) => (
|
||||
<Box
|
||||
key={key}
|
||||
marginTop={0}
|
||||
paddingLeft={2}
|
||||
flexShrink={0}
|
||||
>
|
||||
<Text dimColor>
|
||||
{key}: {value}
|
||||
</Text>
|
||||
</Box>
|
||||
),
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Response Body */}
|
||||
{selectedRequest.responseBody && (
|
||||
<>
|
||||
<Box marginTop={1} flexShrink={0}>
|
||||
<Text bold>Response Body:</Text>
|
||||
</Box>
|
||||
{(() => {
|
||||
try {
|
||||
const parsed = JSON.parse(selectedRequest.responseBody);
|
||||
return JSON.stringify(parsed, null, 2)
|
||||
.split("\n")
|
||||
.map((line: string, idx: number) => (
|
||||
<Box
|
||||
key={`resp-body-${idx}`}
|
||||
marginTop={idx === 0 ? 1 : 0}
|
||||
paddingLeft={2}
|
||||
flexShrink={0}
|
||||
>
|
||||
<Text dimColor>{line}</Text>
|
||||
</Box>
|
||||
));
|
||||
} catch {
|
||||
return (
|
||||
<Box marginTop={1} paddingLeft={2} flexShrink={0}>
|
||||
<Text dimColor>{selectedRequest.responseBody}</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
})()}
|
||||
</>
|
||||
)}
|
||||
</ScrollView>
|
||||
|
||||
{/* Fixed footer - only show when details pane is focused */}
|
||||
{focusedPane === "details" && (
|
||||
<Box
|
||||
flexShrink={0}
|
||||
height={1}
|
||||
justifyContent="center"
|
||||
backgroundColor="gray"
|
||||
>
|
||||
<Text bold color="white">
|
||||
↑/↓ to scroll, + to zoom
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<Box paddingY={1} flexShrink={0}>
|
||||
<Text dimColor>Select a request to view details</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
321
clients/tui/src/components/ResourceTestModal.tsx
Normal file
321
clients/tui/src/components/ResourceTestModal.tsx
Normal file
|
|
@ -0,0 +1,321 @@
|
|||
import React, { useState } from "react";
|
||||
import { Box, Text, useInput, type Key } from "ink";
|
||||
import { Form } from "ink-form";
|
||||
import { InspectorClient } from "@inspector/core/mcp/index.js";
|
||||
import type { ReadResourceResult } from "@modelcontextprotocol/sdk/types.js";
|
||||
import { uriTemplateToForm } from "../utils/uriTemplateToForm.js";
|
||||
import { ScrollView, type ScrollViewRef } from "ink-scroll-view";
|
||||
|
||||
// Helper to extract error message from various error types
|
||||
function getErrorMessage(error: unknown): string {
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
if (typeof error === "string") {
|
||||
return error;
|
||||
}
|
||||
if (error && typeof error === "object" && "message" in error) {
|
||||
return String(error.message);
|
||||
}
|
||||
return "Unknown error";
|
||||
}
|
||||
|
||||
interface ResourceTestModalProps {
|
||||
template: {
|
||||
name: string;
|
||||
uriTemplate: string;
|
||||
description?: string;
|
||||
};
|
||||
inspectorClient: InspectorClient | null;
|
||||
width: number;
|
||||
height: number;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
type ModalState = "form" | "loading" | "results";
|
||||
|
||||
interface ResourceResult {
|
||||
input: Record<string, string>;
|
||||
output: ReadResourceResult | null;
|
||||
error?: string;
|
||||
errorDetails?: unknown;
|
||||
duration: number;
|
||||
uri: string;
|
||||
}
|
||||
|
||||
export function ResourceTestModal({
|
||||
template,
|
||||
inspectorClient,
|
||||
width,
|
||||
height,
|
||||
onClose,
|
||||
}: ResourceTestModalProps) {
|
||||
const [state, setState] = useState<ModalState>("form");
|
||||
const [result, setResult] = useState<ResourceResult | null>(null);
|
||||
const scrollViewRef = React.useRef<ScrollViewRef>(null);
|
||||
|
||||
// Use full terminal dimensions instead of passed dimensions
|
||||
const [terminalDimensions, setTerminalDimensions] = React.useState({
|
||||
width: process.stdout.columns || width,
|
||||
height: process.stdout.rows || height,
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
const updateDimensions = () => {
|
||||
setTerminalDimensions({
|
||||
width: process.stdout.columns || width,
|
||||
height: process.stdout.rows || height,
|
||||
});
|
||||
};
|
||||
process.stdout.on("resize", updateDimensions);
|
||||
updateDimensions();
|
||||
return () => {
|
||||
process.stdout.off("resize", updateDimensions);
|
||||
};
|
||||
}, [width, height]);
|
||||
|
||||
const formStructure = uriTemplateToForm(
|
||||
template.uriTemplate,
|
||||
template.name || "Unknown Template",
|
||||
);
|
||||
|
||||
// Reset state when modal closes
|
||||
React.useEffect(() => {
|
||||
return () => {
|
||||
// Cleanup: reset state when component unmounts
|
||||
setState("form");
|
||||
setResult(null);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Handle all input when modal is open - prevents input from reaching underlying components
|
||||
// When in form mode, only handle escape (form handles its own input)
|
||||
// When in results mode, handle scrolling keys
|
||||
useInput(
|
||||
(input: string, key: Key) => {
|
||||
// Always handle escape to close modal
|
||||
if (key.escape) {
|
||||
setState("form");
|
||||
setResult(null);
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
|
||||
if (state === "form") {
|
||||
// In form mode, let the form handle all other input
|
||||
// Don't process anything else - this prevents input from reaching underlying components
|
||||
return;
|
||||
}
|
||||
|
||||
if (state === "results") {
|
||||
// Allow scrolling in results view
|
||||
if (key.downArrow) {
|
||||
scrollViewRef.current?.scrollBy(1);
|
||||
} else if (key.upArrow) {
|
||||
scrollViewRef.current?.scrollBy(-1);
|
||||
} else if (key.pageDown) {
|
||||
const viewportHeight =
|
||||
scrollViewRef.current?.getViewportHeight() || 1;
|
||||
scrollViewRef.current?.scrollBy(viewportHeight);
|
||||
} else if (key.pageUp) {
|
||||
const viewportHeight =
|
||||
scrollViewRef.current?.getViewportHeight() || 1;
|
||||
scrollViewRef.current?.scrollBy(-viewportHeight);
|
||||
}
|
||||
}
|
||||
},
|
||||
{ isActive: true },
|
||||
);
|
||||
|
||||
const handleFormSubmit = async (values: Record<string, string>) => {
|
||||
if (!inspectorClient || !template) return;
|
||||
|
||||
setState("loading");
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
// Use InspectorClient's readResourceFromTemplate method which encapsulates template expansion and resource reading
|
||||
const invocation = await inspectorClient.readResourceFromTemplate(
|
||||
template.uriTemplate,
|
||||
values,
|
||||
);
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
setResult({
|
||||
input: values,
|
||||
output: invocation.result, // Extract the SDK result from the invocation
|
||||
duration,
|
||||
uri: invocation.expandedUri, // Use expandedUri instead of uri
|
||||
});
|
||||
setState("results");
|
||||
} catch (error) {
|
||||
const duration = Date.now() - startTime;
|
||||
const errorMessage = getErrorMessage(error);
|
||||
|
||||
// Try to get expanded URI from error if available, otherwise use template
|
||||
let uri = template.uriTemplate;
|
||||
// If the error response contains uri, use it
|
||||
if (error && typeof error === "object" && "uri" in error) {
|
||||
uri = (error as { uri: string }).uri;
|
||||
}
|
||||
|
||||
// Extract detailed error information
|
||||
const errorObj: Record<string, unknown> = {
|
||||
message: errorMessage,
|
||||
};
|
||||
if (error instanceof Error) {
|
||||
errorObj.name = error.name;
|
||||
errorObj.stack = error.stack;
|
||||
} else if (error && typeof error === "object") {
|
||||
// Try to extract more details from error object
|
||||
Object.assign(errorObj, error);
|
||||
} else {
|
||||
errorObj.error = String(error);
|
||||
}
|
||||
|
||||
setResult({
|
||||
input: values,
|
||||
output: null,
|
||||
error: errorMessage,
|
||||
errorDetails: errorObj,
|
||||
duration,
|
||||
uri,
|
||||
});
|
||||
setState("results");
|
||||
}
|
||||
};
|
||||
|
||||
// Calculate modal dimensions - use almost full screen
|
||||
const modalWidth = terminalDimensions.width - 2;
|
||||
const modalHeight = terminalDimensions.height - 2;
|
||||
|
||||
return (
|
||||
<Box
|
||||
position="absolute"
|
||||
width={terminalDimensions.width}
|
||||
height={terminalDimensions.height}
|
||||
flexDirection="column"
|
||||
justifyContent="center"
|
||||
alignItems="center"
|
||||
>
|
||||
{/* Modal Content */}
|
||||
<Box
|
||||
width={modalWidth}
|
||||
height={modalHeight}
|
||||
borderStyle="single"
|
||||
borderColor="cyan"
|
||||
flexDirection="column"
|
||||
paddingX={1}
|
||||
paddingY={1}
|
||||
backgroundColor="black"
|
||||
>
|
||||
{/* Header */}
|
||||
<Box flexShrink={0} marginBottom={1}>
|
||||
<Text bold color="cyan">
|
||||
{formStructure.title}
|
||||
</Text>
|
||||
<Text> </Text>
|
||||
<Text dimColor>(Press ESC to close)</Text>
|
||||
</Box>
|
||||
|
||||
{/* Content Area */}
|
||||
<Box flexGrow={1} flexDirection="column" overflow="hidden">
|
||||
{state === "form" && (
|
||||
<Box flexGrow={1} flexDirection="column">
|
||||
{template.description && (
|
||||
<Box marginBottom={1} flexShrink={0}>
|
||||
<Text dimColor>{template.description}</Text>
|
||||
</Box>
|
||||
)}
|
||||
<Form
|
||||
form={formStructure}
|
||||
onSubmit={(values: object) =>
|
||||
handleFormSubmit(values as Record<string, string>)
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{state === "loading" && (
|
||||
<Box flexGrow={1} justifyContent="center" alignItems="center">
|
||||
<Text color="yellow">Reading resource...</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{state === "results" && result && (
|
||||
<Box flexGrow={1} flexDirection="column" overflow="hidden">
|
||||
<ScrollView ref={scrollViewRef}>
|
||||
{/* Timing */}
|
||||
<Box marginBottom={1} flexShrink={0}>
|
||||
<Text bold color="green">
|
||||
Duration: {result.duration}ms
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{/* URI */}
|
||||
<Box marginBottom={1} flexShrink={0}>
|
||||
<Text bold color="cyan">
|
||||
URI:{" "}
|
||||
</Text>
|
||||
<Text dimColor>{result.uri}</Text>
|
||||
</Box>
|
||||
|
||||
{/* Input */}
|
||||
<Box marginBottom={1} flexShrink={0} flexDirection="column">
|
||||
<Text bold color="cyan">
|
||||
Template Values:
|
||||
</Text>
|
||||
<Box paddingLeft={2}>
|
||||
<Text dimColor>
|
||||
{JSON.stringify(result.input, null, 2)}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Output or Error */}
|
||||
{result.error ? (
|
||||
<Box flexShrink={0} flexDirection="column">
|
||||
<Box marginTop={1} flexShrink={0}>
|
||||
<Text bold color="red">
|
||||
Error:
|
||||
</Text>
|
||||
</Box>
|
||||
<Box marginTop={1} paddingLeft={2} flexShrink={0}>
|
||||
<Text color="red">{String(result.error)}</Text>
|
||||
</Box>
|
||||
{result.errorDetails != null ? (
|
||||
<>
|
||||
<Box marginTop={1} flexShrink={0}>
|
||||
<Text bold color="red" dimColor>
|
||||
Error Details:
|
||||
</Text>
|
||||
</Box>
|
||||
<Box marginTop={1} paddingLeft={2} flexShrink={0}>
|
||||
<Text dimColor>
|
||||
{JSON.stringify(result.errorDetails, null, 2)}
|
||||
</Text>
|
||||
</Box>
|
||||
</>
|
||||
) : null}
|
||||
</Box>
|
||||
) : (
|
||||
<Box flexShrink={0} flexDirection="column">
|
||||
<Text bold color="green">
|
||||
Resource Content:
|
||||
</Text>
|
||||
<Box paddingLeft={2}>
|
||||
<Text dimColor>
|
||||
{JSON.stringify(result.output, null, 2)}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</ScrollView>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
430
clients/tui/src/components/ResourcesTab.tsx
Normal file
430
clients/tui/src/components/ResourcesTab.tsx
Normal file
|
|
@ -0,0 +1,430 @@
|
|||
import React, { useState, useEffect, useRef, useMemo } from "react";
|
||||
import { Box, Text, useInput, type Key } from "ink";
|
||||
import { ScrollView, type ScrollViewRef } from "ink-scroll-view";
|
||||
import type { InspectorClient } from "@inspector/core/mcp/index.js";
|
||||
import type {
|
||||
Resource,
|
||||
ReadResourceResult,
|
||||
} from "@modelcontextprotocol/sdk/types.js";
|
||||
import { useSelectableList } from "../hooks/useSelectableList.js";
|
||||
|
||||
interface ResourceTemplate {
|
||||
name: string;
|
||||
uriTemplate: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
interface ResourcesTabProps {
|
||||
resources: Resource[];
|
||||
resourceTemplates?: ResourceTemplate[];
|
||||
inspectorClient: InspectorClient | null;
|
||||
width: number;
|
||||
height: number;
|
||||
onCountChange?: (count: number) => void;
|
||||
focusedPane?: "list" | "details" | null;
|
||||
onViewDetails?: (
|
||||
resource: Resource | { content: ReadResourceResult },
|
||||
) => void;
|
||||
onFetchResource?: (resource: Resource) => void;
|
||||
onFetchTemplate?: (template: ResourceTemplate) => void;
|
||||
modalOpen?: boolean;
|
||||
}
|
||||
|
||||
export function ResourcesTab({
|
||||
resources,
|
||||
resourceTemplates = [],
|
||||
inspectorClient,
|
||||
width,
|
||||
height,
|
||||
onCountChange,
|
||||
focusedPane = null,
|
||||
onViewDetails,
|
||||
onFetchResource,
|
||||
onFetchTemplate,
|
||||
modalOpen = false,
|
||||
}: ResourcesTabProps) {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [resourceContent, setResourceContent] =
|
||||
useState<ReadResourceResult | null>(null);
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
const [shouldFetchResource, setShouldFetchResource] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const scrollViewRef = useRef<ScrollViewRef>(null);
|
||||
|
||||
// Combined list: resources first, then templates - memoized to prevent unnecessary recalculations
|
||||
const allItems = useMemo(
|
||||
() => [
|
||||
...resources.map((r) => ({ type: "resource" as const, data: r })),
|
||||
...resourceTemplates.map((t) => ({ type: "template" as const, data: t })),
|
||||
],
|
||||
[resources, resourceTemplates],
|
||||
);
|
||||
const totalCount = useMemo(
|
||||
() => resources.length + resourceTemplates.length,
|
||||
[resources.length, resourceTemplates.length],
|
||||
);
|
||||
|
||||
const visibleCount = Math.max(1, height - 7);
|
||||
const { selectedIndex, firstVisible, setSelection } = useSelectableList(
|
||||
totalCount,
|
||||
visibleCount,
|
||||
{ resetWhen: resources },
|
||||
);
|
||||
const selectedItem = useMemo(
|
||||
() => allItems[selectedIndex] || null,
|
||||
[allItems, selectedIndex],
|
||||
);
|
||||
|
||||
// Handle arrow key navigation when focused
|
||||
useInput(
|
||||
(input: string, key: Key) => {
|
||||
// Handle Enter key to fetch resource (works from both list and details)
|
||||
if (
|
||||
key.return &&
|
||||
selectedItem &&
|
||||
inspectorClient &&
|
||||
(onFetchResource || onFetchTemplate)
|
||||
) {
|
||||
if (selectedItem.type === "resource" && selectedItem.data.uri) {
|
||||
// Trigger fetch for regular resource
|
||||
setShouldFetchResource(selectedItem.data.uri);
|
||||
if (onFetchResource) {
|
||||
onFetchResource(selectedItem.data);
|
||||
}
|
||||
} else if (selectedItem.type === "template" && onFetchTemplate) {
|
||||
// Open modal for template
|
||||
onFetchTemplate(selectedItem.data);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (focusedPane === "list") {
|
||||
if (key.upArrow && selectedIndex > 0) {
|
||||
setSelection(selectedIndex - 1);
|
||||
} else if (key.downArrow && selectedIndex < totalCount - 1) {
|
||||
setSelection(selectedIndex + 1);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (focusedPane === "details") {
|
||||
// Handle '+' key to view in full screen modal
|
||||
if (input === "+" && resourceContent && onViewDetails) {
|
||||
onViewDetails({ content: resourceContent });
|
||||
return;
|
||||
}
|
||||
|
||||
// Scroll the details pane using ink-scroll-view
|
||||
if (key.upArrow) {
|
||||
scrollViewRef.current?.scrollBy(-1);
|
||||
} else if (key.downArrow) {
|
||||
scrollViewRef.current?.scrollBy(1);
|
||||
} else if (key.pageUp) {
|
||||
const viewportHeight =
|
||||
scrollViewRef.current?.getViewportHeight() || 1;
|
||||
scrollViewRef.current?.scrollBy(-viewportHeight);
|
||||
} else if (key.pageDown) {
|
||||
const viewportHeight =
|
||||
scrollViewRef.current?.getViewportHeight() || 1;
|
||||
scrollViewRef.current?.scrollBy(viewportHeight);
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
isActive:
|
||||
!modalOpen && (focusedPane === "list" || focusedPane === "details"),
|
||||
},
|
||||
);
|
||||
|
||||
// Reset scroll when selection changes
|
||||
useEffect(() => {
|
||||
scrollViewRef.current?.scrollTo(0);
|
||||
}, [selectedIndex]);
|
||||
|
||||
// Clear fetched content when resources change
|
||||
const prevResourcesRef = useRef<Resource[]>(resources);
|
||||
useEffect(() => {
|
||||
if (prevResourcesRef.current !== resources) {
|
||||
setResourceContent(null);
|
||||
setShouldFetchResource(null);
|
||||
prevResourcesRef.current = resources;
|
||||
}
|
||||
}, [resources]);
|
||||
|
||||
const isResource = selectedItem?.type === "resource";
|
||||
const isTemplate = selectedItem?.type === "template";
|
||||
const selectedResource = isResource ? selectedItem.data : null;
|
||||
const selectedTemplate = isTemplate ? selectedItem.data : null;
|
||||
|
||||
// Fetch resource content when shouldFetchResource is set
|
||||
useEffect(() => {
|
||||
if (!shouldFetchResource || !inspectorClient) return;
|
||||
|
||||
const fetchContent = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const invocation =
|
||||
await inspectorClient.readResource(shouldFetchResource);
|
||||
setResourceContent(invocation.result);
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof Error ? err.message : "Failed to read resource",
|
||||
);
|
||||
setResourceContent(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setShouldFetchResource(null);
|
||||
}
|
||||
};
|
||||
|
||||
fetchContent();
|
||||
}, [shouldFetchResource, inspectorClient]);
|
||||
|
||||
const listWidth = Math.floor(width * 0.4);
|
||||
const detailWidth = width - listWidth;
|
||||
|
||||
// Update count when items change - use ref to track previous count and only call when it actually changes
|
||||
const prevCountRef = useRef<number>(totalCount);
|
||||
useEffect(() => {
|
||||
if (prevCountRef.current !== totalCount) {
|
||||
prevCountRef.current = totalCount;
|
||||
onCountChange?.(totalCount);
|
||||
}
|
||||
}, [totalCount, onCountChange]);
|
||||
|
||||
return (
|
||||
<Box flexDirection="row" width={width} height={height}>
|
||||
{/* Resources and Templates List */}
|
||||
<Box
|
||||
width={listWidth}
|
||||
height={height}
|
||||
borderStyle="single"
|
||||
borderTop={false}
|
||||
borderBottom={false}
|
||||
borderLeft={false}
|
||||
borderRight={true}
|
||||
flexDirection="column"
|
||||
paddingX={1}
|
||||
>
|
||||
<Box paddingY={1}>
|
||||
<Text
|
||||
bold
|
||||
backgroundColor={focusedPane === "list" ? "yellow" : undefined}
|
||||
>
|
||||
Resources ({totalCount})
|
||||
</Text>
|
||||
</Box>
|
||||
{error ? (
|
||||
<Box paddingY={1}>
|
||||
<Text color="red">{error}</Text>
|
||||
</Box>
|
||||
) : totalCount === 0 ? (
|
||||
<Box paddingY={1}>
|
||||
<Text dimColor>No resources available</Text>
|
||||
</Box>
|
||||
) : (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
height={visibleCount}
|
||||
overflow="hidden"
|
||||
flexShrink={0}
|
||||
>
|
||||
{allItems
|
||||
.slice(firstVisible, firstVisible + visibleCount)
|
||||
.map((item, i) => {
|
||||
const index = firstVisible + i;
|
||||
const isSelected = index === selectedIndex;
|
||||
const label =
|
||||
item.type === "resource"
|
||||
? item.data.name || item.data.uri || `Resource ${index + 1}`
|
||||
: item.data.name ||
|
||||
`Template ${index - resources.length + 1}`;
|
||||
const key =
|
||||
item.type === "resource"
|
||||
? item.data.uri || index
|
||||
: item.data.uriTemplate || index;
|
||||
return (
|
||||
<Box key={key} paddingY={0} flexShrink={0}>
|
||||
<Text>
|
||||
{isSelected ? "▶ " : " "}
|
||||
{label}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Resource Details */}
|
||||
<Box
|
||||
width={detailWidth}
|
||||
height={height}
|
||||
paddingX={1}
|
||||
flexDirection="column"
|
||||
overflow="hidden"
|
||||
>
|
||||
{selectedResource ? (
|
||||
<>
|
||||
{/* Fixed header */}
|
||||
<Box flexShrink={0} paddingTop={1}>
|
||||
<Text
|
||||
bold
|
||||
backgroundColor={
|
||||
focusedPane === "details" ? "yellow" : undefined
|
||||
}
|
||||
{...(focusedPane === "details" ? {} : { color: "cyan" })}
|
||||
>
|
||||
{selectedResource.name || selectedResource.uri}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{/* Scrollable content area */}
|
||||
<ScrollView ref={scrollViewRef} height={height - 3}>
|
||||
{/* Description */}
|
||||
{selectedResource.description && (
|
||||
<>
|
||||
{selectedResource.description
|
||||
.split("\n")
|
||||
.map((line: string, idx: number) => (
|
||||
<Box
|
||||
key={`desc-${idx}`}
|
||||
marginTop={idx === 0 ? 1 : 0}
|
||||
flexShrink={0}
|
||||
>
|
||||
<Text dimColor>{line}</Text>
|
||||
</Box>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* URI */}
|
||||
{selectedResource.uri && (
|
||||
<Box marginTop={1} flexShrink={0}>
|
||||
<Text dimColor>URI: {selectedResource.uri}</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* MIME Type */}
|
||||
{selectedResource.mimeType && (
|
||||
<Box marginTop={1} flexShrink={0}>
|
||||
<Text dimColor>MIME Type: {selectedResource.mimeType}</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Resource Content */}
|
||||
{loading && (
|
||||
<Box marginTop={1} flexShrink={0}>
|
||||
<Text color="yellow">Loading resource content...</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{!loading && resourceContent && (
|
||||
<>
|
||||
<Box marginTop={1} flexShrink={0}>
|
||||
<Text bold>Content:</Text>
|
||||
</Box>
|
||||
<Box marginTop={1} paddingLeft={2} flexShrink={0}>
|
||||
<Text dimColor>
|
||||
{JSON.stringify(resourceContent, null, 2)}
|
||||
</Text>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!loading && !resourceContent && selectedResource.uri && (
|
||||
<Box marginTop={1} flexShrink={0}>
|
||||
<Text dimColor>[Enter to Fetch Resource]</Text>
|
||||
</Box>
|
||||
)}
|
||||
</ScrollView>
|
||||
|
||||
{/* Fixed footer - only show when details pane is focused */}
|
||||
{focusedPane === "details" && (
|
||||
<Box
|
||||
flexShrink={0}
|
||||
height={1}
|
||||
justifyContent="center"
|
||||
backgroundColor="gray"
|
||||
>
|
||||
<Text bold color="white">
|
||||
{resourceContent
|
||||
? "↑/↓ to scroll, + to zoom"
|
||||
: "Enter to fetch, ↑/↓ to scroll"}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
) : selectedTemplate ? (
|
||||
<>
|
||||
{/* Fixed header */}
|
||||
<Box flexShrink={0} paddingTop={1}>
|
||||
<Text
|
||||
bold
|
||||
backgroundColor={
|
||||
focusedPane === "details" ? "yellow" : undefined
|
||||
}
|
||||
{...(focusedPane === "details" ? {} : { color: "cyan" })}
|
||||
>
|
||||
{selectedTemplate.name}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{/* Scrollable content area */}
|
||||
<ScrollView ref={scrollViewRef} height={height - 3}>
|
||||
{/* Description */}
|
||||
{selectedTemplate.description && (
|
||||
<>
|
||||
{selectedTemplate.description
|
||||
.split("\n")
|
||||
.map((line: string, idx: number) => (
|
||||
<Box
|
||||
key={`desc-${idx}`}
|
||||
marginTop={idx === 0 ? 1 : 0}
|
||||
flexShrink={0}
|
||||
>
|
||||
<Text dimColor>{line}</Text>
|
||||
</Box>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* URI Template */}
|
||||
{selectedTemplate.uriTemplate && (
|
||||
<Box marginTop={1} flexShrink={0}>
|
||||
<Text dimColor>
|
||||
URI Template: {selectedTemplate.uriTemplate}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box marginTop={1} flexShrink={0}>
|
||||
<Text dimColor>[Enter to Fetch Resource]</Text>
|
||||
</Box>
|
||||
</ScrollView>
|
||||
|
||||
{/* Fixed footer - only show when details pane is focused */}
|
||||
{focusedPane === "details" && (
|
||||
<Box
|
||||
flexShrink={0}
|
||||
height={1}
|
||||
justifyContent="center"
|
||||
backgroundColor="gray"
|
||||
>
|
||||
<Text bold color="white">
|
||||
Enter to fetch
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<Box paddingY={1} flexShrink={0}>
|
||||
<Text dimColor>Select a resource or template to view details</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
22
clients/tui/src/components/SelectableItem.tsx
Normal file
22
clients/tui/src/components/SelectableItem.tsx
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import React from "react";
|
||||
import { Box, Text } from "ink";
|
||||
|
||||
/** Renders a selectable item: ▶ + space when selected, space + space when not. Fixed width to prevent layout shift. */
|
||||
export function SelectableItem({
|
||||
isSelected,
|
||||
bold,
|
||||
children,
|
||||
}: {
|
||||
isSelected: boolean;
|
||||
bold?: boolean;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Box flexShrink={0} flexDirection="row">
|
||||
<Box width={2}>
|
||||
<Text bold={bold}>{isSelected ? "▶ " : " "}</Text>
|
||||
</Box>
|
||||
<Text bold={bold}>{children}</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
85
clients/tui/src/components/Tabs.tsx
Normal file
85
clients/tui/src/components/Tabs.tsx
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
import React from "react";
|
||||
import { Box, Text } from "ink";
|
||||
import { type TabType, tabs } from "./tabsConfig.js";
|
||||
|
||||
interface TabsProps {
|
||||
activeTab: TabType;
|
||||
onTabChange: (tab: TabType) => void;
|
||||
width: number;
|
||||
counts?: {
|
||||
info?: number;
|
||||
auth?: number;
|
||||
resources?: number;
|
||||
prompts?: number;
|
||||
tools?: number;
|
||||
messages?: number;
|
||||
requests?: number;
|
||||
logging?: number;
|
||||
};
|
||||
focused?: boolean;
|
||||
showAuth?: boolean;
|
||||
showLogging?: boolean;
|
||||
showRequests?: boolean;
|
||||
}
|
||||
|
||||
export function Tabs({
|
||||
activeTab,
|
||||
width,
|
||||
counts = {},
|
||||
focused = false,
|
||||
showAuth = true,
|
||||
showLogging = true,
|
||||
showRequests = false,
|
||||
}: TabsProps) {
|
||||
let visibleTabs = tabs;
|
||||
if (!showAuth) {
|
||||
visibleTabs = visibleTabs.filter((tab) => tab.id !== "auth");
|
||||
}
|
||||
if (!showLogging) {
|
||||
visibleTabs = visibleTabs.filter((tab) => tab.id !== "logging");
|
||||
}
|
||||
if (!showRequests) {
|
||||
visibleTabs = visibleTabs.filter((tab) => tab.id !== "requests");
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
width={width}
|
||||
flexShrink={0}
|
||||
borderStyle="single"
|
||||
borderTop={false}
|
||||
borderLeft={false}
|
||||
borderRight={false}
|
||||
borderBottom={true}
|
||||
flexDirection="row"
|
||||
justifyContent="space-between"
|
||||
flexWrap="wrap"
|
||||
paddingX={1}
|
||||
>
|
||||
{visibleTabs.map((tab) => {
|
||||
const isActive = activeTab === tab.id;
|
||||
const count = counts[tab.id];
|
||||
const countText = count !== undefined ? ` (${count})` : "";
|
||||
const firstChar = tab.label[0];
|
||||
const restOfLabel = tab.label.slice(1);
|
||||
|
||||
return (
|
||||
<Box key={tab.id} flexShrink={0}>
|
||||
<Text
|
||||
bold={isActive}
|
||||
{...(isActive && focused
|
||||
? {}
|
||||
: { color: isActive ? "cyan" : "gray" })}
|
||||
backgroundColor={isActive && focused ? "yellow" : undefined}
|
||||
>
|
||||
{isActive ? "▶ " : " "}
|
||||
<Text underline>{firstChar}</Text>
|
||||
{restOfLabel}
|
||||
{countText}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
287
clients/tui/src/components/ToolTestModal.tsx
Normal file
287
clients/tui/src/components/ToolTestModal.tsx
Normal file
|
|
@ -0,0 +1,287 @@
|
|||
import React, { useState } from "react";
|
||||
import { Box, Text, useInput, type Key } from "ink";
|
||||
import { Form } from "ink-form";
|
||||
import { InspectorClient } from "@inspector/core/mcp/index.js";
|
||||
import type { Tool, CallToolResult } from "@modelcontextprotocol/sdk/types.js";
|
||||
import type { JsonValue } from "@inspector/core/mcp/index.js";
|
||||
import { schemaToForm } from "../utils/schemaToForm.js";
|
||||
import { ScrollView, type ScrollViewRef } from "ink-scroll-view";
|
||||
|
||||
interface ToolTestModalProps {
|
||||
tool: Tool;
|
||||
inspectorClient: InspectorClient | null;
|
||||
width: number;
|
||||
height: number;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
type ModalState = "form" | "loading" | "results";
|
||||
|
||||
interface ToolResult {
|
||||
input: Record<string, JsonValue>;
|
||||
output: CallToolResult | null;
|
||||
error?: string;
|
||||
errorDetails?: unknown;
|
||||
duration: number;
|
||||
}
|
||||
|
||||
export function ToolTestModal({
|
||||
tool,
|
||||
inspectorClient,
|
||||
width,
|
||||
height,
|
||||
onClose,
|
||||
}: ToolTestModalProps) {
|
||||
const [state, setState] = useState<ModalState>("form");
|
||||
const [result, setResult] = useState<ToolResult | null>(null);
|
||||
const scrollViewRef = React.useRef<ScrollViewRef>(null);
|
||||
|
||||
// Use full terminal dimensions instead of passed dimensions
|
||||
const [terminalDimensions, setTerminalDimensions] = React.useState({
|
||||
width: process.stdout.columns || width,
|
||||
height: process.stdout.rows || height,
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
const updateDimensions = () => {
|
||||
setTerminalDimensions({
|
||||
width: process.stdout.columns || width,
|
||||
height: process.stdout.rows || height,
|
||||
});
|
||||
};
|
||||
process.stdout.on("resize", updateDimensions);
|
||||
updateDimensions();
|
||||
return () => {
|
||||
process.stdout.off("resize", updateDimensions);
|
||||
};
|
||||
}, [width, height]);
|
||||
|
||||
const formStructure = tool?.inputSchema
|
||||
? schemaToForm(tool.inputSchema, tool.name || "Unknown Tool")
|
||||
: {
|
||||
title: `Test Tool: ${tool?.name || "Unknown"}`,
|
||||
sections: [{ title: "Parameters", fields: [] }],
|
||||
};
|
||||
|
||||
// Reset state when modal closes
|
||||
React.useEffect(() => {
|
||||
return () => {
|
||||
// Cleanup: reset state when component unmounts
|
||||
setState("form");
|
||||
setResult(null);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Handle all input when modal is open - prevents input from reaching underlying components
|
||||
// When in form mode, only handle escape (form handles its own input)
|
||||
// When in results mode, handle scrolling keys
|
||||
useInput(
|
||||
(input: string, key: Key) => {
|
||||
// Always handle escape to close modal
|
||||
if (key.escape) {
|
||||
setState("form");
|
||||
setResult(null);
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
|
||||
if (state === "form") {
|
||||
// In form mode, let the form handle all other input
|
||||
// Don't process anything else - this prevents input from reaching underlying components
|
||||
return;
|
||||
}
|
||||
|
||||
if (state === "results") {
|
||||
// Allow scrolling in results view
|
||||
if (key.downArrow) {
|
||||
scrollViewRef.current?.scrollBy(1);
|
||||
} else if (key.upArrow) {
|
||||
scrollViewRef.current?.scrollBy(-1);
|
||||
} else if (key.pageDown) {
|
||||
const viewportHeight =
|
||||
scrollViewRef.current?.getViewportHeight() || 1;
|
||||
scrollViewRef.current?.scrollBy(viewportHeight);
|
||||
} else if (key.pageUp) {
|
||||
const viewportHeight =
|
||||
scrollViewRef.current?.getViewportHeight() || 1;
|
||||
scrollViewRef.current?.scrollBy(-viewportHeight);
|
||||
}
|
||||
}
|
||||
},
|
||||
{ isActive: true },
|
||||
);
|
||||
|
||||
const handleFormSubmit = async (values: Record<string, JsonValue>) => {
|
||||
if (!inspectorClient || !tool) return;
|
||||
|
||||
setState("loading");
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
// Use InspectorClient.callTool() which handles parameter conversion and metadata
|
||||
const invocation = await inspectorClient.callTool(tool, values);
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
// InspectorClient.callTool() returns ToolCallInvocation
|
||||
// Check if the call succeeded and extract the result
|
||||
if (!invocation.success || invocation.result === null) {
|
||||
// Error case: tool call failed
|
||||
setResult({
|
||||
input: values,
|
||||
output: null,
|
||||
error: invocation.error || "Tool call failed",
|
||||
errorDetails: invocation,
|
||||
duration,
|
||||
});
|
||||
} else {
|
||||
// Success case: extract the result
|
||||
const result = invocation.result;
|
||||
// Check for error indicators in the result (SDK may return error in result)
|
||||
const isError = "isError" in result && result.isError === true;
|
||||
|
||||
setResult({
|
||||
input: values,
|
||||
output: isError ? null : result,
|
||||
error: isError ? "Tool returned an error" : undefined,
|
||||
errorDetails: isError ? result : undefined,
|
||||
duration,
|
||||
});
|
||||
}
|
||||
setState("results");
|
||||
} catch (error) {
|
||||
const duration = Date.now() - startTime;
|
||||
const errorObj =
|
||||
error instanceof Error
|
||||
? { message: error.message, name: error.name, stack: error.stack }
|
||||
: { error: String(error) };
|
||||
|
||||
setResult({
|
||||
input: values,
|
||||
output: null,
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
errorDetails: errorObj,
|
||||
duration,
|
||||
});
|
||||
setState("results");
|
||||
}
|
||||
};
|
||||
|
||||
// Calculate modal dimensions - use almost full screen
|
||||
const modalWidth = terminalDimensions.width - 2;
|
||||
const modalHeight = terminalDimensions.height - 2;
|
||||
|
||||
return (
|
||||
<Box
|
||||
position="absolute"
|
||||
width={terminalDimensions.width}
|
||||
height={terminalDimensions.height}
|
||||
flexDirection="column"
|
||||
justifyContent="center"
|
||||
alignItems="center"
|
||||
>
|
||||
{/* Modal Content */}
|
||||
<Box
|
||||
width={modalWidth}
|
||||
height={modalHeight}
|
||||
borderStyle="single"
|
||||
borderColor="cyan"
|
||||
flexDirection="column"
|
||||
paddingX={1}
|
||||
paddingY={1}
|
||||
backgroundColor="black"
|
||||
>
|
||||
{/* Header */}
|
||||
<Box flexShrink={0} marginBottom={1}>
|
||||
<Text bold color="cyan">
|
||||
{formStructure.title}
|
||||
</Text>
|
||||
<Text> </Text>
|
||||
<Text dimColor>(Press ESC to close)</Text>
|
||||
</Box>
|
||||
|
||||
{/* Content Area */}
|
||||
<Box flexGrow={1} flexDirection="column" overflow="hidden">
|
||||
{state === "form" && (
|
||||
<Box flexGrow={1} width="100%">
|
||||
<Form
|
||||
form={formStructure}
|
||||
onSubmit={(value: object) =>
|
||||
void handleFormSubmit(value as Record<string, JsonValue>)
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{state === "loading" && (
|
||||
<Box flexGrow={1} justifyContent="center" alignItems="center">
|
||||
<Text color="yellow">Calling tool...</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{state === "results" && result && (
|
||||
<Box flexGrow={1} flexDirection="column" overflow="hidden">
|
||||
<ScrollView ref={scrollViewRef}>
|
||||
{/* Timing */}
|
||||
<Box marginBottom={1} flexShrink={0}>
|
||||
<Text bold color="green">
|
||||
Duration: {result.duration}ms
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{/* Input */}
|
||||
<Box marginBottom={1} flexShrink={0} flexDirection="column">
|
||||
<Text bold color="cyan">
|
||||
Input:
|
||||
</Text>
|
||||
<Box paddingLeft={2}>
|
||||
<Text dimColor>
|
||||
{JSON.stringify(result.input, null, 2)}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Output or Error */}
|
||||
{result.error ? (
|
||||
<Box flexShrink={0} flexDirection="column">
|
||||
<Text bold color="red">
|
||||
Error:
|
||||
</Text>
|
||||
<Box paddingLeft={2}>
|
||||
<Text color="red">{String(result.error)}</Text>
|
||||
</Box>
|
||||
{result.errorDetails != null ? (
|
||||
<>
|
||||
<Box marginTop={1}>
|
||||
<Text bold color="red" dimColor>
|
||||
Error Details:
|
||||
</Text>
|
||||
</Box>
|
||||
<Box paddingLeft={2}>
|
||||
<Text dimColor>
|
||||
{JSON.stringify(result.errorDetails, null, 2)}
|
||||
</Text>
|
||||
</Box>
|
||||
</>
|
||||
) : null}
|
||||
</Box>
|
||||
) : (
|
||||
<Box flexShrink={0} flexDirection="column">
|
||||
<Text bold color="green">
|
||||
Output:
|
||||
</Text>
|
||||
<Box paddingLeft={2}>
|
||||
<Text dimColor>
|
||||
{JSON.stringify(result.output, null, 2)}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</ScrollView>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
247
clients/tui/src/components/ToolsTab.tsx
Normal file
247
clients/tui/src/components/ToolsTab.tsx
Normal file
|
|
@ -0,0 +1,247 @@
|
|||
import React, { useState, useEffect, useRef } from "react";
|
||||
import { Box, Text, useInput, type Key } from "ink";
|
||||
import { ScrollView, type ScrollViewRef } from "ink-scroll-view";
|
||||
import type { Tool } from "@modelcontextprotocol/sdk/types.js";
|
||||
import { useSelectableList } from "../hooks/useSelectableList.js";
|
||||
|
||||
interface ToolsTabProps {
|
||||
tools: Tool[];
|
||||
isConnected: boolean;
|
||||
width: number;
|
||||
height: number;
|
||||
onCountChange?: (count: number) => void;
|
||||
focusedPane?: "list" | "details" | null;
|
||||
onTestTool?: (tool: Tool) => void;
|
||||
onViewDetails?: (tool: Tool) => void;
|
||||
modalOpen?: boolean;
|
||||
}
|
||||
|
||||
export function ToolsTab({
|
||||
tools,
|
||||
isConnected,
|
||||
width,
|
||||
height,
|
||||
focusedPane = null,
|
||||
onTestTool,
|
||||
onViewDetails,
|
||||
modalOpen = false,
|
||||
}: ToolsTabProps) {
|
||||
const visibleCount = Math.max(1, height - 7);
|
||||
const { selectedIndex, firstVisible, setSelection } = useSelectableList(
|
||||
tools.length,
|
||||
visibleCount,
|
||||
{ resetWhen: tools },
|
||||
);
|
||||
const [error] = useState<string | null>(null);
|
||||
const scrollViewRef = useRef<ScrollViewRef>(null);
|
||||
const listWidth = Math.floor(width * 0.4);
|
||||
const detailWidth = width - listWidth;
|
||||
|
||||
// Handle arrow key navigation when focused
|
||||
useInput(
|
||||
(input: string, key: Key) => {
|
||||
// Handle Enter key to test tool (works from both list and details)
|
||||
if (key.return && selectedTool && isConnected && onTestTool) {
|
||||
onTestTool(selectedTool);
|
||||
return;
|
||||
}
|
||||
|
||||
if (focusedPane === "list") {
|
||||
if (key.upArrow && selectedIndex > 0) {
|
||||
setSelection(selectedIndex - 1);
|
||||
} else if (key.downArrow && selectedIndex < tools.length - 1) {
|
||||
setSelection(selectedIndex + 1);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (focusedPane === "details") {
|
||||
// Handle '+' key to view in full screen modal
|
||||
if (input === "+" && selectedTool && onViewDetails) {
|
||||
onViewDetails(selectedTool);
|
||||
return;
|
||||
}
|
||||
|
||||
// Scroll the details pane using ink-scroll-view
|
||||
if (key.upArrow) {
|
||||
scrollViewRef.current?.scrollBy(-1);
|
||||
} else if (key.downArrow) {
|
||||
scrollViewRef.current?.scrollBy(1);
|
||||
} else if (key.pageUp) {
|
||||
const viewportHeight =
|
||||
scrollViewRef.current?.getViewportHeight() || 1;
|
||||
scrollViewRef.current?.scrollBy(-viewportHeight);
|
||||
} else if (key.pageDown) {
|
||||
const viewportHeight =
|
||||
scrollViewRef.current?.getViewportHeight() || 1;
|
||||
scrollViewRef.current?.scrollBy(viewportHeight);
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
isActive:
|
||||
!modalOpen && (focusedPane === "list" || focusedPane === "details"),
|
||||
},
|
||||
);
|
||||
|
||||
// Reset scroll when selection changes
|
||||
useEffect(() => {
|
||||
scrollViewRef.current?.scrollTo(0);
|
||||
}, [selectedIndex]);
|
||||
|
||||
const selectedTool = tools[selectedIndex] || null;
|
||||
|
||||
return (
|
||||
<Box flexDirection="row" width={width} height={height}>
|
||||
{/* Tools List */}
|
||||
<Box
|
||||
width={listWidth}
|
||||
height={height}
|
||||
borderStyle="single"
|
||||
borderTop={false}
|
||||
borderBottom={false}
|
||||
borderLeft={false}
|
||||
borderRight={true}
|
||||
flexDirection="column"
|
||||
paddingX={1}
|
||||
>
|
||||
<Box paddingY={1}>
|
||||
<Text
|
||||
bold
|
||||
backgroundColor={focusedPane === "list" ? "yellow" : undefined}
|
||||
>
|
||||
Tools ({tools.length})
|
||||
</Text>
|
||||
</Box>
|
||||
{error ? (
|
||||
<Box paddingY={1}>
|
||||
<Text color="red">{error}</Text>
|
||||
</Box>
|
||||
) : tools.length === 0 ? (
|
||||
<Box paddingY={1}>
|
||||
<Text dimColor>No tools available</Text>
|
||||
</Box>
|
||||
) : (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
height={visibleCount}
|
||||
overflow="hidden"
|
||||
flexShrink={0}
|
||||
>
|
||||
{tools
|
||||
.slice(firstVisible, firstVisible + visibleCount)
|
||||
.map((tool, i) => {
|
||||
const index = firstVisible + i;
|
||||
const isSelected = index === selectedIndex;
|
||||
return (
|
||||
<Box key={tool.name || index} paddingY={0} flexShrink={0}>
|
||||
<Text>
|
||||
{isSelected ? "▶ " : " "}
|
||||
{tool.name || `Tool ${index + 1}`}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Tool Details */}
|
||||
<Box
|
||||
width={detailWidth}
|
||||
height={height}
|
||||
paddingX={1}
|
||||
flexDirection="column"
|
||||
overflow="hidden"
|
||||
>
|
||||
{selectedTool ? (
|
||||
<>
|
||||
{/* Fixed header */}
|
||||
<Box
|
||||
flexShrink={0}
|
||||
flexDirection="row"
|
||||
justifyContent="space-between"
|
||||
paddingTop={1}
|
||||
>
|
||||
<Text
|
||||
bold
|
||||
backgroundColor={
|
||||
focusedPane === "details" ? "yellow" : undefined
|
||||
}
|
||||
{...(focusedPane === "details" ? {} : { color: "cyan" })}
|
||||
>
|
||||
{selectedTool.name}
|
||||
</Text>
|
||||
{isConnected && (
|
||||
<Text>
|
||||
<Text color="cyan" bold>
|
||||
[Enter to Test]
|
||||
</Text>
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Scrollable content area - direct ScrollView with height prop like NotificationsTab */}
|
||||
<ScrollView ref={scrollViewRef} height={height - 5}>
|
||||
{/* Description */}
|
||||
{selectedTool.description && (
|
||||
<>
|
||||
{selectedTool.description
|
||||
.split("\n")
|
||||
.map((line: string, idx: number) => (
|
||||
<Box
|
||||
key={`desc-${idx}`}
|
||||
marginTop={idx === 0 ? 1 : 0}
|
||||
flexShrink={0}
|
||||
>
|
||||
<Text dimColor>{line}</Text>
|
||||
</Box>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Input Schema */}
|
||||
{selectedTool.inputSchema && (
|
||||
<>
|
||||
<Box marginTop={1} flexShrink={0}>
|
||||
<Text bold>Input Schema:</Text>
|
||||
</Box>
|
||||
{JSON.stringify(selectedTool.inputSchema, null, 2)
|
||||
.split("\n")
|
||||
.map((line: string, idx: number) => (
|
||||
<Box
|
||||
key={`schema-${idx}`}
|
||||
marginTop={idx === 0 ? 1 : 0}
|
||||
paddingLeft={2}
|
||||
flexShrink={0}
|
||||
>
|
||||
<Text dimColor>{line}</Text>
|
||||
</Box>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</ScrollView>
|
||||
|
||||
{/* Fixed footer - only show when details pane is focused */}
|
||||
{focusedPane === "details" && (
|
||||
<Box
|
||||
flexShrink={0}
|
||||
height={1}
|
||||
justifyContent="center"
|
||||
backgroundColor="gray"
|
||||
>
|
||||
<Text bold color="white">
|
||||
↑/↓ to scroll, + to zoom
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<Box paddingY={1} flexShrink={0}>
|
||||
<Text dimColor>Select a tool to view details</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
20
clients/tui/src/components/tabsConfig.ts
Normal file
20
clients/tui/src/components/tabsConfig.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
export type TabType =
|
||||
| "info"
|
||||
| "auth"
|
||||
| "resources"
|
||||
| "prompts"
|
||||
| "tools"
|
||||
| "messages"
|
||||
| "requests"
|
||||
| "logging";
|
||||
|
||||
export const tabs: { id: TabType; label: string; accelerator: string }[] = [
|
||||
{ id: "info", label: "Info", accelerator: "i" },
|
||||
{ id: "auth", label: "Auth", accelerator: "a" },
|
||||
{ id: "resources", label: "Resources", accelerator: "r" },
|
||||
{ id: "prompts", label: "Prompts", accelerator: "p" },
|
||||
{ id: "tools", label: "Tools", accelerator: "t" },
|
||||
{ id: "messages", label: "Messages", accelerator: "m" },
|
||||
{ id: "requests", label: "HTTP Requests", accelerator: "h" },
|
||||
{ id: "logging", label: "Logging", accelerator: "l" },
|
||||
];
|
||||
61
clients/tui/src/hooks/useSelectableList.ts
Normal file
61
clients/tui/src/hooks/useSelectableList.ts
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
import { useState, useEffect, useCallback } from "react";
|
||||
|
||||
function clampFirstVisible(
|
||||
first: number,
|
||||
selected: number,
|
||||
visibleCount: number,
|
||||
): number {
|
||||
if (selected < first) return selected;
|
||||
if (selected >= first + visibleCount) return selected - visibleCount + 1;
|
||||
return first;
|
||||
}
|
||||
|
||||
export interface UseSelectableListOptions {
|
||||
/** When this reference changes, reset selection to 0 (e.g. tools list when switching servers) */
|
||||
resetWhen?: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages selection and scroll position for a virtualized list.
|
||||
* Returns selection state and a setSelection that updates both
|
||||
* selectedIndex and firstVisible so the selected item stays in view.
|
||||
*/
|
||||
export function useSelectableList(
|
||||
itemCount: number,
|
||||
visibleCount: number,
|
||||
options?: UseSelectableListOptions,
|
||||
) {
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
const [firstVisible, setFirstVisible] = useState(0);
|
||||
|
||||
const setSelection = useCallback(
|
||||
(newIndex: number) => {
|
||||
setSelectedIndex(newIndex);
|
||||
setFirstVisible((prev) =>
|
||||
clampFirstVisible(prev, newIndex, visibleCount),
|
||||
);
|
||||
},
|
||||
[visibleCount],
|
||||
);
|
||||
|
||||
// Reset when the list reference changes (e.g. different server).
|
||||
useEffect(() => {
|
||||
if (options?.resetWhen !== undefined) {
|
||||
setSelectedIndex(0);
|
||||
setFirstVisible(0);
|
||||
}
|
||||
}, [options?.resetWhen]);
|
||||
|
||||
// Clamp when list shrinks
|
||||
useEffect(() => {
|
||||
if (itemCount > 0 && selectedIndex >= itemCount) {
|
||||
const newIndex = itemCount - 1;
|
||||
setSelectedIndex(newIndex);
|
||||
setFirstVisible((prev) =>
|
||||
clampFirstVisible(prev, newIndex, visibleCount),
|
||||
);
|
||||
}
|
||||
}, [itemCount, selectedIndex, visibleCount]);
|
||||
|
||||
return { selectedIndex, firstVisible, setSelection };
|
||||
}
|
||||
30
clients/tui/src/logger.ts
Normal file
30
clients/tui/src/logger.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import path from "node:path";
|
||||
import pino from "pino";
|
||||
import type { Logger } from "pino";
|
||||
|
||||
let tuiLoggerInstance: Logger | undefined;
|
||||
|
||||
/**
|
||||
* TUI file logger for auth and InspectorClient events.
|
||||
* Writes to ~/.mcp-inspector/auth.log so TUI console output is not corrupted.
|
||||
* Lazy-initialized so --help and other early exits never open the log stream.
|
||||
*/
|
||||
export function getTuiLogger(): Logger {
|
||||
if (!tuiLoggerInstance) {
|
||||
const logDir =
|
||||
process.env.MCP_INSPECTOR_LOG_DIR ??
|
||||
path.join(
|
||||
process.env.HOME || process.env.USERPROFILE || ".",
|
||||
".mcp-inspector",
|
||||
);
|
||||
const logPath = path.join(logDir, "auth.log");
|
||||
tuiLoggerInstance = pino(
|
||||
{
|
||||
name: "mcp-inspector-tui",
|
||||
level: process.env.LOG_LEVEL ?? "info",
|
||||
},
|
||||
pino.destination({ dest: logPath, append: true, mkdir: true }),
|
||||
);
|
||||
}
|
||||
return tuiLoggerInstance;
|
||||
}
|
||||
100
clients/tui/src/tui-servers.ts
Normal file
100
clients/tui/src/tui-servers.ts
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import type {
|
||||
InspectorServerSettings,
|
||||
MCPServerConfig,
|
||||
MCPConfig,
|
||||
StdioServerConfig,
|
||||
} from "@inspector/core/mcp/types.js";
|
||||
import { DEFAULT_TASK_TTL_MS } from "@inspector/core/mcp/types.js";
|
||||
import { mcpConfigToServerEntries } from "@inspector/core/mcp/serverList.js";
|
||||
import {
|
||||
resolveServerConfigs,
|
||||
withDefaultConfigPath,
|
||||
type ServerConfigOptions,
|
||||
} from "@inspector/core/mcp/node/config.js";
|
||||
|
||||
export type TuiServer = {
|
||||
config: MCPServerConfig;
|
||||
settings?: InspectorServerSettings;
|
||||
};
|
||||
|
||||
export function headersToServerSettings(
|
||||
headers?: Record<string, string>,
|
||||
): InspectorServerSettings | undefined {
|
||||
if (!headers || Object.keys(headers).length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
headers: Object.entries(headers).map(([key, value]) => ({ key, value })),
|
||||
metadata: [],
|
||||
connectionTimeout: 0,
|
||||
requestTimeout: 0,
|
||||
taskTtl: DEFAULT_TASK_TTL_MS,
|
||||
autoRefreshOnListChanged: false,
|
||||
roots: [],
|
||||
};
|
||||
}
|
||||
|
||||
function applyStdioOverrides(
|
||||
config: MCPServerConfig,
|
||||
overrides: { env?: Record<string, string>; cwd?: string },
|
||||
): MCPServerConfig {
|
||||
if (config.type !== "stdio") return config;
|
||||
const c = { ...config } as StdioServerConfig;
|
||||
if (overrides.env && Object.keys(overrides.env).length > 0) {
|
||||
c.env = { ...(c.env ?? {}), ...overrides.env };
|
||||
}
|
||||
if (overrides.cwd?.trim()) {
|
||||
c.cwd = overrides.cwd.trim();
|
||||
}
|
||||
return c;
|
||||
}
|
||||
|
||||
function mergeSettings(
|
||||
base: InspectorServerSettings | undefined,
|
||||
headers?: Record<string, string>,
|
||||
): InspectorServerSettings | undefined {
|
||||
const fromHeaders = headersToServerSettings(headers);
|
||||
if (!fromHeaders) return base;
|
||||
if (!base) return fromHeaders;
|
||||
return { ...base, headers: fromHeaders.headers };
|
||||
}
|
||||
|
||||
export function loadTuiServers(
|
||||
serverOptions: ServerConfigOptions & { headers?: Record<string, string> },
|
||||
): Record<string, TuiServer> {
|
||||
serverOptions = withDefaultConfigPath(serverOptions);
|
||||
const hasConfigPath = Boolean(serverOptions.configPath?.trim());
|
||||
|
||||
if (hasConfigPath) {
|
||||
const configPath = serverOptions.configPath!;
|
||||
const resolvedPath = resolve(process.cwd(), configPath);
|
||||
const config = JSON.parse(readFileSync(resolvedPath, "utf-8")) as MCPConfig;
|
||||
const entries = mcpConfigToServerEntries(config);
|
||||
const result: Record<string, TuiServer> = {};
|
||||
for (const entry of entries) {
|
||||
result[entry.name] = {
|
||||
config: applyStdioOverrides(entry.config, {
|
||||
env: serverOptions.env,
|
||||
cwd: serverOptions.cwd,
|
||||
}),
|
||||
settings: mergeSettings(entry.settings, serverOptions.headers),
|
||||
};
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
const configs = resolveServerConfigs(serverOptions, "multi");
|
||||
if (configs.length === 0) {
|
||||
throw new Error(
|
||||
"At least one server is required. Use --config <path> or ad-hoc target (command/URL).",
|
||||
);
|
||||
}
|
||||
return {
|
||||
default: {
|
||||
config: configs[0]!,
|
||||
settings: headersToServerSettings(serverOptions.headers),
|
||||
},
|
||||
};
|
||||
}
|
||||
12
clients/tui/src/utils/openUrl.ts
Normal file
12
clients/tui/src/utils/openUrl.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import open from "open";
|
||||
|
||||
/**
|
||||
* Opens a URL in the user's default browser.
|
||||
* Used when handling oauthAuthorizationRequired to launch the OAuth authorization page.
|
||||
*
|
||||
* @param url - URL to open (string or URL)
|
||||
* @returns Promise that resolves when the opener completes (or rejects on error)
|
||||
*/
|
||||
export async function openUrl(url: string | URL): Promise<void> {
|
||||
await open(typeof url === "string" ? url : url.href);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue