pi-smooth-scroll0.2.0
Smooth (inertial, sub-line accurate) mouse-wheel / trackpad scrolling for the pi coding agent fullscreen TUI
Untested on omp
This is a pi package. No one has reported whether it works on omp yet. If you try it, add your result below.
Reports 0
No reports yet.
Readme
pi-smooth-scroll
给 pi coding agent 的 fullscreen(全屏备用屏幕)模式加上「平滑滚动」的独立扩展。
滚轮 / 触控板不再一格跳一行或三行,而是每帧稳定推进一行、松手后按排程收尾;小数滚动量会累加保留,不会丢行。默认保留鼠标滚轮的 3 行/格语义,但检测到高频连续事件时会自动切到触控板的 1 行/事件节奏。帧率(每帧多长)可配:默认 60fps,可开到 120fps 让「每帧一行」的速度上限翻倍。
这是一个纯扩展:不修改 pi 源码,不 fork,不改 node_modules,不使用全局 Symbol 补丁,不 monkey-patch pi 内部对象的方法,不读写私有字段。依赖见 输入接管策略(1 个非公开方法名 + 2 个公开接口 + 1 个公开静态字段)。
平滑到底是什么(以及帧率为什么是上限)
终端只能按整行滚动,pi 的渲染器又节流到每 16ms 最多一帧。所以「平滑」的物理定义是:每渲染帧恰好推进一行(60fps → 62.5 行/秒)。比这更慢就必须是「两帧一步」(32ms 空档,看起来像顿一下),比这更快就必须一帧多行(看起来像跳)。
0.1 用的是指数衰减 + trunc 交付:一次 3 行的滚动会拖到 ~330ms,间隔是 86 → 173 → 500ms,大甩动还允许单帧 6 行 —— 这种排程无论怎么调参都不可能是平滑的。
0.2 换成「每帧一行巡航 + 手势跟手 + 时长上限」,并且把帧预算本身做成了配置:
| 场景(默认 frameMs 16) | 交付节奏 |
|---|---|
| 1 格(3 行) | 3 次交付,每 16ms 一次,共 48ms |
| 10 行 | 每帧 1 行,约 170ms |
| 30 行 | 每帧 1 行,约 500ms |
| 100 行 | 每帧 2–5 行,约 350ms(时长上限生效) |
| 400 行 | 每帧最多 16 行,约 350–400ms(单帧硬上限生效) |
| 触控板连续滑动 | 高频流自动按每事件最多 1 行;松手后只保留短 backlog |
真机实测(pi 0.87.0 + Ghostty + pty 驱动,见 开发):
- 默认 60fps:连续 50 次交付的间隔是 16–19ms(每渲染帧一次),每次 1–2 行,没有 ≥2 帧的空档、没有 ≥3 行的跳变。
- 把帧预算开到 8ms(120fps)后,pi 的渲染帧数翻倍(同一个 40 行甩动:19 帧 → 38 帧),同一速度从「每次 2 行」变成「每次 1 行」,交付间隔中位数 16ms → 8ms,滑行时长不变。
特性
- 每帧稳定一行:默认巡航速度 62.5 行/秒 = 16ms 一帧正好一行,这是 60fps 终端能做到的最平滑粒度。
- 帧预算可调(frameMs):
16= 60fps,8= 120fps。120fps 下「每帧一行」的速度上限从 62.5 行/秒翻到 125 行/秒,交付时刻的量化精度也从 16ms 细到 8ms(消除 60fps 下不可避免的偶发两帧空档)。需要时扩展会把 pi 的渲染节流一并调低,退出时恢复。 - 手势跟手(lagMs):连续事件(触控板)按手指产生的速率交付,默认滞后上限 60ms;手指停下后跟手项随时间自然衰减,与滑行曲线平滑交叉,没有换挡感。
- 鼠标 / 触控板自适应:间隔不超过约 24ms 的同方向连续事件自动按每事件最多 1 行处理;间隔较大的鼠标滚轮仍使用配置中的
linesPerNotch。 - 时长上限(maxGlideMs):大批量滑动目标约 350ms,再长的距离就自动加速(而不是滑 3 秒);中等距离优先保持每帧一行,避免为了省一点时间而跳行。
- 跳变上限(maxLinesPerFrame):默认单帧最多 2 行;普通滚动永远不会出现一次跨好几行。
- 可选软着陆(easeLines):默认 0(每一步都隔一帧,绝对均匀);调大即在末尾减速(60fps 下减速步必然是两帧一步,会看到一点点顿,120fps 下则依然均匀)。
- 小数累加:
linesPerNotch可以是 0.5 这类小数,余数保留到下一次,不丢行。 - Alt 加速:与 pi 内建行为一致(5 倍),倍率可配。
- 不干扰其它交互:选择器 / 对话框打开时完全不接管滚轮;follow-end(自动跟随新输出)不被破坏;非 fullscreen 模式完全 no-op。
- 可运行时调参:
/smooth-scroll命令可开关、查看状态、临时改参数、套用预设。
环境要求
本项目在下列环境实际验证通过:
| 项目 | 版本 |
|---|---|
| pi | 0.87.0 |
@earendil-works/pi-tui |
0.87.0 |
| Node.js | v24.14.0(要求 >= 22.19.0) |
| 终端 | Ghostty(macOS) |
安装
# 安装到用户级配置(会写入 ~/.pi/agent/settings.json)
pi install /Users/star/Workspace/code/ai/pi-smooth-scroll
# 只试用一次,不写入任何配置
pi -e /Users/star/Workspace/code/ai/pi-smooth-scroll
也可以直接用文件路径加载入口(适合调试):
pi -e /Users/star/Workspace/code/ai/pi-smooth-scroll/src/index.ts
冒烟检查
扩展加载成功时,pi --help 的 Extension CLI Flags 一节会列出它注册的旗标:
pi -e /Users/star/Workspace/code/ai/pi-smooth-scroll --help | grep -A2 "Extension CLI Flags"
Extension CLI Flags:
--no-smooth-scroll Disable pi-smooth-scroll (use pi's stock fullscreen wheel scrolling)
卸载
pi remove /Users/star/Workspace/code/ai/pi-smooth-scroll
或临时停用(不改配置):
pi --no-smooth-scroll
配置
配置文件:~/.pi/agent/smooth-scroll.json(若设置了 PI_CODING_AGENT_DIR,则读取该目录下的同名文件)。
文件不存在时使用下面的默认值;字段缺失、类型错误或越界时,逐字段回退到默认值,并在启动时用一条 notify 报告问题。
{
"enabled": true,
"inputStrategy": "auto",
"linesPerNotch": 3,
"frameMs": 16,
"piFrameThrottle": true,
"inertia": {
"enabled": true,
"speed": 62.5,
"easeLines": 0,
"lagMs": 60,
"maxGlideMs": 350,
"maxLinesPerFrame": 2
},
"trackpad": {
"gestureWindowMs": 150,
"gain": 1.0
},
"altMultiplier": 5
}
| 字段 | 类型 | 默认值 | 范围 | 说明 |
|---|---|---|---|---|
enabled |
boolean | true |
– | 总开关;false 时行为完全等同原生 pi |
inputStrategy |
string | "auto" |
auto / listener / terminal | 见下节 |
linesPerNotch |
number | 3 |
0.05 ~ 50 | 一格滚轮的行数,允许小数 |
frameMs |
number | 16 |
4 ~ 50 | 帧预算(ms):交付节拍和渲染间隔都按它设计。16=60fps,8=120fps |
piFrameThrottle |
boolean | true |
– | frameMs < 16 时是否把 pi 的渲染节流(TuiBase.MIN_RENDER_INTERVAL_MS)也调低到同一个值;退出/关掉扩展时恢复。false = 完全不碰 pi |
inertia.enabled |
boolean | true |
– | 关闭后滚轮立即生效,没有滑行 |
inertia.speed |
number | 62.5 |
5 ~ 400 | 巡航速度(行/秒)。62.5 = 每帧一行;想让 120fps 发挥满速可以设 125 |
inertia.easeLines |
number | 0 |
0 ~ 50 | 末尾减速距离;0 = 全程等速(最均匀),调大 = 有减速尾 |
inertia.lagMs |
number | 60 |
30 ~ 2000 | 连续滑动时允许落后手指多久;越小越跟手 |
inertia.maxGlideMs |
number | 350 |
80 ~ 5000 | 大批量滑动的时长预算;超长距离会自动加速,中等距离保持平滑巡航 |
inertia.maxLinesPerFrame |
number | 2 |
1 ~ 16 | 单帧最多走几行(跳变上限) |
trackpad.gestureWindowMs |
number | 150 |
0 ~ 1000 | 间隔小于该值的事件视为同一次连续手势(自动跟手) |
trackpad.gain |
number | 1.0 |
0.05 ~ 10 | 每格滚动量的整体倍率 |
altMultiplier |
number | 5 |
1 ~ 50 | Alt + 滚轮的倍率,与 pi 内建 5 倍速一致 |
手感调节
| 想要的效果 | 怎么调 |
|---|---|
| 更高帧率(推荐先试) | /smooth-scroll fps 120(= frameMs 8):帧数翻倍、交付时刻细到 8ms,与 pi 的渲染节流对齐 |
| 更跟手、少惯性 | lag 60 |
| 更跟手且更慢 | speed 32(此时必然是两帧一步,已经是慢速下最均匀的排程) |
| 更长的惯性滑行 | /smooth-scroll preset floaty(ease 8 + glide 1500) |
| 触控板(显式固定每个事件 1 行) | /smooth-scroll preset trackpad |
| 鼠标滚轮(每格 3 行) | /smooth-scroll preset mouse |
| 大甩动别滑太久 | 调小 glide(例如 glide 500) |
| 彻底不要跳变 | cap 1(代价:超长距离会滑得久) |
| 不要碰 pi 的渲染节流 | piFrameThrottle: false(此时 frameMs 8 会被渲染器合并回 60fps 行为) |
现在不需要为触控板手动切 preset:扩展会把同方向、约 24ms 内连续到达的事件识别为高频手势,并临时按每事件最多 1 行处理;暂停后,间隔较大的事件会恢复鼠标滚轮的 linesPerNotch。preset trackpad 仍可用于显式固定触控板语义,preset mouse 可用于恢复鼠标向的默认距离与巡航参数,但快速流仍会自动适配。
命令与旗标
| 命令 | 作用 |
|---|---|
/smooth-scroll |
开 / 关切换 |
/smooth-scroll on / off |
显式开关(off 后立即回到原生行为,并恢复 pi 的渲染节流) |
/smooth-scroll status |
打印策略、全部参数(含帧预算与节流状态)以及当前 backlog / 速度 |
/smooth-scroll preset mouse|trackpad|floaty|fps120 |
套用预设(不动 enabled / inputStrategy、不动 frameMs 以外的项) |
/smooth-scroll fps 120 |
按帧率设置帧预算(>fps 60 需要 piFrameThrottle) |
/smooth-scroll frame 8 |
直接设置 frameMs |
/smooth-scroll lines 1.5 |
临时改 linesPerNotch |
/smooth-scroll speed 90 |
临时改巡航速度 |
/smooth-scroll ease 4 / lag 200 / glide 1500 / cap 1 |
见配置表 |
/smooth-scroll gain 2 / gesture 40 / alt 3 |
trackpad / Alt 参数 |
/smooth-scroll inertia off |
临时关掉惯性 |
/smooth-scroll rearm |
重新安装输入接管(用于 TUI 被重建后手动恢复) |
/smooth-scroll reload |
重新读取配置文件 |
命令行旗标:--no-smooth-scroll 启动即禁用(优先级高于配置文件)。
命令里的改动只作用于当前会话,重启即失效;要持久化请写进
smooth-scroll.json(例如把"frameMs": 8写进去让 120fps 常驻)。0.1 的
half-life MS(按7200 / ms换算成speed)和burst MS(等价gesture MS)仍可用,会提示一次。
输入接管策略(重要)
这一节说明实现上最关键的取舍,以及它与最初设计假设的差异。
背景:ctx.ui.onTerminalInput 在 fullscreen 下拿不到滚轮
设计假设是「扩展的 onTerminalInput 会先于 pi 内建处理拿到原始输入」。在 pi 0.87.0 上这个假设不成立:
TuiAltScreen在构造函数里就注册了自己的输入监听器 (this.addInputListener((data) => this.handleViewportInput(data))), 而TuiBase.handleTerminalInput()是按注册顺序遍历inputListeners,遇到consume: true直接 return。- pi 的启动顺序是「先启动 UI,再把 widget 交给扩展」,所以扩展注册的监听器永远排在后面,滚轮事件早就被
handleViewportInput消费掉了。
实测(真实 pi 0.87.0 + pty,扩展只做记录):普通按键能到达扩展监听器,滚轮序列一个都到不了。
三种策略
| 策略 | 机制 | 0.87.0 是否可用 |
|---|---|---|
listener |
ctx.ui.onTerminalInput,纯公开扩展 API |
否(滚轮被 pi 先消费) |
terminal |
接管共享的终端输入泵 | 是 |
auto(默认) |
fullscreen 且可用时用 terminal,否则退回 listener |
是 |
terminal 策略做的事非常克制:
tui.terminal(TUI接口上的公开字段)暴露的公开方法stop()/start(onInput, onResize)。- 安装一个只做两件事的 handler:滚轮序列交给本扩展;其它输入原样转发给
tui.handleTerminalInput(data),也就是 pi 自己原本的入口。 - 卸载时把输入泵恢复成纯透传(行为等价于 pi 原生)。
它没有做的事:不替换 pi 对象上的任何方法或属性,不读写私有字段,不用 Symbol 补丁,不改 node_modules。
依赖清单(准确版,共 4 项,全部有守卫与降级):
| 接口 | 性质 | 挂了会怎样 |
|---|---|---|
tui.terminal |
TUI 接口的公开字段 |
检测不到就退回 listener 策略 |
Terminal.start/stop |
公开方法 | 同上 |
tui.handleTerminalInput |
非公开(pi-tui 里是 private 方法名) |
检测不到就静默不接管(回到原生) |
TuiBase.MIN_RENDER_INTERVAL_MS |
公开静态字段,只在 frameMs < 16 时改写 |
找不到就提示一次并退回 60fps 节奏 |
另外,pi 交给扩展 UI 工厂的 tui 其实是 createInteractiveTuiReference() 返回的 Proxy(interactive-mode.js:355):每次取属性都会解析到「当前 renderer」并返回绑定好的调用。本扩展所有调用都经过 tui 对象(而不是缓存裸函数指针),所以 this 永远正确,也不会在 pi 换 renderer 后指向旧对象。
为什么必须先 stop():Terminal.start() 会重建 stdin 缓冲并再次 process.stdin.on("data", ...),直接二次 start() 会让每次按键被投递两遍。代价是每次 arm/disarm 会向终端写 ?2004l+?2004h、弹一次并重发 Kitty 协议探测、stdin pause/resume 并切换 raw mode。退出时会话清理只做「停止拦截」而不重启输入泵(否则会在 pi 恢复终端之后又把 raw mode / 括号粘贴打开)。
上游修复建议
要让 listener 策略真正可用,pi-tui 侧只需很小的改动,任选其一:
- 让扩展注册的输入监听器先于渲染器自身的 viewport 监听器执行(例如把 viewport 处理器放到监听器链末尾,或给
addInputListener增加优先级参数); - 或者在
TuiAltScreen里把滚轮事件也发给扩展监听器(例如先问一遍监听器,再走routeWheel)。
工作原理
速度是「剩余距离的函数」
没有速度累加器,也就没有漂移:每一帧按当前剩余距离算一个目标速度,再把 速度 × 帧间隔 通过误差扩散(保留小数 credit、四舍五入后交付整行)铺到帧网格上——这样交付时刻尽可能均匀,而不是「攒够了再吐一把」。
cruise = speed / 1000 行/ms(默认 0.0625 = 每帧一行)
brake = sqrt(cruise² × (d - 0.5) / easeLines) 最后 easeLines 行开始的减速曲线
glide = min(cruise, brake)
budget = 本次滑动总距离 / maxGlideMs 保证时长上限
track = 剩余距离 / (lagMs + 距上次事件的时间) 仅连续手势,随时间衰减
floor = min(cruise, 1 行 / 两帧) 着陆节奏,不允许更长的空档
v = clamp(max(glide, budget, track), floor, frameCap / frameMs)
- 起始与巡航:一次滑动的前几行以 62.5 行/秒交付 = 每帧一行。第一行在第一帧(≤16ms)就落地,不会先卡 3 帧。
- 均匀优先:
easeLines默认 0,全程等速;末端这一步由floor兜底(两帧一行),既不会出现「指数尾」那种半秒空档,也不会比两帧更慢。 - 时长上限:
budget让 100 行左右的甩动也能在 ~350ms 内走完,代价是每帧多走几行。 - 中距离保护:总滑行距离不超过约 40 行时不启用时长预算,继续按单行巡航,避免调小
maxGlideMs后普通鼠标滚动变得跳跃。 - 跟手:
track只在真的连续手势(事件间隔 ≤gestureWindowMs)时生效,且分母含「距上次事件的时间」,所以手指停下后它会自然衰减、与滑行曲线平滑交叉。 - 设备自适应:同方向事件间隔约 24ms 以内时,输入量按触控板节奏收敛到每事件最多 1 行;事件恢复为较慢间隔后,鼠标滚轮配置重新生效。
- 单帧上限:
frameCap = max(maxLinesPerFrame, ceil(总距离 / (maxGlideMs/frameMs))),硬顶 16 行。普通滚动就是 1–2 行/帧;只有超长甩动才会放大到几行。
帧预算与时钟对齐
frameMs 同时是交付节拍和渲染间隔,两件事必须对齐:
- 引擎的帧定时器按绝对时间网格自调度(某一帧迟到时,下一帧的延迟会自动缩短),不会像
setInterval那样把整个滑行越拖越偏;因此交付时刻始终锁在 pi 真正愿意出帧的节拍上。 frameMs < 16时,扩展会把 pi 的渲染节流(TuiBase.MIN_RENDER_INTERVAL_MS,公开静态字段)也调到同一个值,否则渲染器会把帧合并掉、退回 60fps 观感。只在扩展生效期间改写,退出/关闭/改回 60fps 时立刻恢复。
实测收益(40 行固定甩动、巡航 125 行/秒):
frameMs |
渲染帧数 | 每次交付行数 | 交付间隔中位数 |
|---|---|---|---|
| 16(60fps) | 19 | 2 | 16ms |
| 8(120fps) | 38 | 1 | 8ms |
也就是说:同样的速度、同样的时长,120fps 下从「一次吃 2 行」变成「每次 1 行」,单位时间的量化精度也翻倍。
与 pi 原生行为的兼容
| pi 原生行为 | 本扩展的处理 |
|---|---|
| 0.86 起 fullscreen 滚轮默认步长 1 行 | 由 linesPerNotch 决定(默认 3,即 0.86 之前的步长) |
| Alt + 滚轮 = 5 倍速 | altMultiplier,默认 5,行为一致 |
tui.altScreen.lineUp/lineDown/halfPageUp/…/top/bottom 快捷键 |
不拦截,仍由 pi 处理 |
| 滚到底后自动恢复 follow(跟随新输出) | 不抢:向下滚且处于 follow 状态时直接放行;向上滚动时才接管(这一步正是「脱离跟随」的手势) |
| 打开选择器 / 对话框 | 完全不接管滚轮,pi 自己滚动列表 |
| regular(非 fullscreen)模式 | 完全 no-op,扩展不注册任何输入接管 |
| 滚动条拖拽 / 文本选择 / 超链接 | 不涉及 |
对话框识别
pi 的 /settings、/model、ctx.ui.select 等不是 overlay:它们把编辑器从布局里换掉并夺取键盘焦点(ui.setFocus(selector)),此时 tui.hasOverlay() 仍然是 false。而 SelectList / SettingsList 自己会处理 wheelDelta。
因此扩展用两条线索判断「滚轮现在不属于转录区」:
tui.hasOverlay()为真(真正的 overlay 对话框、搜索框);- 公开 API
tui.getFocusedComponent()的焦点归属:无对话框时焦点在编辑器上,扩展会记住它;焦点转移到别处即视为对话框,且只在「新焦点明显是文本编辑器」时才更新记忆(这样替换编辑器能自愈,而自定义对话框保持受保护)。
输入泵自愈
pi 在几种情况下会重建自己的输入泵(Ctrl+Z 挂起后恢复、外部编辑器、运行时切换 TUI 模式)。此时扩展的接管会被覆盖。扩展会额外注册一个只做观察的 onTerminalInput 监听器:一旦发现「某次输入没有经过自己的泵」就延迟重建接管(带 1 秒去抖、最多连续 5 次;一旦确认 seam 活着就重置计数)。也可以用 /smooth-scroll rearm 手动重建。
从 0.1.x 升级
| 0.1 键 | 0.2 处理 |
|---|---|
inertia.halfLifeMs |
按 speed = 7200 / halfLifeMs 换算(默认 120ms → 62.5 行/秒)并提示 |
inertia.minVelocity / maxVelocity |
已移除(指数模型不存在了),提示后忽略;用 speed / maxLinesPerFrame |
trackpad.burstWindowMs |
重命名为 gestureWindowMs,取同值并提示 |
命令 half-life / burst |
保留为别名,提示一次 |
| 新增 | frameMs / piFrameThrottle(帧预算,见上) |
已知限制
- 帧预算就是平滑的上限。
frameMs 16时「每帧一行」= 62.5 行/秒;要更快还保持每帧一行,就得frameMs 8(125 行/秒)。再快只能一帧多行。 - 显示刷新率仍是最后一道墙。120fps 交付在 60Hz 屏幕上会被屏幕合并;真正受益的是 120Hz(ProMotion)屏幕。慢速终端 / SSH 上建议保持
frameMs 16(120fps 会让整屏 diff 的写入量翻倍)。 - 改
frameMs < 16会改到 pi 的全局渲染节流(公开静态字段,退出即恢复)。它只把「最小渲染间隔」降低,不会让 pi 主动多画帧;但期间 pi 的所有 UI(流式输出、spinner)都可以更快刷新,CPU / 带宽略增。不接受就设piFrameThrottle: false。 - 设备识别基于事件节奏。默认
linesPerNotch: 3保留鼠标滚轮的步长;同方向、约 24ms 内连续事件会自动收敛到每事件最多 1 行。极慢或经过代理转发的输入可能无法被识别为高频触控板流,此时仍可手动使用preset trackpad。 cap与glide是取舍:cap越小越不可能跳,但超长距离只能滑更久;glide越小越不拖沓,但大甩动每帧行数会变多。- 只滚主滚区。扩展不带指针坐标做路由:pi 原生会把滚轮派发给光标下的嵌套 scroll view 并更新滚动条 hover,接管后这些都不发生(转录区之外的嵌套滚动区收不到滚轮,滚动条 hover 高亮不更新)。选择器 / 对话框仍然完全不受影响。
- 惯性只在手势之后发生:不会主动「飞」到某处,也不会改变总滚动距离。
speed与floor的关系:floor = min(speed, 一行/两帧),所以speed低于约 31 行/秒时实际速度就被floor兜住了(这正是「慢速滚动必然是两帧一步」的体现)。- 边界与对话框会丢距离:滚到顶/底、或中途打开对话框时会 cancel 当前滑行(只保留不足一行的余数)。这是刻意的(不能和边界/对话框抢),但总交付量会小于输入量。
- 运行时切换 TUI 模式(regular ↔ fullscreen)后需要
/smooth-scroll on(或/reload)才能恢复接管;挂起恢复和外部编辑器场景有自愈逻辑覆盖。
开发
# 单元测试(Node 24 原生跑 TypeScript,零依赖);113 个用例
npm test
# 类型检查
npm install
npm run typecheck
模块划分:
| 文件 | 职责 |
|---|---|
src/wheel-parser.ts |
解析 SGR / 旧式滚轮序列,非滚轮输入一律放行 |
src/scroll-engine.ts |
纯逻辑核心:速度曲线、误差扩散排程、帧预算与时钟对齐、单帧上限、手势跟手、小数累加 |
src/config.ts |
读取并校验 smooth-scroll.json,坏配置逐字段回退,0.1 旧键迁移 |
src/input-source.ts |
输入源:公开监听器、终端输入泵接管、渲染节流调整 |
src/index.ts |
接线层:事件、命令、预设、旗标、对话框识别、自愈 |
排障:设置环境变量后,扩展会把接管 / 消费决策写到文件。
PI_SMOOTH_SCROLL_DEBUG=/tmp/smooth-scroll.log pi -e /path/to/pi-smooth-scroll
日志里每行 scrollBy N from M 就是一次真实交付,配合时间戳可以量出交付节奏(默认应为每 16–18ms 一次、每次 1–2 行;frameMs 8 时应为每 8–9ms 一次)。想同时量渲染帧数,可以在 pty 输出里数同步输出标记 \x1b[?2026h(每帧一个)。
许可
MIT