fix: improve mobile terminal alternate-screen detection and scrolling

- Revert touch-action: none on .xterm-viewport so xterm.js can fall back
  to its own viewport scrolling when the custom handler doesn't take over.
- Detect alternate screen via reference equality
  (term.buffer.active === term.buffer.alternate) instead of the
  string, which could report normal buffer incorrectly.
- Lower vertical-scroll activation threshold from 4px to 2px and only
  prevent default once a vertical gesture is recognized.
- In normal buffer use term.scrollLines() so xterm.js handles the buffer
  scroll consistently; in alternate screen continue sending SGR 1006
  mouse-wheel sequences to tmux/vim.

Quality gates: typecheck, lint clean, npm test -- --run 87 passed.
This commit is contained in:
Developer
2026-06-13 20:42:52 +00:00
parent c7ab2de25e
commit 2ece7074f6
18 changed files with 59 additions and 61 deletions
@@ -355,10 +355,9 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
// In normal mode xterm.js has a scrollable viewport; in alternate
// screen (tmux/vim) there is no scrollback and the only way to
// scroll is to send mouse-wheel protocol sequences to the
// application. We detect which situation we're in by checking
// the active buffer type. We also lock the browser into the
// terminal area: touchstart prevents the browser from starting a
// page-scroll gesture, so swipes always go to the terminal.
// application. We detect the active buffer by reference equality
// (term.buffer.active === term.buffer.alternate) because the
// `type` string can be unreliable in some xterm.js versions.
let touchCleanup: (() => void) | undefined;
if (isMobile) {
let startY = 0;
@@ -388,8 +387,10 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
scrollPending = 0;
};
const isNormalBuffer = () => {
return termRef.current?.buffer.active.type === "normal";
const isAlternateScreen = () => {
const term = termRef.current;
if (!term) return false;
return term.buffer.active === term.buffer.alternate;
};
const onTouchStart = (e: TouchEvent) => {
@@ -405,35 +406,36 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
const touch = e.touches[0];
const deltaY = startY - touch.clientY;
const deltaX = Math.abs(startX - touch.clientX);
// Decide early whether this is a vertical scroll gesture.
if (!isScrolling) {
if (Math.abs(deltaY) > deltaX && Math.abs(deltaY) > 4) {
if (Math.abs(deltaY) > deltaX && Math.abs(deltaY) > 2) {
isScrolling = true;
}
}
if (isScrolling) {
// Always stop the browser from treating this as a page scroll.
e.preventDefault();
if (!isScrolling) return;
const normalBuffer = isNormalBuffer();
const viewport = container.querySelector(
".xterm-viewport",
) as HTMLElement | null;
// Take over the gesture so the page/toolbar never scrolls.
e.preventDefault();
if (normalBuffer && viewport) {
// Normal buffer: scroll the xterm viewport directly.
viewport.scrollTop += deltaY;
} else {
// Alternate screen (tmux/vim): accumulate the swipe and
// send SGR 1006 mouse-wheel events in steps.
scrollPending += deltaY;
flushScroll();
if (isAlternateScreen()) {
// Alternate screen (tmux/vim): accumulate the swipe and
// send SGR 1006 mouse-wheel events in steps.
scrollPending += deltaY;
flushScroll();
} else if (termRef.current) {
// Normal buffer: let xterm.js scroll its own viewport by
// the number of lines corresponding to the swipe distance.
const lineHeight =
termRef.current.options.fontSize != null
? (termRef.current.options.fontSize as number) * 1.2
: 10;
const lines = Math.round(deltaY / lineHeight);
if (lines !== 0) {
termRef.current.scrollLines(lines);
}
startY = touch.clientY;
} else if (Math.abs(deltaY) > 4 || Math.abs(deltaX) > 4) {
// Once the user has moved far enough to be considered a
// gesture, prevent any default page scroll/pinch behavior.
e.preventDefault();
}
startY = touch.clientY;
};
const onTouchEnd = () => {
if (isScrolling) {