inspector/clients/tui/src/components/SkillsTab.tsx
cliffhall 033600e4ca fix: address Copilot review round 2 on #2248
Four findings, two of them silent holes in the verification itself.

**A blob-served SKILL.md skipped the frontmatter check entirely.** The entry's
own file was captured as `contents.text`, so a server returning the markdown
as a base64 `blob` — a legal `resources/read` shape this module already
decodes for the digest — never reached the comparison, and the report still
said `ok`. A MUST that quietly did not run. The file is now held as bytes and
the text derived from them, which also guarantees the digest and the
frontmatter describe one snapshot.

**A dynamic skill whose SKILL.md could not be read reported `ok: true`.** It
has no manifest rows, so `files` stayed empty, and its only static finding is
a warning — a verification that could not be performed was reported as one
that passed. The fallback read now records a `read-error` for all three ways
it can fail. It is gated on `manifestListsSelf` (by normalized identity) so a
self-entry the manifest listed and failed to read is not read, or reported,
twice.

**A served `.nan` compared equal to a listed `null`.** YAML expresses
non-finite numbers, JSON does not, and `JSON.stringify` turns all of them
into `null` — so the canonical comparison reported a real mismatch as
agreement. Verified against the parser before fixing. They now canonicalize
to a form no JSON scalar can equal, and the value is named in the finding.

Also: the self-entry match is by normalized identity rather than raw string,
which stops a second read and stops this function disagreeing with
`checkSkillConformance`; and `SkillsTab.tsx` gains the file header AGENTS.md
requires.

The four `err instanceof Error` ternaries are one `reasonOf` helper now —
extracted because the coverage gate found every one of their non-Error arms
uncovered, and one honestly-tested branch beats four ignores.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ND46Ci64DC3tzBy9aG81Nc
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-09-07 19:24:30 -04:00

469 lines
17 KiB
TypeScript

