Skip to content

Commit ff7cee9

Browse files
committed
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.
1 parent 54a40ed commit ff7cee9

3 files changed

Lines changed: 106 additions & 5 deletions

File tree

src/tui-opentui/landing.test.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,13 @@ import {
1111
appendStreamRow,
1212
applyLandingSuggestion,
1313
createAppShell,
14+
noticeText,
1415
paintChrome,
1516
setPromptWorkspace,
1617
isLanding,
1718
paintLanding,
19+
streamRowCount,
20+
surfaceStartupNotice,
1821
} from "./shell"
1922
import { makeOperatorQuestion, openOperatorOverlay } from "./overlays"
2023
import {
@@ -489,4 +492,53 @@ describe("landing screen", () => {
489492
}
490493
}, SIZE)
491494
})
495+
496+
test("startup MCP/load errors keep the mountain and ride the notice strip", async () => {
497+
// CL-5618 / CL-5600: system notices on load used to appendStreamRow →
498+
// clearLandingMark, wiping the brand hero. They must surface as secondary
499+
// chrome while geometry still seats MARK_SMALL or larger.
500+
await withTestRenderer(async (h) => {
501+
const shell = createAppShell(h.renderer, {
502+
terminal: { columns: 80, rows: 24 },
503+
wireKeys: false,
504+
run: "idle",
505+
})
506+
try {
507+
await settle(h)
508+
expect(isLanding(shell)).toBe(true)
509+
const before = markRows(h)
510+
expect([MARK_LARGE, MARK_MID, MARK_SMALL].map((g) => g.rows)).toContain(
511+
before.length,
512+
)
513+
514+
const mcpError =
515+
"mcp github did not connect (ECONNREFUSED) — its tools are unavailable; /mcp for detail"
516+
surfaceStartupNotice(shell, mcpError)
517+
await settle(h)
518+
519+
// The mountain stays; the notice strip carries the wording.
520+
expect(isLanding(shell)).toBe(true)
521+
expect(streamRowCount(shell)).toBe(0)
522+
expect(shell.statusFlash).toBe(mcpError)
523+
expect(noticeText(shell)).toContain("mcp github did not connect")
524+
const after = markRows(h)
525+
expect(after.length).toBe(before.length)
526+
expect([MARK_LARGE, MARK_MID, MARK_SMALL].map((g) => g.rows)).toContain(
527+
after.length,
528+
)
529+
530+
// A real session row still ends the landing; deferred notices become
531+
// durable transcript rows rather than vanishing with the flash.
532+
appendStreamRow(shell, { role: "user", text: "first prompt" })
533+
await settle(h)
534+
expect(isLanding(shell)).toBe(false)
535+
expect(markRows(h)).toEqual([])
536+
const frame = h.captureCharFrame()
537+
expect(frame).toContain("first prompt")
538+
expect(frame).toContain("mcp github did not connect")
539+
} finally {
540+
shell.dispose()
541+
}
542+
}, SIZE)
543+
})
492544
})

src/tui-opentui/product-host.ts

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ import {
4747
setPaletteOnCommand,
4848
setMcpNeedsAuth,
4949
setStatusFlash,
50+
surfaceStartupNotice,
5051
type AppShell,
5152
type ItemDescription,
5253
type OverlaySelection,
@@ -336,12 +337,14 @@ export async function mountProductHost(
336337
: {}),
337338
})
338339

339-
// Announced in the transcript rather than logged: a log line is invisible
340-
// behind a full-screen shell, and the operator is the only one who can fix a
341-
// terminal setting.
340+
// Announced on the notice strip (or transcript once the session has content)
341+
// rather than logged: a log line is invisible behind a full-screen shell, and
342+
// the operator is the only one who can fix a terminal setting. Using the
343+
// startup-notice path keeps the landing mountain painted when this fires
344+
// before the first turn (CL-5618).
342345
const widthReport = checkWidthContract(renderer.widthMethod)
343346
if (!widthReport.agrees) {
344-
appendStreamRow(shell, { role: "system", text: widthContractNotice(widthReport) })
347+
surfaceStartupNotice(shell, widthContractNotice(widthReport))
345348
}
346349

