Skip to content

Commit a1dd9fd

Browse files
committed
Keep the landing hero when a startup diagnostic arrives
Anything the runner says before the first turn lands while the landing still owns the screen, and a transcript row there reaches clearLandingMark and takes the whole composition with it — the mark, the guidance beside it, and the centred prompt box. The previous fix routed the MCP and hook producers away from the transcript one at a time and the plugin producer kept the defect. Routing the runner's own notice helper through the shell's notice path instead fixes every producer at once, including the ones nobody has written yet, and gives the constraint a single owner rather than a rule each call site has to remember. The gutter label goes with it. A system row's text already says what it is, so stamping it "command" only leaked wiring into a column the operator reads.
1 parent bbfcd29 commit a1dd9fd

4 files changed

Lines changed: 113 additions & 20 deletions

File tree

‎src/tui-opentui/landing.test.ts‎

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
streamRowCount,
2020
surfaceStartupNotice,
2121
} from "./shell"
22+
import { flushStartupNotices } from "./startup-notices"
2223
import { makeOperatorQuestion, openOperatorOverlay } from "./overlays"
2324
import {
2425
LANDING_HINTS,
@@ -541,4 +542,63 @@ describe("landing screen", () => {
541542
}
542543
}, SIZE)
543544
})
545+
546+
test("startup plugin diagnostics keep the mountain too", async () => {
547+
// CL-5718: CL-5618 routed MCP and hook notices away from the transcript
548+
// but left plugin diagnostics going through the runner's own system-row
549+
// helper, so any missing skill wiped the whole hero on load. The flush is
550+
// a named seam now precisely so no producer of a startup diagnostic gets
551+
// to decide this again.
552+
await withTestRenderer(async (h) => {
553+
const shell = createAppShell(h.renderer, {
554+
terminal: { columns: 80, rows: 24 },
555+
wireKeys: false,
556+
run: "idle",
557+
})
558+
try {
559+
await settle(h)
560+
expect(isLanding(shell)).toBe(true)
561+
const before = markRows(h)
562+
expect(before.length).toBeGreaterThan(0)
563+
564+
const summary = "plugins: 3 skills missing: brand-identity, style, philosophy"
565+
flushStartupNotices(shell, [summary])
566+
await settle(h)
567+
568+
expect(isLanding(shell)).toBe(true)
569+
expect(markRows(h).length).toBe(before.length)
570+
expect(streamRowCount(shell)).toBe(0)
571+
expect(noticeText(shell)).toContain("3 skills missing")
572+
} finally {
573+
shell.dispose()
574+
}
575+
}, SIZE)
576+
})
577+
578+
test("a flushed startup notice never carries a plumbing gutter label", async () => {
579+
// The transcript must never label a row "command": a system row's text
580+
// already says what it is, and the meta column is the operator's, not the
581+
// wiring's.
582+
await withTestRenderer(async (h) => {
583+
const shell = createAppShell(h.renderer, {
584+
terminal: { columns: 80, rows: 24 },
585+
wireKeys: false,
586+
run: "idle",
587+
})
588+
try {
589+
await settle(h)
590+
flushStartupNotices(shell, ["plugins: 1 skill missing: style"])
591+
appendStreamRow(shell, { role: "user", text: "first prompt" })
592+
await settle(h)
593+
594+
expect(isLanding(shell)).toBe(false)
595+
const frame = h.captureCharFrame()
596+
expect(frame).toContain("1 skill missing")
597+
expect(frame).not.toContain("command")
598+
expect(frame).not.toContain("overlay")
599+
} finally {
600+
shell.dispose()
601+
}
602+
}, SIZE)
603+
})
544604
})

‎src/tui-opentui/runner-host.ts‎

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,12 +31,12 @@ import type { ItemDescription } from "./shell.js"
3131
import { mountProductHost, type ProductHost } from "./product-host.js"
3232
import { onTurnBoundary } from "../agent/reactor-events.js"
3333
import {
34-
appendStreamRow,
3534
clearShellExitHandler,
3635
setPromptCostContext,
3736
setPromptModelLabel,
3837
setPromptWorkspace,
3938
setShellExitHandler,
39+
surfaceStartupNotice,
4040
} from "./shell.js"
4141
import type { CostSummary } from "../cost/cost-summary.js"
4242
import { watchGitBranch, type FetchBranch } from "./workspace-watch.js"
@@ -326,8 +326,7 @@ export async function mountRunnerHost(deps: RunnerHostDeps): Promise<RunnerHost>
326326
const surfaceDeps: CommandSurfaceDeps = {
327327
...(deps.surfaces ?? {}),
328328
...(host.openModels !== undefined ? { openModels: host.openModels } : {}),
329-
notify: (text) =>
330-
appendStreamRow(host.shell, { role: "system", text, meta: "command" }),
329+
notify: (text) => surfaceStartupNotice(host.shell, text),
331330
}
332331

