Compare commits

...
Author SHA1 Message Date
Vasanthdev2004 8adc625d16 test(tui): pin the inherited-environment limit of the mouse-mode guess
WT_SESSION and TERM_PROGRAM are inherited, so they answer for whichever
process set them rather than for the console host attached now. A shell
started from Windows Terminal or Git Bash that later runs zero.exe
against the legacy console still carries them, and mouseModeFor then
asks for AllMotion on a host that may drop every mouse event: the #870
failure class through a narrower door.

Not fixed, and deliberately so. Telling an inherited variable from a live
one needs the console host rather than the environment, and erring the
other way costs every Windows Terminal user their hover highlighting for
a case that takes an unusual launch path to reach.

So the limit is written down in the code and pinned by a test that also
asserts ZERO_MOUSE_MODE=cell still overrides it, since that override is
the only recourse an affected user has. If real host detection lands
later the test fails by design, and its message says to delete it.

Raised by @jatmn on #872.
2026-08-07 21:06:00 +05:30
Vasanthdev2004 e4bc1f65da fix(tui): fall back to CellMotion where AllMotion is unreliable
AllMotion (1003) buys exactly one thing over CellMotion (1002): hover
highlighting. Wheel, click and drag all arrive under either. But a
terminal that does not implement 1003 does not degrade to 1002, it sends
nothing at all, and because the TUI holds the alternate screen the
terminal's own scrollback and wheel are gone too. The user is left
unable to scroll by any means, and a long answer then reads as a hang
rather than as a missing highlight. That is how #870 arrived: a
completed turn with "59 more" lines below the fold, reported as a
freeze.

The existing code already conceded 1003 is fragile by carving out PRoot.
The legacy Windows console is the same case and was not covered, so a
bare zero.exe outside Windows Terminal asked for a mode its terminal
does not answer.

Move the decision into mouseModeFor, which trusts terminals that
identify themselves (WT_SESSION for Windows Terminal, TERM_PROGRAM for
hosts like VS Code and mintty) and assumes an unidentified Windows
terminal is the legacy console. Guessing wrong that way costs a hover
highlight; guessing wrong the other way costs every mouse event.

ZERO_MOUSE_MODE=all|cell|off overrides it, so a user on a terminal we
guessed wrong about can fix it without waiting for a release. An
unrecognised value is ignored rather than treated as "off", so a typo
cannot silently kill the mouse.

Refs #870.
2026-08-06 21:28:13 +05:30
3 changed files with 224 additions and 17 deletions

No files matched your search

