diff --git a/src/tui-opentui/landing.test.ts b/src/tui-opentui/landing.test.ts index f077c7107..f714b3899 100644 --- a/src/tui-opentui/landing.test.ts +++ b/src/tui-opentui/landing.test.ts @@ -11,10 +11,13 @@ import { appendStreamRow, applyLandingSuggestion, createAppShell, + noticeText, paintChrome, setPromptWorkspace, isLanding, paintLanding, + streamRowCount, + surfaceStartupNotice, } from "./shell" import { makeOperatorQuestion, openOperatorOverlay } from "./overlays" import { @@ -489,4 +492,53 @@ describe("landing screen", () => { } }, SIZE) }) + + test("startup MCP/load errors keep the mountain and ride the notice strip", async () => { + // CL-5618 / CL-5600: system notices on load used to appendStreamRow → + // clearLandingMark, wiping the brand hero. They must surface as secondary + // chrome while geometry still seats MARK_SMALL or larger. + 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([MARK_LARGE, MARK_MID, MARK_SMALL].map((g) => g.rows)).toContain( + before.length, + ) + + const mcpError = + "mcp github did not connect (ECONNREFUSED) — its tools are unavailable; /mcp for detail" + surfaceStartupNotice(shell, mcpError) + await settle(h) + + // The mountain stays; the notice strip carries the wording. + expect(isLanding(shell)).toBe(true) + expect(streamRowCount(shell)).toBe(0) + expect(shell.statusFlash).toBe(mcpError) + expect(noticeText(shell)).toContain("mcp github did not connect") + const after = markRows(h) + expect(after.length).toBe(before.length) + expect([MARK_LARGE, MARK_MID, MARK_SMALL].map((g) => g.rows)).toContain( + after.length, + ) + + // A real session row still ends the landing; deferred notices become + // durable transcript rows rather than vanishing with the flash. + appendStreamRow(shell, { role: "user", text: "first prompt" }) + await settle(h) + expect(isLanding(shell)).toBe(false) + expect(markRows(h)).toEqual([]) + const frame = h.captureCharFrame() + expect(frame).toContain("first prompt") + expect(frame).toContain("mcp github did not connect") + } finally { + shell.dispose() + } + }, SIZE) + }) }) diff --git a/src/tui-opentui/product-host.ts b/src/tui-opentui/product-host.ts index 7a99ea7c9..376ff255b 100644 --- a/src/tui-opentui/product-host.ts +++ b/src/tui-opentui/product-host.ts @@ -47,6 +47,7 @@ import { setPaletteOnCommand, setMcpNeedsAuth, setStatusFlash, + surfaceStartupNotice, type AppShell, type ItemDescription, type OverlaySelection, @@ -336,12 +337,14 @@ export async function mountProductHost( : {}), }) - // Announced in the transcript rather than logged: a log line is invisible - // behind a full-screen shell, and the operator is the only one who can fix a - // terminal setting. + // Announced on the notice strip (or transcript once the session has content) + // rather than logged: a log line is invisible behind a full-screen shell, and + // the operator is the only one who can fix a terminal setting. Using the + // startup-notice path keeps the landing mountain painted when this fires + // before the first turn (CL-5618). const widthReport = checkWidthContract(renderer.widthMethod) if (!widthReport.agrees) { - appendStreamRow(shell, { role: "system", text: widthContractNotice(widthReport) }) + surfaceStartupNotice(shell, widthContractNotice(widthReport)) } const port = createLiveSessionPort({ @@ -457,7 +460,10 @@ export async function mountProductHost( function show(notice: RuntimeNotice | null): void { if (notice === null) return if (notice.kind === "row") { - appendStreamRow(shell, { role: "system", text: notice.text }) + // MCP load failures and hook failures must not wipe the landing mark. + // surfaceStartupNotice keeps the mountain while the notice strip carries + // the wording, then flushes a durable row once the session starts. + surfaceStartupNotice(shell, notice.text) return } setStatusFlash(shell, notice.text, { ttlMs: RUNTIME_FLASH_MS }) diff --git a/src/tui-opentui/shell.ts b/src/tui-opentui/shell.ts index 676a79814..bb7875e4a 100644 --- a/src/tui-opentui/shell.ts +++ b/src/tui-opentui/shell.ts @@ -1797,6 +1797,13 @@ type ShellInternals = { * rather than a screen the first prompt wipes. */ landingNotice: string | null + /** + * System/runtime notices that arrived while the landing was still up (MCP + * load failures, width-contract warnings, hook failures). Held here and + * painted on the notice strip so they never call `clearLandingMark`; flushed + * into the transcript when the first real session row ends the landing. + */ + landingDeferredRows: StreamRow[] /** What the rows below the box are painting, so they can be repainted. */ landingBelow: LandingBelowContent | null /** Starters are offered only while the prompt is empty. */ @@ -2031,6 +2038,28 @@ function evictedRowsNotice(evicted: number): string { return ` … ${evicted} earlier row${evicted === 1 ? "" : "s"} dropped (past the retention limit)` } +/** + * Surface a runtime/load notice without stealing the landing hero. + * + * MCP connection failures, hook failures and similar startup chatter used to + * call `appendStreamRow` → `clearLandingMark`, wiping the mountain the moment + * anything went wrong on load (CL-5618 / CL-5600). While the landing is still + * 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. + */ +export function surfaceStartupNotice(shell: AppShell, text: string): void { + if (isLanding(shell)) { + const bag = internals.get(shell) + if (bag !== undefined) { + bag.landingDeferredRows.push({ role: "system", text }) + } + setStatusFlash(shell, text) + return + } + appendStreamRow(shell, { role: "system", text }) +} + /** * Paint + push onto the visible streamLog (child while observing, parent * otherwise). The paint tree stays 1:1 with the (retention-capped) log — @@ -2356,6 +2385,10 @@ export function repaintTranscriptWindow(shell: AppShell): void { * The prompt box travels from the middle of the screen to the bottom, which is * a jump; it happens on the same frame as the operator's own first row so it * reads as the screen answering them rather than as the layout twitching. + * + * System/runtime notices deferred while the hero was up are flushed into the + * transcript here so they stay durable once the session has content, without + * ever having stolen the mountain on the way in. */ function clearLandingMark(shell: AppShell): void { const bag = internals.get(shell) @@ -2373,6 +2406,15 @@ function clearLandingMark(shell: AppShell): void { bag.landingNotice = null appendStreamRow(shell, { role: "system", text: notice }) } + + const deferred = bag.landingDeferredRows + if (deferred.length > 0) { + bag.landingDeferredRows = [] + // The notice strip held the latest wording while the mark was up; the + // rows themselves are durable now, so drop the flash rather than double-paint. + setStatusFlash(shell, null) + for (const row of deferred) appendStreamRow(shell, row) + } } /** @@ -5599,6 +5641,7 @@ export function createAppShell( paletteFilter: null, landing: { above: landingAbove, below: landingBelow }, landingNotice: options?.telemetryNotice ?? null, + landingDeferredRows: [], landingBelow: landingBelowState, landingSuggestionsVisible: true, landingAnimating: false,