Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions src/tui-opentui/landing.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,13 @@ import {
appendStreamRow,
applyLandingSuggestion,
createAppShell,
noticeText,
paintChrome,
setPromptWorkspace,
isLanding,
paintLanding,
streamRowCount,
surfaceStartupNotice,
} from "./shell"
import { makeOperatorQuestion, openOperatorOverlay } from "./overlays"
import {
Expand Down Expand Up @@ -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)
})
})
16 changes: 11 additions & 5 deletions src/tui-opentui/product-host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ import {
setPaletteOnCommand,
setMcpNeedsAuth,
setStatusFlash,
surfaceStartupNotice,
type AppShell,
type ItemDescription,
type OverlaySelection,
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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 })
Expand Down
43 changes: 43 additions & 0 deletions src/tui-opentui/shell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -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 —
Expand Down Expand Up @@ -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)
Expand All @@ -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)
}
}

/**
Expand Down Expand Up @@ -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,
Expand Down
Loading