From bbfcd29a7dc91590b445184e394a6487a74d934c Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sat, 8 Aug 2026 14:18:46 -0700 Subject: [PATCH 1/6] Count each missing plugin skill once in the load summary A skill referenced by three plugins and absent from the search path is one missing skill, not three: the operator installs it once and all three resolve. Counting raw warnings made the summary both wrong and self-contradicting, reading "7 skills missing" above a list of nine names. --- src/plugins/diagnostics.test.ts | 27 +++++++++++++++++++++++++++ src/plugins/diagnostics.ts | 31 ++++++++++++++++++++----------- 2 files changed, 47 insertions(+), 11 deletions(-) diff --git a/src/plugins/diagnostics.test.ts b/src/plugins/diagnostics.test.ts index 4040a56c9..a1fc6dfb3 100644 --- a/src/plugins/diagnostics.test.ts +++ b/src/plugins/diagnostics.test.ts @@ -50,6 +50,33 @@ describe("formatPluginWarningsSummary", () => { expect(summary).toContain("1 skill missing"); expect(summary).toContain("1 other warning"); }); + + test("names a skill once however many sources missed it", () => { + // The same skill missing from three plugins is one missing skill, not + // three: the operator installs it once to fix all of them. + const summary = formatPluginWarningsSummary([ + 'agent a: skill "brand-identity" referenced but not found in skill search path', + 'agent a: skill "style" referenced but not found in skill search path', + 'agent b: skill "philosophy" referenced but not found in skill search path', + 'agent b: skill "style" referenced but not found in skill search path', + 'agent c: skill "philosophy" referenced but not found in skill search path', + 'agent c: skill "style" referenced but not found in skill search path', + 'agent c: skill "brand-identity" referenced but not found in skill search path', + ]); + expect(summary).toBe( + "plugins: 3 skills missing: brand-identity, style, philosophy", + ); + }); + + test("mixed-warning count also counts distinct skills", () => { + const summary = formatPluginWarningsSummary([ + 'agent a: skill "style" referenced but not found in skill search path', + 'agent b: skill "style" referenced but not found in skill search path', + "other problem", + ]); + expect(summary).toContain("1 skill missing (style)"); + expect(summary).toContain("1 other warning"); + }); }); describe("emitPluginWarningSummary", () => { diff --git a/src/plugins/diagnostics.ts b/src/plugins/diagnostics.ts index a66e6fc6a..78fc44730 100644 --- a/src/plugins/diagnostics.ts +++ b/src/plugins/diagnostics.ts @@ -43,31 +43,40 @@ export function stderrPluginWarning(msg: string): void { * One-line summary for a batch of load warnings. Skill-miss messages are * collapsed to `N skills missing: a, b, c`; mixed warnings get a count line. * Returns undefined when there is nothing to report. + * + * Skill names are deduplicated because a skill is missing once no matter how + * many plugins referenced it — the operator installs it once to fix all of + * them — and the count is taken from the deduplicated list so the number can + * never disagree with the names printed beside it. */ export function formatPluginWarningsSummary( warnings: readonly string[], ): string | undefined { if (warnings.length === 0) return undefined; - const skillMisses: string[] = []; + const missedSkills = new Set(); + let skillMissWarnings = 0; for (const w of warnings) { const m = /skill "([^"]+)" referenced but not found/.exec(w); - if (m?.[1] !== undefined) skillMisses.push(m[1]); + if (m?.[1] === undefined) continue; + skillMissWarnings += 1; + missedSkills.add(m[1]); } - if (skillMisses.length > 0 && skillMisses.length === warnings.length) { - const n = skillMisses.length; - return `plugins: ${n} skill${n === 1 ? "" : "s"} missing: ${skillMisses.join(", ")}`; + const names = [...missedSkills]; + const n = names.length; + + if (n > 0 && skillMissWarnings === warnings.length) { + return `plugins: ${n} skill${n === 1 ? "" : "s"} missing: ${names.join(", ")}`; } - if (skillMisses.length > 0) { - const n = skillMisses.length; - const other = warnings.length - n; - return `plugins: ${n} skill${n === 1 ? "" : "s"} missing (${skillMisses.join(", ")}); ${other} other warning${other === 1 ? "" : "s"}`; + if (n > 0) { + const other = warnings.length - skillMissWarnings; + return `plugins: ${n} skill${n === 1 ? "" : "s"} missing (${names.join(", ")}); ${other} other warning${other === 1 ? "" : "s"}`; } - const n = warnings.length; - return `plugins: ${n} warning${n === 1 ? "" : "s"} during load`; + const total = warnings.length; + return `plugins: ${total} warning${total === 1 ? "" : "s"} during load`; } /** From a1dd9fd65b35f847bad18f011b0619e8fb2a44dc Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sat, 8 Aug 2026 14:22:24 -0700 Subject: [PATCH 2/6] Keep the landing hero when a startup diagnostic arrives MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/tui-opentui/landing.test.ts | 60 ++++++++++++++++++++++++++++++ src/tui-opentui/runner-host.ts | 5 +-- src/tui-opentui/startup-notices.ts | 28 ++++++++++++++ src/tui/runner.ts | 40 +++++++++++--------- 4 files changed, 113 insertions(+), 20 deletions(-) create mode 100644 src/tui-opentui/startup-notices.ts diff --git a/src/tui-opentui/landing.test.ts b/src/tui-opentui/landing.test.ts index 6f99cf33a..966a13541 100644 --- a/src/tui-opentui/landing.test.ts +++ b/src/tui-opentui/landing.test.ts @@ -19,6 +19,7 @@ import { streamRowCount, surfaceStartupNotice, } from "./shell" +import { flushStartupNotices } from "./startup-notices" import { makeOperatorQuestion, openOperatorOverlay } from "./overlays" import { LANDING_HINTS, @@ -541,4 +542,63 @@ describe("landing screen", () => { } }, SIZE) }) + + test("startup plugin diagnostics keep the mountain too", async () => { + // CL-5718: CL-5618 routed MCP and hook notices away from the transcript + // but left plugin diagnostics going through the runner's own system-row + // helper, so any missing skill wiped the whole hero on load. The flush is + // a named seam now precisely so no producer of a startup diagnostic gets + // to decide this again. + 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(before.length).toBeGreaterThan(0) + + const summary = "plugins: 3 skills missing: brand-identity, style, philosophy" + flushStartupNotices(shell, [summary]) + await settle(h) + + expect(isLanding(shell)).toBe(true) + expect(markRows(h).length).toBe(before.length) + expect(streamRowCount(shell)).toBe(0) + expect(noticeText(shell)).toContain("3 skills missing") + } finally { + shell.dispose() + } + }, SIZE) + }) + + test("a flushed startup notice never carries a plumbing gutter label", async () => { + // The transcript must never label a row "command": a system row's text + // already says what it is, and the meta column is the operator's, not the + // wiring's. + await withTestRenderer(async (h) => { + const shell = createAppShell(h.renderer, { + terminal: { columns: 80, rows: 24 }, + wireKeys: false, + run: "idle", + }) + try { + await settle(h) + flushStartupNotices(shell, ["plugins: 1 skill missing: style"]) + appendStreamRow(shell, { role: "user", text: "first prompt" }) + await settle(h) + + expect(isLanding(shell)).toBe(false) + const frame = h.captureCharFrame() + expect(frame).toContain("1 skill missing") + expect(frame).not.toContain("command") + expect(frame).not.toContain("overlay") + } finally { + shell.dispose() + } + }, SIZE) + }) }) diff --git a/src/tui-opentui/runner-host.ts b/src/tui-opentui/runner-host.ts index b98abd8d8..8ea9ded7d 100644 --- a/src/tui-opentui/runner-host.ts +++ b/src/tui-opentui/runner-host.ts @@ -31,12 +31,12 @@ import type { ItemDescription } from "./shell.js" import { mountProductHost, type ProductHost } from "./product-host.js" import { onTurnBoundary } from "../agent/reactor-events.js" import { - appendStreamRow, clearShellExitHandler, setPromptCostContext, setPromptModelLabel, setPromptWorkspace, setShellExitHandler, + surfaceStartupNotice, } from "./shell.js" import type { CostSummary } from "../cost/cost-summary.js" import { watchGitBranch, type FetchBranch } from "./workspace-watch.js" @@ -326,8 +326,7 @@ export async function mountRunnerHost(deps: RunnerHostDeps): Promise const surfaceDeps: CommandSurfaceDeps = { ...(deps.surfaces ?? {}), ...(host.openModels !== undefined ? { openModels: host.openModels } : {}), - notify: (text) => - appendStreamRow(host.shell, { role: "system", text, meta: "command" }), + notify: (text) => surfaceStartupNotice(host.shell, text), } const refreshModels = ( diff --git a/src/tui-opentui/startup-notices.ts b/src/tui-opentui/startup-notices.ts new file mode 100644 index 000000000..bf7de9ba3 --- /dev/null +++ b/src/tui-opentui/startup-notices.ts @@ -0,0 +1,28 @@ +/** + * Startup diagnostics on their way to the operator. + * + * These are produced before the first turn, which is exactly when the landing + * hero owns the screen. Delivered as transcript rows they reach + * `clearLandingMark` and take the whole composition with them — the mark, the + * guidance beside it, and the centred prompt box — not merely the mountain. + * + * This is a named seam rather than a loop at each call site because the + * constraint is "a startup diagnostic is never a transcript row", and that + * belongs in one place. CL-5618 fixed the MCP and hook producers individually + * and the plugin producer kept the defect; a second producer getting it wrong + * is what a per-call-site rule buys you. + */ + +import { surfaceStartupNotice, type AppShell } from "./shell.js" + +/** + * Hand a batch of load-time diagnostics to the shell. Each rides the notice + * strip while the landing is up and becomes a durable transcript row once a + * real session row ends it; after that they are ordinary system rows. + */ +export function flushStartupNotices( + shell: AppShell, + notices: readonly string[], +): void { + for (const notice of notices) surfaceStartupNotice(shell, notice) +} diff --git a/src/tui/runner.ts b/src/tui/runner.ts index 1a4d4ec6c..1b3b5e8fa 100644 --- a/src/tui/runner.ts +++ b/src/tui/runner.ts @@ -144,13 +144,14 @@ import { consumeStream } from "../session/stream-consumer.js"; import { createCycleTextRecorder } from "../session/stream-journal.js"; import { mountRunnerHost } from "../tui-opentui/runner-host.js"; import { - appendStreamRow, attachClipboardImage, setMentionSuggestionSource, setPromptRecognitionSource, setSentMessageHistory, setShellRunState, + surfaceStartupNotice, } from "../tui-opentui/shell.js"; +import { flushStartupNotices } from "../tui-opentui/startup-notices.js"; import { classifyAgentSendFailure, shouldSettleUiAfterSendFailure, @@ -434,8 +435,8 @@ export async function runTUI(initialConfig: Config): Promise { // Fire-and-forget startup diagnostics (this + tool-plugin resolution below) // have no result channel back to an operator action, unlike verify/add-path/ // trust-grant. A log-only summary is invisible — nobody watches - // ~/.corbits/logs/corbits.log — so these are also queued as transcript rows - // once the shell mounts (see `systemRow` calls after `mountRunnerHost`). + // ~/.corbits/logs/corbits.log — so these are queued and handed to + // `flushStartupNotices` once the shell mounts. const startupPluginNotices: string[] = []; const discoveryNotice = formatPluginWarningsSummary(pluginLoadDiag.warnings); if (discoveryNotice !== undefined) startupPluginNotices.push(discoveryNotice); @@ -1840,8 +1841,13 @@ export async function runTUI(initialConfig: Config): Promise { }, }; - const systemRow = (text: string): void => { - appendStreamRow(host.shell, { role: "system", text, meta: "command" }); + // Routed through the shell's notice path rather than straight into the + // transcript: anything the runner says before the first turn arrives while + // the landing hero still owns the screen, and a transcript row there wipes + // the whole composition. Once a session row has ended the landing this is an + // ordinary system row, so there is no second behaviour to reason about. + const systemNotice = (text: string): void => { + surfaceStartupNotice(host.shell, text); }; /** 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 { ); if (!shouldSettleUiAfterSendFailure(kind)) return; recordRunError(err); - systemRow(err instanceof Error ? err.message : String(err)); + systemNotice(err instanceof Error ? err.message : String(err)); setShellRunState(host.shell, "idle"); }; @@ -1899,29 +1905,29 @@ export async function runTUI(initialConfig: Config): Promise { const applyCommandResult = (result: CommandResult): void => { switch (result.type) { case "message": - systemRow(result.text); + systemNotice(result.text); return; case "send": void agentProxy.send(result.text).catch(handleSendFailure); return; case "workflow": - systemRow(workflowController.start(result.name)); + systemNotice(workflowController.start(result.name)); return; case "noop": return; case "overlay": if (!host.openSurface(result.overlay)) { - systemRow(`No surface for /${result.overlay}.`); + systemNotice(`No surface for /${result.overlay}.`); } return; case "modal": // /model is the only modal reachable from a command; provider login is // reached from the picker itself. if (result.modal === "agent" && host.openSurface("models")) return; - systemRow(`${result.modal} is not available in this renderer yet`); + systemNotice(`${result.modal} is not available in this renderer yet`); return; case "view": - systemRow(`${result.view} is not available in this renderer yet`); + systemNotice(`${result.view} is not available in this renderer yet`); return; case "paste-image": void attachClipboardImage(host.shell); @@ -1958,7 +1964,7 @@ export async function runTUI(initialConfig: Config): Promise { const dispatchCommand = (name: string, args: string): void => { const command = getCommand(name); if (command === undefined) { - systemRow(`Unknown command: ${name}`); + systemNotice(`Unknown command: ${name}`); return; } applyCommandResult(command.handler(args, commandContext)); @@ -2015,7 +2021,7 @@ export async function runTUI(initialConfig: Config): Promise { existing: config.settings ?? null, }); } catch (err) { - systemRow( + systemNotice( `Connecting ${providerName} failed: ${err instanceof Error ? err.message : String(err)}`, ); return; @@ -2040,7 +2046,7 @@ export async function runTUI(initialConfig: Config): Promise { providers, computeUnconnectedProviders(providers), ); - systemRow(`Connected ${result.providerName ?? providerName}. Open /model to pick a model.`); + systemNotice(`Connected ${result.providerName ?? providerName}. Open /model to pick a model.`); })().catch((err: unknown) => { tuiLogger.debug("provider connect failed: {error}", { error: err instanceof Error ? err.message : String(err), @@ -2370,9 +2376,9 @@ export async function runTUI(initialConfig: Config): Promise { }); }); - // Surface fire-and-forget startup plugin diagnostics now that the shell has - // a transcript to write into (queued above, before `host` existed). - for (const notice of startupPluginNotices) systemRow(notice); + // Surface fire-and-forget startup plugin diagnostics now that there is a + // shell to say them to (queued above, before `host` existed). + flushStartupNotices(host.shell, startupPluginNotices); await host.waitUntilExit(); // Quitting mid-stream is an abnormal end for the in-flight cycle: nothing From 49b77eac7e6801ad1bf3bbbbf25d89dee295d093 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sat, 8 Aug 2026 14:25:40 -0700 Subject: [PATCH 3/6] Let snow fall over the landing mark The sky above the ridgeline was dead space. A sparse field of pixel snow drifts through it on the clock the mark already runs on, so the hero has motion even while the silhouette is held. Density and fall rate stay low deliberately: the mark has to keep reading as a mark, and a storm would turn the one legible thing on the screen into texture. Flakes never land on mountain coverage, and they stop entirely when the mark is held still or is fading out, so the decoration never outlives the thing it drifts over. Absorbed from the standalone snow branch rather than reimplemented; the taste question it was open on is settled by making snow part of the default landing. --- src/tui-opentui/mark-anim.test.ts | 111 ++++++++++++++++++++++++++---- src/tui-opentui/mark-anim.ts | 64 ++++++++++++++++- 2 files changed, 159 insertions(+), 16 deletions(-) diff --git a/src/tui-opentui/mark-anim.test.ts b/src/tui-opentui/mark-anim.test.ts index 0fd988fb7..cb321f7f5 100644 --- a/src/tui-opentui/mark-anim.test.ts +++ b/src/tui-opentui/mark-anim.test.ts @@ -2,14 +2,20 @@ import { describe, expect, test } from "bun:test" import { MARK_PERIOD_SECONDS, + SNOW_CHAR, markFrame, markText, renderMark, smooth, } from "./mark-anim" -import { MARK_COLS, MARK_COVERAGE, MARK_LARGE, MARK_ROWS } from "./mark-shape" +import { MARK_COLS, MARK_LARGE, MARK_ROWS, MARK_SMALL } from "./mark-shape" import { UI } from "./theme" +const MOUNTAIN_CHARS = "▁▂▃▄▅▆▇█" + +const isMountain = (char: string): boolean => MOUNTAIN_CHARS.includes(char) +const isSnow = (char: string): boolean => char === SNOW_CHAR + describe("smooth", () => { test("clamps outside [0, 1] and eases inside it", () => { expect(smooth(-3)).toBe(0) @@ -62,8 +68,17 @@ describe("markFrame", () => { }) describe("renderMark", () => { - const emptyCells = (grid: readonly (readonly { char: string }[])[]): number => - grid.flat().filter((cell) => cell.char === " ").length + /** Mountain-block weight only — snow must not pollute silhouette metrics. */ + const mountainWeight = ( + grid: readonly (readonly { char: string }[])[], + ): number => + grid + .flat() + .reduce((sum, cell) => sum + Math.max(0, MOUNTAIN_CHARS.indexOf(cell.char) + 1), 0) + + const mountainCells = ( + grid: readonly (readonly { char: string }[])[], + ): number => grid.flat().filter((cell) => isMountain(cell.char)).length test("is the mark's cell dimensions", () => { const grid = renderMark({ nowMs: 0, still: true }) @@ -71,11 +86,18 @@ describe("renderMark", () => { for (const row of grid) expect(row).toHaveLength(MARK_COLS) }) - test("never paints outside the silhouette", () => { - const grid = renderMark({ nowMs: 2000, still: false }) + test("sky is empty or snow; mountain cells never hold snow", () => { + const grid = renderMark({ nowMs: 2000, still: false, grid: MARK_LARGE }) grid.forEach((row, y) => { row.forEach((cell, x) => { - if ((MARK_COVERAGE[y]?.[x] ?? 0) === 0) expect(cell.char).toBe(" ") + const coverage = MARK_LARGE.coverage[y]?.[x] ?? 0 + if (coverage === 0) { + expect(cell.char === " " || isSnow(cell.char)).toBe(true) + if (isSnow(cell.char)) expect(cell.fg).toBe(UI.textFaint) + } else if (isMountain(cell.char)) { + expect(cell.fg).toBe(UI.action) + expect(isSnow(cell.char)).toBe(false) + } }) }) }) @@ -84,17 +106,19 @@ describe("renderMark", () => { const grid = renderMark({ nowMs: 0, still: true, grid: MARK_LARGE }) grid.forEach((row, y) => { row.forEach((cell, x) => { - expect(" ▁▂▃▄▅▆▇█").toContain(cell.char) + // Still mode has no snow — only space or mountain blocks. + expect(` ${MOUNTAIN_CHARS}`).toContain(cell.char) if ((MARK_LARGE.coverage[y]?.[x] ?? 0) === 1) expect(cell.char).toBe("█") }) }) }) - test("the still frame is clock-independent", () => { + test("the still frame is clock-independent and has no snow", () => { const a = markText(renderMark({ nowMs: 0, still: true })) const b = markText(renderMark({ nowMs: 987_654, still: true })) expect(b).toBe(a) expect(a.replace(/[\s\n]/g, "").length).toBeGreaterThan(0) + expect(a.includes(SNOW_CHAR)).toBe(false) }) test("the animated frame advances with the injected clock", () => { @@ -105,14 +129,14 @@ describe("renderMark", () => { }) test("the outline reveals left to right", () => { - // Early in the draw phase only the leftmost columns may be lit. + // Early in the draw phase only the leftmost mountain columns may be lit. const grid = renderMark({ nowMs: 0.06 * MARK_PERIOD_SECONDS * 1000, still: false }) const lit = grid.flatMap((row) => - row.flatMap((cell, col) => (cell.char === " " ? [] : [col])), + row.flatMap((cell, col) => (isMountain(cell.char) ? [col] : [])), ) expect(Math.max(...lit, -1)).toBeLessThan(MARK_COLS) const full = renderMark({ nowMs: 0.4 * MARK_PERIOD_SECONDS * 1000, still: false }) - expect(emptyCells(grid)).toBeGreaterThan(emptyCells(full)) + expect(mountainCells(full)).toBeGreaterThan(mountainCells(grid)) }) test("the fade thins the mark out toward empty", () => { @@ -121,12 +145,10 @@ describe("renderMark", () => { nowMs: 0.995 * MARK_PERIOD_SECONDS * 1000, still: false, }) - expect(emptyCells(fading)).toBeGreaterThan(emptyCells(held)) + expect(mountainCells(held)).toBeGreaterThan(mountainCells(fading)) }) test("filling makes the mark denser than its outline alone", () => { - const weight = (grid: readonly (readonly { char: string }[])[]): number => - grid.flat().reduce((sum, cell) => sum + " ▁▂▃▄▅▆▇█".indexOf(cell.char), 0) const outlineOnly = renderMark({ nowMs: 0.42 * MARK_PERIOD_SECONDS * 1000, still: false, @@ -135,6 +157,65 @@ describe("renderMark", () => { nowMs: 0.8 * MARK_PERIOD_SECONDS * 1000, still: false, }) - expect(weight(filled)).toBeGreaterThan(weight(outlineOnly)) + expect(mountainWeight(filled)).toBeGreaterThan(mountainWeight(outlineOnly)) + }) + + test("snow drifts over time without overwriting the silhouette", () => { + // Sample across several seconds so flakes advance even at a slow fall rate. + const times = [0, 1500, 3000, 4500, 6000, 7500] + const snowSets = times.map((nowMs) => { + const grid = renderMark({ nowMs, still: false, grid: MARK_LARGE }) + const snow: string[] = [] + grid.forEach((row, y) => { + row.forEach((cell, x) => { + if (isSnow(cell.char)) { + snow.push(`${y},${x}`) + // Flakes live only in sky cells — never on mountain coverage. + expect(MARK_LARGE.coverage[y]?.[x] ?? 0).toBe(0) + } + }) + }) + return snow.join("|") + }) + + const withSnow = snowSets.filter((s) => s.length > 0) + expect(withSnow.length).toBeGreaterThan(1) + expect(new Set(withSnow).size).toBeGreaterThan(1) + + // During the full-hold phase the ridgeline dominates the flake field. + const held = renderMark({ + nowMs: 0.82 * MARK_PERIOD_SECONDS * 1000, + still: false, + grid: MARK_LARGE, + }) + let flakes = 0 + let mountains = 0 + held.forEach((row, y) => { + row.forEach((cell, x) => { + if (isSnow(cell.char)) { + flakes += 1 + expect(MARK_LARGE.coverage[y]?.[x] ?? 0).toBe(0) + } + if (isMountain(cell.char)) mountains += 1 + }) + }) + expect(mountains).toBeGreaterThan(20) + expect(mountains).toBeGreaterThan(flakes) + }) + + test("still mode freezes the mark with no snow motion", () => { + const a = renderMark({ nowMs: 0, still: true, grid: MARK_SMALL }) + const b = renderMark({ nowMs: 50_000, still: true, grid: MARK_SMALL }) + expect(markText(b)).toBe(markText(a)) + expect(a.flat().some((cell) => isSnow(cell.char))).toBe(false) + }) + + test("snow drops out during the fade-out phase, matching the mark", () => { + const fading = renderMark({ + nowMs: 0.995 * MARK_PERIOD_SECONDS * 1000, + still: false, + grid: MARK_LARGE, + }) + expect(fading.flat().some((cell) => isSnow(cell.char))).toBe(false) }) }) diff --git a/src/tui-opentui/mark-anim.ts b/src/tui-opentui/mark-anim.ts index e5be69807..a3a62dafe 100644 --- a/src/tui-opentui/mark-anim.ts +++ b/src/tui-opentui/mark-anim.ts @@ -7,6 +7,11 @@ * wave; at hero size a terminal renders that as visible noise rather than as * shimmer, so the terminal mark is opaque instead. * + * Over the sky (zero-coverage cells) a sparse field of pixel snow falls on the + * same injected clock. Density and speed stay low so the ridgeline keeps its + * silhouette; `still` (idle or reduced motion) freezes the mark and drops the + * snow entirely. Mountain cells always win over flakes. + * * Everything here is pure and clock-injected: `nowMs` is the only time source, * so the caller's existing 250 ms status tick drives the animation and tests * drive it deterministically. There is no timer in this module. @@ -70,6 +75,18 @@ const EIGHTHS = ["▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"] as cons */ const FILL_GAMMA = 0.6 +/** One snowflake pixel. Exported so tests can distinguish sky from mountain. */ +export const SNOW_CHAR = "·" + +/** + * Fraction of columns that host a flake. Kept low so the sky reads as empty + * with occasional drift rather than a storm. + */ +const SNOW_COLUMN_FRACTION = 0.18 + +/** Baseline rows-per-second fall rate. Slow enough to feel like drift. */ +const SNOW_FALL_SPEED = 0.55 + export type MarkCell = { readonly char: string readonly fg: string @@ -83,6 +100,35 @@ export type MarkInput = { readonly grid?: MarkGrid } +/** + * Stable unit hash in [0, 1) from integer seeds. Pure and clock-independent so + * flake columns and phases never jitter between frames. + */ +function unitHash(a: number, b = 0): number { + const n = Math.imul(a + 1, 374761393) ^ Math.imul(b + 1, 668265263) + const x = Math.imul(n ^ (n >>> 13), 1274126177) + return ((x >>> 0) % 10_000) / 10_000 +} + +/** + * Whether a sky cell at (row, col) holds a flake at `seconds`. Sparse columns + * only; each active column carries one flake with a private phase and a slight + * speed variation so the field does not march as a rigid lattice. + */ +function snowflakeAt( + row: number, + col: number, + seconds: number, + rows: number, +): boolean { + if (rows <= 0) return false + if (unitHash(col, 1) > SNOW_COLUMN_FRACTION) return false + const phase = unitHash(col, 2) * rows + const speed = SNOW_FALL_SPEED * (0.75 + unitHash(col, 3) * 0.5) + const wrapped = (((seconds * speed + phase) % rows) + rows) % rows + return Math.floor(wrapped) === row +} + /** * Composite one frame into a row-major cell grid. * @@ -91,6 +137,9 @@ export type MarkInput = { * slopes instead of staircasing. No dither texture survives inside the shape — * the mark is a mountain, and a mountain is opaque. * + * Sky cells (zero coverage) may hold a single falling snow pixel. Flakes never + * overwrite mountain coverage, and `still` suppresses them entirely. + * * `alpha` has no terminal equivalent, so it scales the block height instead: * the mark sinks toward empty rather than blending to black. */ @@ -100,6 +149,9 @@ export function renderMark(input: MarkInput): readonly (readonly MarkCell[])[] { const { drawProg, fillProg, alpha } = markFrame(seconds, input.still) const revealed = drawProg * shape.cols const fillLine = shape.rows * (1 - fillProg) + // Fade out drops the snow too so the decoration doesn't outlast the mark + // it drifts over. + const snowOn = !input.still && alpha === 1 const grid: MarkCell[][] = [] for (let row = 0; row < shape.rows; row++) { @@ -110,7 +162,17 @@ export function renderMark(input: MarkInput): readonly (readonly MarkCell[])[] { const coverage = shape.coverage[row]?.[col] ?? 0 const reveal = clamp01(revealed - col) if (coverage === 0 || reveal === 0) { - cells.push({ char: " ", fg: UI.action }) + // Snow only in true sky. Unrevealed mountain cells stay empty so the + // left-to-right draw still reads as a clean silhouette edge. + if ( + snowOn && + coverage === 0 && + snowflakeAt(row, col, seconds, shape.rows) + ) { + cells.push({ char: SNOW_CHAR, fg: UI.textFaint }) + } else { + cells.push({ char: " ", fg: UI.action }) + } continue } // The outline states the shape at its true coverage; filling lifts it From 5a76641b477882977cb1ddd007dd54f52e61838c Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sat, 8 Aug 2026 14:26:29 -0700 Subject: [PATCH 4/6] Stop labelling transcript rows with the wiring that produced them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The meta column is the operator's: it says what a row is about, and it is read at a glance beside every row in the transcript. A row labelled "palette" says only which part of the code emitted it, which is a fact about us and not about their session — and the three rows carrying it already open with "palette:" in their own text, so the column was repeating a word it sat next to. --- src/tui-opentui/shell.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/tui-opentui/shell.ts b/src/tui-opentui/shell.ts index d133b9905..26fa5a384 100644 --- a/src/tui-opentui/shell.ts +++ b/src/tui-opentui/shell.ts @@ -3940,7 +3940,6 @@ export function acceptOverlaySelection(shell: AppShell): void { appendStreamRow(shell, { role: "system", text: `palette: no action for ${label}`, - meta: "palette", }) } return @@ -4016,7 +4015,6 @@ export function dispatchPaletteSelection( appendStreamRow(shell, { role: "system", text: `palette: /${cmd.id} (no onCommand handler)`, - meta: "palette", }) return } @@ -4027,7 +4025,6 @@ export function dispatchPaletteSelection( appendStreamRow(shell, { role: "system", text: `palette: unknown residual ${cmd.id}`, - meta: "palette", }) } From 875143c2ecf0d92ef3f092be13a9275b116828de Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sat, 8 Aug 2026 14:32:09 -0700 Subject: [PATCH 5/6] Set the landing's two doors as a pair The key and its description were joined by a single space, so the two lines started their descriptions on different columns and read as two unrelated notes rather than as the set they are. A fixed key column lines them up. The version moves a row away from them for the same reason: sitting flush under the two keys it read as a third door, when it is only a statement of what is running. --- src/tui-opentui/landing.test.ts | 7 ++++++- src/tui-opentui/landing.ts | 37 ++++++++++++++++++++++++++++++--- 2 files changed, 40 insertions(+), 4 deletions(-) diff --git a/src/tui-opentui/landing.test.ts b/src/tui-opentui/landing.test.ts index 966a13541..af9686bc8 100644 --- a/src/tui-opentui/landing.test.ts +++ b/src/tui-opentui/landing.test.ts @@ -150,13 +150,18 @@ describe("landing screen", () => { mark.length, ) expect(painted.indexOf(mark.at(-1) as string)).toBeLessThan(top) - // The two doors sit beside the mark, not under it. + // The two doors sit beside the mark, not under it, and their + // descriptions share one column — ragged, the pair reads as two + // unrelated lines rather than as a set. + const descriptionColumns = new Set() for (const hint of LANDING_HINTS) { const row = painted.find((line) => line.includes(hint.rest)) expect(row).toBeDefined() expect(row).toContain(hint.key) expect(row!.indexOf(hint.key)).toBeGreaterThan(0) + descriptionColumns.add(row!.indexOf(hint.rest)) } + expect(descriptionColumns.size).toBe(1) // The version sits with the hints, and cannot drift from package.json. expect(LANDING_VERSION).toBe(`v${pkg.version}`) expect(h.captureCharFrame()).toContain(LANDING_VERSION) diff --git a/src/tui-opentui/landing.ts b/src/tui-opentui/landing.ts index adb5fdc53..21c61d86f 100644 --- a/src/tui-opentui/landing.ts +++ b/src/tui-opentui/landing.ts @@ -65,9 +65,25 @@ export const LANDING_HINTS: readonly { { key: "?", rest: "for shortcuts" }, ] +/** + * Columns held for the key, so the descriptions beside them start on one + * column. Ragged, the pair reads as two unrelated lines rather than as a set. + */ +export const LANDING_KEY_WIDTH = LANDING_HINTS.reduce( + (widest, hint) => Math.max(widest, hint.key.length), + 0, +) + +/** Air between the key column and the description it labels. */ +const LANDING_KEY_GAP = 2 + /** Columns the hint block needs, its longest line deciding. */ export const LANDING_HINT_WIDTH = Math.max( - LANDING_HINTS.reduce((widest, hint) => Math.max(widest, hint.key.length + 1 + hint.rest.length), 0), + LANDING_HINTS.reduce( + (widest, hint) => + Math.max(widest, LANDING_KEY_WIDTH + LANDING_KEY_GAP + hint.rest.length), + 0, + ), LANDING_VERSION.length, ) @@ -336,17 +352,30 @@ function createHintBlock(ctx: CliRenderer): BoxRenderable { backgroundColor: UI.ground, }) LANDING_HINTS.forEach((hint, index) => { + const gap = " ".repeat( + LANDING_KEY_WIDTH - hint.key.length + LANDING_KEY_GAP, + ) block.add( new TextRenderable(ctx, { id: `shell-landing-hint-${index}`, height: 1, content: new StyledText([ fgChunk(UI.text)(hint.key), - fgChunk(UI.textDim)(` ${hint.rest}`), + fgChunk(UI.textDim)(`${gap}${hint.rest}`), ]), }), ) }) + // The build is a fact about what is running, not a third door. Flush against + // the two keys it read as one of them. + block.add( + new TextRenderable(ctx, { + id: "shell-landing-version-gap", + height: 1, + content: "", + fg: UI.ground, + }), + ) block.add( new TextRenderable(ctx, { id: "shell-landing-version", @@ -364,7 +393,9 @@ function createHintBlock(ctx: CliRenderer): BoxRenderable { */ export function fitLandingMark(above: LandingAbove, grid: MarkGrid | null): void { above.grid = grid - const rows = grid?.rows ?? LANDING_HINTS.length + 1 + // With no mark, the hero is exactly the hint block: the two keys, the blank + // row, and the version. + const rows = grid?.rows ?? LANDING_HINTS.length + 2 above.hero.height = rows above.markColumn.visible = grid !== null above.markColumn.width = grid?.cols ?? 0 From 6b3948dbb97dcdb546b75617ad3888debcea1232 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sat, 8 Aug 2026 14:48:03 -0700 Subject: [PATCH 6/6] Name the notice path for what it carries The wrapper module added nothing the shell function did not already do. It introduced no type and narrowed no export, so a producer reaching for appendStreamRow directly was exactly as easy with it as without, and the history it documented reads better on the function itself. The name was also wrong. The path carries unknown commands, unavailable modals and provider failures, none of which happen at startup, and a name that lies to the next reader is how this constraint got lost twice. --- src/tui-opentui/landing.test.ts | 9 ++++----- src/tui-opentui/product-host.ts | 8 ++++---- src/tui-opentui/runner-host.ts | 4 ++-- src/tui-opentui/shell.ts | 7 ++++++- src/tui-opentui/startup-notices.ts | 28 ---------------------------- src/tui/runner.ts | 10 +++++----- 6 files changed, 21 insertions(+), 45 deletions(-) delete mode 100644 src/tui-opentui/startup-notices.ts diff --git a/src/tui-opentui/landing.test.ts b/src/tui-opentui/landing.test.ts index af9686bc8..284ebea8c 100644 --- a/src/tui-opentui/landing.test.ts +++ b/src/tui-opentui/landing.test.ts @@ -17,9 +17,8 @@ import { isLanding, paintLanding, streamRowCount, - surfaceStartupNotice, + surfaceSystemNotice, } from "./shell" -import { flushStartupNotices } from "./startup-notices" import { makeOperatorQuestion, openOperatorOverlay } from "./overlays" import { LANDING_HINTS, @@ -519,7 +518,7 @@ describe("landing screen", () => { const mcpError = "mcp github did not connect (ECONNREFUSED) — its tools are unavailable; /mcp for detail" - surfaceStartupNotice(shell, mcpError) + surfaceSystemNotice(shell, mcpError) await settle(h) // The mountain stays; the notice strip carries the wording. @@ -567,7 +566,7 @@ describe("landing screen", () => { expect(before.length).toBeGreaterThan(0) const summary = "plugins: 3 skills missing: brand-identity, style, philosophy" - flushStartupNotices(shell, [summary]) + surfaceSystemNotice(shell, summary) await settle(h) expect(isLanding(shell)).toBe(true) @@ -592,7 +591,7 @@ describe("landing screen", () => { }) try { await settle(h) - flushStartupNotices(shell, ["plugins: 1 skill missing: style"]) + surfaceSystemNotice(shell, "plugins: 1 skill missing: style") appendStreamRow(shell, { role: "user", text: "first prompt" }) await settle(h) diff --git a/src/tui-opentui/product-host.ts b/src/tui-opentui/product-host.ts index fb07d70aa..0f36b7cc6 100644 --- a/src/tui-opentui/product-host.ts +++ b/src/tui-opentui/product-host.ts @@ -47,7 +47,7 @@ import { setPaletteOnCommand, setMcpNeedsAuth, setStatusFlash, - surfaceStartupNotice, + surfaceSystemNotice, type AppShell, type ItemDescription, type OverlaySelection, @@ -348,7 +348,7 @@ export async function mountProductHost( // before the first turn (CL-5618). const widthReport = checkWidthContract(renderer.widthMethod) if (!widthReport.agrees) { - surfaceStartupNotice(shell, widthContractNotice(widthReport)) + surfaceSystemNotice(shell, widthContractNotice(widthReport)) } const port = createLiveSessionPort({ @@ -465,9 +465,9 @@ export async function mountProductHost( if (notice === null) return if (notice.kind === "row") { // MCP load failures and hook failures must not wipe the landing mark. - // surfaceStartupNotice keeps the mountain while the notice strip carries + // surfaceSystemNotice keeps the mountain while the notice strip carries // the wording, then flushes a durable row once the session starts. - surfaceStartupNotice(shell, notice.text) + surfaceSystemNotice(shell, notice.text) return } setStatusFlash(shell, notice.text, { ttlMs: RUNTIME_FLASH_MS }) diff --git a/src/tui-opentui/runner-host.ts b/src/tui-opentui/runner-host.ts index 8ea9ded7d..9d6ab83a5 100644 --- a/src/tui-opentui/runner-host.ts +++ b/src/tui-opentui/runner-host.ts @@ -36,7 +36,7 @@ import { setPromptModelLabel, setPromptWorkspace, setShellExitHandler, - surfaceStartupNotice, + surfaceSystemNotice, } from "./shell.js" import type { CostSummary } from "../cost/cost-summary.js" import { watchGitBranch, type FetchBranch } from "./workspace-watch.js" @@ -326,7 +326,7 @@ export async function mountRunnerHost(deps: RunnerHostDeps): Promise const surfaceDeps: CommandSurfaceDeps = { ...(deps.surfaces ?? {}), ...(host.openModels !== undefined ? { openModels: host.openModels } : {}), - notify: (text) => surfaceStartupNotice(host.shell, text), + notify: (text) => surfaceSystemNotice(host.shell, text), } const refreshModels = ( diff --git a/src/tui-opentui/shell.ts b/src/tui-opentui/shell.ts index 26fa5a384..96215214a 100644 --- a/src/tui-opentui/shell.ts +++ b/src/tui-opentui/shell.ts @@ -2118,8 +2118,13 @@ function evictedRowsNotice(evicted: number): string { * 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. + * + * Every producer of a system-class row belongs here rather than at + * `appendStreamRow`. CL-5618 fixed the MCP and hook producers one at a time + * and the plugin producer kept the defect, which is what per-call-site rules + * buy you. Reaching for `appendStreamRow` directly is the bug. */ -export function surfaceStartupNotice(shell: AppShell, text: string): void { +export function surfaceSystemNotice(shell: AppShell, text: string): void { if (isLanding(shell)) { const bag = internals.get(shell) if (bag !== undefined) { diff --git a/src/tui-opentui/startup-notices.ts b/src/tui-opentui/startup-notices.ts deleted file mode 100644 index bf7de9ba3..000000000 --- a/src/tui-opentui/startup-notices.ts +++ /dev/null @@ -1,28 +0,0 @@ -/** - * Startup diagnostics on their way to the operator. - * - * These are produced before the first turn, which is exactly when the landing - * hero owns the screen. Delivered as transcript rows they reach - * `clearLandingMark` and take the whole composition with them — the mark, the - * guidance beside it, and the centred prompt box — not merely the mountain. - * - * This is a named seam rather than a loop at each call site because the - * constraint is "a startup diagnostic is never a transcript row", and that - * belongs in one place. CL-5618 fixed the MCP and hook producers individually - * and the plugin producer kept the defect; a second producer getting it wrong - * is what a per-call-site rule buys you. - */ - -import { surfaceStartupNotice, type AppShell } from "./shell.js" - -/** - * Hand a batch of load-time diagnostics to the shell. Each rides the notice - * strip while the landing is up and becomes a durable transcript row once a - * real session row ends it; after that they are ordinary system rows. - */ -export function flushStartupNotices( - shell: AppShell, - notices: readonly string[], -): void { - for (const notice of notices) surfaceStartupNotice(shell, notice) -} diff --git a/src/tui/runner.ts b/src/tui/runner.ts index 1b3b5e8fa..fb0695f62 100644 --- a/src/tui/runner.ts +++ b/src/tui/runner.ts @@ -149,9 +149,8 @@ import { setPromptRecognitionSource, setSentMessageHistory, setShellRunState, - surfaceStartupNotice, + surfaceSystemNotice, } from "../tui-opentui/shell.js"; -import { flushStartupNotices } from "../tui-opentui/startup-notices.js"; import { classifyAgentSendFailure, shouldSettleUiAfterSendFailure, @@ -436,7 +435,7 @@ export async function runTUI(initialConfig: Config): Promise { // have no result channel back to an operator action, unlike verify/add-path/ // trust-grant. A log-only summary is invisible — nobody watches // ~/.corbits/logs/corbits.log — so these are queued and handed to - // `flushStartupNotices` once the shell mounts. + // the shell one at a time once it mounts. const startupPluginNotices: string[] = []; const discoveryNotice = formatPluginWarningsSummary(pluginLoadDiag.warnings); if (discoveryNotice !== undefined) startupPluginNotices.push(discoveryNotice); @@ -1847,7 +1846,7 @@ export async function runTUI(initialConfig: Config): Promise { // the whole composition. Once a session row has ended the landing this is an // ordinary system row, so there is no second behaviour to reason about. const systemNotice = (text: string): void => { - surfaceStartupNotice(host.shell, text); + surfaceSystemNotice(host.shell, text); }; /** Settle the shell after a rejected send so the run does not look live. */ @@ -2378,7 +2377,8 @@ export async function runTUI(initialConfig: Config): Promise { // Surface fire-and-forget startup plugin diagnostics now that there is a // shell to say them to (queued above, before `host` existed). - flushStartupNotices(host.shell, startupPluginNotices); + for (const notice of startupPluginNotices) + surfaceSystemNotice(host.shell, notice); await host.waitUntilExit(); // Quitting mid-stream is an abnormal end for the in-flight cycle: nothing