diff --git a/.changeset/quiet-opentui-scroll-reset.md b/.changeset/quiet-opentui-scroll-reset.md new file mode 100644 index 00000000..230220a3 --- /dev/null +++ b/.changeset/quiet-opentui-scroll-reset.md @@ -0,0 +1,5 @@ +--- +"hunkdiff": patch +--- + +Fail clearly when an OpenTUI upgrade removes the shifted-wheel scroll reset Hunk requires. diff --git a/src/ui/components/panes/DiffPane.test.tsx b/src/ui/components/panes/DiffPane.test.tsx new file mode 100644 index 00000000..3ca904d5 --- /dev/null +++ b/src/ui/components/panes/DiffPane.test.tsx @@ -0,0 +1,19 @@ +import { describe, expect, test } from "bun:test"; +import { ScrollBoxRenderable } from "@opentui/core"; +import { resetOpenTuiScrollAccumulators } from "./DiffPane"; + +describe("resetOpenTuiScrollAccumulators", () => { + test("requires the OpenTUI 0.5.1 compatibility operation", () => { + const resetScrollAccumulators = ( + ScrollBoxRenderable.prototype as unknown as { resetScrollAccumulators?: () => void } + ).resetScrollAccumulators; + + expect(resetScrollAccumulators).toBeFunction(); + }); + + test("fails clearly when an OpenTUI upgrade removes the compatibility operation", () => { + expect(() => resetOpenTuiScrollAccumulators({} as unknown as ScrollBoxRenderable)).toThrow( + "OpenTUI 0.5.1 ScrollBoxRenderable.resetScrollAccumulators is required after shifted wheel input.", + ); + }); +}); diff --git a/src/ui/components/panes/DiffPane.tsx b/src/ui/components/panes/DiffPane.tsx index 9e935268..d7c2fe70 100644 --- a/src/ui/components/panes/DiffPane.tsx +++ b/src/ui/components/panes/DiffPane.tsx @@ -113,6 +113,27 @@ import { const EMPTY_VISIBLE_AGENT_NOTES: VisibleAgentNote[] = []; +/** + * Resets OpenTUI's wheel remainder after Hunk reroutes a shifted wheel event. + * + * OpenTUI 0.5.1 keeps this operation private, so retain this compatibility bridge only until + * OpenTUI exposes a public reset API. A missing operation must fail loudly rather than let a + * later vertical wheel event consume the stale remainder and move the review viewport. + */ +export function resetOpenTuiScrollAccumulators(scrollBox: ScrollBoxRenderable) { + const compatibilityScrollBox = scrollBox as unknown as { + resetScrollAccumulators?: () => void; + }; + + if (!compatibilityScrollBox.resetScrollAccumulators) { + throw new Error( + "OpenTUI 0.5.1 ScrollBoxRenderable.resetScrollAccumulators is required after shifted wheel input. Update this compatibility bridge when upgrading OpenTUI.", + ); + } + + compatibilityScrollBox.resetScrollAccumulators(); +} + /** * Clamp one vertical scroll target into the currently reachable review-stream extent. * @@ -479,9 +500,7 @@ export function DiffPane({ currentScrollBox.scrollTo({ x: preservedScrollLeft, y: preservedScrollTop }); currentScrollBox.scrollAcceleration.reset(); - ( - currentScrollBox as unknown as { resetScrollAccumulators?: () => void } - ).resetScrollAccumulators?.(); + resetOpenTuiScrollAccumulators(currentScrollBox); }); event.preventDefault(); diff --git a/test/pty/layout.test.ts b/test/pty/layout.test.ts index 69d36af4..a3ac1c3d 100644 --- a/test/pty/layout.test.ts +++ b/test/pty/layout.test.ts @@ -555,6 +555,40 @@ describe("PTY layout", () => { } }); + test("shifted mouse-wheel input scrolls code horizontally in a real PTY", async () => { + const fixture = harness.createLongWrapFilePair(); + const session = await harness.launchHunk({ + args: ["diff", fixture.before, fixture.after, "--mode", "split"], + cols: 102, + rows: 20, + }); + + try { + const initial = await session.waitForText(/View\s+Navigate\s+Agent\s+Help/, { + timeout: 15_000, + }); + + expect(initial).toContain("this is a very long"); + expect(initial).not.toContain("ge';"); + + let shifted = initial; + for (let index = 0; index < 96; index += 1) { + // SGR button 69 is a wheel-down event with the Shift modifier. + session.writeRaw("\x1b[<69;61;11M"); + await session.waitIdle(); + shifted = await session.text({ immediate: true }); + if (shifted.includes("ge';")) { + break; + } + } + + expect(shifted).toContain("ge';"); + expect(shifted).not.toContain("this is a very long"); + } finally { + session.close(); + } + }); + test("wrap toggles reset horizontal code scrolling in a real PTY", async () => { const fixture = harness.createLongWrapFilePair(); const session = await harness.launchHunk({