merge PR #5714 via eval/pr-5714: fix(tui): preserved plan review text selection

This commit is contained in:
can1357
2026-07-17 04:39:15 +02:00
6 changed files with 99 additions and 22 deletions
+3
View File
@@ -50,6 +50,9 @@
### Fixed
- Fixed unknown `__omp_worker_*` CLI selectors exiting 0 with empty output instead of erroring; an unrecognized worker-host selector now writes `Error: unknown worker selector: …` to stderr and exits nonzero, so a stale or mistyped selector can no longer look healthy to a parent process or install smoke path ([#5712](https://github.com/can1357/oh-my-pi/issues/5712)).
### Fixed
- Fixed Plan Review capturing mouse drags as pointer events, preventing native terminal text selection ([#5711](https://github.com/can1357/oh-my-pi/issues/5711)).
## [17.0.1] - 2026-07-16
@@ -2599,6 +2599,7 @@ export class InteractiveMode implements InteractiveModeContext {
maxHeight: "100%",
margin: 0,
fullscreen: true,
mouseTracking: false,
});
this.ui.setFocus(overlay);
this.ui.requestRender();
@@ -10,7 +10,7 @@ import { resetSettingsForTest, Settings } from "@oh-my-pi/pi-coding-agent/config
import { resolveLocalUrlToPath } from "@oh-my-pi/pi-coding-agent/internal-urls";
import { AssistantMessageComponent } from "@oh-my-pi/pi-coding-agent/modes/components/assistant-message";
import type { HookSelectorSlider } from "@oh-my-pi/pi-coding-agent/modes/components/hook-selector";
import type { PlanReviewOverlay } from "@oh-my-pi/pi-coding-agent/modes/components/plan-review-overlay";
import { PlanReviewOverlay } from "@oh-my-pi/pi-coding-agent/modes/components/plan-review-overlay";
import { InteractiveMode } from "@oh-my-pi/pi-coding-agent/modes/interactive-mode";
import { initTheme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme";
import { AgentSession } from "@oh-my-pi/pi-coding-agent/session/agent-session";
@@ -19,7 +19,7 @@ import { SILENT_ABORT_MARKER, USER_INTERRUPT_LABEL } from "@oh-my-pi/pi-coding-a
import { SessionManager } from "@oh-my-pi/pi-coding-agent/session/session-manager";
import { AUTO_THINKING } from "@oh-my-pi/pi-coding-agent/thinking";
import * as clipboard from "@oh-my-pi/pi-coding-agent/utils/clipboard";
import { setKeybindings, Text } from "@oh-my-pi/pi-tui";
import { type OverlayHandle, type OverlayOptions, setKeybindings, Text } from "@oh-my-pi/pi-tui";
import { formatNumber, TempDir } from "@oh-my-pi/pi-utils";
/**
@@ -333,6 +333,28 @@ describe("InteractiveMode plan review rendering", () => {
}
});
it("leaves terminal mouse tracking disabled while Plan Review is open", async () => {
let capturedOverlay: PlanReviewOverlay | undefined;
let capturedOptions: OverlayOptions | undefined;
const overlayHandle: OverlayHandle = {
hide: vi.fn(),
setHidden: vi.fn(),
isHidden: vi.fn(() => false),
};
vi.spyOn(mode.ui, "showOverlay").mockImplementation((component, options) => {
if (!(component instanceof PlanReviewOverlay)) throw new Error("Expected Plan Review overlay");
capturedOverlay = component;
capturedOptions = options;
return overlayHandle;
});
const choice = mode.showPlanReview("# Plan\n\nSelectable body", "Plan mode - next step", ["Approve"]);
expect(capturedOptions).toMatchObject({ fullscreen: true, mouseTracking: false });
capturedOverlay?.handleInput("\x1b");
await expect(choice).resolves.toBeUndefined();
});
it("copies the overlay's current edited plan markdown from the real plan review overlay", async () => {
let capturedOverlay: PlanReviewOverlay | undefined;
const overlayHandle = { hide: vi.fn() };
+3
View File
@@ -7,6 +7,9 @@
- Fixed Enter accepting a mid-prompt `/skill:<name>` autocomplete from submitting and clearing the draft; acceptance now inserts the skill token and leaves the prompt open ([#4773](https://github.com/can1357/oh-my-pi/issues/4773)).
- Fixed Markdown rendering turning local file paths into HTTP links when a `www.` or `http(s)://`/`ftp://` sequence was glued to a preceding character (e.g. `~/meta/www.share/blog/index.dj`); extended autolinks now require a valid GFM left boundary (start of line, whitespace, or one of `*_~(`) ([#5652](https://github.com/can1357/oh-my-pi/issues/5652)).
- Fixed multi-row direct Kitty images being clipped or detached from their cells in native terminal scrollback ([#5669](https://github.com/can1357/oh-my-pi/pull/5669) by [@jeffscottward](https://github.com/jeffscottward)).
### Added
- Added a fullscreen overlay mouse-tracking opt-out so selection-first dialogs can preserve native terminal text selection ([#5711](https://github.com/can1357/oh-my-pi/issues/5711)).
## [17.0.1] - 2026-07-16
+26 -20
View File
@@ -90,11 +90,11 @@ const CURSOR_BEGIN = `${HIDE_CURSOR}${SYNC_OUTPUT_BEGIN}`;
const CURSOR_BEGIN_NO_SYNC = HIDE_CURSOR;
const CURSOR_END = SYNC_OUTPUT_END;
const CURSOR_END_NO_SYNC = "";
// Mouse reporting, enabled only for the lifetime of a fullscreen overlay so the
// rest of the app keeps the terminal's native text selection. 1000h = button
// click tracking, 1003h = any-motion tracking so overlays can light up hover
// targets (the pointer moving with no button held), 1006h = SGR extended
// coordinates so columns/rows past 223 are reported.
// Mouse reporting is scoped to fullscreen overlays that opt into pointer
// interaction. 1000h = button click tracking, 1003h = any-motion tracking for
// hover targets, and 1006h = SGR extended coordinates past column/row 223.
// Selection-first overlays leave these modes disabled so the terminal retains
// native text selection.
const MOUSE_TRACKING_ON = "\x1b[?1000h\x1b[?1003h\x1b[?1006h";
const MOUSE_TRACKING_OFF = "\x1b[?1006l\x1b[?1003l\x1b[?1000l";
@@ -464,6 +464,11 @@ export interface OverlayOptions {
* unchanged and still draw over the transcript on the normal screen.
*/
fullscreen?: boolean;
/**
* Enable terminal mouse reporting while fullscreen. Defaults on; disable it
* when native terminal text selection takes precedence over pointer events.
*/
mouseTracking?: boolean;
}
/**
@@ -1096,6 +1101,7 @@ export class TUI extends Container {
// untouched, so exiting reconciles cleanly against the terminal-restored
// normal screen. #altPreviousLines is the last alt frame, for repaint-skip.
#altActive = false;
#altMouseTrackingActive = false;
#altPreviousLines: string[] = [];
#altEnterWidth = 0;
#altEnterHeight = 0;
@@ -1752,11 +1758,12 @@ export class TUI extends Container {
stop(): void {
if (this.#altActive || this.#pendingAltExit) {
const exitSequence =
this.#pendingAltExit || `${MOUSE_TRACKING_OFF}${this.#keyboardEnhancementExit()}\x1b[?1049l`;
const mouseExit = this.#altMouseTrackingActive ? MOUSE_TRACKING_OFF : "";
const exitSequence = this.#pendingAltExit || `${mouseExit}${this.#keyboardEnhancementExit()}\x1b[?1049l`;
this.terminal.write(exitSequence);
setAltScreenActive(false);
this.#altActive = false;
this.#altMouseTrackingActive = false;
this.#altPreviousLines = [];
this.#pendingAltExit = "";
}
@@ -2776,24 +2783,29 @@ export class TUI extends Container {
// requests it, borrow the terminal's alternate buffer and paint only the
// modal there; the normal screen and all accounting stay untouched.
let deferredAltExit = this.#pendingAltExit;
const wantAlt = this.#wantsAltScreen();
const topOverlay = this.#getTopmostVisibleOverlay();
const wantAlt = topOverlay?.options?.fullscreen === true;
const wantMouseTracking = wantAlt && topOverlay.options?.mouseTracking !== false;
if (wantAlt && !this.#altActive) {
// Enhanced keyboard modes can be buffer-local: re-push the active
// modified-key reporting sequence on the freshly entered alternate
// screen, or Esc/modified keys revert to legacy encoding inside
// fullscreen overlays (Ghostty/kitty/iTerm2).
this.terminal.write(`\x1b[?1049h${this.#keyboardEnhancementEnter()}${MOUSE_TRACKING_ON}`);
const mouseEnter = wantMouseTracking ? MOUSE_TRACKING_ON : "";
this.terminal.write(`\x1b[?1049h${this.#keyboardEnhancementEnter()}${mouseEnter}`);
setAltScreenActive(true);
this.terminal.hideCursor();
this.#forgetHardwareCursorState();
this.#recordHardwareCursorHidden();
this.#altActive = true;
this.#altMouseTrackingActive = wantMouseTracking;
this.#altPreviousLines = [];
this.#altEnterWidth = width;
this.#altEnterHeight = height;
} else if (!wantAlt && this.#altActive) {
const mouseExit = this.#altMouseTrackingActive ? MOUSE_TRACKING_OFF : "";
const enhancementExit = this.#keyboardEnhancementExit();
const exitSequence = `${MOUSE_TRACKING_OFF}${enhancementExit}\x1b[?1049l`;
const exitSequence = `${mouseExit}${enhancementExit}\x1b[?1049l`;
// Session replacement can finish while a fullscreen selector is still
// covering the old normal buffer. Keep the overlay visible until the
// replacement is ready, then fuse the buffer restore into that full paint;
@@ -2805,6 +2817,7 @@ export class TUI extends Container {
setAltScreenActive(false);
this.#forgetHardwareCursorState();
this.#altActive = false;
this.#altMouseTrackingActive = false;
this.#altPreviousLines = [];
// A resize while on the alt buffer reflowed the terminal's saved
// normal screen; it no longer matches our accounting, so force the
@@ -2812,6 +2825,9 @@ export class TUI extends Container {
if (width !== this.#altEnterWidth || height !== this.#altEnterHeight) {
this.#resizeEventPending = true;
}
} else if (wantMouseTracking !== this.#altMouseTrackingActive) {
this.terminal.write(wantMouseTracking ? MOUSE_TRACKING_ON : MOUSE_TRACKING_OFF);
this.#altMouseTrackingActive = wantMouseTracking;
}
if (this.#altActive) {
this.#componentRenderTargets.clear();
@@ -3776,16 +3792,6 @@ export class TUI extends Container {
this.terminal.write(buffer);
}
/** Topmost visible overlay requests the alternate-screen buffer. */
#wantsAltScreen(): boolean {
for (let i = this.overlayStack.length - 1; i >= 0; i--) {
const entry = this.overlayStack[i]!;
if (!this.#isOverlayVisible(entry)) continue;
return entry.options?.fullscreen === true;
}
return false;
}
/**
* Compose and paint a single fullscreen overlay frame on the alt buffer.
* Cursor markers are stripped (the modal draws its own in-band caret and
@@ -3310,6 +3310,48 @@ describe("TUI terminal-state regressions", () => {
}
});
it("leaves native text selection available for selection-first fullscreen overlays", async () => {
const term = new VirtualTerminal(40, 8, 200);
const writes = captureWrites(term);
const tui = new TUI(term);
tui.addChild(new MutableLinesComponent(rows("base-", 8)));
try {
tui.start();
await settle(term);
const showFrom = writes.length;
const handle = tui.showOverlay(new MutableLinesComponent(["SELECTABLE PLAN TEXT"]), {
anchor: "bottom-center",
width: "100%",
maxHeight: "100%",
margin: 0,
fullscreen: true,
mouseTracking: false,
});
await settle(term);
const modalWrites = writes.slice(showFrom).join("");
expect(modalWrites).toContain("\x1b[?1049h");
expect(modalWrites).not.toContain("\x1b[?1000h");
expect(modalWrites).not.toContain("\x1b[?1003h");
expect(modalWrites).not.toContain("\x1b[?1006h");
expect(visible(term).some(line => line.includes("SELECTABLE PLAN TEXT"))).toBeTrue();
const hideFrom = writes.length;
handle.hide();
await settle(term);
const hideWrites = writes.slice(hideFrom).join("");
expect(hideWrites).toContain("\x1b[?1049l");
expect(hideWrites).not.toContain("\x1b[?1000l");
expect(hideWrites).not.toContain("\x1b[?1003l");
expect(hideWrites).not.toContain("\x1b[?1006l");
} finally {
tui.stop();
}
});
it("falls back to kittyEnableSequence for legacy custom terminals", async () => {
const term = new LegacyKeyboardVirtualTerminal(40, 8, 200);
const writes = captureWrites(term);