diff --git a/src/tui/product-host.test.ts b/src/tui/product-host.test.ts index 7097cd4b2..106055e77 100644 --- a/src/tui/product-host.test.ts +++ b/src/tui/product-host.test.ts @@ -203,6 +203,26 @@ describe("mountProductHost", () => { } }) + test("session.clear wipes the painted transcript (CL-5612)", async () => { + const { host, emitter } = await mountHeadless() + try { + emitter.emit("event", { type: "user", text: "old prompt" }) + emitter.emit("event", { type: "assistant", text: "old reply" }) + expect(host.shell.streamLog.length).toBe(2) + + emitter.emit("session.clear") + expect(host.shell.streamLog).toEqual([]) + expect(host.shell.streamLogBase).toBe(0) + expect(host.shell.lineCount).toBe(0) + + // Subsequent turns land on the empty transcript. + emitter.emit("event", { type: "user", text: "fresh prompt" }) + expect(host.shell.streamLog).toEqual([{ role: "user", text: "fresh prompt" }]) + } finally { + host.dispose() + } + }) + test("permission.gate opens the overlay and resolves through the emitter's resolve callback", async () => { const { host, emitter } = await mountHeadless() try { @@ -253,6 +273,7 @@ describe("mountProductHost", () => { expect(emitter.listenerCount("event")).toBe(1) expect(emitter.listenerCount("history.hydrate")).toBe(1) expect(emitter.listenerCount("session.title")).toBe(1) + expect(emitter.listenerCount("session.clear")).toBe(1) expect(emitter.listenerCount("permission.gate")).toBe(1) expect(emitter.listenerCount("operator.gate")).toBe(1) @@ -263,6 +284,7 @@ describe("mountProductHost", () => { expect(emitter.listenerCount("event")).toBe(0) expect(emitter.listenerCount("history.hydrate")).toBe(0) expect(emitter.listenerCount("session.title")).toBe(0) + expect(emitter.listenerCount("session.clear")).toBe(0) expect(emitter.listenerCount("permission.gate")).toBe(0) expect(emitter.listenerCount("operator.gate")).toBe(0) }) diff --git a/src/tui/product-host.ts b/src/tui/product-host.ts index d0af6fa1b..8bfa2261c 100644 --- a/src/tui/product-host.ts +++ b/src/tui/product-host.ts @@ -39,6 +39,7 @@ import type { PaletteCommand } from "./command-catalog.js" import { appendObserveStreamRow, appendStreamRow, + clearTranscript, createAppShell, paintChrome, setChromeZones, @@ -425,6 +426,7 @@ export async function mountProductHost( disposeGates() config.eventEmitter.off("history.hydrate", onHistory) config.eventEmitter.off("session.title", onTitle) + config.eventEmitter.off("session.clear", onSessionClear) config.eventEmitter.off("hook", onHook) config.eventEmitter.off("mcp.status", onMcpStatus) config.eventEmitter.off("permission.grant", onPermissionGrant) @@ -534,6 +536,14 @@ export async function mountProductHost( } } + // /clear and /new rotate the backend session in the runner; the host must + // wipe the painted transcript so the screen matches a brand-new session. + // The Ink App used to own this unconditionally — OpenTUI regressed it. + function onSessionClear(): void { + if (disposed) return + clearTranscript(shell) + } + let currentModels = config.models ?? [] let currentDescribeModel = config.describeModel let openModels: (() => void) | undefined @@ -629,6 +639,7 @@ export async function mountProductHost( config.eventEmitter.on("event", onEvent) config.eventEmitter.on("history.hydrate", onHistory) config.eventEmitter.on("session.title", onTitle) + config.eventEmitter.on("session.clear", onSessionClear) config.eventEmitter.on("hook", onHook) config.eventEmitter.on("mcp.status", onMcpStatus) config.eventEmitter.on("permission.grant", onPermissionGrant) diff --git a/src/tui/runner.ts b/src/tui/runner.ts index e8b9062c3..56dd18ed4 100644 --- a/src/tui/runner.ts +++ b/src/tui/runner.ts @@ -1657,10 +1657,16 @@ export async function runTUI(initialConfig: Config): Promise { // abort handles → child agent.close) before clearing the session store so // /clear does not leave orphaned child reactors burning tokens. const newSession = (): void => { - // The App clears its transcript unconditionally on /clear, so the backend - // rotation is always enqueued regardless of contention; the queue serialises - // it behind any in-progress op. Sub-agents nest under the new session - // automatically because getWorkdirBase reads the live sessionId. + // Wipe the painted transcript immediately. The product host listens for + // session.clear; the Ink App used to clear its own stream unconditionally + // and that path never moved to OpenTUI. + emitter.emit("session.clear"); + // Cancel live workers before rotation so /clear does not leave orphaned + // child reactors burning tokens under the old session id. + subAgentSessions.cancelAll("Session cleared"); + // Backend rotation is always enqueued regardless of contention; the queue + // serialises it behind any in-progress op. Sub-agents nest under the new + // session automatically because getWorkdirBase reads the live sessionId. void enqueueOp(async () => { try { // Tear the old agent down and dispose the recorder before workdir is diff --git a/src/tui/shell.ts b/src/tui/shell.ts index 1412a82ff..74f062427 100644 --- a/src/tui/shell.ts +++ b/src/tui/shell.ts @@ -2326,6 +2326,43 @@ export function truncateStreamRows(shell: AppShell, length: number): void { paintChrome(shell) } +/** + * Empty the visible transcript for a fresh session (/clear, /new). + * + * Backend session rotation lives in the runner; this is only the on-screen wipe + * the OpenTUI host must own after the Ink App path went away. Observe mode is + * dropped first so a child view cannot keep painting into a cleared parent. + * Retention base resets so the screen matches a brand-new session, not a window + * over an empty retained log with a stale eviction marker. + */ +export function clearTranscript(shell: AppShell): void { + if (shell.observe !== null) { + // Drop observe without the "left observe" system row — the whole log is + // about to go and a farewell row would only flash then vanish. + shell.observe = null + shell.parentStreamLog = null + shell.parentStreamLogBase = null + let guard = 4 + while (guard-- > 0 && focusOwner(shell.focus) === "observe") { + shell.focus = popFocus(shell.focus) + } + const frames = shell.focus.frames.filter((f) => f.target !== "observe") + if (frames.length !== shell.focus.frames.length) { + shell.focus = { frames } + } + setChromeZones(shell, { agents: null }) + applyFocus(shell) + } + shell.streamLog.length = 0 + shell.streamLogBase = 0 + shell.lineCount = 0 + shell.parentStreamLog = null + shell.parentStreamLogBase = null + repaintTranscriptWindow(shell) + paintChrome(shell) +} + + /** * Identifies a transcript child as the eviction notice rather than a row. * Identity, not position or state, is the source of truth: `streamLogBase`