From 9cec6603c58cc0d09c784ac9cdb5e862a14bd08b Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sun, 9 Aug 2026 04:18:07 -0700 Subject: [PATCH] Clear the OpenTUI transcript on /new and /clear Backend session rotation still ran, but the painted stream stayed on screen after the Ink App path went away. Emit session.clear from the runner, wipe the shell log in the product host, and cancel live sub-agents so orphans do not keep burning tokens under the old session. --- src/tui/product-host.test.ts | 22 +++++++++++++++++++++ src/tui/product-host.ts | 11 +++++++++++ src/tui/runner.ts | 14 ++++++++++---- src/tui/shell.ts | 37 ++++++++++++++++++++++++++++++++++++ 4 files changed, 80 insertions(+), 4 deletions(-) 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`