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
1 changed files with 25594 additions and 236 deletions
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>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue