Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions src/tui/product-host.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)

Expand All @@ -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)
})
Expand Down
11 changes: 11 additions & 0 deletions src/tui/product-host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import type { PaletteCommand } from "./command-catalog.js"
import {
appendObserveStreamRow,
appendStreamRow,
clearTranscript,
createAppShell,
paintChrome,
setChromeZones,
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
14 changes: 10 additions & 4 deletions src/tui/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1657,10 +1657,16 @@ export async function runTUI(initialConfig: Config): Promise<number> {
// 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
Expand Down
37 changes: 37 additions & 0 deletions src/tui/shell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
Loading