+9 -17
View File
@@ -5,6 +5,7 @@ import (
"encoding/json"
"fmt"
"os"
"runtime"
"strings"
"time"
"unicode"
@@ -2788,23 +2789,14 @@ func (m model) View() tea.View {
// only when the value changes, so gating this costs nothing (§10).
view.KeyboardEnhancements.ReportEventTypes = m.dictation.voiceModeEnabled
if m.wantsMouseCapture() {
if isRunningUnderPRoot() {
// Under PRoot the AllMotion (1003) sequence doesn't work
// reliably, breaking touch-gesture scrolling. Fall back to
// CellMotion which still delivers wheel events, clicks, and
// drag — the only thing lost is hover-highlighting.
view.MouseMode = tea.MouseModeCellMotion
} else {
// AllMotion (not CellMotion) is required for hover highlighting:
// it reports cursor movement even with no button pressed.
// CellMotion only reports motion while a button is held (drag) —
// see bubbletea's MouseMode docs. AllMotion has marginally worse
// terminal compatibility but is well supported by the terminals
// this app targets; the existing 15ms mouse-event throttle
// (mouseEventThrottleInterval) already bounds the redraw rate
// from the extra motion events.
view.MouseMode = tea.MouseModeAllMotion
}
// AllMotion (1003) is what hover highlighting needs: it reports cursor
// movement with no button pressed, where CellMotion (1002) reports motion
// only while a button is held. mouseModeFor decides which is safe here,
// and documents why one is not simply better than the other: a terminal
// that does not implement 1003 sends nothing at all rather than degrading.
// The 15ms throttle (mouseEventThrottleInterval) already bounds the redraw
// rate from AllMotion's extra motion events.
view.MouseMode = mouseModeFor(runtime.GOOS, os.Getenv, isRunningUnderPRoot())
}
return view
}
+64
View File
@@ -8,6 +8,70 @@ import (
tea "charm.land/bubbletea/v2"
)
// mouseModeEnv forces a mouse mode: "all", "cell", or "off"/"none". It exists
// because the failure it guards against is invisible from here and total for the
// user: a terminal we guessed wrong about delivers no mouse events at all, and
// there is no in-app way to recover. Anything unrecognised is ignored rather
// than treated as "off", so a typo cannot silently kill the mouse.
const mouseModeEnv = "ZERO_MOUSE_MODE"
// mouseModeFor picks the mouse reporting mode, preferring the widest support
// over the nicest behaviour.
//
// The trade is asymmetric. AllMotion (1003) buys exactly one thing, hover
// highlighting, because wheel, click and drag all arrive under CellMotion
// (1002). But when a terminal does not implement 1003, it does not degrade to
// 1002: no mouse event arrives at all. The app holds the alternate screen, so
// the terminal's own scrollback and wheel are gone too, and the user is left
// with no way to scroll by mouse. A long answer then looks like a hang rather
// than like a missing highlight, which is how #870 was reported.
//
// So AllMotion is asked for only where it is known to work.
func mouseModeFor(goos string, env func(string) string, underPRoot bool) tea.MouseMode {
switch strings.ToLower(strings.TrimSpace(env(mouseModeEnv))) {
case "all":
return tea.MouseModeAllMotion
case "cell":
return tea.MouseModeCellMotion
case "off", "none":
return tea.MouseModeNone
}
if underPRoot {
return tea.MouseModeCellMotion
}
if goos == "windows" && !windowsTerminalReportsAllMotion(env) {
return tea.MouseModeCellMotion
}
return tea.MouseModeAllMotion
}
// windowsTerminalReportsAllMotion reports whether the Windows terminal in use is
// one that handles 1003.
//
// Identified terminals are trusted: Windows Terminal sets WT_SESSION, and hosts
// like VS Code and mintty set TERM_PROGRAM. The legacy console host sets
// neither, and it is what a user gets by double-clicking zero.exe or running it
// from an old cmd window, so an unidentified Windows terminal is assumed to be
// that one. Guessing wrong in this direction costs a hover highlight; guessing
// wrong in the other direction costs every mouse event.
// KNOWN LIMIT: these variables are INHERITED, so they answer for the process
// that set them rather than for the console host attached right now. A shell
// launched from Windows Terminal, or from Git Bash, that later runs zero.exe
// against the legacy console still carries them, and this returns true for a
// host that may drop every mouse event.
//
// Left as is on purpose. Deciding it properly means asking the console host
// rather than the environment, and erring the other way costs every Windows
// Terminal user their hover highlighting for a case that needs an unusual
// launch path to reach. ZERO_MOUSE_MODE=cell is the recourse in the meantime,
// which is the main reason that override exists.
// TestInheritedWindowsTerminalEnvStillAsksForAllMotion pins this, so it is a
// documented limit rather than a surprise.
func windowsTerminalReportsAllMotion(env func(string) string) bool {
return strings.TrimSpace(env("WT_SESSION")) != "" ||
strings.TrimSpace(env("TERM_PROGRAM")) != ""
}
// parseTracerPid returns true when /proc/self/status contains a non-zero
// TracerPid. This detects any ptrace tracer (PRoot, gdb, strace, dlv, etc.),
// not PRoot specifically. That's intentional: under any ptrace tracer the
+151
View File
@@ -0,0 +1,151 @@
package tui
import (
"testing"
tea "charm.land/bubbletea/v2"
)
func envFrom(pairs map[string]string) func(string) string {
return func(key string) string { return pairs[key] }
}
// AllMotion (1003) buys hover highlighting and nothing else: wheel, click and
// drag all arrive under CellMotion (1002). When a terminal does not support
// 1003 the cost is not a missing highlight, it is that NO mouse event arrives
// at all, and because the app holds the alternate screen the terminal's own
// scrollback is gone too. The user cannot scroll by any means, and a screen of
// cut-off output reads as a hang (#870).
//
// So the rule is: ask for AllMotion only where it is known to work.
func TestMouseModeFallsBackWhereAllMotionIsUnreliable(t *testing.T) {
tests := []struct {
name string
goos string
env map[string]string
underPRoot bool
want tea.MouseMode
}{
{
name: "linux terminal",
goos: "linux",
want: tea.MouseModeAllMotion,
},
{
name: "macOS terminal",
goos: "darwin",
want: tea.MouseModeAllMotion,
},
{
// Pre-existing carve-out: under PRoot the 1003 sequence is unreliable
// and breaks touch-gesture scrolling.
name: "under PRoot",
goos: "linux",
underPRoot: true,
want: tea.MouseModeCellMotion,
},
{
// #870. The legacy Windows console does not handle 1003 reliably, and
// it is what a user gets by running zero.exe from anywhere that is not
// Windows Terminal.
name: "legacy Windows console",
goos: "windows",
want: tea.MouseModeCellMotion,
},
{
name: "Windows Terminal",
goos: "windows",
env: map[string]string{"WT_SESSION": "d1c2-session"},
want: tea.MouseModeAllMotion,
},
{
name: "VS Code terminal on Windows",
goos: "windows",
env: map[string]string{"TERM_PROGRAM": "vscode"},
want: tea.MouseModeAllMotion,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
got := mouseModeFor(test.goos, envFrom(test.env), test.underPRoot)
if got != test.want {
t.Fatalf("mouseModeFor(%q) = %v, want %v", test.goos, got, test.want)
}
})
}
}
// A KNOWN LIMIT, pinned deliberately rather than left to be rediscovered.
//
// Windows children inherit the parent environment, so WT_SESSION or
// TERM_PROGRAM can outlive the terminal that set them: a shell started from
// Windows Terminal, or from Git Bash, that later runs zero.exe against a
// different console host still carries them, and mouseModeFor then asks for
// AllMotion on a host that may drop every mouse event. That is the #870 failure
// class arriving by a narrower door.
//
// It is not fixed here, and this test says so out loud. Telling an inherited
// variable from a live one needs the actual console host rather than the
// environment, and guessing wrong in the other direction costs every Windows
// Terminal user their hover highlighting. ZERO_MOUSE_MODE=cell is the recourse
// until that host check exists, which is why the override is not a nicety.
//
// If someone later adds real host detection, this test SHOULD fail. That is the
// point of it.
func TestInheritedWindowsTerminalEnvStillAsksForAllMotion(t *testing.T) {
for _, env := range []map[string]string{
{"WT_SESSION": "inherited-from-an-ancestor"},
{"TERM_PROGRAM": "vscode"},
} {
if got := mouseModeFor("windows", envFrom(env), false); got != tea.MouseModeAllMotion {
t.Fatalf("mouseModeFor(windows, %v) = %v; if this now returns CellMotion the "+
"inherited-environment limit has been fixed, so delete this test and its note", env, got)
}
// The escape hatch has to keep working for exactly this case, since it is
// the only recourse a user has when the guess is wrong.
withOverride := map[string]string{mouseModeEnv: "cell"}
for key, value := range env {
withOverride[key] = value
}
if got := mouseModeFor("windows", envFrom(withOverride), false); got != tea.MouseModeCellMotion {
t.Fatalf("ZERO_MOUSE_MODE=cell did not override inherited %v, so an affected user has no recourse", env)
}
}
}
// The override exists so a user who hits an unsupported terminal can fix it
// themselves without waiting for a release, and so a maintainer can ask a bug
// reporter to test one specific mode.
func TestMouseModeOverrideWins(t *testing.T) {
tests := []struct {
value string
want tea.MouseMode
}{
{"cell", tea.MouseModeCellMotion},
{"CELL", tea.MouseModeCellMotion},
{" cell ", tea.MouseModeCellMotion},
{"all", tea.MouseModeAllMotion},
{"off", tea.MouseModeNone},
{"none", tea.MouseModeNone},
}
for _, test := range tests {
t.Run(test.value, func(t *testing.T) {
// windows + PRoot would otherwise force CellMotion, so "all" proves the
// override beats the fallbacks rather than agreeing with them.
got := mouseModeFor("windows", envFrom(map[string]string{mouseModeEnv: test.value}), true)
if got != test.want {
t.Fatalf("%s=%q gave %v, want %v", mouseModeEnv, test.value, got, test.want)
}
})
}
}
// An unreadable override must not silently disable the mouse: fall through to
// the normal decision rather than guessing.
func TestMouseModeIgnoresAnUnknownOverride(t *testing.T) {
got := mouseModeFor("linux", envFrom(map[string]string{mouseModeEnv: "sideways"}), false)
if got != tea.MouseModeAllMotion {
t.Fatalf("an unknown override changed the mode to %v, want the normal AllMotion", got)
}
}