From 83c9e26453782cdea5ea838b46744a2e4cff70bc Mon Sep 17 00:00:00 2001 From: Christian Klotz Date: Tue, 6 Oct 2026 16:35:24 +0200 Subject: [PATCH] fix(tui,coding-agent): clear fullscreen selection on transcript rebuild (#10567) Selection coordinates survived session switches and other transcript rebuilds, highlighting unrelated text in the new transcript. Reset selection and multi-click state before re-rendering session entries. Based on #9310. fixes #9311 Co-authored-by: xdagiz --- .../src/modes/interactive/interactive-mode.ts | 2 + .../coding-agent/test/interactive-tui.test.ts | 40 +++++++++++++++++++ packages/tui/src/tui-alt-screen.ts | 6 +++ 3 files changed, 48 insertions(+) diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 18d6d95dd..85fc86edc 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -4054,6 +4054,8 @@ export class InteractiveMode { entries: SessionEntry[], options: { updateFooter?: boolean; populateHistory?: boolean } = {}, ): void { + // Selection coordinates point into the transcript being replaced (#9311). + if (this.renderer instanceof TuiAltScreen) this.renderer.resetTextSelection(); const items = entries.flatMap((entry): RenderSessionItem[] => { if (entry.type === "custom" || (entry.type === "usage" && entry.kind === "cache_warm")) { return [entry]; diff --git a/packages/coding-agent/test/interactive-tui.test.ts b/packages/coding-agent/test/interactive-tui.test.ts index 559edfe36..ffb9e1aed 100644 --- a/packages/coding-agent/test/interactive-tui.test.ts +++ b/packages/coding-agent/test/interactive-tui.test.ts @@ -363,6 +363,46 @@ describe("InteractiveMode copy confirmation", () => { }); }); +type RenderSessionEntriesContext = { + renderer: ReturnType; + renderSessionItems: (items: unknown[]) => void; +}; + +describe("InteractiveMode transcript rebuild", () => { + it("drops the fullscreen selection when session entries are re-rendered", async () => { + // Regression test for #9311: selection coordinates survived session switches. + const terminal = new RecordingTerminal(40, 4); + const ui = createInteractiveTui({ + tuiMode: "fullscreen", + showHardwareCursor: false, + logDirectory: "/tmp", + terminal, + fullscreenCopyOnSelect: false, + }); + ui.addChild(new Text("alpha\nbeta\ngamma\ndelta", 0, 0)); + const context: RenderSessionEntriesContext = { renderer: ui, renderSessionItems: vi.fn() }; + const { renderSessionEntries } = InteractiveMode.prototype as unknown as { + renderSessionEntries(this: RenderSessionEntriesContext, entries: unknown[]): void; + }; + + ui.start(); + try { + await terminal.waitForRender(); + terminal.sendInput("\x1b[<0;1;1M"); + terminal.sendInput("\x1b[<32;4;2M"); + terminal.sendInput("\x1b[<0;4;2m"); + await terminal.waitForRender(); + expect(ui.hasActiveSelection()).toBe(true); + + renderSessionEntries.call(context, []); + + expect(ui.hasActiveSelection()).toBe(false); + } finally { + ui.stop(); + } + }); +}); + type StatusEditor = { embedWorkingStatus: boolean; setWorkingStatusIndicator: (indicator: StatusIndicator | undefined) => void; diff --git a/packages/tui/src/tui-alt-screen.ts b/packages/tui/src/tui-alt-screen.ts index c0631f12f..8bcac9682 100644 --- a/packages/tui/src/tui-alt-screen.ts +++ b/packages/tui/src/tui-alt-screen.ts @@ -313,6 +313,12 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI { return this.copyTextToClipboard(text); } + /** Drop the text selection and multi-click history, e.g. before the host replaces the transcript. */ + resetTextSelection(): void { + this.clearTextSelection(); + this.lastClick = undefined; + } + /** The lines of the last rendered frame, one per terminal row, as written to the terminal. */ getScreenLines(): string[] { return [...this.previousScreen];