触摸板滚动灵敏度排查:从 tmux 到 opencode 的三层事件链
八月 15, 2026 [AI] #opencode #tmux现象
外接 2.4G 无线键鼠套装(HS-LP88 - PTP-01-23-1F,VID 1c4f:0086)自带的触摸板(102×51mm 精度触控板),在终端里两指轻轻一动就滚掉半页;浏览器里"还凑合";普通终端(Ptyxis)里则感觉正常。
排查过程
第一层怀疑:tmux 滚轮步长
环境是 GNOME Wayland + Ptyxis + tmux(set -g mouse on)。tmux 默认每格滚轮 = 5 行(send-keys -X -N 5 scroll-up),44 行的 pane 里一个轻扫就 20+ 行,确实像"半页"。
修改 ~/.tmux.conf 把步长降到 2 行、1 行:
bind-key -T copy-mode-vi WheelUpPane select-pane \; send-keys -X -N 1 scroll-up
bind-key -T copy-mode-vi WheelDownPane select-pane \; send-keys -X -N 1 scroll-down
但用户反馈"没什么变化"——说明问题不只在 tmux。
第二层:实测事件流,排除 libinput / mutter
用工具逐层测量(libinput-tools、evtest、wev):
evtest看内核原始坐标:轴分辨率 14 units/mm,坐标流平滑,无异常libinput debug-events看 libinput 输出:滑动时每帧产生 0.38 格滚轮(设备事件率本就偏高,无线设备报告率低、位置跳变被放大)wev看应用实际收到的 Wayland axis 事件:慢速 2cm 手势 ≈ 60px,快速轻扫 ≈ 260px——mutter 已经做了正常化,应用层像素量正常
结论:libinput 和 mutter 都不是主要问题(全局 quirk 调 ScrollDistance 会影响所有应用,先排除)。
第三层:VTE 的 SGR 事件放大
tmux 里用启用鼠标模式的程序统计实际收到的滚轮事件数:一次快速轻扫 ≈ 31 个 SGR 滚轮事件(260px ÷ 31 ≈ 8px/事件 ≈ 半个行高)。
原来 VTE 把平滑滚动每累计 0.5 行就发一个 SGR 滚轮事件给全屏应用(正常鼠标滚轮是 1 格/3 行,差了 6 倍)。所以:
触控板 → libinput(hi-res 事件) → mutter(Wayland px, 已正常化)
→ VTE(每 0.5 行发一个 SGR 事件, 放大 ~6 倍)
→ 全屏 TUI(每事件滚 N 行)
第四层:真正的元凶——opencode TUI
用户实际感觉"太灵敏"的终端是 opencode TUI。查源码 packages/tui/src/util/scroll.ts:
export function getScrollAcceleration(tuiConfig?: ScrollConfig): ScrollAcceleration {
if (tuiConfig?.scroll_acceleration?.enabled) {
return new MacOSScrollAccel()
}
if (tuiConfig?.scroll_speed !== undefined) {
return new CustomSpeedScroll(tuiConfig.scroll_speed)
}
return new CustomSpeedScroll(3) // ← 默认每次滚轮事件滚 3 行
}
默认每次滚轮事件 = 3 行,且不区分慢速/快速。一个快速轻扫 = 31 个事件 × 3 行 = 93 行 ≈ 2 页多,所以"随便一动就半页"。
浏览器"凑合"是因为浏览器使用 Wayland 平滑滚动 + 惯性动画(kinetic),对高频小 delta 有缓冲;而 TUI 是逐事件跳行。
排查弯路与错误记录
整个排查过程中犯过的错、被推翻的假设,都值得记下来防止重蹈覆辙:
| # | 错误假设 / 失误 | 为什么错 | 如何纠正 |
|---|---|---|---|
| 1 | 一开始就锁定 tmux,投入大量时间调 -N 5→2→1 | 用户实际滚动的是 opencode TUI,tmux 配置根本不参与那条滚动路径 | 先确认"你说的终端到底是哪个程序"再排查;事后发现 tmux 改动只影响 copy-mode,用户场景里无感 |
| 2 | 怀疑触摸板设备本身有问题(libinput debug-events 显示每帧 0.38 格、VTE 每 0.5 行发一个 SGR 事件,一度以为要写 libinput quirk) | 原始坐标流(evtest)是平滑的;wev 证明 mutter 已把应用层像素量正常化(慢速 60px / 快速 260px),设备不是主因 | 逐层测量事件流(evtest → libinput → wev),把"全局 quirk"方案压到最后一档,避免影响所有应用 |
| 3 | 测量 tmux 滚动行数时用 capture-pane 对比 | capture-pane 抓不到 copy-mode 的滚动视图,连续两次测出 0 行,误以为用户没滚 | 改用 tmux 内部 copy_cursor_line 做差分测量,才拿到真实滚动量 |
| 4 | 让用户做受控手势时没有先输出指令、等确认就开始倒计时 | 用户看到消息时测量窗口已过,测试数据无效 | 改为"我输出指令 → 用户确认 → 开始测量"的节奏 |
| 5 | 没意识到 Wayland 滚轮事件发给指针所在窗口而非活动窗口 | 用户把指针放在别的窗口上做手势,测试窗口 0 滚动 | 明确要求"把鼠标指针移到测试窗口中间"再测 |
| 6 | 一度以为 tmux 配置改动"没作用"是配置没生效 | 配置曾被还原回 -N 5;且就算生效,作用域也只是 tmux copy-mode | 每次改动后 `tmux list-keys -T copy-mode-vi |
排查用到的命令清单
按事件链各层整理,附用途和关键输出,方便下次直接复用。
设备识别
# 找出所有被识别为触摸板的设备节点(NAME 为设备名)
for f in /dev/input/event*; do
name=$(udevadm info -q property -n "$f" 2>/dev/null | grep '^NAME=' | sed 's/NAME=//')
tp=$(udevadm info -q property -n "$f" 2>/dev/null | grep -c 'ID_INPUT_TOUCHPAD=1')
[ "$tp" = "1" ] && echo "$f $name"
done
# 查看设备的完整属性(vendor/model、尺寸、usb 路径)
udevadm info -q property -n /dev/input/event8 | sort
# 查看设备在 /proc/bus/input/devices 中的注册信息(轴位图、handlers)
awk '/Name="HS-LP88 -PTP-01-23-1F USB KeyBoard Touchpad"/{found=1} found{print} found&&/^$/{exit}' /proc/bus/input/devices
系统设置与 quirk
# GNOME 触摸板设置(speed 0.0 默认、natural-scroll、two-finger-scrolling)
gsettings get org.gnome.desktop.peripherals.touchpad speed
gsettings get org.gnome.desktop.peripherals.touchpad natural-scroll
# 查看设备当前生效的 libinput quirks(无输出 = 无 quirk)
libinput quirks list /dev/input/event8
逐层测量事件流
# ① 内核原始事件 + 轴分辨率(evtest 输出含 Min/Max/Resolution)
sudo evtest /dev/input/event8
# → ABS_X Max 1433 Resolution 14 (14 units/mm,102×51mm)
# ② libinput 处理后的滚动事件(POINTER_SCROLL_FINGER,1.0 = 一格滚轮)
sudo libinput debug-events --device /dev/input/event8
# ③ Wayland 应用层实际收到的 axis 事件(数值即像素,mutter 正常化后的真实输入)
timeout 15 wev
# 统计 wev 中每个手势的像素总量:
python3 - <<'EOF'
import re
times, vals = [], []
for line in open('/tmp/wev.txt'):
m = re.search(r'axis: time: (\d+); axis: 0 \(vertical\), value: ([\d.]+)', line)
if m: times.append(int(m.group(1))); vals.append(float(m.group(2)))
g, acc, prev = 0, 0, None
for t, v in zip(times, vals):
if prev and t - prev > 300: print(f"gesture {g}: {acc:.1f}px"); g += 1; acc = 0
acc += v; prev = t
print(f"gesture {g}: {acc:.1f}px")
EOF
tmux 层
# 查看当前滚轮绑定(确认 -N 步长是否生效,改完配置必查)
tmux list-keys -T copy-mode-vi | grep Wheel
# 统计一个手势产生多少个 SGR 滚轮事件(在 tmux 窗格里运行,鼠标模式由程序自己开启)
tmux new-window -d -n wc "python3 -c '
import sys, time
sys.stdout.write(chr(27)+chr(91)+chr(63)+chr(49)+chr(48)+chr(48)+chr(48)+chr(104))
sys.stdout.flush()
n=0; start=time.time()
while time.time()-start<20:
d=sys.stdin.buffer.read(1)
if not d: continue
if d==b\"\\x1b\":
rest=sys.stdin.buffer.read(3)
if rest.startswith(b\"[M\"): n+=1
print(); print(\"SGR events:\", n)
'"
# 测真实滚动行数:copy_cursor_line 差分(capture-pane 抓不到 copy-mode 视图,别用那个)
tmux display-message -p -t <pane> 'copy_cursor_line=#{copy_cursor_line}' # 手势前
tmux display-message -p -t <pane> 'copy_cursor_line=#{copy_cursor_line}' # 手势后,差值即行数
opencode 配置
# 配置文件位置(启动时加载,改后需重启 opencode)
cat ~/.config/opencode/tui.json
# {"$schema": "https://opencode.ai/tui.json", "scroll_speed": 1}
# 源码中的默认值定位(packages/tui/src/util/scroll.ts)
# getScrollAcceleration(): scroll_speed 未配置时默认 CustomSpeedScroll(3)
修复
opencode 的 TUI 配置在 ~/.config/opencode/tui.json(schema: https://opencode.ai/tui.json),写入:
{
"$schema": "https://opencode.ai/tui.json",
"scroll_speed": 1
}
配置启动时加载,重启 opencode 生效。想要浏览器式惯性平滑手感可加:
{
"scroll_speed": 1,
"scroll_acceleration": { "enabled": true }
}
scroll_speed 范围 ≥0.001,越大越快。
总结
- 同一个滚动灵敏度问题,事件链上每一层都有各自的"每事件行数"乘数:libinput(每 mm 格数)→ mutter(px)→ VTE(SGR 事件率)→ TUI(每事件行数)。感知上的速度 = 各层乘数之积,改任何一层都有效,但要注意影响面:
- libinput
ScrollDistancequirk:全局,所有应用一起变 - tmux
-N:只影响 tmux copy-mode 滚动 - opencode
scroll_speed:只影响 opencode TUI(本项目最终采用)
- libinput
- 排查工具清单:
evtest(内核原始事件)、libinput debug-events(libinput 输出)、wev(Wayland 应用层事件)、tmuxcopy_cursor_line差分(测实际滚动行数)。 - 教训:先确认"你说的终端到底是哪个程序",再开始排查——本问题在 tmux 层投入了大量时间,实际元凶是 opencode。