From 9af4929a16db76c4d394a8ad9bb05d0ffaeff834 Mon Sep 17 00:00:00 2001 From: wutongyuonce <147830929+wutongyuonce@users.noreply.github.com> Date: Sat, 22 Aug 2026 22:09:59 +0800 Subject: [PATCH 1/4] feat(tui): add opt-in overlay selection exclusion --- packages/tui/src/tui-alt-screen.ts | 42 ++++++++++++++++++-- packages/tui/src/tui.ts | 49 +++++++++++++++++++++++- packages/tui/test/tui-alt-screen.test.ts | 33 ++++++++++++++++ 3 files changed, 119 insertions(+), 5 deletions(-) diff --git a/packages/tui/src/tui-alt-screen.ts b/packages/tui/src/tui-alt-screen.ts index 1ecc08162..8f349271e 100644 --- a/packages/tui/src/tui-alt-screen.ts +++ b/packages/tui/src/tui-alt-screen.ts @@ -33,6 +33,7 @@ import { CURSOR_MARKER, compositeTuiLine, type OverlayHandle, + type OverlaySelectionRegion, TuiBase, type TuiStopOptions, VIEWPORT_TUI, @@ -716,7 +717,7 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI { } private getScrollbarTargetAt(x: number, y: number): ScrollbarTarget | undefined { - if (this.hasOverlay() || !this.currentLayout) return undefined; + if (this.hasSelectionBlockingOverlay() || !this.currentLayout) return undefined; for (const scrollView of getScrollViewsAt(this.currentLayout, x, y)) { const box = getScrollViewBox(this.currentLayout, scrollView); const geometry = box ? getScrollbarGeometry(box) : undefined; @@ -814,7 +815,21 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI { }; } - private getSelectionPoint(event: SgrMouseEvent, scrollView?: ScrollView): SelectionPoint { + private getSelectionExclusionAt(x: number, y: number): OverlaySelectionRegion | undefined { + return this.getOverlaySelectionExclusions().find( + (region) => + x >= region.col && x < region.col + region.width && y >= region.row && y < region.row + region.height, + ); + } + + private getSelectionPoint(event: SgrMouseEvent, scrollView?: ScrollView): SelectionPoint | undefined { + const exclusion = this.getSelectionExclusionAt(event.x, event.y); + if (exclusion) { + if (!scrollView) return undefined; + const boundaryX = event.x < exclusion.col ? exclusion.col - 1 : exclusion.col + exclusion.width; + const point = this.getScrollSelectionPoint(scrollView, boundaryX, event.y); + if (point) return point; + } if (scrollView) { const point = this.getScrollSelectionPoint(scrollView, event.x, event.y); if (point) return point; @@ -983,6 +998,17 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI { if (button !== 0 && !(event.release && button === 3)) return; const anchorScrollView = this.selectionAnchor?.scrollView; const point = this.getSelectionPoint(event, anchorScrollView); + if (!point) { + this.selectionPressActive = false; + this.stopSelectionAutoScroll(); + this.selectionAnchor = undefined; + this.selectionFocus = undefined; + this.selectionInitialRange = undefined; + this.selectionGranularity = "character"; + this.pressedUrl = undefined; + this.requestRender(); + return; + } if (event.release) { if (!this.selectionPressActive) return; this.selectionPressActive = false; @@ -1025,10 +1051,20 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI { this.stopSelectionAutoScroll(); this.selectionPressActive = true; const scrollView = - !this.hasOverlay() && this.currentLayout + !this.hasSelectionBlockingOverlay() && this.currentLayout ? getScrollViewsAt(this.currentLayout, event.x, event.y)[0] : undefined; const anchor = this.getSelectionPoint(event, scrollView); + if (!anchor) { + this.selectionPressActive = false; + this.selectionAnchor = undefined; + this.selectionFocus = undefined; + this.selectionInitialRange = undefined; + this.selectionGranularity = "character"; + this.pressedUrl = undefined; + this.requestRender(); + return; + } const word = this.getWordSelection(anchor); const clickCount = this.getClickCount(anchor, word); const range = clickCount === 2 ? word : clickCount === 3 ? this.getLineSelection(anchor) : undefined; diff --git a/packages/tui/src/tui.ts b/packages/tui/src/tui.ts index 5172a9a14..3efb3a930 100644 --- a/packages/tui/src/tui.ts +++ b/packages/tui/src/tui.ts @@ -159,6 +159,19 @@ export interface OverlayOptions { visible?: (termWidth: number, termHeight: number) => boolean; /** If true, don't capture keyboard focus when shown */ nonCapturing?: boolean; + /** + * Control whether fullscreen text selection includes this overlay. + * Defaults to "normal" to preserve existing behavior. + */ + selection?: "normal" | "exclude"; +} + +/** Screen region occupied by an overlay excluded from fullscreen text selection. */ +export interface OverlaySelectionRegion { + row: number; + col: number; + width: number; + height: number; } /** Options for {@link OverlayHandle.unfocus}. */ @@ -354,6 +367,7 @@ export abstract class TuiBase extends Container implements TUI { // Overlay stack for modal components rendered on top of base content private focusOrderCounter = 0; private overlayStack: OverlayStackEntry[] = []; + private overlaySelectionExclusions: OverlaySelectionRegion[] = []; get hasOverlayEntries(): boolean { return this.overlayStack.length > 0; @@ -662,6 +676,16 @@ export abstract class TuiBase extends Container implements TUI { return this.overlayStack.some((o) => this.isOverlayVisible(o)); } + /** Check whether a visible overlay should block fullscreen text selection. */ + protected hasSelectionBlockingOverlay(): boolean { + return this.overlayStack.some((entry) => this.isOverlayVisible(entry) && entry.options?.selection !== "exclude"); + } + + /** Get the screen regions occupied by overlays excluded from text selection. */ + protected getOverlaySelectionExclusions(): readonly OverlaySelectionRegion[] { + return this.overlaySelectionExclusions; + } + /** Check if the focused component is a visible overlay */ protected isOverlayFocused(): boolean { return this.overlayStack.some( @@ -1097,11 +1121,18 @@ export abstract class TuiBase extends Container implements TUI { /** Composite all overlays into content lines (sorted by focusOrder, higher = on top). */ protected compositeOverlays(lines: string[], termWidth: number, termHeight: number): string[] { + this.overlaySelectionExclusions = []; if (this.overlayStack.length === 0) return lines; const result = [...lines]; // Pre-render all visible overlays and calculate positions - const rendered: { overlayLines: string[]; row: number; col: number; w: number }[] = []; + const rendered: { + overlayLines: string[]; + row: number; + col: number; + w: number; + selection: OverlayOptions["selection"]; + }[] = []; let minLinesNeeded = result.length; const visibleEntries = this.overlayStack.filter((e) => this.isOverlayVisible(e)); @@ -1124,7 +1155,13 @@ export abstract class TuiBase extends Container implements TUI { // Get final row/col with actual overlay height const { row, col } = this.resolveOverlayLayout(options, overlayLines.length, termWidth, termHeight); - rendered.push({ overlayLines, row, col, w: width }); + rendered.push({ + overlayLines, + row, + col, + w: width, + selection: options?.selection, + }); minLinesNeeded = Math.max(minLinesNeeded, row + overlayLines.length); } @@ -1139,6 +1176,14 @@ export abstract class TuiBase extends Container implements TUI { } const viewportStart = Math.max(0, workingHeight - termHeight); + this.overlaySelectionExclusions = rendered + .filter(({ selection }) => selection === "exclude") + .map(({ row, col, w, overlayLines }) => ({ + row, + col, + width: w, + height: overlayLines.length, + })); // Composite each overlay for (const { overlayLines, row, col, w } of rendered) { diff --git a/packages/tui/test/tui-alt-screen.test.ts b/packages/tui/test/tui-alt-screen.test.ts index 621e7f6a8..8f34ca69a 100644 --- a/packages/tui/test/tui-alt-screen.test.ts +++ b/packages/tui/test/tui-alt-screen.test.ts @@ -1124,6 +1124,39 @@ describe("TuiAltScreen", () => { tui.stop(); }); + it("keeps excluded overlays out of fullscreen text selection", async () => { + const terminal = new RecordingTerminal(20, 2); + const copied: string[] = []; + const tui = new TuiAltScreen(terminal, undefined, undefined, { + copySelection: async (text) => { + copied.push(text); + return true; + }, + }); + tui.addChild(new Text("alpha\nbeta", 0, 0)); + tui.showOverlay(new Text("SIDE\nPANEL", 0, 0), { + anchor: "top-right", + width: 5, + margin: 0, + nonCapturing: true, + selection: "exclude", + }); + tui.start(); + await terminal.waitForRender(); + + terminal.sendInput("\x1b[<0;1;1M"); + terminal.sendInput("\x1b[<0;1;1m"); + terminal.sendInput("\x1b[<0;1;1M"); + terminal.sendInput("\x1b[<0;1;1m"); + terminal.sendInput("\x1b[<0;1;1M"); + terminal.sendInput("\x1b[<0;1;1m"); + await terminal.waitForRender(); + + assert.ok(copied.length > 0); + assert.ok(copied.every((text) => text === "alpha")); + tui.stop(); + }); + it("does not repaint idle or zero-width selections on focus loss", async () => { const terminal = new RecordingTerminal(20, 4); const tui = new TuiAltScreen(terminal); From fdb50d88411a94ca1e1b76d4e84516f74842bb91 Mon Sep 17 00:00:00 2001 From: wutongyuonce <147830929+wutongyuonce@users.noreply.github.com> Date: Thu, 27 Aug 2026 22:19:04 +0800 Subject: [PATCH 2/4] fix(tui): cover excluded overlay drag selection --- packages/tui/test/tui-alt-screen.test.ts | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/packages/tui/test/tui-alt-screen.test.ts b/packages/tui/test/tui-alt-screen.test.ts index 8f34ca69a..f8608fec2 100644 --- a/packages/tui/test/tui-alt-screen.test.ts +++ b/packages/tui/test/tui-alt-screen.test.ts @@ -1133,7 +1133,13 @@ describe("TuiAltScreen", () => { return true; }, }); - tui.addChild(new Text("alpha\nbeta", 0, 0)); + const transcript = new ScrollView(new Text("alpha\nbeta", 0, 0), { primary: true }); + tui.setLayoutRoot( + new HStack([ + { component: transcript, basis: 15, shrink: 0 }, + { component: new Text("", 0, 0), basis: 5, shrink: 0 }, + ]), + ); tui.showOverlay(new Text("SIDE\nPANEL", 0, 0), { anchor: "top-right", width: 5, @@ -1145,15 +1151,16 @@ describe("TuiAltScreen", () => { await terminal.waitForRender(); terminal.sendInput("\x1b[<0;1;1M"); - terminal.sendInput("\x1b[<0;1;1m"); - terminal.sendInput("\x1b[<0;1;1M"); - terminal.sendInput("\x1b[<0;1;1m"); - terminal.sendInput("\x1b[<0;1;1M"); - terminal.sendInput("\x1b[<0;1;1m"); + terminal.sendInput("\x1b[<32;18;2M"); + terminal.sendInput("\x1b[<0;18;2m"); await terminal.waitForRender(); - assert.ok(copied.length > 0); - assert.ok(copied.every((text) => text === "alpha")); + assert.deepStrictEqual(copied, ["alpha\nbeta"]); + + terminal.sendInput("\x1b[<0;18;1M"); + terminal.sendInput("\x1b[<0;18;1m"); + await terminal.waitForRender(); + assert.deepStrictEqual(copied, ["alpha\nbeta"]); tui.stop(); }); From aa5a86deee052333be89035e0cf5834c9c46c3bf Mon Sep 17 00:00:00 2001 From: wutongyuonce <147830929+wutongyuonce@users.noreply.github.com> Date: Wed, 16 Sep 2026 18:18:05 +0800 Subject: [PATCH 3/4] fix(tui): snap excluded overlay drags to the approach side `getSelectionPoint` picked the exclusion boundary with `event.x < exclusion.col`, but that branch is only reached when the pointer is already inside the region, so the comparison was always false and every drag snapped to the right edge. Dragging into an excluded overlay from the left therefore also selected the text the overlay covers. The edge now follows the pointer's previous position. The reference point is passed only for motion and release, so a fresh press inside an excluded overlay still cannot start a selection. --- packages/tui/src/tui-alt-screen.ts | 20 ++++++- packages/tui/test/tui-alt-screen.test.ts | 70 ++++++++++++++++++++++++ 2 files changed, 87 insertions(+), 3 deletions(-) diff --git a/packages/tui/src/tui-alt-screen.ts b/packages/tui/src/tui-alt-screen.ts index c99c9b3c5..54b277a33 100644 --- a/packages/tui/src/tui-alt-screen.ts +++ b/packages/tui/src/tui-alt-screen.ts @@ -1142,11 +1142,18 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI { ); } - private getSelectionPoint(event: SgrMouseEvent, scrollView?: ScrollView): SelectionPoint | undefined { + private getSelectionPoint( + event: SgrMouseEvent, + scrollView?: ScrollView, + reference?: SelectionPoint, + ): SelectionPoint | undefined { const exclusion = this.getSelectionExclusionAt(event.x, event.y); if (exclusion) { if (!scrollView) return undefined; - const boundaryX = event.x < exclusion.col ? exclusion.col - 1 : exclusion.col + exclusion.width; + // Unlike a press, a drag may cross the excluded region: it has to snap to the + // edge the pointer came from, not always to the same one. + if (!reference) return undefined; + const boundaryX = reference.col > event.x ? exclusion.col + exclusion.width : exclusion.col - 1; const point = this.getScrollSelectionPoint(scrollView, boundaryX, event.y); if (point) return point; } @@ -1317,7 +1324,14 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI { const button = event.button & 3; if (button !== 0 && !(event.release && button === 3)) return; const anchorScrollView = this.selectionAnchor?.scrollView; - const point = this.getSelectionPoint(event, anchorScrollView); + // Motion and release continue an in-progress drag; a bare press starts a new one and + // must not start inside an excluded overlay. + const continuing = event.release || (event.button & 32) !== 0; + const point = this.getSelectionPoint( + event, + anchorScrollView, + continuing ? (this.selectionFocus ?? this.selectionAnchor) : undefined, + ); if (!point) { this.selectionPressActive = false; this.stopSelectionAutoScroll(); diff --git a/packages/tui/test/tui-alt-screen.test.ts b/packages/tui/test/tui-alt-screen.test.ts index 42995fc28..bad7e62df 100644 --- a/packages/tui/test/tui-alt-screen.test.ts +++ b/packages/tui/test/tui-alt-screen.test.ts @@ -1492,6 +1492,76 @@ describe("TuiAltScreen", () => { tui.stop(); }); + it("snaps an excluded overlay drag to the edge the pointer came from", async () => { + const terminal = new RecordingTerminal(20, 2); + const copied: string[] = []; + const tui = new TuiAltScreen(terminal, undefined, undefined, { + copySelection: async (text) => { + copied.push(text); + return true; + }, + }); + tui.setLayoutRoot(new ScrollView(new Text("abcdefghijklmnopqrst", 0, 0), { primary: true })); + tui.showOverlay(new Text("SECRET", 0, 0), { + row: 0, + col: 5, + width: 6, + margin: 0, + nonCapturing: true, + selection: "exclude", + }); + tui.start(); + await terminal.waitForRender(); + + terminal.sendInput("\x1b[<0;1;1M"); + terminal.sendInput("\x1b[<32;7;1M"); + terminal.sendInput("\x1b[<0;7;1m"); + await terminal.waitForRender(); + assert.deepStrictEqual(copied, ["abcde"]); + + terminal.sendInput("\x1b[<0;14;1M"); + terminal.sendInput("\x1b[<32;7;1M"); + terminal.sendInput("\x1b[<0;7;1m"); + await terminal.waitForRender(); + assert.deepStrictEqual(copied, ["abcde", "lmn"]); + tui.stop(); + }); + + it("does not start a selection inside an excluded overlay while a selection exists", async () => { + const terminal = new RecordingTerminal(20, 2); + const copied: string[] = []; + const tui = new TuiAltScreen(terminal, undefined, undefined, { + copySelection: async (text) => { + copied.push(text); + return true; + }, + }); + tui.setLayoutRoot(new ScrollView(new Text("abcdefghijklmnopqrst", 0, 0), { primary: true })); + tui.showOverlay(new Text("SECRET", 0, 0), { + row: 0, + col: 5, + width: 6, + margin: 0, + nonCapturing: true, + selection: "exclude", + }); + tui.start(); + await terminal.waitForRender(); + + terminal.sendInput("\x1b[<0;1;1M"); + terminal.sendInput("\x1b[<32;4;1M"); + terminal.sendInput("\x1b[<0;4;1m"); + await terminal.waitForRender(); + assert.deepStrictEqual(copied, ["abcd"]); + + terminal.sendInput("\x1b[<0;8;1M"); + terminal.sendInput("\x1b[<32;3;1M"); + terminal.sendInput("\x1b[<0;3;1m"); + await terminal.waitForRender(); + assert.deepStrictEqual(copied, ["abcd"]); + tui.stop(); + }); + it("does not repaint idle or zero-width selections on focus loss", async () => { const terminal = new RecordingTerminal(20, 4); const tui = new TuiAltScreen(terminal); From 3245ae2217c8d3779eb10d43dc12f1de9fe1a4b1 Mon Sep 17 00:00:00 2001 From: wutongyuonce <147830929+wutongyuonce@users.noreply.github.com> Date: Wed, 16 Sep 2026 18:19:00 +0800 Subject: [PATCH 4/4] fix(tui): keep in-progress selection drags away from overlays Mouse dispatch asked overlays first and returned on `{ handled: true }`, so an excluded overlay that handles mouse events swallowed the motion and release of a drag that had started on selectable content. A persistent interactive sidebar broke the drag at its edge and copied nothing. An in-progress selection drag now owns the pointer until release, the way a component that received the press owns its gesture. Overlays, the scroll-to-end indicator, the scrollbar and the layout are skipped for those events; a press still routes normally, so clicking inside an overlay keeps working. --- packages/tui/src/tui-alt-screen.ts | 14 +++++++++ packages/tui/test/tui-alt-screen.test.ts | 36 ++++++++++++++++++++++++ 2 files changed, 50 insertions(+) diff --git a/packages/tui/src/tui-alt-screen.ts b/packages/tui/src/tui-alt-screen.ts index 54b277a33..709c22412 100644 --- a/packages/tui/src/tui-alt-screen.ts +++ b/packages/tui/src/tui-alt-screen.ts @@ -910,6 +910,20 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI { if (this.handleSearchMouseEvent(raw)) return; + // A selection drag that is already in progress keeps the pointer, the same way a component + // that received the press owns the whole gesture. Without this, an overlay that handles mouse + // events swallows the motion and the release, and the drag dies at its edge. + if ( + type !== "press" && + ((raw.button & 3) === 0 || ((raw.button & 3) === 3 && type === "release")) && + this.selectionPressActive && + !this.mouseCapture && + !this.mousePressTarget + ) { + this.handleSelectionMouseEvent(raw); + return; + } + const overlay = this.dispatchMouseToOverlay(event); if (!overlay.hit) { if (this.handleScrollToEndIndicatorMouseEvent(raw)) return; diff --git a/packages/tui/test/tui-alt-screen.test.ts b/packages/tui/test/tui-alt-screen.test.ts index bad7e62df..a85fd44d6 100644 --- a/packages/tui/test/tui-alt-screen.test.ts +++ b/packages/tui/test/tui-alt-screen.test.ts @@ -1562,6 +1562,42 @@ describe("TuiAltScreen", () => { tui.stop(); }); + it("keeps an in-progress selection drag away from a mouse-handling excluded overlay", async () => { + const terminal = new RecordingTerminal(20, 2); + const copied: string[] = []; + const overlayEvents: string[] = []; + const tui = new TuiAltScreen(terminal, undefined, undefined, { + copySelection: async (text) => { + copied.push(text); + return true; + }, + }); + tui.setLayoutRoot(new ScrollView(new Text("abcdefghijklmnopqrst", 0, 0), { primary: true })); + tui.showOverlay( + new MouseRegion(new Text("SECRET", 0, 0), (event) => { + overlayEvents.push(event.type); + return { handled: true }; + }), + { row: 0, col: 5, width: 6, margin: 0, nonCapturing: true, selection: "exclude" }, + ); + tui.start(); + await terminal.waitForRender(); + + terminal.sendInput("\x1b[<0;1;1M"); + terminal.sendInput("\x1b[<32;7;1M"); + terminal.sendInput("\x1b[<0;7;1m"); + await terminal.waitForRender(); + assert.deepStrictEqual(copied, ["abcde"]); + assert.deepStrictEqual(overlayEvents, []); + + terminal.sendInput("\x1b[<0;7;1M"); + terminal.sendInput("\x1b[<0;7;1m"); + await terminal.waitForRender(); + assert.deepStrictEqual(overlayEvents, ["press", "release", "click"]); + assert.deepStrictEqual(copied, ["abcde"]); + tui.stop(); + }); + it("does not repaint idle or zero-width selections on focus loss", async () => { const terminal = new RecordingTerminal(20, 4); const tui = new TuiAltScreen(terminal);