333332
const refreshModels = (

‎src/tui-opentui/startup-notices.ts‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
/**
2+
* Startup diagnostics on their way to the operator.
3+
*
4+
* These are produced before the first turn, which is exactly when the landing
5+
* hero owns the screen. Delivered as transcript rows they reach
6+
* `clearLandingMark` and take the whole composition with them — the mark, the
7+
* guidance beside it, and the centred prompt box — not merely the mountain.
8+
*
9+
* This is a named seam rather than a loop at each call site because the
10+
* constraint is "a startup diagnostic is never a transcript row", and that
11+
* belongs in one place. CL-5618 fixed the MCP and hook producers individually
12+
* and the plugin producer kept the defect; a second producer getting it wrong
13+
* is what a per-call-site rule buys you.
14+
*/
15+
16+
import { surfaceStartupNotice, type AppShell } from "./shell.js"
17+
18+
/**
19+
* Hand a batch of load-time diagnostics to the shell. Each rides the notice
20+
* strip while the landing is up and becomes a durable transcript row once a
21+
* real session row ends it; after that they are ordinary system rows.
22+
*/
23+
export function flushStartupNotices(
24+
shell: AppShell,
25+
notices: readonly string[],
26+
): void {
27+
for (const notice of notices) surfaceStartupNotice(shell, notice)
28+
}

‎src/tui/runner.ts‎

Lines changed: 23 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -144,13 +144,14 @@ import { consumeStream } from "../session/stream-consumer.js";
144144
import { createCycleTextRecorder } from "../session/stream-journal.js";
145145
import { mountRunnerHost } from "../tui-opentui/runner-host.js";
146146
import {
147-
appendStreamRow,
148147
attachClipboardImage,
149148
setMentionSuggestionSource,
150149
setPromptRecognitionSource,
151150
setSentMessageHistory,
152151
setShellRunState,
152+
surfaceStartupNotice,
153153
} from "../tui-opentui/shell.js";
154+
import { flushStartupNotices } from "../tui-opentui/startup-notices.js";
154155
import {
155156
classifyAgentSendFailure,
156157
shouldSettleUiAfterSendFailure,
@@ -434,8 +435,8 @@ export async function runTUI(initialConfig: Config): Promise<number> {
434435
// Fire-and-forget startup diagnostics (this + tool-plugin resolution below)
435436
// have no result channel back to an operator action, unlike verify/add-path/
436437
// trust-grant. A log-only summary is invisible — nobody watches
437-
// ~/.corbits/logs/corbits.log — so these are also queued as transcript rows
438-
// once the shell mounts (see `systemRow` calls after `mountRunnerHost`).
438+
// ~/.corbits/logs/corbits.log — so these are queued and handed to
439+
// `flushStartupNotices` once the shell mounts.
439440
const startupPluginNotices: string[] = [];
440441
const discoveryNotice = formatPluginWarningsSummary(pluginLoadDiag.warnings);
441442
if (discoveryNotice !== undefined) startupPluginNotices.push(discoveryNotice);
@@ -1840,8 +1841,13 @@ export async function runTUI(initialConfig: Config): Promise<number> {
18401841
},
18411842
};
18421843

1843-
const systemRow = (text: string): void => {
1844-
appendStreamRow(host.shell, { role: "system", text, meta: "command" });
1844+
// Routed through the shell's notice path rather than straight into the
1845+
// transcript: anything the runner says before the first turn arrives while
1846+
// the landing hero still owns the screen, and a transcript row there wipes
1847+
// the whole composition. Once a session row has ended the landing this is an
1848+
// ordinary system row, so there is no second behaviour to reason about.
1849+
const systemNotice = (text: string): void => {
1850+
surfaceStartupNotice(host.shell, text);
18451851
};
18461852

18471853
/** Settle the shell after a rejected send so the run does not look live. */
@@ -1854,7 +1860,7 @@ export async function runTUI(initialConfig: Config): Promise<number> {
18541860
);
18551861
if (!shouldSettleUiAfterSendFailure(kind)) return;
18561862
recordRunError(err);
1857-
systemRow(err instanceof Error ? err.message : String(err));
1863+
systemNotice(err instanceof Error ? err.message : String(err));
18581864
setShellRunState(host.shell, "idle");
18591865
};
18601866

@@ -1899,29 +1905,29 @@ export async function runTUI(initialConfig: Config): Promise<number> {
18991905
const applyCommandResult = (result: CommandResult): void => {
19001906
switch (result.type) {
19011907
case "message":
1902-
systemRow(result.text);
1908+
systemNotice(result.text);
19031909
return;
19041910
case "send":
19051911
void agentProxy.send(result.text).catch(handleSendFailure);
19061912
return;
19071913
case "workflow":
1908-
systemRow(workflowController.start(result.name));
1914+
systemNotice(workflowController.start(result.name));
19091915
return;
19101916
case "noop":
19111917
return;
19121918
case "overlay":
19131919
if (!host.openSurface(result.overlay)) {
1914-
systemRow(`No surface for /${result.overlay}.`);
1920+
systemNotice(`No surface for /${result.overlay}.`);
19151921
}
19161922
return;
19171923
case "modal":
19181924
// /model is the only modal reachable from a command; provider login is
19191925
// reached from the picker itself.
19201926
if (result.modal === "agent" && host.openSurface("models")) return;
1921-
systemRow(`${result.modal} is not available in this renderer yet`);
1927+
systemNotice(`${result.modal} is not available in this renderer yet`);
19221928
return;
19231929
case "view":
1924-
systemRow(`${result.view} is not available in this renderer yet`);
1930+
systemNotice(`${result.view} is not available in this renderer yet`);
19251931
return;
19261932
case "paste-image":
19271933
void attachClipboardImage(host.shell);
@@ -1958,7 +1964,7 @@ export async function runTUI(initialConfig: Config): Promise<number> {
19581964
const dispatchCommand = (name: string, args: string): void => {
19591965
const command = getCommand(name);
19601966
if (command === undefined) {
1961-
systemRow(`Unknown command: ${name}`);
1967+
systemNotice(`Unknown command: ${name}`);
19621968
return;
19631969
}
19641970
applyCommandResult(command.handler(args, commandContext));
@@ -2015,7 +2021,7 @@ export async function runTUI(initialConfig: Config): Promise<number> {
20152021
existing: config.settings ?? null,
20162022
});
20172023
} catch (err) {
2018-
systemRow(
2024+
systemNotice(
20192025
`Connecting ${providerName} failed: ${err instanceof Error ? err.message : String(err)}`,
20202026
);
20212027
return;
@@ -2040,7 +2046,7 @@ export async function runTUI(initialConfig: Config): Promise<number> {
20402046
providers,
20412047
computeUnconnectedProviders(providers),
20422048
);
2043-
systemRow(`Connected ${result.providerName ?? providerName}. Open /model to pick a model.`);
2049+
systemNotice(`Connected ${result.providerName ?? providerName}. Open /model to pick a model.`);
20442050
})().catch((err: unknown) => {
20452051
tuiLogger.debug("provider connect failed: {error}", {
20462052
error: err instanceof Error ? err.message : String(err),
@@ -2370,9 +2376,9 @@ export async function runTUI(initialConfig: Config): Promise<number> {
23702376
});
23712377
});
23722378

2373-
// Surface fire-and-forget startup plugin diagnostics now that the shell has
2374-
// a transcript to write into (queued above, before `host` existed).
2375-
for (const notice of startupPluginNotices) systemRow(notice);
2379+
// Surface fire-and-forget startup plugin diagnostics now that there is a
2380+
// shell to say them to (queued above, before `host` existed).
2381+
flushStartupNotices(host.shell, startupPluginNotices);
23762382

23772383
await host.waitUntilExit();
23782384
// Quitting mid-stream is an abnormal end for the in-flight cycle: nothing

0 commit comments

Comments
 (0)