From fc3554e16defffcf76553b1bd12f676adf26d0a4 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Thu, 6 Aug 2026 18:20:48 +0200 Subject: [PATCH] fix(tui): reduce mouse tracking in terminal multiplexers --- packages/tui/CHANGELOG.md | 1 + packages/tui/src/tui-alt-screen.ts | 16 +++++++- packages/tui/test/tui-alt-screen.test.ts | 49 +++++++++++++++++++++++- 3 files changed, 62 insertions(+), 4 deletions(-) diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 22903fe27..1a4ca82de 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -5,6 +5,7 @@ ### Fixed - Fixed LaTeX relation, multiplication, and named-operator spacing, and correctly composed matrices with stacked fractions, operator limits, and adjacent matrices. +- Reduced fullscreen mouse event volume under tmux, Zellij, and GNU Screen by using button-motion tracking instead of all-motion tracking. ## [0.84.0] - 2026-08-06 diff --git a/packages/tui/src/tui-alt-screen.ts b/packages/tui/src/tui-alt-screen.ts index aa062e4f5..f0d80fb79 100644 --- a/packages/tui/src/tui-alt-screen.ts +++ b/packages/tui/src/tui-alt-screen.ts @@ -44,7 +44,8 @@ const ENTER_ALT_SCREEN = "\x1b[?1049h"; const EXIT_ALT_SCREEN = "\x1b[?1049l"; const DISABLE_AUTOWRAP = "\x1b[?7l"; const ENABLE_AUTOWRAP = "\x1b[?7h"; -const ENABLE_MOUSE = "\x1b[?1000h\x1b[?1002h\x1b[?1003h\x1b[?1004h\x1b[?1006h"; +const ENABLE_BUTTON_MOTION_MOUSE = "\x1b[?1000h\x1b[?1002h\x1b[?1004h\x1b[?1006h"; +const ENABLE_ALL_MOTION_MOUSE = "\x1b[?1000h\x1b[?1002h\x1b[?1003h\x1b[?1004h\x1b[?1006h"; const DISABLE_MOUSE = "\x1b[?1006l\x1b[?1004l\x1b[?1003l\x1b[?1002l\x1b[?1000l"; const FOCUS_IN = "\x1b[I"; const FOCUS_OUT = "\x1b[O"; @@ -201,8 +202,19 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI { this.pressedUrl = undefined; this.selectionDragged = false; this.resetRenderState(); + const term = process.env.TERM?.toLowerCase() ?? ""; + // Multiplexers can lag when every pointer movement is forwarded. Button-motion + // tracking preserves clicks, wheel events, selections, and scrollbar dragging. + const mouseSequence = + process.env.TMUX !== undefined || + process.env.ZELLIJ !== undefined || + process.env.STY !== undefined || + term.startsWith("tmux") || + term.startsWith("screen") + ? ENABLE_BUTTON_MOTION_MOUSE + : ENABLE_ALL_MOTION_MOUSE; this.terminal.write( - `${ENTER_ALT_SCREEN}${DISABLE_AUTOWRAP}${this.mouseEnabled ? ENABLE_MOUSE : ""}\x1b[2J\x1b[H\x1b[?25l`, + `${ENTER_ALT_SCREEN}${DISABLE_AUTOWRAP}${this.mouseEnabled ? mouseSequence : ""}\x1b[2J\x1b[H\x1b[?25l`, ); } diff --git a/packages/tui/test/tui-alt-screen.test.ts b/packages/tui/test/tui-alt-screen.test.ts index 5b2dabf48..15e07a69f 100644 --- a/packages/tui/test/tui-alt-screen.test.ts +++ b/packages/tui/test/tui-alt-screen.test.ts @@ -161,6 +161,53 @@ describe("TuiAltScreen", () => { tui.stop(); }); + it("uses button-motion tracking inside terminal multiplexers", () => { + const environmentKeys = ["TMUX", "ZELLIJ", "STY", "TERM"] as const; + const previousEnvironment = new Map(environmentKeys.map((key) => [key, process.env[key]])); + try { + for (const key of environmentKeys) delete process.env[key]; + process.env.TERM = "xterm-256color"; + const directTerminal = new RecordingTerminal(); + const directTui = new TuiAltScreen(directTerminal); + directTui.start(); + const directWrites = directTerminal.events + .filter((event): event is { type: "write"; data: string } => event.type === "write") + .map((event) => event.data) + .join(""); + assert.ok(directWrites.includes("\x1b[?1003h")); + directTui.stop(); + + const multiplexers = [ + { name: "tmux environment", environment: { TMUX: "/tmp/tmux/default,1,0" } }, + { name: "tmux TERM", environment: { TERM: "tmux-256color" } }, + { name: "Zellij environment", environment: { ZELLIJ: "0" } }, + { name: "Screen environment", environment: { STY: "123.session" } }, + { name: "Screen TERM", environment: { TERM: "screen-256color" } }, + ]; + for (const { name, environment } of multiplexers) { + for (const key of environmentKeys) delete process.env[key]; + for (const [key, value] of Object.entries(environment)) process.env[key] = value; + const terminal = new RecordingTerminal(); + const tui = new TuiAltScreen(terminal); + tui.start(); + const writes = terminal.events + .filter((event): event is { type: "write"; data: string } => event.type === "write") + .map((event) => event.data) + .join(""); + assert.ok(writes.includes("\x1b[?1002h"), `${name} should enable button-motion tracking`); + assert.ok(!writes.includes("\x1b[?1003h"), `${name} should not enable all-motion tracking`); + assert.ok(writes.includes("\x1b[?1006h"), `${name} should enable SGR mouse encoding`); + tui.stop(); + } + } finally { + for (const key of environmentKeys) { + const value = previousEnvironment.get(key); + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + } + }); + it("drags a visible scrollbar thumb and keeps it visible until release", async () => { const terminal = new RecordingTerminal(10, 5); const tui = new TuiAltScreen(terminal); @@ -214,9 +261,7 @@ describe("TuiAltScreen", () => { assert.strictEqual(scrollView.isScrollbarVisible, false); assert.ok(terminal.events.every((event) => event.type !== "write" || !event.data.includes("\x1b]52;c;"))); - assert.ok(terminal.events.some((event) => event.type === "write" && event.data.includes("\x1b[?1003h"))); tui.stop(); - assert.ok(terminal.events.some((event) => event.type === "write" && event.data.includes("\x1b[?1003l"))); }); it("keeps the scrollbar column selectable while the thumb is hidden", async () => {