326 lines
11 KiB
TypeScript
326 lines
11 KiB
TypeScript
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}
|
|
>
|
|
Protocol ({messages.length})
|
|
</Text>
|
|
</Box>
|
|
|
|
{/* Protocol message 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>
|
|
);
|
|
}
|