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>
This commit is contained in:
cliffhall 2026-09-07 21:57:48 -04:00
commit 27f1b2f1bd

View file

@ -34,3 +34,73 @@ export const tabs: { id: TabType; label: string; accelerator: string }[] = [
{ 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;
}