Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions src/plugins/diagnostics.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,33 @@ describe("formatPluginWarningsSummary", () => {
expect(summary).toContain("1 skill missing");
expect(summary).toContain("1 other warning");
});

test("names a skill once however many sources missed it", () => {
// The same skill missing from three plugins is one missing skill, not
// three: the operator installs it once to fix all of them.
const summary = formatPluginWarningsSummary([
'agent a: skill "brand-identity" referenced but not found in skill search path',
'agent a: skill "style" referenced but not found in skill search path',
'agent b: skill "philosophy" referenced but not found in skill search path',
'agent b: skill "style" referenced but not found in skill search path',
'agent c: skill "philosophy" referenced but not found in skill search path',
'agent c: skill "style" referenced but not found in skill search path',
'agent c: skill "brand-identity" referenced but not found in skill search path',
]);
expect(summary).toBe(
"plugins: 3 skills missing: brand-identity, style, philosophy",
);
});

test("mixed-warning count also counts distinct skills", () => {
const summary = formatPluginWarningsSummary([
'agent a: skill "style" referenced but not found in skill search path',
'agent b: skill "style" referenced but not found in skill search path',
"other problem",
]);
expect(summary).toContain("1 skill missing (style)");
expect(summary).toContain("1 other warning");
});
});

