Skip to content

Commit c74aafb

Browse files
Merge pull request #393 from corbitsdev/cl-5618-mountain-load-errors-v2
Keep the landing mountain painted through startup load notices
2 parents 7d992d8 + ff7cee9 commit c74aafb

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)