Skip to content

Commit 9cec660

Browse files
committed
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.
1 parent 264a365 commit 9cec660

4 files changed

Lines changed: 80 additions & 4 deletions

File tree

src/tui/product-host.test.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,26 @@ describe("mountProductHost", () => {
203203
}
204204
})
205205

206+
test("session.clear wipes the painted transcript (CL-5612)", async () => {
207+
const { host, emitter } = await mountHeadless()
208+
try {
209+
emitter.emit("event", { type: "user", text: "old prompt" })
210+
emitter.emit("event", { type: "assistant", text: "old reply" })
211+
expect(host.shell.streamLog.length).toBe(2)
212+
213+
emitter.emit("session.clear")
214+
expect(host.shell.streamLog).toEqual([])
215+
expect(host.shell.streamLogBase).toBe(0)
216+
expect(host.shell.lineCount).toBe(0)
217+
218+
// Subsequent turns land on the empty transcript.
219+
emitter.emit("event", { type: "user", text: "fresh prompt" })
220+
expect(host.shell.streamLog).toEqual([{ role: "user", text: "fresh prompt" }])
221+
} finally {
222+
host.dispose()
223+
}
224+
})
225+
206226
test("permission.gate opens the overlay and resolves through the emitter's resolve callback", async () => {
207227
const { host, emitter } = await mountHeadless()
208228
try {
@@ -253,6 +273,7 @@ describe("mountProductHost", () => {
253273
expect(emitter.listenerCount("event")).toBe(1)
254274
expect(emitter.listenerCount("history.hydrate")).toBe(1)
255275
expect(emitter.listenerCount("session.title")).toBe(1)
276+
expect(emitter.listenerCount("session.clear")).toBe(1)
256277
expect(emitter.listenerCount("permission.gate")).toBe(1)
257278
expect(emitter.listenerCount("operator.gate")).toBe(1)
258279

@@ -263,6 +284,7 @@ describe("mountProductHost", () => {
263284
expect(emitter.listenerCount("event")).toBe(0)
264285
expect(emitter.listenerCount("history.hydrate")).toBe(0)
265286
expect(emitter.listenerCount("session.title")).toBe(0)
287+
expect(emitter.listenerCount("session.clear")).toBe(0)
266288
expect(emitter.listenerCount("permission.gate")).toBe(0)
267289
expect(emitter.listenerCount("operator.gate")).toBe(0)
268290
})

src/tui/product-host.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ import type { PaletteCommand } from "./command-catalog.js"
3939
import {
4040
appendObserveStreamRow,
4141
appendStreamRow,
42+
clearTranscript,
4243
createAppShell,
4344
paintChrome,
4445
setChromeZones,
@@ -425,6 +426,7 @@ export async function mountProductHost(
425426
disposeGates()
426427
config.eventEmitter.off("history.hydrate", onHistory)
427428
config.eventEmitter.off("session.title", onTitle)
429+
config.eventEmitter.off("session.clear", onSessionClear)
428430
config.eventEmitter.off("hook", onHook)
429431
config.eventEmitter.off("mcp.status", onMcpStatus)
430432
config.eventEmitter.off("permission.grant", onPermissionGrant)
@@ -534,6 +536,14 @@ export async function mountProductHost(
534536
}
535537
}
536538

539+
// /clear and /new rotate the backend session in the runner; the host must
540+
// wipe the painted transcript so the screen matches a brand-new session.
541+
// The Ink App used to own this unconditionally — OpenTUI regressed it.
542+
function onSessionClear(): void {
543+
if (disposed) return
544+
clearTranscript(shell)
545+
}
546+
537547
let currentModels = config.models ?? []
538548
let currentDescribeModel = config.describeModel
539549
let openModels: (() => void) | undefined
@@ -629,6 +639,7 @@ export async function mountProductHost(
629639
config.eventEmitter.on("event", onEvent)
630640
config.eventEmitter.on("history.hydrate", onHistory)
631641
config.eventEmitter.on("session.title", onTitle)
642+
config.eventEmitter.on("session.clear", onSessionClear)
632643
config.eventEmitter.on("hook", onHook)
633644
config.eventEmitter.on("mcp.status", onMcpStatus)
634645
config.eventEmitter.on("permission.grant", onPermissionGrant)

src/tui/runner.ts

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1657,10 +1657,16 @@ export async function runTUI(initialConfig: Config): Promise<number> {
16571657
// abort handles → child agent.close) before clearing the session store so
16581658
// /clear does not leave orphaned child reactors burning tokens.
16591659
const newSession = (): void => {
1660-
// The App clears its transcript unconditionally on /clear, so the backend
1661-
// rotation is always enqueued regardless of contention; the queue serialises
1662-
// it behind any in-progress op. Sub-agents nest under the new session
1663-
// automatically because getWorkdirBase reads the live sessionId.
1660+
// Wipe the painted transcript immediately. The product host listens for
1661+
// session.clear; the Ink App used to clear its own stream unconditionally
1662+
// and that path never moved to OpenTUI.
1663+
emitter.emit("session.clear");
1664+
// Cancel live workers before rotation so /clear does not leave orphaned
1665+
// child reactors burning tokens under the old session id.
1666+
subAgentSessions.cancelAll("Session cleared");
1667+
// Backend rotation is always enqueued regardless of contention; the queue
1668+
// serialises it behind any in-progress op. Sub-agents nest under the new
1669+
// session automatically because getWorkdirBase reads the live sessionId.
16641670
void enqueueOp(async () => {
16651671
try {
16661672
// Tear the old agent down and dispose the recorder before workdir is

src/tui/shell.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2326,6 +2326,43 @@ export function truncateStreamRows(shell: AppShell, length: number): void {
23262326
paintChrome(shell)
23272327
}
23282328

2329+
/**
2330+
* Empty the visible transcript for a fresh session (/clear, /new).
2331+
*
2332+
* Backend session rotation lives in the runner; this is only the on-screen wipe
2333+
* the OpenTUI host must own after the Ink App path went away. Observe mode is
2334+
* dropped first so a child view cannot keep painting into a cleared parent.
2335+
* Retention base resets so the screen matches a brand-new session, not a window
2336+
* over an empty retained log with a stale eviction marker.
2337+
*/
2338+
export function clearTranscript(shell: AppShell): void {
2339+
if (shell.observe !== null) {
2340+
// Drop observe without the "left observe" system row — the whole log is
2341+
// about to go and a farewell row would only flash then vanish.
2342+
shell.observe = null
2343+
shell.parentStreamLog = null
2344+
shell.parentStreamLogBase = null
2345+
let guard = 4
2346+
while (guard-- > 0 && focusOwner(shell.focus) === "observe") {
2347+
shell.focus = popFocus(shell.focus)
2348+
}
2349+
const frames = shell.focus.frames.filter((f) => f.target !== "observe")
2350+
if (frames.length !== shell.focus.frames.length) {
2351+
shell.focus = { frames }
2352+
}
2353+
setChromeZones(shell, { agents: null })
2354+
applyFocus(shell)
2355+
}
2356+
shell.streamLog.length = 0
2357+
shell.streamLogBase = 0
2358+
shell.lineCount = 0
2359+
shell.parentStreamLog = null
2360+
shell.parentStreamLogBase = null
2361+
repaintTranscriptWindow(shell)
2362+
paintChrome(shell)
2363+
}
2364+
2365+
23292366
/**
23302367
* Identifies a transcript child as the eviction notice rather than a row.
23312368
* Identity, not position or state, is the source of truth: `streamLogBase`

0 commit comments

Comments
 (0)