From ff7cee9abcca5e953718e666161f048b2fe1010a Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sat, 8 Aug 2026 11:39:23 -0700 Subject: [PATCH] Keep the landing mountain painted through startup load notices MCP connection failures, hook failures, and width-contract warnings called appendStreamRow directly, which cleared the landing mark and wiped the mountain hero the moment anything went wrong on load. These notices now ride the notice strip via surfaceStartupNotice while the landing is still mounted, and flush into the transcript as durable rows once the first real session row ends the landing. --- src/tui-opentui/landing.test.ts | 52 +++++++++++++++++++++++++++++++++ src/tui-opentui/product-host.ts | 16 ++++++---- src/tui-opentui/shell.ts | 43 +++++++++++++++++++++++++++ 3 files changed, 106 insertions(+), 5 deletions(-) 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,