A floated promise is invisible at review time — the call reads like an awaited one minus four characters — and the unhandled rejection it produces surfaces in a different test, in a different file, as a stack pointing at SDK internals. In #1947 two un-held `callTool` calls made `npm run ci` unpassable and took a full investigation to attribute; the fix was two lines. `@typescript-eslint/no-floating-promises` catches the class outright, so enable it at `error` everywhere. The rule is type-aware and `tseslint.configs.recommended` is not, so each scope's config gains a parser project. The four clients name their own tsconfig projects (both of them — `src` and `__tests__`/`server` live in different ones). The root scope has no tsconfig at all, so add `tsconfig.lint.json`: a lint-only project covering `core/**`, `test-servers/src/**`, and `vitest.shared.mts`, emitting nothing and gating nothing. It sets `moduleResolution: bundler` deliberately — under NodeNext `core/`'s extensionless imports fail to resolve, every import degrades to `any`, and the rule silently stops seeing promises at all. Measured surface, and the fixes: - core (2) — `stream.close()` in two Hono `onAbort` listeners. Hono invokes subscribers with a bare `subscriber()`, so a promise returned from the listener is floated one layer up; `closeAbortedStream` owns the rejection where it can actually be owned. - tui (5) — `handleDisconnect` had no catch of its own and its only caller is a key handler that cannot await, so a failing disconnect was a live unhandled rejection; it now surfaces the message the way `handleConnect` does. The rest are `void` at callers that genuinely cannot await, over callees that already own their failures. - web (92) — 83 are Storybook `expect(...)`, which is instrumented and returns a promise, now awaited (`scrollAreaStoryAssertions` is async for the same reason). Two tests were asserting against a provider call they never awaited. `open(url)` in both web servers now warns instead of escaping a synchronous callback. - cli, launcher — already clean. Type-aware linting costs time: `clients/web`'s `eslint .` goes from ~8s to ~19s, inside `validate`. Narrowing the loaded projects is the lever if that needs reducing later; dropping the rule is not. Closes #1959 Signed-off-by: cliffhall <cliff@futurescale.com>
285 lines
9 KiB
TypeScript
285 lines
9 KiB
TypeScript
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 { AuthRecoveryRequiredError } from "@inspector/core/auth/challenge.js";
|
|
import type {
|
|
Prompt,
|
|
PromptArgument,
|
|
GetPromptResult,
|
|
} from "@modelcontextprotocol/client";
|
|
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;
|
|
onAuthRecoveryRequired?: (error: AuthRecoveryRequiredError) => void;
|
|
modalOpen?: boolean;
|
|
}
|
|
|
|
export function PromptsTab({
|
|
prompts,
|
|
inspectorClient,
|
|
width,
|
|
height,
|
|
focusedPane = null,
|
|
onViewDetails,
|
|
onFetchPrompt,
|
|
onAuthRecoveryRequired,
|
|
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. The IIFE catches everything it can
|
|
// throw, so there is no rejection for this key handler — which
|
|
// cannot await — to own.
|
|
void (async () => {
|
|
try {
|
|
const invocation = await inspectorClient.getPrompt(
|
|
selectedPrompt.name,
|
|
);
|
|
// Show result in details modal
|
|
if (onViewDetails) {
|
|
onViewDetails({
|
|
...selectedPrompt,
|
|
result: invocation.result,
|
|
});
|
|
}
|
|
} catch (error) {
|
|
if (error instanceof AuthRecoveryRequiredError) {
|
|
onAuthRecoveryRequired?.(error);
|
|
return;
|
|
}
|
|
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>
|
|
);
|
|
}
|