describe("emitPluginWarningSummary", () => {
Expand Down
31 changes: 20 additions & 11 deletions src/plugins/diagnostics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,31 +43,40 @@ export function stderrPluginWarning(msg: string): void {
* One-line summary for a batch of load warnings. Skill-miss messages are
* collapsed to `N skills missing: a, b, c`; mixed warnings get a count line.
* Returns undefined when there is nothing to report.
*
* Skill names are deduplicated because a skill is missing once no matter how
* many plugins referenced it — the operator installs it once to fix all of
* them — and the count is taken from the deduplicated list so the number can
* never disagree with the names printed beside it.
*/
export function formatPluginWarningsSummary(
warnings: readonly string[],
): string | undefined {
if (warnings.length === 0) return undefined;

const skillMisses: string[] = [];
const missedSkills = new Set<string>();
let skillMissWarnings = 0;
for (const w of warnings) {
const m = /skill "([^"]+)" referenced but not found/.exec(w);
if (m?.[1] !== undefined) skillMisses.push(m[1]);
if (m?.[1] === undefined) continue;
skillMissWarnings += 1;
missedSkills.add(m[1]);
}

if (skillMisses.length > 0 && skillMisses.length === warnings.length) {
const n = skillMisses.length;
return `plugins: ${n} skill${n === 1 ? "" : "s"} missing: ${skillMisses.join(", ")}`;
const names = [...missedSkills];
const n = names.length;

if (n > 0 && skillMissWarnings === warnings.length) {
return `plugins: ${n} skill${n === 1 ? "" : "s"} missing: ${names.join(", ")}`;
}

if (skillMisses.length > 0) {
const n = skillMisses.length;
const other = warnings.length - n;
return `plugins: ${n} skill${n === 1 ? "" : "s"} missing (${skillMisses.join(", ")}); ${other} other warning${other === 1 ? "" : "s"}`;
if (n > 0) {
const other = warnings.length - skillMissWarnings;
return `plugins: ${n} skill${n === 1 ? "" : "s"} missing (${names.join(", ")}); ${other} other warning${other === 1 ? "" : "s"}`;
}

const n = warnings.length;
return `plugins: ${n} warning${n === 1 ? "" : "s"} during load`;
const total = warnings.length;
return `plugins: ${total} warning${total === 1 ? "" : "s"} during load`;
}

/**
Expand Down
70 changes: 67 additions & 3 deletions src/tui-opentui/landing.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import {
isLanding,
paintLanding,
streamRowCount,
surfaceStartupNotice,
surfaceSystemNotice,
} from "./shell"
import { makeOperatorQuestion, openOperatorOverlay } from "./overlays"
import {
Expand Down Expand Up @@ -149,13 +149,18 @@ describe("landing screen", () => {
mark.length,
)
expect(painted.indexOf(mark.at(-1) as string)).toBeLessThan(top)
// The two doors sit beside the mark, not under it.
// The two doors sit beside the mark, not under it, and their
// descriptions share one column — ragged, the pair reads as two
// unrelated lines rather than as a set.
const descriptionColumns = new Set<number>()
for (const hint of LANDING_HINTS) {
const row = painted.find((line) => line.includes(hint.rest))
expect(row).toBeDefined()
expect(row).toContain(hint.key)
expect(row!.indexOf(hint.key)).toBeGreaterThan(0)
descriptionColumns.add(row!.indexOf(hint.rest))
}
expect(descriptionColumns.size).toBe(1)
// The version sits with the hints, and cannot drift from package.json.
expect(LANDING_VERSION).toBe(`v${pkg.version}`)
expect(h.captureCharFrame()).toContain(LANDING_VERSION)
Expand Down Expand Up @@ -513,7 +518,7 @@ describe("landing screen", () => {

const mcpError =
"mcp github did not connect (ECONNREFUSED) — its tools are unavailable; /mcp for detail"
surfaceStartupNotice(shell, mcpError)
surfaceSystemNotice(shell, mcpError)
await settle(h)

// The mountain stays; the notice strip carries the wording.
Expand Down Expand Up @@ -541,4 +546,63 @@ describe("landing screen", () => {
}
}, SIZE)
})

test("startup plugin diagnostics keep the mountain too", async () => {
// CL-5718: CL-5618 routed MCP and hook notices away from the transcript
// but left plugin diagnostics going through the runner's own system-row
// helper, so any missing skill wiped the whole hero on load. The flush is
// a named seam now precisely so no producer of a startup diagnostic gets
// to decide this again.
await withTestRenderer(async (h) => {
const shell = createAppShell(h.renderer, {
terminal: { columns: 80, rows: 24 },
wireKeys: false,
run: "idle",
})
try {
await settle(h)
expect(isLanding(shell)).toBe(true)
const before = markRows(h)
expect(before.length).toBeGreaterThan(0)

const summary = "plugins: 3 skills missing: brand-identity, style, philosophy"
surfaceSystemNotice(shell, summary)
await settle(h)

expect(isLanding(shell)).toBe(true)
expect(markRows(h).length).toBe(before.length)
expect(streamRowCount(shell)).toBe(0)
expect(noticeText(shell)).toContain("3 skills missing")
} finally {
shell.dispose()
}
}, SIZE)
})

test("a flushed startup notice never carries a plumbing gutter label", async () => {
// The transcript must never label a row "command": a system row's text
// already says what it is, and the meta column is the operator's, not the
// wiring's.
await withTestRenderer(async (h) => {
const shell = createAppShell(h.renderer, {
terminal: { columns: 80, rows: 24 },
wireKeys: false,
run: "idle",
})
try {
await settle(h)
surfaceSystemNotice(shell, "plugins: 1 skill missing: style")
appendStreamRow(shell, { role: "user", text: "first prompt" })
await settle(h)

expect(isLanding(shell)).toBe(false)
const frame = h.captureCharFrame()
expect(frame).toContain("1 skill missing")
expect(frame).not.toContain("command")
expect(frame).not.toContain("overlay")
} finally {
shell.dispose()
}
}, SIZE)
})
})
37 changes: 34 additions & 3 deletions src/tui-opentui/landing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,9 +65,25 @@ export const LANDING_HINTS: readonly {
{ key: "?", rest: "for shortcuts" },
]

/**
* Columns held for the key, so the descriptions beside them start on one
* column. Ragged, the pair reads as two unrelated lines rather than as a set.
*/
export const LANDING_KEY_WIDTH = LANDING_HINTS.reduce(
(widest, hint) => Math.max(widest, hint.key.length),
0,
)

/** Air between the key column and the description it labels. */
const LANDING_KEY_GAP = 2

/** Columns the hint block needs, its longest line deciding. */
export const LANDING_HINT_WIDTH = Math.max(
LANDING_HINTS.reduce((widest, hint) => Math.max(widest, hint.key.length + 1 + hint.rest.length), 0),
LANDING_HINTS.reduce(
(widest, hint) =>
Math.max(widest, LANDING_KEY_WIDTH + LANDING_KEY_GAP + hint.rest.length),
0,
),
LANDING_VERSION.length,
)

Expand Down Expand Up @@ -336,17 +352,30 @@ function createHintBlock(ctx: CliRenderer): BoxRenderable {
backgroundColor: UI.ground,
})
LANDING_HINTS.forEach((hint, index) => {
const gap = " ".repeat(
LANDING_KEY_WIDTH - hint.key.length + LANDING_KEY_GAP,
)
block.add(
new TextRenderable(ctx, {
id: `shell-landing-hint-${index}`,
height: 1,
content: new StyledText([
fgChunk(UI.text)(hint.key),
fgChunk(UI.textDim)(` ${hint.rest}`),
fgChunk(UI.textDim)(`${gap}${hint.rest}`),
]),
}),
)
})
// The build is a fact about what is running, not a third door. Flush against
// the two keys it read as one of them.
block.add(
new TextRenderable(ctx, {
id: "shell-landing-version-gap",
height: 1,
content: "",
fg: UI.ground,
}),
)
block.add(
new TextRenderable(ctx, {
id: "shell-landing-version",
Expand All @@ -364,7 +393,9 @@ function createHintBlock(ctx: CliRenderer): BoxRenderable {
*/
export function fitLandingMark(above: LandingAbove, grid: MarkGrid | null): void {
above.grid = grid
const rows = grid?.rows ?? LANDING_HINTS.length + 1
// With no mark, the hero is exactly the hint block: the two keys, the blank
// row, and the version.
const rows = grid?.rows ?? LANDING_HINTS.length + 2
above.hero.height = rows
above.markColumn.visible = grid !== null
above.markColumn.width = grid?.cols ?? 0
Expand Down
Loading
Loading