347350
const port = createLiveSessionPort({
@@ -457,7 +460,10 @@ export async function mountProductHost(
457460
function show(notice: RuntimeNotice | null): void {
458461
if (notice === null) return
459462
if (notice.kind === "row") {
460-
appendStreamRow(shell, { role: "system", text: notice.text })
463+
// MCP load failures and hook failures must not wipe the landing mark.
464+
// surfaceStartupNotice keeps the mountain while the notice strip carries
465+
// the wording, then flushes a durable row once the session starts.
466+
surfaceStartupNotice(shell, notice.text)
461467
return
462468
}
463469
setStatusFlash(shell, notice.text, { ttlMs: RUNTIME_FLASH_MS })

src/tui-opentui/shell.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1797,6 +1797,13 @@ type ShellInternals = {
17971797
* rather than a screen the first prompt wipes.
17981798
*/
17991799
landingNotice: string | null
1800+
/**
1801+
* System/runtime notices that arrived while the landing was still up (MCP
1802+
* load failures, width-contract warnings, hook failures). Held here and
1803+
* painted on the notice strip so they never call `clearLandingMark`; flushed
1804+
* into the transcript when the first real session row ends the landing.
1805+
*/
1806+
landingDeferredRows: StreamRow[]
18001807
/** What the rows below the box are painting, so they can be repainted. */
18011808
landingBelow: LandingBelowContent | null
18021809
/** Starters are offered only while the prompt is empty. */
@@ -2031,6 +2038,28 @@ function evictedRowsNotice(evicted: number): string {
20312038
return ` … ${evicted} earlier row${evicted === 1 ? "" : "s"} dropped (past the retention limit)`
20322039
}
20332040

2041+
/**
2042+
* Surface a runtime/load notice without stealing the landing hero.
2043+
*
2044+
* MCP connection failures, hook failures and similar startup chatter used to
2045+
* call `appendStreamRow` → `clearLandingMark`, wiping the mountain the moment
2046+
* anything went wrong on load (CL-5618 / CL-5600). While the landing is still
2047+
* mounted the wording rides the notice strip and the row is held for flush
2048+
* once a real session row ends the landing; after that it is a normal system
2049+
* row.
2050+
*/
2051+
export function surfaceStartupNotice(shell: AppShell, text: string): void {
2052+
if (isLanding(shell)) {
2053+
const bag = internals.get(shell)
2054+
if (bag !== undefined) {
2055+
bag.landingDeferredRows.push({ role: "system", text })
2056+
}
2057+
setStatusFlash(shell, text)
2058+
return
2059+
}
2060+
appendStreamRow(shell, { role: "system", text })
2061+
}
2062+
20342063
/**
20352064
* Paint + push onto the visible streamLog (child while observing, parent
20362065
* otherwise). The paint tree stays 1:1 with the (retention-capped) log —
@@ -2356,6 +2385,10 @@ export function repaintTranscriptWindow(shell: AppShell): void {
23562385
* The prompt box travels from the middle of the screen to the bottom, which is
23572386
* a jump; it happens on the same frame as the operator's own first row so it
23582387
* reads as the screen answering them rather than as the layout twitching.
2388+
*
2389+
* System/runtime notices deferred while the hero was up are flushed into the
2390+
* transcript here so they stay durable once the session has content, without
2391+
* ever having stolen the mountain on the way in.
23592392
*/
23602393
function clearLandingMark(shell: AppShell): void {
23612394
const bag = internals.get(shell)
@@ -2373,6 +2406,15 @@ function clearLandingMark(shell: AppShell): void {
23732406
bag.landingNotice = null
23742407
appendStreamRow(shell, { role: "system", text: notice })
23752408
}
2409+
2410+
const deferred = bag.landingDeferredRows
2411+
if (deferred.length > 0) {
2412+
bag.landingDeferredRows = []
2413+
// The notice strip held the latest wording while the mark was up; the
2414+
// rows themselves are durable now, so drop the flash rather than double-paint.
2415+
setStatusFlash(shell, null)
2416+
for (const row of deferred) appendStreamRow(shell, row)
2417+
}
23762418
}
23772419

23782420
/**
@@ -5599,6 +5641,7 @@ export function createAppShell(
55995641
paletteFilter: null,
56005642
landing: { above: landingAbove, below: landingBelow },
56015643
landingNotice: options?.telemetryNotice ?? null,
5644+
landingDeferredRows: [],
56025645
landingBelow: landingBelowState,
56035646
landingSuggestionsVisible: true,
56045647
landingAnimating: false,

0 commit comments

Comments
 (0)