omp packages

pi-smooth-scroll0.2.0

Smooth (inertial, sub-line accurate) mouse-wheel / trackpad scrolling for the pi coding agent fullscreen TUI

opentokenz Published 29 days ago MIT ~177 downloads/mo

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.

Did it work for you?

Result

One report per person. A new report replaces your old one.

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 策略做的事非常克制:

  1. tui.terminal(TUI 接口上的公开字段)暴露的公开方法 stop() / start(onInput, onResize)。
  2. 安装一个只做两件事的 handler:滚轮序列交给本扩展;其它输入原样转发给 tui.handleTerminalInput(data),也就是 pi 自己原本的入口。
  3. 卸载时把输入泵恢复成纯透传(行为等价于 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 同时是交付节拍和渲染间隔,两件事必须对齐:

  1. 引擎的帧定时器按绝对时间网格自调度(某一帧迟到时,下一帧的延迟会自动缩短),不会像 setInterval 那样把整个滑行越拖越偏;因此交付时刻始终锁在 pi 真正愿意出帧的节拍上。
  2. 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。

因此扩展用两条线索判断「滚轮现在不属于转录区」:

  1. tui.hasOverlay() 为真(真正的 overlay 对话框、搜索框);
  2. 公开 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(帧预算,见上)

已知限制

  1. 帧预算就是平滑的上限。frameMs 16 时「每帧一行」= 62.5 行/秒;要更快还保持每帧一行,就得 frameMs 8(125 行/秒)。再快只能一帧多行。
  2. 显示刷新率仍是最后一道墙。120fps 交付在 60Hz 屏幕上会被屏幕合并;真正受益的是 120Hz(ProMotion)屏幕。慢速终端 / SSH 上建议保持 frameMs 16(120fps 会让整屏 diff 的写入量翻倍)。
  3. 改 frameMs < 16 会改到 pi 的全局渲染节流(公开静态字段,退出即恢复)。它只把「最小渲染间隔」降低,不会让 pi 主动多画帧;但期间 pi 的所有 UI(流式输出、spinner)都可以更快刷新,CPU / 带宽略增。不接受就设 piFrameThrottle: false。
  4. 设备识别基于事件节奏。默认 linesPerNotch: 3 保留鼠标滚轮的步长;同方向、约 24ms 内连续事件会自动收敛到每事件最多 1 行。极慢或经过代理转发的输入可能无法被识别为高频触控板流,此时仍可手动使用 preset trackpad。
  5. cap 与 glide 是取舍:cap 越小越不可能跳,但超长距离只能滑更久;glide 越小越不拖沓,但大甩动每帧行数会变多。
  6. 只滚主滚区。扩展不带指针坐标做路由:pi 原生会把滚轮派发给光标下的嵌套 scroll view 并更新滚动条 hover,接管后这些都不发生(转录区之外的嵌套滚动区收不到滚轮,滚动条 hover 高亮不更新)。选择器 / 对话框仍然完全不受影响。
  7. 惯性只在手势之后发生:不会主动「飞」到某处,也不会改变总滚动距离。
  8. speed 与 floor 的关系:floor = min(speed, 一行/两帧),所以 speed 低于约 31 行/秒时实际速度就被 floor 兜住了(这正是「慢速滚动必然是两帧一步」的体现)。
  9. 边界与对话框会丢距离:滚到顶/底、或中途打开对话框时会 cancel 当前滑行(只保留不足一行的余数)。这是刻意的(不能和边界/对话框抢),但总交付量会小于输入量。
  10. 运行时切换 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