From 13643673326aab17a7652088d974006f828c9623 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sun, 13 Sep 2026 20:28:26 -0700 Subject: [PATCH 1/3] feat(tui): brand the startup transcript with a quiet session header --- docs/TUI.md | 2 +- .../prompt-action-bar-label.test.ts | 36 +++++++- src/tui/components/prompt-action-bar-label.ts | 18 +++- src/tui/components/session-header.test.ts | 21 +++++ src/tui/components/session-header.ts | 22 +++++ src/tui/runner/commands.ts | 20 ++++- src/tui/runner/index.ts | 2 + src/tui/runner/wiring.ts | 35 +++++++- src/tui/shell/chrome.ts | 30 +++++++ src/tui/startup-transcript.test.ts | 89 +++++++++++++++++++ 10 files changed, 269 insertions(+), 6 deletions(-) create mode 100644 src/tui/components/session-header.test.ts create mode 100644 src/tui/components/session-header.ts create mode 100644 src/tui/startup-transcript.test.ts diff --git a/docs/TUI.md b/docs/TUI.md index 27a04d152..597413770 100644 --- a/docs/TUI.md +++ b/docs/TUI.md @@ -134,7 +134,7 @@ full output, hiding the preview; the idiom and the arrow are unchanged. The prompt box's border carries the metadata that would otherwise cost a titlebar row: the model label sits right-aligned in the top rule as -`profile · model · effort` (empty segments omitted), and a +`profile · model · effort · mode` (empty segments omitted), and a compact `mcp !` sits immediately left of it when any MCP server still needs authorization (`/mcp` is the surface that names them), painted in `UI.warning` (sand, `#d1ad7d`) — the same role `plugin !` uses. Orange is diff --git a/src/tui/components/prompt-action-bar-label.test.ts b/src/tui/components/prompt-action-bar-label.test.ts index c03c707ae..980eaafac 100644 --- a/src/tui/components/prompt-action-bar-label.test.ts +++ b/src/tui/components/prompt-action-bar-label.test.ts @@ -1,5 +1,8 @@ import { describe, expect, test } from "bun:test"; -import { composePromptActionBarModelLabel } from "./prompt-action-bar-label.js"; +import { + composePromptActionBarModelLabel, + yoloModeLabel, +} from "./prompt-action-bar-label.js"; describe("composePromptActionBarModelLabel", () => { test("omits profile when unset", () => { @@ -44,4 +47,35 @@ describe("composePromptActionBarModelLabel", () => { expect(composePromptActionBarModelLabel({ profile: "" })).toBeUndefined(); expect(composePromptActionBarModelLabel({ model: "" })).toBeUndefined(); }); + + test("appends a permission mode segment when set", () => { + expect( + composePromptActionBarModelLabel({ model: "gpt-5", mode: "yolo" }), + ).toBe("gpt-5 · yolo"); + expect( + composePromptActionBarModelLabel({ + profile: "work", + model: "gpt-5", + effort: "high", + mode: "yolo", + }), + ).toBe("work · gpt-5 · high · yolo"); + }); + + test("omits an empty permission mode segment", () => { + expect(composePromptActionBarModelLabel({ model: "gpt-5", mode: "" })).toBe( + "gpt-5", + ); + expect(composePromptActionBarModelLabel({ mode: "yolo" })).toBe("yolo"); + }); +}); + +describe("yoloModeLabel", () => { + test("returns the yolo segment while permission prompts are skipped", () => { + expect(yoloModeLabel(true)).toBe("yolo"); + }); + + test("returns undefined otherwise so the segment omits", () => { + expect(yoloModeLabel(false)).toBeUndefined(); + }); }); diff --git a/src/tui/components/prompt-action-bar-label.ts b/src/tui/components/prompt-action-bar-label.ts index 34ab5afee..0b660f4f8 100644 --- a/src/tui/components/prompt-action-bar-label.ts +++ b/src/tui/components/prompt-action-bar-label.ts @@ -1,10 +1,13 @@ +export const ESSENTIALS_SEPARATOR = " · "; + export interface PromptActionBarModelLabelInput { profile?: string; model?: string; effort?: string; + mode?: string | undefined; } -/** Right-aligned muted label above the prompt: `profile · model · effort` with omitted empty segments. */ +/** Right-aligned muted label above the prompt: `profile · model · effort · mode` with omitted empty segments. */ export function composePromptActionBarModelLabel( input: PromptActionBarModelLabelInput, ): string | undefined { @@ -18,5 +21,16 @@ export function composePromptActionBarModelLabel( if (input.effort !== undefined && input.effort.length > 0) { segments.push(input.effort); } - return segments.length > 0 ? segments.join(" · ") : undefined; + if (input.mode !== undefined && input.mode.length > 0) { + segments.push(input.mode); + } + return segments.length > 0 ? segments.join(ESSENTIALS_SEPARATOR) : undefined; +} + +/** + * Trailing label segment while permission prompts are skipped. Undefined + * otherwise, so the segment simply omits. + */ +export function yoloModeLabel(skipsPermissions: boolean): "yolo" | undefined { + return skipsPermissions ? "yolo" : undefined; } diff --git a/src/tui/components/session-header.test.ts b/src/tui/components/session-header.test.ts new file mode 100644 index 000000000..af483c255 --- /dev/null +++ b/src/tui/components/session-header.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, test } from "bun:test"; +import { composeSessionHeader } from "./session-header.js"; + +describe("composeSessionHeader", () => { + test("wordmark alone when no essentials apply", () => { + expect(composeSessionHeader()).toBe("corbits code"); + expect(composeSessionHeader({})).toBe("corbits code"); + expect(composeSessionHeader({ essentials: "" })).toBe("corbits code"); + }); + + test("wordmark leads the quiet essentials line", () => { + expect( + composeSessionHeader({ essentials: "thegreataxios · muse-spark" }), + ).toBe("corbits code · thegreataxios · muse-spark"); + expect( + composeSessionHeader({ + essentials: "thegreataxios · muse-spark · yolo", + }), + ).toBe("corbits code · thegreataxios · muse-spark · yolo"); + }); +}); diff --git a/src/tui/components/session-header.ts b/src/tui/components/session-header.ts new file mode 100644 index 000000000..155ef7ac3 --- /dev/null +++ b/src/tui/components/session-header.ts @@ -0,0 +1,22 @@ +import { LOCKUP_WORDMARK } from "../lockup.js"; +import { ESSENTIALS_SEPARATOR } from "./prompt-action-bar-label.js"; + +export interface SessionHeaderInput { + /** Quiet session essentials, e.g. `profile · model · effort · mode`. */ + essentials?: string | undefined; +} + +const WORDMARK = LOCKUP_WORDMARK; + +/** + * First among the deferred startup rows once the landing clears: the wordmark + * leading the quiet essentials line. Wordmark alone when nothing applies. A + * re-filed telemetry disclosure still lands ahead of it, and later /yolo or + * effort toggles move the prompt border label only — the header is a startup + * snapshot. + */ +export function composeSessionHeader(input: SessionHeaderInput = {}): string { + const essentials = input.essentials; + if (essentials === undefined || essentials.length === 0) return WORDMARK; + return `${WORDMARK}${ESSENTIALS_SEPARATOR}${essentials}`; +} diff --git a/src/tui/runner/commands.ts b/src/tui/runner/commands.ts index bcc72be30..fb935f980 100644 --- a/src/tui/runner/commands.ts +++ b/src/tui/runner/commands.ts @@ -37,7 +37,14 @@ import { contextTokensFromUsage } from "../../provider/context-window.js"; import { fleetDigest } from "../../subagent/index.js"; import { renameSession } from "../../session/index.js"; import { truncateSessionLabel } from "../../session/session-label.js"; -import { surfaceSystemNotice, attachClipboardImage } from "../shell/prompt.js"; +import { + surfaceSystemNotice, + attachClipboardImage, + setPromptModelLabel, +} from "../shell/prompt.js"; +import { yoloModeLabel } from "../components/prompt-action-bar-label.js"; +import { isCodexProviderName } from "../../config/codex-providers.js"; +import { resolveSessionEffort } from "../../provider/reasoning-effort.js"; import type { InferenceErrorLike } from "../../inference-gateway-error.js"; import { terminalProviderFailureMessage } from "../../inference-error-message.js"; import type { InferenceAttemptIdentity } from "./state.js"; @@ -109,6 +116,17 @@ export function createCommandLayer( setSkipPermissions: (value: boolean) => { services.permissionGate.setSkipPermissions(value); state.config.dangerouslySkipPermissions = value; + const effort = resolveSessionEffort( + state.config.model, + state.config.reasoningEffort, + isCodexProviderName(state.config.providerName), + ); + setPromptModelLabel(hostOf(state).shell, { + profile: state.config.providerName, + model: state.config.model, + ...(effort !== undefined ? { effort } : {}), + mode: yoloModeLabel(value), + }); void services.globalSettingsWriter.enqueue(async () => { try { const result = await persistSkipPermissionsDefault( diff --git a/src/tui/runner/index.ts b/src/tui/runner/index.ts index 28fbcf871..f013f9c46 100644 --- a/src/tui/runner/index.ts +++ b/src/tui/runner/index.ts @@ -21,6 +21,7 @@ import { providerChoices, } from "../provider/choices.js"; import { listCommands } from "../commands/registry.js"; +import { yoloModeLabel } from "../components/prompt-action-bar-label.js"; import { mountRunnerHost } from "./host.js"; import { assembleTUISession } from "./session.js"; import { createRunLifecycle, finalizeTUIRun } from "./exit.js"; @@ -145,6 +146,7 @@ export async function runTUI(initialConfig: Config): Promise { profile: state.config.providerName, model: state.config.model, ...(effort !== undefined ? { effort } : {}), + mode: yoloModeLabel(services.permissionGate.getSkipPermissions()), }; }, activeModel: () => ({ diff --git a/src/tui/runner/wiring.ts b/src/tui/runner/wiring.ts index c907e347a..21eee1af6 100644 --- a/src/tui/runner/wiring.ts +++ b/src/tui/runner/wiring.ts @@ -35,7 +35,10 @@ import { import { scheduleUpgradeNotice } from "../../upgrade/index.js"; import pkg from "../../../package.json" with { type: "json" }; import { hydrateTasksFromTurns } from "../../agent/director.js"; -import { cycleReasoningEffort } from "../../provider/reasoning-effort.js"; +import { + cycleReasoningEffort, + resolveSessionEffort, +} from "../../provider/reasoning-effort.js"; import { isCodexProviderName } from "../../config/codex-providers.js"; import { RUNTIME_FLASH_MS } from "../runtime-notices.js"; import { @@ -54,6 +57,11 @@ import { surfaceSystemNotice, } from "../shell/prompt.js"; import { listPathSuggestions } from "../components/at-mention/list.js"; +import { + composePromptActionBarModelLabel, + yoloModeLabel, +} from "../components/prompt-action-bar-label.js"; +import { composeSessionHeader } from "../components/session-header.js"; import { listCommands } from "../commands/registry.js"; import type { MCPConnectCallbacks } from "../../agent/tools.js"; import { createRuntimeShutdown } from "./shutdown.js"; @@ -374,6 +382,7 @@ export function wirePostStartup( profile: state.config.providerName, model: state.config.model, effort: next, + mode: yoloModeLabel(state.config.dangerouslySkipPermissions), }); setStatusFlash(hostOf(state).shell, `reasoning effort: ${next}`, { ttlMs: RUNTIME_FLASH_MS, @@ -465,6 +474,30 @@ export function wirePostStartup( ); }); + // The branded session header goes first among the deferred startup rows: + // it is the row the deferred queue flushes ahead of every other startup + // notice once the landing clears (a re-filed telemetry disclosure still + // lands ahead of it). A startup snapshot — later /yolo or effort toggles + // move the prompt border label only. While the landing holds, the notice + // strip shows the latest deferred wording; the transcript keeps the full + // order. + const headerEffort = resolveSessionEffort( + state.config.model, + state.config.reasoningEffort, + isCodexProviderName(state.config.providerName), + ); + surfaceSystemNotice( + hostOf(state).shell, + composeSessionHeader({ + essentials: composePromptActionBarModelLabel({ + profile: state.config.providerName, + model: state.config.model, + ...(headerEffort !== undefined ? { effort: headerEffort } : {}), + mode: yoloModeLabel(state.config.dangerouslySkipPermissions), + }), + }), + ); + // Surface fire-and-forget startup notices now that there is a shell (queued // above, before `host` existed). Plugin load warnings are NOT notices — they // drive `plugin !` and `/plugins` instead. diff --git a/src/tui/shell/chrome.ts b/src/tui/shell/chrome.ts index 186cd88ca..90f0326f0 100644 --- a/src/tui/shell/chrome.ts +++ b/src/tui/shell/chrome.ts @@ -1039,6 +1039,8 @@ export function appendTranscript( opts?: { readonly fg?: string }, ): void { clearLandingMark(shell); + // A raw paint like any other: it breaks a run of identical system echoes. + paintSequence.set(shell, (paintSequence.get(shell) ?? 0) + 1); shell.lineCount += 1; shell.transcript.add( new TextRenderable(shell.renderer as CliRenderer, { @@ -1081,6 +1083,30 @@ export function appendObserveStreamRow( return true; } +/** + * Startup echoes (model-picker choice, permission notices) can arrive once per + * account or session and read as stutter when they paint back to back. A + * system row identical to the one already on top of the transcript adds + * nothing, so it collapses — but only when nothing painted since that top row + * landed. Observe child rows paint through this same path without touching the + * parent log, so log adjacency alone would swallow repeat farewell rows like + * "left observe" across enter/leave cycles; the sequence check restores them. + */ +const paintSequence = new WeakMap(); +const systemPushSequence = new WeakMap(); + +function isDuplicateSystemEcho(shell: AppShell, row: StreamRow): boolean { + if (row.role !== "system") return false; + const top = shell.streamLog[shell.streamLog.length - 1]; + if (top === undefined || top.role !== "system" || top.text !== row.text) { + return false; + } + // The in-flight call already advanced the sequence, so the top row is + // back-to-back only when it was pushed by the immediately previous paint. + const seq = paintSequence.get(shell) ?? 0; + return systemPushSequence.get(shell) === seq - 1; +} + /** * Paint + push onto the visible streamLog (child while observing, parent * otherwise). The paint tree stays 1:1 with the (retention-capped) log — @@ -1092,8 +1118,12 @@ export function appendObserveStreamRow( */ function paintAppendStreamRow(shell: AppShell, row: StreamRow): void { clearLandingMark(shell); + const seq = (paintSequence.get(shell) ?? 0) + 1; + paintSequence.set(shell, seq); + if (isDuplicateSystemEcho(shell, row)) return; const gainedVoice = noteAgentVoice(shell, row); shell.streamLog.push(row); + if (row.role === "system") systemPushSequence.set(shell, seq); const baseBefore = shell.streamLogBase; shell.streamLogBase = trimRetainedLog(shell.streamLog, shell.streamLogBase); shell.lineCount = shell.streamLog.length; diff --git a/src/tui/startup-transcript.test.ts b/src/tui/startup-transcript.test.ts new file mode 100644 index 000000000..7030780e2 --- /dev/null +++ b/src/tui/startup-transcript.test.ts @@ -0,0 +1,89 @@ +/** + * CL-7938: consecutive duplicate system echoes collapse instead of painting + * twice, and a deferred session header flushes first when the landing clears. + */ +import { describe, expect, test } from "bun:test"; +import { withTestRenderer } from "./harness"; +import { appendStreamRow } from "./shell/chrome"; +import { createAppShell } from "./shell/index"; +import { isLanding } from "./shell/internals"; +import { surfaceSystemNotice } from "./shell/prompt"; +import { streamRowCount } from "./shell/transcript"; + +const OPTIONS = { + terminal: { columns: 80, rows: 24 }, + wireKeys: false, +}; + +describe("startup transcript", () => { + test("consecutive duplicate system rows paint once", async () => { + await withTestRenderer(async (h) => { + const shell = createAppShell(h.renderer, OPTIONS); + try { + appendStreamRow(shell, { + role: "system", + text: "Chose muse-spark.", + meta: "model picker", + }); + appendStreamRow(shell, { + role: "system", + text: "Chose muse-spark.", + meta: "model picker", + }); + expect(streamRowCount(shell)).toBe(1); + expect(shell.streamLog.map((row) => row.text)).toEqual([ + "Chose muse-spark.", + ]); + } finally { + shell.dispose(); + } + }); + }); + + test("separated repeats and other roles still paint", async () => { + await withTestRenderer(async (h) => { + const shell = createAppShell(h.renderer, OPTIONS); + try { + appendStreamRow(shell, { role: "system", text: "Chose muse-spark." }); + appendStreamRow(shell, { role: "user", text: "hi" }); + appendStreamRow(shell, { role: "system", text: "Chose muse-spark." }); + appendStreamRow(shell, { role: "system", text: "Chose muse-spark." }); + appendStreamRow(shell, { role: "tool", text: "Chose muse-spark." }); + expect(shell.streamLog.map((row) => row.text)).toEqual([ + "Chose muse-spark.", + "hi", + "Chose muse-spark.", + "Chose muse-spark.", + ]); + } finally { + shell.dispose(); + } + }); + }); + + test("a deferred session header flushes first when the landing clears", async () => { + await withTestRenderer(async (h) => { + const shell = createAppShell(h.renderer, { ...OPTIONS, run: "idle" }); + try { + expect(isLanding(shell)).toBe(true); + surfaceSystemNotice( + shell, + "corbits code · thegreataxios · muse-spark · yolo", + ); + surfaceSystemNotice( + shell, + "Permission prompts are disabled by your saved default (/yolo off to re-enable).", + ); + expect(streamRowCount(shell)).toBe(0); + appendStreamRow(shell, { role: "user", text: "first prompt" }); + expect(shell.streamLog.map((row) => row.text)).toEqual([ + "corbits code · thegreataxios · muse-spark · yolo", + "Permission prompts are disabled by your saved default (/yolo off to re-enable).", + "first prompt", + ]); + } finally { + shell.dispose(); + } + }); + }); +}); From 304199401d9d07f248f477cd5dfddbbef25f531a Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sun, 13 Sep 2026 20:53:08 -0700 Subject: [PATCH 2/3] Collapse three identical system rows instead of leaking the third --- src/tui/shell/chrome.ts | 5 ++++- src/tui/startup-transcript.test.ts | 21 +++++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/src/tui/shell/chrome.ts b/src/tui/shell/chrome.ts index 90f0326f0..e4dddb669 100644 --- a/src/tui/shell/chrome.ts +++ b/src/tui/shell/chrome.ts @@ -1120,7 +1120,10 @@ function paintAppendStreamRow(shell: AppShell, row: StreamRow): void { clearLandingMark(shell); const seq = (paintSequence.get(shell) ?? 0) + 1; paintSequence.set(shell, seq); - if (isDuplicateSystemEcho(shell, row)) return; + if (isDuplicateSystemEcho(shell, row)) { + systemPushSequence.set(shell, seq); + return; + } const gainedVoice = noteAgentVoice(shell, row); shell.streamLog.push(row); if (row.role === "system") systemPushSequence.set(shell, seq); diff --git a/src/tui/startup-transcript.test.ts b/src/tui/startup-transcript.test.ts index 7030780e2..cf2608384 100644 --- a/src/tui/startup-transcript.test.ts +++ b/src/tui/startup-transcript.test.ts @@ -40,6 +40,27 @@ describe("startup transcript", () => { }); }); + test("three identical system rows in a row paint once", async () => { + await withTestRenderer(async (h) => { + const shell = createAppShell(h.renderer, OPTIONS); + try { + for (let i = 0; i < 3; i += 1) { + appendStreamRow(shell, { + role: "system", + text: "Chose muse-spark.", + meta: "model picker", + }); + } + expect(streamRowCount(shell)).toBe(1); + expect(shell.streamLog.map((row) => row.text)).toEqual([ + "Chose muse-spark.", + ]); + } finally { + shell.dispose(); + } + }); + }); + test("separated repeats and other roles still paint", async () => { await withTestRenderer(async (h) => { const shell = createAppShell(h.renderer, OPTIONS); From ee4d584d0746cb4c0da1baee85f3101c6addad85 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sun, 13 Sep 2026 21:59:24 -0700 Subject: [PATCH 3/3] Stop pinning startup transcript copy and key dedupe on writer Tests asserted other modules' wording, so a brand or notice rename broke them with no behavior change; the suite now uses synthetic strings and the imported wordmark. Back-to-back system rows from different writers or with different labels no longer collapse, keeping the second-voice relabel. --- .../prompt-action-bar-label.test.ts | 32 ++++++ src/tui/components/session-header.test.ts | 22 ++-- src/tui/shell/chrome.ts | 15 ++- src/tui/startup-transcript.test.ts | 104 +++++++++++++----- 4 files changed, 135 insertions(+), 38 deletions(-) diff --git a/src/tui/components/prompt-action-bar-label.test.ts b/src/tui/components/prompt-action-bar-label.test.ts index 980eaafac..0a59616f2 100644 --- a/src/tui/components/prompt-action-bar-label.test.ts +++ b/src/tui/components/prompt-action-bar-label.test.ts @@ -79,3 +79,35 @@ describe("yoloModeLabel", () => { expect(yoloModeLabel(false)).toBeUndefined(); }); }); + +describe("toggle label preservation", () => { + test("an effort update keeps the yolo mode segment", () => { + expect( + composePromptActionBarModelLabel({ + profile: "work", + model: "gpt-5", + effort: "high", + mode: yoloModeLabel(true), + }), + ).toBe("work · gpt-5 · high · yolo"); + }); + + test("a yolo toggle keeps the effort segment", () => { + expect( + composePromptActionBarModelLabel({ + profile: "work", + model: "gpt-5", + effort: "high", + mode: yoloModeLabel(true), + }), + ).toBe("work · gpt-5 · high · yolo"); + expect( + composePromptActionBarModelLabel({ + profile: "work", + model: "gpt-5", + effort: "high", + mode: yoloModeLabel(false), + }), + ).toBe("work · gpt-5 · high"); + }); +}); diff --git a/src/tui/components/session-header.test.ts b/src/tui/components/session-header.test.ts index af483c255..c2e085ca7 100644 --- a/src/tui/components/session-header.test.ts +++ b/src/tui/components/session-header.test.ts @@ -1,21 +1,21 @@ import { describe, expect, test } from "bun:test"; +import { LOCKUP_WORDMARK } from "../lockup.js"; +import { ESSENTIALS_SEPARATOR } from "./prompt-action-bar-label.js"; import { composeSessionHeader } from "./session-header.js"; describe("composeSessionHeader", () => { test("wordmark alone when no essentials apply", () => { - expect(composeSessionHeader()).toBe("corbits code"); - expect(composeSessionHeader({})).toBe("corbits code"); - expect(composeSessionHeader({ essentials: "" })).toBe("corbits code"); + expect(composeSessionHeader()).toBe(LOCKUP_WORDMARK); + expect(composeSessionHeader({})).toBe(LOCKUP_WORDMARK); + expect(composeSessionHeader({ essentials: "" })).toBe(LOCKUP_WORDMARK); }); test("wordmark leads the quiet essentials line", () => { - expect( - composeSessionHeader({ essentials: "thegreataxios · muse-spark" }), - ).toBe("corbits code · thegreataxios · muse-spark"); - expect( - composeSessionHeader({ - essentials: "thegreataxios · muse-spark · yolo", - }), - ).toBe("corbits code · thegreataxios · muse-spark · yolo"); + expect(composeSessionHeader({ essentials: "profile · model" })).toBe( + `${LOCKUP_WORDMARK}${ESSENTIALS_SEPARATOR}profile · model`, + ); + expect(composeSessionHeader({ essentials: "profile · model · yolo" })).toBe( + `${LOCKUP_WORDMARK}${ESSENTIALS_SEPARATOR}profile · model · yolo`, + ); }); }); diff --git a/src/tui/shell/chrome.ts b/src/tui/shell/chrome.ts index e4dddb669..c0d69cadf 100644 --- a/src/tui/shell/chrome.ts +++ b/src/tui/shell/chrome.ts @@ -71,7 +71,12 @@ import { steerCount, type RunState, } from "../session-queue.js"; -import { agentVoicesIn, isCollapsibleRow, type StreamRow } from "../stream.js"; +import { + agentVoicesIn, + isCollapsibleRow, + MAIN_AGENT, + type StreamRow, +} from "../stream.js"; import { UI } from "../theme.js"; import { isDecisionOverlay, @@ -1098,7 +1103,13 @@ const systemPushSequence = new WeakMap(); function isDuplicateSystemEcho(shell: AppShell, row: StreamRow): boolean { if (row.role !== "system") return false; const top = shell.streamLog[shell.streamLog.length - 1]; - if (top === undefined || top.role !== "system" || top.text !== row.text) { + if ( + top === undefined || + top.role !== "system" || + top.text !== row.text || + (top.agent ?? MAIN_AGENT) !== (row.agent ?? MAIN_AGENT) || + top.meta !== row.meta + ) { return false; } // The in-flight call already advanced the sequence, so the top row is diff --git a/src/tui/startup-transcript.test.ts b/src/tui/startup-transcript.test.ts index cf2608384..934ce1429 100644 --- a/src/tui/startup-transcript.test.ts +++ b/src/tui/startup-transcript.test.ts @@ -1,8 +1,13 @@ /** * CL-7938: consecutive duplicate system echoes collapse instead of painting * twice, and a deferred session header flushes first when the landing clears. + * + * The duplicate-collapse and FIFO flush-order contracts hold with synthetic + * strings here: the wording of other modules' notices (model picker, wiring) + * is their own copy to pin, not this suite's. */ import { describe, expect, test } from "bun:test"; +import { composeSessionHeader } from "./components/session-header.js"; import { withTestRenderer } from "./harness"; import { appendStreamRow } from "./shell/chrome"; import { createAppShell } from "./shell/index"; @@ -15,6 +20,9 @@ const OPTIONS = { wireKeys: false, }; +const DUPLICATE_TEXT = "synthetic duplicate notice."; +const OTHER_TEXT = "synthetic second startup notice."; + describe("startup transcript", () => { test("consecutive duplicate system rows paint once", async () => { await withTestRenderer(async (h) => { @@ -22,17 +30,17 @@ describe("startup transcript", () => { try { appendStreamRow(shell, { role: "system", - text: "Chose muse-spark.", - meta: "model picker", + text: DUPLICATE_TEXT, + meta: "synthetic source", }); appendStreamRow(shell, { role: "system", - text: "Chose muse-spark.", - meta: "model picker", + text: DUPLICATE_TEXT, + meta: "synthetic source", }); expect(streamRowCount(shell)).toBe(1); expect(shell.streamLog.map((row) => row.text)).toEqual([ - "Chose muse-spark.", + DUPLICATE_TEXT, ]); } finally { shell.dispose(); @@ -47,13 +55,13 @@ describe("startup transcript", () => { for (let i = 0; i < 3; i += 1) { appendStreamRow(shell, { role: "system", - text: "Chose muse-spark.", - meta: "model picker", + text: DUPLICATE_TEXT, + meta: "synthetic source", }); } expect(streamRowCount(shell)).toBe(1); expect(shell.streamLog.map((row) => row.text)).toEqual([ - "Chose muse-spark.", + DUPLICATE_TEXT, ]); } finally { shell.dispose(); @@ -65,16 +73,65 @@ describe("startup transcript", () => { await withTestRenderer(async (h) => { const shell = createAppShell(h.renderer, OPTIONS); try { - appendStreamRow(shell, { role: "system", text: "Chose muse-spark." }); + appendStreamRow(shell, { role: "system", text: DUPLICATE_TEXT }); appendStreamRow(shell, { role: "user", text: "hi" }); - appendStreamRow(shell, { role: "system", text: "Chose muse-spark." }); - appendStreamRow(shell, { role: "system", text: "Chose muse-spark." }); - appendStreamRow(shell, { role: "tool", text: "Chose muse-spark." }); + appendStreamRow(shell, { role: "system", text: DUPLICATE_TEXT }); + appendStreamRow(shell, { role: "system", text: DUPLICATE_TEXT }); + appendStreamRow(shell, { role: "tool", text: DUPLICATE_TEXT }); expect(shell.streamLog.map((row) => row.text)).toEqual([ - "Chose muse-spark.", + DUPLICATE_TEXT, "hi", - "Chose muse-spark.", - "Chose muse-spark.", + DUPLICATE_TEXT, + DUPLICATE_TEXT, + ]); + } finally { + shell.dispose(); + } + }); + }); + + test("same-text rows from different writers both paint", async () => { + await withTestRenderer(async (h) => { + const shell = createAppShell(h.renderer, OPTIONS); + try { + appendStreamRow(shell, { + role: "system", + text: DUPLICATE_TEXT, + agent: "synthetic-agent-a", + }); + appendStreamRow(shell, { + role: "system", + text: DUPLICATE_TEXT, + agent: "synthetic-agent-b", + }); + expect(shell.streamLog.map((row) => row.text)).toEqual([ + DUPLICATE_TEXT, + DUPLICATE_TEXT, + ]); + expect(shell.agentVoices.size).toBe(2); + } finally { + shell.dispose(); + } + }); + }); + + test("same-text rows with different meta both paint", async () => { + await withTestRenderer(async (h) => { + const shell = createAppShell(h.renderer, OPTIONS); + try { + appendStreamRow(shell, { + role: "system", + text: DUPLICATE_TEXT, + meta: "synthetic source a", + }); + appendStreamRow(shell, { + role: "system", + text: DUPLICATE_TEXT, + meta: "synthetic source b", + }); + expect(shell.streamLog.map((row) => row.text)).toEqual([ + DUPLICATE_TEXT, + DUPLICATE_TEXT, ]); } finally { shell.dispose(); @@ -86,20 +143,17 @@ describe("startup transcript", () => { await withTestRenderer(async (h) => { const shell = createAppShell(h.renderer, { ...OPTIONS, run: "idle" }); try { + const header = composeSessionHeader({ + essentials: "synthetic profile · synthetic model", + }); expect(isLanding(shell)).toBe(true); - surfaceSystemNotice( - shell, - "corbits code · thegreataxios · muse-spark · yolo", - ); - surfaceSystemNotice( - shell, - "Permission prompts are disabled by your saved default (/yolo off to re-enable).", - ); + surfaceSystemNotice(shell, header); + surfaceSystemNotice(shell, OTHER_TEXT); expect(streamRowCount(shell)).toBe(0); appendStreamRow(shell, { role: "user", text: "first prompt" }); expect(shell.streamLog.map((row) => row.text)).toEqual([ - "corbits code · thegreataxios · muse-spark · yolo", - "Permission prompts are disabled by your saved default (/yolo off to re-enable).", + header, + OTHER_TEXT, "first prompt", ]); } finally {