inspector/clients/tui/src/components/PromptTestModal.tsx
2026-07-02 16:35:31 -07:00

309 lines
9.3 KiB
TypeScript

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 { AuthRecoveryRequiredError } from "@inspector/core/auth/challenge.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;
onAuthRecoveryRequired?: (error: AuthRecoveryRequiredError) => 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,
onAuthRecoveryRequired,
}: 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) {
if (error instanceof AuthRecoveryRequiredError) {
onAuthRecoveryRequired?.(error);
onClose();
return;
}
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>
);
}