diff --git a/CHANGELOG.md b/CHANGELOG.md
index 550d6df..05c46ab 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -11,6 +11,7 @@ All notable user-visible changes to Hunk are documented in this file.
### Fixed
- Smoothed mouse-wheel review scrolling so small diffs stay precise while sustained wheel gestures still speed up.
+- Fixed Shift+mouse-wheel horizontal scrolling so it no longer leaks a one-line vertical scroll in some terminals.
## [0.9.4] - 2026-04-14
diff --git a/src/ui/AppHost.interactions.test.tsx b/src/ui/AppHost.interactions.test.tsx
index a426868..1104b42 100644
--- a/src/ui/AppHost.interactions.test.tsx
+++ b/src/ui/AppHost.interactions.test.tsx
@@ -701,6 +701,40 @@ describe("App interactions", () => {
}
});
+ test("shift plus native horizontal wheel events do not move the vertical review position", async () => {
+ const setup = await testRender(, {
+ width: 92,
+ height: 20,
+ });
+
+ try {
+ await flush(setup);
+
+ let frame = setup.captureCharFrame();
+ const initialTopLine = firstVisibleAddedLineNumber(frame);
+ expect(initialTopLine).toBeTruthy();
+ expect(frame).not.toContain("viewport anchoring");
+
+ for (let index = 0; index < 8; index += 1) {
+ await act(async () => {
+ await setup.mockMouse.scroll(60, 10, "right", { modifiers: { shift: true } });
+ });
+ await flush(setup);
+ frame = setup.captureCharFrame();
+ if (frame.includes("viewport anchoring")) {
+ break;
+ }
+ }
+
+ expect(frame).toContain("viewport anchoring");
+ expect(firstVisibleAddedLineNumber(frame)).toBe(initialTopLine);
+ } finally {
+ await act(async () => {
+ setup.renderer.destroy();
+ });
+ }
+ });
+
test("wrap toggles reset the horizontal code offset", async () => {
const setup = await testRender(, {
width: 92,
diff --git a/src/ui/components/panes/DiffPane.tsx b/src/ui/components/panes/DiffPane.tsx
index 9c1b6b4..d93f7ef 100644
--- a/src/ui/components/panes/DiffPane.tsx
+++ b/src/ui/components/panes/DiffPane.tsx
@@ -204,6 +204,7 @@ export function DiffPane({
const preservedScrollTop = scrollBox.scrollTop;
const preservedScrollLeft = scrollBox.scrollLeft;
+ const scrollInfo = event.scroll;
if (direction === "left") {
onScrollCodeHorizontally(-1);
@@ -217,8 +218,14 @@ export function DiffPane({
return;
}
- // OpenTUI runs ScrollBox's own wheel handler after this listener and it does not honor
- // preventDefault(), so restore the pre-event viewport position on the next microtask.
+ // OpenTUI runs ScrollBox's own wheel handler after this listener and it ignores
+ // preventDefault(). Zero the wheel delta first so native Shift+Wheel left/right events
+ // cannot be remapped back into vertical scroll, then restore the viewport and clear any
+ // residual fractional state on the next microtask as a final guard.
+ if (scrollInfo) {
+ scrollInfo.delta = 0;
+ }
+
queueMicrotask(() => {
const currentScrollBox = scrollRef.current;
if (!currentScrollBox) {
@@ -226,6 +233,10 @@ export function DiffPane({
}
currentScrollBox.scrollTo({ x: preservedScrollLeft, y: preservedScrollTop });
+ currentScrollBox.scrollAcceleration.reset();
+ (
+ currentScrollBox as unknown as { resetScrollAccumulators?: () => void }
+ ).resetScrollAccumulators?.();
});
event.preventDefault();