inspector/clients/tui/src/components/tabsConfig.ts
cliffhall 27f1b2f1bd fix: address Copilot review round 12 on #2248
**The read bounds could produce a false `ok: true`.** Entries past the cap are
never fetched, `fileFailed` only examines fetched rows, and
`resource-limit-exceeded` is a WARNING — so a manifest whose 513th file was
tampered with reported the skill verified. That trades a denial of service
for a wrong answer, which is the failure this whole PR argues against. A
truncated read is now recorded as `incomplete` and forces `ok: false`,
reported separately so a consumer can tell "this skill is wrong" from "this
skill was not fully checked". And a self entry excluded by the cap is now
verified against its declared digest, not merely read for frontmatter —
otherwise the skill's own SKILL.md was the one file nobody checked.

**`entryKey` could crash the TUI.** It called `JSON.stringify` on the whole
server-controlled entry, during render. `skillEntryKey` is decided by the
same `jsonGraphError` guard that bounds the frontmatter comparison, with a
coarse fallback that still separates two unrepresentable entries by URI —
collapsing them would show one skill's verdict under another's name.

**The tab bar wraps and `tabsHeight` was hard-coded to 1.** An OAuth-capable
stdio server serving Skills needs ~107 columns, so it wraps at 132 as well as
at 80, and every pane below was sized a row too tall. `tabBarRows` derives it
from the same list `Tabs` renders, via a shared `visibleTabs(flags)` — the
duplicated filter being how the two would drift apart again.

Nine tests in a new `tabsConfig.test.ts`, including the 80-column regression.
Two assert properties rather than instances: rows never decrease as the
terminal narrows, and a tab wider than the row gets its own rather than
looping.

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 21:57:48 -04:00

106 lines
3.6 KiB
TypeScript

export type TabType =
| "info"
| "auth"
| "resources"
| "prompts"
| "skills"
| "tools"
| "messages"
| "requests"
| "logging";
/**
* Tab bar labels + single-letter accelerators.
*
* Accelerators must be unique and appear in the label. Prefer the first letter;
* when that conflicts (Protocol vs Prompts both want `p`, Console vs Connect's
* global `c`), pick the earliest remaining letter in the word.
*
* Connect (`c`) / Disconnect (`d`) are global actions, not tab accelerators —
* Console therefore uses `o` (C**o**nsole).
*/
export const tabs: { id: TabType; label: string; accelerator: string }[] = [
{ id: "info", label: "Info", accelerator: "i" },
{ id: "auth", label: "Auth", accelerator: "a" },
{ id: "resources", label: "Resources", accelerator: "r" },
{ id: "prompts", label: "Prompts", accelerator: "m" },
// `k`, not `s`: `s` is not in conflict today, but the accelerator has to
// appear in the label and be unique, and `S`kills against a future `S`ampling
// or `S`ettings is the collision this rule anticipates. `k` is the earliest
// remaining letter in the word after `s` and `i` (Info).
{ id: "skills", label: "Skills", accelerator: "k" },
{ id: "tools", label: "Tools", accelerator: "t" },
{ id: "messages", label: "Protocol", accelerator: "p" },
{ id: "requests", label: "Network", accelerator: "n" },
{ id: "logging", label: "Console", accelerator: "o" },
];
/** Which optional tabs the connected server supports. */
export interface TabVisibility {
showAuth: boolean;
showLogging: boolean;
showRequests: boolean;
showSkills: boolean;
}
/**
* The tabs actually rendered for a given server.
*
* One source for the answer, shared by `Tabs` (which draws them) and `App`
* (which must size the pane below them). Duplicating the filter is how the two
* come to disagree about how tall the bar is.
*/
export function visibleTabs(v: TabVisibility): typeof tabs {
return tabs.filter((tab) => {
if (tab.id === "auth") return v.showAuth;
if (tab.id === "logging") return v.showLogging;
if (tab.id === "requests") return v.showRequests;
if (tab.id === "skills") return v.showSkills;
return true;
});
}
/** Rendered width of one tab: the 2-column marker, the label, and any count. */
function tabWidth(
tab: { id: TabType; label: string },
counts: Partial<Record<TabType, number>>,
): number {
const count = counts[tab.id];
return (
2 + tab.label.length + (count === undefined ? 0 : ` (${count})`.length)
);
}
/**
* How many terminal rows the tab bar occupies at a given width.
*
* ⚠️ **Not always 1.** Adding Skills pushed a stdio server's bar past 100
* columns, so it wraps at any ordinary terminal width — and `App` hard-coded
* `tabsHeight = 1`, sizing every content pane one row too tall and clipping the
* bottom of the TUI (Copilot). Deriving the height from the same list `Tabs`
* renders is what keeps the two in agreement as tabs are added.
*
* The bar is a `flexWrap="wrap"` row with one column of padding each side and
* no gaps, so greedy packing by rendered width matches what Ink lays out.
*/
export function tabBarRows(
visible: readonly { id: TabType; label: string }[],
counts: Partial<Record<TabType, number>>,
width: number,
): number {
const inner = Math.max(1, width - 2);
let rows = 1;
let used = 0;
for (const tab of visible) {
const w = tabWidth(tab, counts);
// A tab wider than the whole row still occupies one of its own rather than
// looping forever.
if (used > 0 && used + w > inner) {
rows += 1;
used = w;
} else {
used += w;
}
}
return rows;
}