diff --git a/src/plugins/diagnostics.test.ts b/src/plugins/diagnostics.test.ts index 4040a56c9..a1fc6dfb3 100644 --- a/src/plugins/diagnostics.test.ts +++ b/src/plugins/diagnostics.test.ts @@ -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", () => { diff --git a/src/plugins/diagnostics.ts b/src/plugins/diagnostics.ts index a66e6fc6a..78fc44730 100644 --- a/src/plugins/diagnostics.ts +++ b/src/plugins/diagnostics.ts @@ -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(); + 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`; } /** diff --git a/src/tui-opentui/landing.test.ts b/src/tui-opentui/landing.test.ts index 6f99cf33a..284ebea8c 100644 --- a/src/tui-opentui/landing.test.ts +++ b/src/tui-opentui/landing.test.ts @@ -17,7 +17,7 @@ import { isLanding, paintLanding, streamRowCount, - surfaceStartupNotice, + surfaceSystemNotice, } from "./shell" import { makeOperatorQuestion, openOperatorOverlay } from "./overlays" import { @@ -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() 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) @@ -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. @@ -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) + }) }) diff --git a/src/tui-opentui/landing.ts b/src/tui-opentui/landing.ts index adb5fdc53..21c61d86f 100644 --- a/src/tui-opentui/landing.ts +++ b/src/tui-opentui/landing.ts @@ -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, ) @@ -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", @@ -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 diff --git a/src/tui-opentui/mark-anim.test.ts b/src/tui-opentui/mark-anim.test.ts index 0fd988fb7..cb321f7f5 100644 --- a/src/tui-opentui/mark-anim.test.ts +++ b/src/tui-opentui/mark-anim.test.ts @@ -2,14 +2,20 @@ import { describe, expect, test } from "bun:test" import { MARK_PERIOD_SECONDS, + SNOW_CHAR, markFrame, markText, renderMark, smooth, } from "./mark-anim" -import { MARK_COLS, MARK_COVERAGE, MARK_LARGE, MARK_ROWS } from "./mark-shape" +import { MARK_COLS, MARK_LARGE, MARK_ROWS, MARK_SMALL } from "./mark-shape" import { UI } from "./theme" +const MOUNTAIN_CHARS = "▁▂▃▄▅▆▇█" + +const isMountain = (char: string): boolean => MOUNTAIN_CHARS.includes(char) +const isSnow = (char: string): boolean => char === SNOW_CHAR + describe("smooth", () => { test("clamps outside [0, 1] and eases inside it", () => { expect(smooth(-3)).toBe(0) @@ -62,8 +68,17 @@ describe("markFrame", () => { }) describe("renderMark", () => { - const emptyCells = (grid: readonly (readonly { char: string }[])[]): number => - grid.flat().filter((cell) => cell.char === " ").length + /** Mountain-block weight only — snow must not pollute silhouette metrics. */ + const mountainWeight = ( + grid: readonly (readonly { char: string }[])[], + ): number => + grid + .flat() + .reduce((sum, cell) => sum + Math.max(0, MOUNTAIN_CHARS.indexOf(cell.char) + 1), 0) + + const mountainCells = ( + grid: readonly (readonly { char: string }[])[], + ): number => grid.flat().filter((cell) => isMountain(cell.char)).length test("is the mark's cell dimensions", () => { const grid = renderMark({ nowMs: 0, still: true }) @@ -71,11 +86,18 @@ describe("renderMark", () => { for (const row of grid) expect(row).toHaveLength(MARK_COLS) }) - test("never paints outside the silhouette", () => { - const grid = renderMark({ nowMs: 2000, still: false }) + test("sky is empty or snow; mountain cells never hold snow", () => { + const grid = renderMark({ nowMs: 2000, still: false, grid: MARK_LARGE }) grid.forEach((row, y) => { row.forEach((cell, x) => { - if ((MARK_COVERAGE[y]?.[x] ?? 0) === 0) expect(cell.char).toBe(" ") + const coverage = MARK_LARGE.coverage[y]?.[x] ?? 0 + if (coverage === 0) { + expect(cell.char === " " || isSnow(cell.char)).toBe(true) + if (isSnow(cell.char)) expect(cell.fg).toBe(UI.textFaint) + } else if (isMountain(cell.char)) { + expect(cell.fg).toBe(UI.action) + expect(isSnow(cell.char)).toBe(false) + } }) }) }) @@ -84,17 +106,19 @@ describe("renderMark", () => { const grid = renderMark({ nowMs: 0, still: true, grid: MARK_LARGE }) grid.forEach((row, y) => { row.forEach((cell, x) => { - expect(" ▁▂▃▄▅▆▇█").toContain(cell.char) + // Still mode has no snow — only space or mountain blocks. + expect(` ${MOUNTAIN_CHARS}`).toContain(cell.char) if ((MARK_LARGE.coverage[y]?.[x] ?? 0) === 1) expect(cell.char).toBe("█") }) }) }) - test("the still frame is clock-independent", () => { + test("the still frame is clock-independent and has no snow", () => { const a = markText(renderMark({ nowMs: 0, still: true })) const b = markText(renderMark({ nowMs: 987_654, still: true })) expect(b).toBe(a) expect(a.replace(/[\s\n]/g, "").length).toBeGreaterThan(0) + expect(a.includes(SNOW_CHAR)).toBe(false) }) test("the animated frame advances with the injected clock", () => { @@ -105,14 +129,14 @@ describe("renderMark", () => { }) test("the outline reveals left to right", () => { - // Early in the draw phase only the leftmost columns may be lit. + // Early in the draw phase only the leftmost mountain columns may be lit. const grid = renderMark({ nowMs: 0.06 * MARK_PERIOD_SECONDS * 1000, still: false }) const lit = grid.flatMap((row) => - row.flatMap((cell, col) => (cell.char === " " ? [] : [col])), + row.flatMap((cell, col) => (isMountain(cell.char) ? [col] : [])), ) expect(Math.max(...lit, -1)).toBeLessThan(MARK_COLS) const full = renderMark({ nowMs: 0.4 * MARK_PERIOD_SECONDS * 1000, still: false }) - expect(emptyCells(grid)).toBeGreaterThan(emptyCells(full)) + expect(mountainCells(full)).toBeGreaterThan(mountainCells(grid)) }) test("the fade thins the mark out toward empty", () => { @@ -121,12 +145,10 @@ describe("renderMark", () => { nowMs: 0.995 * MARK_PERIOD_SECONDS * 1000, still: false, }) - expect(emptyCells(fading)).toBeGreaterThan(emptyCells(held)) + expect(mountainCells(held)).toBeGreaterThan(mountainCells(fading)) }) test("filling makes the mark denser than its outline alone", () => { - const weight = (grid: readonly (readonly { char: string }[])[]): number => - grid.flat().reduce((sum, cell) => sum + " ▁▂▃▄▅▆▇█".indexOf(cell.char), 0) const outlineOnly = renderMark({ nowMs: 0.42 * MARK_PERIOD_SECONDS * 1000, still: false, @@ -135,6 +157,65 @@ describe("renderMark", () => { nowMs: 0.8 * MARK_PERIOD_SECONDS * 1000, still: false, }) - expect(weight(filled)).toBeGreaterThan(weight(outlineOnly)) + expect(mountainWeight(filled)).toBeGreaterThan(mountainWeight(outlineOnly)) + }) + + test("snow drifts over time without overwriting the silhouette", () => { + // Sample across several seconds so flakes advance even at a slow fall rate. + const times = [0, 1500, 3000, 4500, 6000, 7500] + const snowSets = times.map((nowMs) => { + const grid = renderMark({ nowMs, still: false, grid: MARK_LARGE }) + const snow: string[] = [] + grid.forEach((row, y) => { + row.forEach((cell, x) => { + if (isSnow(cell.char)) { + snow.push(`${y},${x}`) + // Flakes live only in sky cells — never on mountain coverage. + expect(MARK_LARGE.coverage[y]?.[x] ?? 0).toBe(0) + } + }) + }) + return snow.join("|") + }) + + const withSnow = snowSets.filter((s) => s.length > 0) + expect(withSnow.length).toBeGreaterThan(1) + expect(new Set(withSnow).size).toBeGreaterThan(1) + + // During the full-hold phase the ridgeline dominates the flake field. + const held = renderMark({ + nowMs: 0.82 * MARK_PERIOD_SECONDS * 1000, + still: false, + grid: MARK_LARGE, + }) + let flakes = 0 + let mountains = 0 + held.forEach((row, y) => { + row.forEach((cell, x) => { + if (isSnow(cell.char)) { + flakes += 1 + expect(MARK_LARGE.coverage[y]?.[x] ?? 0).toBe(0) + } + if (isMountain(cell.char)) mountains += 1 + }) + }) + expect(mountains).toBeGreaterThan(20) + expect(mountains).toBeGreaterThan(flakes) + }) + + test("still mode freezes the mark with no snow motion", () => { + const a = renderMark({ nowMs: 0, still: true, grid: MARK_SMALL }) + const b = renderMark({ nowMs: 50_000, still: true, grid: MARK_SMALL }) + expect(markText(b)).toBe(markText(a)) + expect(a.flat().some((cell) => isSnow(cell.char))).toBe(false) + }) + + test("snow drops out during the fade-out phase, matching the mark", () => { + const fading = renderMark({ + nowMs: 0.995 * MARK_PERIOD_SECONDS * 1000, + still: false, + grid: MARK_LARGE, + }) + expect(fading.flat().some((cell) => isSnow(cell.char))).toBe(false) }) }) diff --git a/src/tui-opentui/mark-anim.ts b/src/tui-opentui/mark-anim.ts index e5be69807..a3a62dafe 100644 --- a/src/tui-opentui/mark-anim.ts +++ b/src/tui-opentui/mark-anim.ts @@ -7,6 +7,11 @@ * wave; at hero size a terminal renders that as visible noise rather than as * shimmer, so the terminal mark is opaque instead. * + * Over the sky (zero-coverage cells) a sparse field of pixel snow falls on the + * same injected clock. Density and speed stay low so the ridgeline keeps its + * silhouette; `still` (idle or reduced motion) freezes the mark and drops the + * snow entirely. Mountain cells always win over flakes. + * * Everything here is pure and clock-injected: `nowMs` is the only time source, * so the caller's existing 250 ms status tick drives the animation and tests * drive it deterministically. There is no timer in this module. @@ -70,6 +75,18 @@ const EIGHTHS = ["▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"] as cons */ const FILL_GAMMA = 0.6 +/** One snowflake pixel. Exported so tests can distinguish sky from mountain. */ +export const SNOW_CHAR = "·" + +/** + * Fraction of columns that host a flake. Kept low so the sky reads as empty + * with occasional drift rather than a storm. + */ +const SNOW_COLUMN_FRACTION = 0.18 + +/** Baseline rows-per-second fall rate. Slow enough to feel like drift. */ +const SNOW_FALL_SPEED = 0.55 + export type MarkCell = { readonly char: string readonly fg: string @@ -83,6 +100,35 @@ export type MarkInput = { readonly grid?: MarkGrid } +/** + * Stable unit hash in [0, 1) from integer seeds. Pure and clock-independent so + * flake columns and phases never jitter between frames. + */ +function unitHash(a: number, b = 0): number { + const n = Math.imul(a + 1, 374761393) ^ Math.imul(b + 1, 668265263) + const x = Math.imul(n ^ (n >>> 13), 1274126177) + return ((x >>> 0) % 10_000) / 10_000 +} + +/** + * Whether a sky cell at (row, col) holds a flake at `seconds`. Sparse columns + * only; each active column carries one flake with a private phase and a slight + * speed variation so the field does not march as a rigid lattice. + */ +function snowflakeAt( + row: number, + col: number, + seconds: number, + rows: number, +): boolean { + if (rows <= 0) return false + if (unitHash(col, 1) > SNOW_COLUMN_FRACTION) return false + const phase = unitHash(col, 2) * rows + const speed = SNOW_FALL_SPEED * (0.75 + unitHash(col, 3) * 0.5) + const wrapped = (((seconds * speed + phase) % rows) + rows) % rows + return Math.floor(wrapped) === row +} + /** * Composite one frame into a row-major cell grid. * @@ -91,6 +137,9 @@ export type MarkInput = { * slopes instead of staircasing. No dither texture survives inside the shape — * the mark is a mountain, and a mountain is opaque. * + * Sky cells (zero coverage) may hold a single falling snow pixel. Flakes never + * overwrite mountain coverage, and `still` suppresses them entirely. + * * `alpha` has no terminal equivalent, so it scales the block height instead: * the mark sinks toward empty rather than blending to black. */ @@ -100,6 +149,9 @@ export function renderMark(input: MarkInput): readonly (readonly MarkCell[])[] { const { drawProg, fillProg, alpha } = markFrame(seconds, input.still) const revealed = drawProg * shape.cols const fillLine = shape.rows * (1 - fillProg) + // Fade out drops the snow too so the decoration doesn't outlast the mark + // it drifts over. + const snowOn = !input.still && alpha === 1 const grid: MarkCell[][] = [] for (let row = 0; row < shape.rows; row++) { @@ -110,7 +162,17 @@ export function renderMark(input: MarkInput): readonly (readonly MarkCell[])[] { const coverage = shape.coverage[row]?.[col] ?? 0 const reveal = clamp01(revealed - col) if (coverage === 0 || reveal === 0) { - cells.push({ char: " ", fg: UI.action }) + // Snow only in true sky. Unrevealed mountain cells stay empty so the + // left-to-right draw still reads as a clean silhouette edge. + if ( + snowOn && + coverage === 0 && + snowflakeAt(row, col, seconds, shape.rows) + ) { + cells.push({ char: SNOW_CHAR, fg: UI.textFaint }) + } else { + cells.push({ char: " ", fg: UI.action }) + } continue } // The outline states the shape at its true coverage; filling lifts it diff --git a/src/tui-opentui/product-host.ts b/src/tui-opentui/product-host.ts index fb07d70aa..0f36b7cc6 100644 --- a/src/tui-opentui/product-host.ts +++ b/src/tui-opentui/product-host.ts @@ -47,7 +47,7 @@ import { setPaletteOnCommand, setMcpNeedsAuth, setStatusFlash, - surfaceStartupNotice, + surfaceSystemNotice, type AppShell, type ItemDescription, type OverlaySelection, @@ -348,7 +348,7 @@ export async function mountProductHost( // before the first turn (CL-5618). const widthReport = checkWidthContract(renderer.widthMethod) if (!widthReport.agrees) { - surfaceStartupNotice(shell, widthContractNotice(widthReport)) + surfaceSystemNotice(shell, widthContractNotice(widthReport)) } const port = createLiveSessionPort({ @@ -465,9 +465,9 @@ export async function mountProductHost( if (notice === null) return if (notice.kind === "row") { // MCP load failures and hook failures must not wipe the landing mark. - // surfaceStartupNotice keeps the mountain while the notice strip carries + // surfaceSystemNotice keeps the mountain while the notice strip carries // the wording, then flushes a durable row once the session starts. - surfaceStartupNotice(shell, notice.text) + surfaceSystemNotice(shell, notice.text) return } setStatusFlash(shell, notice.text, { ttlMs: RUNTIME_FLASH_MS }) diff --git a/src/tui-opentui/runner-host.ts b/src/tui-opentui/runner-host.ts index b98abd8d8..9d6ab83a5 100644 --- a/src/tui-opentui/runner-host.ts +++ b/src/tui-opentui/runner-host.ts @@ -31,12 +31,12 @@ import type { ItemDescription } from "./shell.js" import { mountProductHost, type ProductHost } from "./product-host.js" import { onTurnBoundary } from "../agent/reactor-events.js" import { - appendStreamRow, clearShellExitHandler, setPromptCostContext, setPromptModelLabel, setPromptWorkspace, setShellExitHandler, + surfaceSystemNotice, } from "./shell.js" import type { CostSummary } from "../cost/cost-summary.js" import { watchGitBranch, type FetchBranch } from "./workspace-watch.js" @@ -326,8 +326,7 @@ export async function mountRunnerHost(deps: RunnerHostDeps): Promise const surfaceDeps: CommandSurfaceDeps = { ...(deps.surfaces ?? {}), ...(host.openModels !== undefined ? { openModels: host.openModels } : {}), - notify: (text) => - appendStreamRow(host.shell, { role: "system", text, meta: "command" }), + notify: (text) => surfaceSystemNotice(host.shell, text), } const refreshModels = ( diff --git a/src/tui-opentui/shell.ts b/src/tui-opentui/shell.ts index d133b9905..96215214a 100644 --- a/src/tui-opentui/shell.ts +++ b/src/tui-opentui/shell.ts @@ -2118,8 +2118,13 @@ function evictedRowsNotice(evicted: number): string { * mounted the wording rides the notice strip and the row is held for flush * once a real session row ends the landing; after that it is a normal system * row. + * + * Every producer of a system-class row belongs here rather than at + * `appendStreamRow`. CL-5618 fixed the MCP and hook producers one at a time + * and the plugin producer kept the defect, which is what per-call-site rules + * buy you. Reaching for `appendStreamRow` directly is the bug. */ -export function surfaceStartupNotice(shell: AppShell, text: string): void { +export function surfaceSystemNotice(shell: AppShell, text: string): void { if (isLanding(shell)) { const bag = internals.get(shell) if (bag !== undefined) { @@ -3940,7 +3945,6 @@ export function acceptOverlaySelection(shell: AppShell): void { appendStreamRow(shell, { role: "system", text: `palette: no action for ${label}`, - meta: "palette", }) } return @@ -4016,7 +4020,6 @@ export function dispatchPaletteSelection( appendStreamRow(shell, { role: "system", text: `palette: /${cmd.id} (no onCommand handler)`, - meta: "palette", }) return } @@ -4027,7 +4030,6 @@ export function dispatchPaletteSelection( appendStreamRow(shell, { role: "system", text: `palette: unknown residual ${cmd.id}`, - meta: "palette", }) } diff --git a/src/tui/runner.ts b/src/tui/runner.ts index 1a4d4ec6c..fb0695f62 100644 --- a/src/tui/runner.ts +++ b/src/tui/runner.ts @@ -144,12 +144,12 @@ import { consumeStream } from "../session/stream-consumer.js"; import { createCycleTextRecorder } from "../session/stream-journal.js"; import { mountRunnerHost } from "../tui-opentui/runner-host.js"; import { - appendStreamRow, attachClipboardImage, setMentionSuggestionSource, setPromptRecognitionSource, setSentMessageHistory, setShellRunState, + surfaceSystemNotice, } from "../tui-opentui/shell.js"; import { classifyAgentSendFailure, @@ -434,8 +434,8 @@ export async function runTUI(initialConfig: Config): Promise { // Fire-and-forget startup diagnostics (this + tool-plugin resolution below) // have no result channel back to an operator action, unlike verify/add-path/ // trust-grant. A log-only summary is invisible — nobody watches - // ~/.corbits/logs/corbits.log — so these are also queued as transcript rows - // once the shell mounts (see `systemRow` calls after `mountRunnerHost`). + // ~/.corbits/logs/corbits.log — so these are queued and handed to + // the shell one at a time once it mounts. const startupPluginNotices: string[] = []; const discoveryNotice = formatPluginWarningsSummary(pluginLoadDiag.warnings); if (discoveryNotice !== undefined) startupPluginNotices.push(discoveryNotice); @@ -1840,8 +1840,13 @@ export async function runTUI(initialConfig: Config): Promise { }, }; - const systemRow = (text: string): void => { - appendStreamRow(host.shell, { role: "system", text, meta: "command" }); + // Routed through the shell's notice path rather than straight into the + // transcript: anything the runner says before the first turn arrives while + // the landing hero still owns the screen, and a transcript row there wipes + // the whole composition. Once a session row has ended the landing this is an + // ordinary system row, so there is no second behaviour to reason about. + const systemNotice = (text: string): void => { + surfaceSystemNotice(host.shell, text); }; /** Settle the shell after a rejected send so the run does not look live. */ @@ -1854,7 +1859,7 @@ export async function runTUI(initialConfig: Config): Promise { ); if (!shouldSettleUiAfterSendFailure(kind)) return; recordRunError(err); - systemRow(err instanceof Error ? err.message : String(err)); + systemNotice(err instanceof Error ? err.message : String(err)); setShellRunState(host.shell, "idle"); }; @@ -1899,29 +1904,29 @@ export async function runTUI(initialConfig: Config): Promise { const applyCommandResult = (result: CommandResult): void => { switch (result.type) { case "message": - systemRow(result.text); + systemNotice(result.text); return; case "send": void agentProxy.send(result.text).catch(handleSendFailure); return; case "workflow": - systemRow(workflowController.start(result.name)); + systemNotice(workflowController.start(result.name)); return; case "noop": return; case "overlay": if (!host.openSurface(result.overlay)) { - systemRow(`No surface for /${result.overlay}.`); + systemNotice(`No surface for /${result.overlay}.`); } return; case "modal": // /model is the only modal reachable from a command; provider login is // reached from the picker itself. if (result.modal === "agent" && host.openSurface("models")) return; - systemRow(`${result.modal} is not available in this renderer yet`); + systemNotice(`${result.modal} is not available in this renderer yet`); return; case "view": - systemRow(`${result.view} is not available in this renderer yet`); + systemNotice(`${result.view} is not available in this renderer yet`); return; case "paste-image": void attachClipboardImage(host.shell); @@ -1958,7 +1963,7 @@ export async function runTUI(initialConfig: Config): Promise { const dispatchCommand = (name: string, args: string): void => { const command = getCommand(name); if (command === undefined) { - systemRow(`Unknown command: ${name}`); + systemNotice(`Unknown command: ${name}`); return; } applyCommandResult(command.handler(args, commandContext)); @@ -2015,7 +2020,7 @@ export async function runTUI(initialConfig: Config): Promise { existing: config.settings ?? null, }); } catch (err) { - systemRow( + systemNotice( `Connecting ${providerName} failed: ${err instanceof Error ? err.message : String(err)}`, ); return; @@ -2040,7 +2045,7 @@ export async function runTUI(initialConfig: Config): Promise { providers, computeUnconnectedProviders(providers), ); - systemRow(`Connected ${result.providerName ?? providerName}. Open /model to pick a model.`); + systemNotice(`Connected ${result.providerName ?? providerName}. Open /model to pick a model.`); })().catch((err: unknown) => { tuiLogger.debug("provider connect failed: {error}", { error: err instanceof Error ? err.message : String(err), @@ -2370,9 +2375,10 @@ export async function runTUI(initialConfig: Config): Promise { }); }); - // Surface fire-and-forget startup plugin diagnostics now that the shell has - // a transcript to write into (queued above, before `host` existed). - for (const notice of startupPluginNotices) systemRow(notice); + // Surface fire-and-forget startup plugin diagnostics now that there is a + // shell to say them to (queued above, before `host` existed). + for (const notice of startupPluginNotices) + surfaceSystemNotice(host.shell, notice); await host.waitUntilExit(); // Quitting mid-stream is an abnormal end for the in-flight cycle: nothing