/** * 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 = { error: "✗", warning: "!", }; const ISSUE_COLOR: Record = { error: "red", warning: "yellow", }; /** Per-file verification glyph, same reasoning as {@link ISSUE_MARK}. */ const FILE_MARK: Record = { verified: "✓", mismatch: "✗", unverifiable: "?", error: "✗", "read-error": "✗", }; const FILE_COLOR: Record = { 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(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(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 ( Skills ({skills.length} {pageCount > 1 ? `, ${pageCount} pages` : ""}) {loadError ? ( {loadError.message} ) : skills.length === 0 ? ( No skills available ) : ( {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 ( {isSelected ? "▶ " : " "} {worst ? ( {ISSUE_MARK[worst]}{" "} ) : ( ✓ )} {skillDisplayName(skill)} ); })} )} {selectedSkill ? ( <> {skillDisplayName(selectedSkill)} {selectedSkill.uri} {selectedSkill.frontmatter.description && ( {selectedSkill.frontmatter.description} )} Conformance{issues.length === 0 ? ": conforms" : ":"} {issues.map((issue, idx) => ( {ISSUE_MARK[issue.severity]} {issue.message} ))} Manifest {selectedSkill.resources === DYNAMIC_RESOURCES ? ': "dynamic" — no files advertised' : ` (${manifest.length})`} {manifest.map((resource, idx) => { const fileReport = activeReport?.files.find( (file) => file.uri === resource.uri, ); return ( {fileReport ? ( {FILE_MARK[fileReport.status] ?? "?"}{" "} ) : ( · )} {fileNameOf(resource.uri)} {resource.size !== undefined ? ( ({resource.size} B) ) : null} {fileReport?.reason && ( {fileReport.reason} )} ); })} {activeReport && activeReport.frontmatter.length > 0 && ( <> Frontmatter cross-check: {activeReport.frontmatter.map((issue, idx) => ( {ISSUE_MARK[issue.severity]} {issue.message} ))} )} {error && ( {error} )} {verifying ? "[Verifying…]" : activeReport ? activeReport.ok ? "[Verified — Enter to re-verify]" : "[Verification FAILED — Enter to re-verify]" : "[Enter to verify digests and frontmatter]"} {focusedPane === "details" && ( ↑/↓ to scroll, Enter to verify )} ) : ( Select a skill to view details )} ); }