/**
* The TUI's Skills pane — the SEP-2640 catalog in a terminal (#2248).
*
* **Why the TUI owns a pane rather than reusing the web screen's logic.** It
* does reuse everything that decides an answer: `checkSkillConformance`,
* `checkSkillNameCollisions` and `verifySkills` all live in `core/` and are
* driven identically here, so a verdict cannot differ depending on which client
* you asked. What is local is presentation, and the terminal's constraints are
* genuinely different — two panes in 80 columns, no colour to rely on, and a
* keyboard rather than a pointer.
*
* ⚠️ **Severity is carried by a glyph as well as a colour** (`✓` / `!` / `✗`).
* This pane is read over ssh, inside tmux, and piped through `script(1)`, where
* colour may not survive; a row whose only signal was `red` would then be
* indistinguishable from a clean one.
*
* The pane is shown only when the connected server declares the extension —
* that gate, and the reset that leaves the tab when it goes false, live in
* `App.tsx` because they are navigation concerns rather than this pane's.
*/
import React, { useCallback, useEffect, useRef, useState } 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 {
checkSkillConformance,
checkSkillNameCollisions,
skillDisplayName,
skillUriIdentity,
type SkillIssue,
} from "@inspector/core/mcp/skills.js";
import {
DYNAMIC_RESOURCES,
type SkillEntry,
} from "@inspector/core/mcp/skillsSchemas.js";
import {
verifySkills,
type SkillVerifyReport,
} from "@inspector/core/mcp/skillsVerification.js";
import { useSelectableList } from "../hooks/useSelectableList.js";
interface SkillsTabProps {
skills: SkillEntry[];
/** Pages the last `skills/list` walk took; shown so pagination is visible. */
pageCount: number;
/** A failed list walk, rendered in place of the list. */
loadError?: Error | null;
inspectorClient: InspectorClient | null;
width: number;
height: number;
focusedPane?: "list" | "details" | null;
onAuthRecoveryRequired?: (error: AuthRecoveryRequiredError) => void;
modalOpen?: boolean;
}
/**
* The character that leads a finding line, by severity. A terminal pane cannot
* lean on colour alone — the Inspector is run over ssh, in tmux, and piped
* through `script(1)` — so severity is carried by a glyph as well as a colour.
*/
const ISSUE_MARK: Record<SkillIssue["severity"], string> = {
error: "✗",
warning: "!",
};
const ISSUE_COLOR: Record<SkillIssue["severity"], string> = {
error: "red",
warning: "yellow",
};
/** Per-file verification glyph, same reasoning as {@link ISSUE_MARK}. */
const FILE_MARK: Record<string, string> = {
verified: "✓",
mismatch: "✗",
unverifiable: "?",
error: "✗",
"read-error": "✗",
};
const FILE_COLOR: Record<string, string> = {
verified: "green",
mismatch: "red",
unverifiable: "yellow",
error: "red",
"read-error": "red",
};
/**
* What a verification result is a result *about*: the whole entry, serialized.
*
* `JSON.stringify` is enough here — this compares an entry against a later copy
* of *itself* from the same server, so key order is stable and there is no need
* for the canonical form `skillEntriesMatch` uses to compare two independently
* produced entries.
*/
function entryKey(entry: SkillEntry): string {
return JSON.stringify(entry);
}
/** The file name a manifest URI ends in, for a list that must fit 40 columns. */
function fileNameOf(uri: string): string {
const cut = uri.lastIndexOf("/");
return cut === -1 ? uri : uri.slice(cut + 1);
}
/**
* The Skills pane (SEP-2640, #2248): the catalog on the left, and on the right
* the selected skill's frontmatter, its conformance findings, and its manifest.
*
* **Enter verifies.** The static checks run on every render — they are a pure
* walk over a list already in memory — but digest verification needs the bytes,
* so it is one `resources/read` per manifest entry and must be asked for. That
* split is the same one the web screen makes and the same one SEP-2640 makes:
* hosts MUST NOT retrieve a skill's files ahead of need.
*/
export function SkillsTab({
skills,
pageCount,
loadError = null,
inspectorClient,
width,
height,
focusedPane = null,
onAuthRecoveryRequired,
modalOpen = false,
}: SkillsTabProps) {
const visibleCount = Math.max(1, height - 7);
const { selectedIndex, firstVisible, setSelection } = useSelectableList(
skills.length,
visibleCount,
{ resetWhen: skills },
);
const [error, setError] = useState<string | null>(null);
const [verifying, setVerifying] = useState(false);
/**
* The last verification, keyed by the **entry it was computed against**.
*
* Keyed rather than cleared on selection change, so moving off a skill and
* back does not silently discard a verdict the user just paid a round trip
* for. Keyed by a serialization of the entry rather than by its index, so a
* refresh that reorders the list cannot show one skill's verdict under
* another's name — and rather than by its URI alone, because a refresh can
* replace the manifest or the frontmatter *under the same URI*, and a
* URI-keyed verdict would then present hashes and findings computed for the
* previous snapshot as if they described the new one (Copilot).
*
* The same key the web screen uses, for the same reason: re-verifying after a
* metadata-only refresh is the cheap direction to be wrong in; showing a
* verdict computed against a different entry is not.
*/
const [report, setReport] = useState<{
key: string;
result: SkillVerifyReport;
} | null>(null);
const scrollViewRef = useRef<ScrollViewRef>(null);
const selectedSkill = skills[selectedIndex] ?? null;
const runVerify = useCallback(
(skill: SkillEntry) => {
if (!inspectorClient || verifying) return;
setVerifying(true);
setError(null);
// 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 [result] = await verifySkills(inspectorClient, [skill]);
setReport({ key: entryKey(skill), result });
} catch (err) {
if (err instanceof AuthRecoveryRequiredError) {
onAuthRecoveryRequired?.(err);
return;
}
/* v8 ignore start -- `verifySkills` records an ordinary read failure
against the file it happened on and keeps walking, and it re-throws
exactly one error, handled directly above. So nothing the call
graph can produce reaches here; this is the guard that keeps a
future change from becoming an unhandled rejection instead of a
visible message. Exercising it would mean faking a throw the walk
cannot make, which tests the fake rather than the code. */
setError(
err instanceof Error ? err.message : "Failed to verify skill",
);
/* v8 ignore stop */
} finally {
setVerifying(false);
}
})();
},
[inspectorClient, onAuthRecoveryRequired, verifying],
);
useInput(
(input: string, key: Key) => {
if (key.return && selectedSkill && inspectorClient) {
runVerify(selectedSkill);
return;
}
if (focusedPane === "list") {
if (key.upArrow && selectedIndex > 0) {
setSelection(selectedIndex - 1);
} else if (key.downArrow && selectedIndex < skills.length - 1) {
setSelection(selectedIndex + 1);
}
return;
}
if (focusedPane === "details") {
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. A genuine synchronization with an
// external system (the ScrollView's imperative handle), not state derived
// from a prop — so an effect is the right tool here.
useEffect(() => {
scrollViewRef.current?.scrollTo(0);
}, [selectedIndex]);
const listWidth = Math.floor(width * 0.4);
const detailWidth = width - listWidth;
// A name collision is a property of the LISTING, not of an entry, so it is
// computed once here and merged into each entry's own findings — which is
// what carries it into the row marks below as well as the detail pane.
const collisions = checkSkillNameCollisions(skills);
const findingsFor = (skill: SkillEntry): SkillIssue[] => {
const collision = collisions.get(skillUriIdentity(skill.uri));
return [...checkSkillConformance(skill), ...(collision ? [collision] : [])];
};
const issues = selectedSkill ? findingsFor(selectedSkill) : [];
const activeReport =
selectedSkill && report?.key === entryKey(selectedSkill)
? report.result
: null;
const manifest =
selectedSkill && selectedSkill.resources !== DYNAMIC_RESOURCES
? selectedSkill.resources
: [];
return (
<Box flexDirection="row" width={width} height={height}>
<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}
>
Skills ({skills.length}
{pageCount > 1 ? `, ${pageCount} pages` : ""})
</Text>
</Box>
{loadError ? (
<Box paddingY={1}>
<Text color="red">{loadError.message}</Text>
</Box>
) : skills.length === 0 ? (
<Box paddingY={1}>
<Text dimColor>No skills available</Text>
</Box>
) : (
<Box
flexDirection="column"
height={visibleCount}
overflow="hidden"
flexShrink={0}
>
{skills
.slice(firstVisible, firstVisible + visibleCount)
.map((skill, i) => {
const index = firstVisible + i;
const isSelected = index === selectedIndex;
// The per-row mark is the static conformance verdict, which
// costs nothing — it is what makes a bad skill visible in the
// list rather than only after selecting it.
const rowIssues = findingsFor(skill);
const worst = rowIssues.some((it) => it.severity === "error")
? "error"
: rowIssues.length > 0
? "warning"
: null;
return (
<Box key={skill.uri || index} paddingY={0} flexShrink={0}>
<Text>
{isSelected ? "▶ " : " "}
{worst ? (
<Text color={ISSUE_COLOR[worst]}>
{ISSUE_MARK[worst]}{" "}
</Text>
) : (
<Text color="green">✓ </Text>
)}
{skillDisplayName(skill)}
</Text>
</Box>
);
})}
</Box>
)}
</Box>
<Box
width={detailWidth}
height={height}
paddingX={1}
flexDirection="column"
overflow="hidden"
>
{selectedSkill ? (
<>
<Box flexShrink={0} paddingTop={1}>
<Text
bold
backgroundColor={
focusedPane === "details" ? "yellow" : undefined
}
{...(focusedPane === "details" ? {} : { color: "cyan" })}
>
{skillDisplayName(selectedSkill)}
</Text>
</Box>
<ScrollView ref={scrollViewRef} height={height - 5}>
<Box marginTop={1} flexShrink={0}>
<Text dimColor>{selectedSkill.uri}</Text>
</Box>
{selectedSkill.frontmatter.description && (
<Box marginTop={1} flexShrink={0}>
<Text dimColor>{selectedSkill.frontmatter.description}</Text>
</Box>
)}
<Box marginTop={1} flexShrink={0}>
<Text bold>
Conformance{issues.length === 0 ? ": conforms" : ":"}
</Text>
</Box>
{issues.map((issue, idx) => (
<Box key={`issue-${idx}`} paddingLeft={2} flexShrink={0}>
<Text color={ISSUE_COLOR[issue.severity]}>
{ISSUE_MARK[issue.severity]} {issue.message}
</Text>
</Box>
))}
<Box marginTop={1} flexShrink={0}>
<Text bold>
Manifest
{selectedSkill.resources === DYNAMIC_RESOURCES
? ': "dynamic" — no files advertised'
: ` (${manifest.length})`}
</Text>
</Box>
{manifest.map((resource, idx) => {
const fileReport = activeReport?.files.find(
(file) => file.uri === resource.uri,
);
return (
<Box
key={`file-${idx}`}
paddingLeft={2}
flexShrink={0}
flexDirection="column"
>
<Text>
{fileReport ? (
<Text color={FILE_COLOR[fileReport.status] ?? "white"}>
{FILE_MARK[fileReport.status] ?? "?"}{" "}
</Text>
) : (
<Text dimColor>· </Text>
)}
{fileNameOf(resource.uri)}
{resource.size !== undefined ? (
<Text dimColor> ({resource.size} B)</Text>
) : null}
</Text>
{fileReport?.reason && (
<Box paddingLeft={4} flexShrink={0}>
<Text color="red">{fileReport.reason}</Text>
</Box>
)}
</Box>
);
})}
{activeReport && activeReport.frontmatter.length > 0 && (
<>
<Box marginTop={1} flexShrink={0}>
<Text bold>Frontmatter cross-check:</Text>
</Box>
{activeReport.frontmatter.map((issue, idx) => (
<Box key={`fm-${idx}`} paddingLeft={2} flexShrink={0}>
<Text color={ISSUE_COLOR[issue.severity]}>
{ISSUE_MARK[issue.severity]} {issue.message}
</Text>
</Box>
))}
</>
)}
{error && (
<Box marginTop={1} flexShrink={0}>
<Text color="red">{error}</Text>
</Box>
)}
<Box marginTop={1} flexShrink={0}>
<Text dimColor>
{verifying
? "[Verifying…]"
: activeReport
? activeReport.ok
? "[Verified — Enter to re-verify]"
: "[Verification FAILED — Enter to re-verify]"
: "[Enter to verify digests and frontmatter]"}
</Text>
</Box>
</ScrollView>
{focusedPane === "details" && (
<Box
flexShrink={0}
height={1}
justifyContent="center"
backgroundColor="gray"
>
<Text bold color="white">
↑/↓ to scroll, Enter to verify
</Text>
</Box>
)}
</>
) : (
<Box paddingY={1} flexShrink={0}>
<Text dimColor>Select a skill to view details</Text>
</Box>
)}
</Box>
</Box>
);
}