问题概述
在 Pi regular TUI 中运行 detached/background Workflow 时,用户向上滚动查看历史内容,视口可能突然被打回底部。
这次现象是在 Workflow V2 的真实长任务中暴露的:Workflow 工具调用已经返回、主会话已经释放,但后台 run 仍在执行;此时 OpenPI 会继续让 transcript 中那张已经进入历史区的 Workflow 工具卡每 120ms 更新 spinner。Pi regular renderer 发现“旧 viewport 之上的行发生变化”时,会走 fullRender(true) 并发送 CSI 3J 清除终端 scrollback。用户正在阅读的位置因此丢失。
这不是一个单纯的终端“新输出自动滚到底”设置问题,也不能用 fullscreen ScrollView 的 follow 状态解释当前现场:本次运行明确使用 tuiMode: regular。
真实现场
OpenPI runtime:PR feat(workflows): release parent turns and harden large fan-out #92 合入后的 cc92f05,由本地 checkout 单一加载;
Pi / pi-tui:@earendil-works/* 0.84.2;
TUI:regular;
终端:Ghostty;其默认 scroll-to-bottom = keystroke,no-output,普通新输出默认不会自动吸底;
相关 Workflow:
wf_490469b9fb68:background,1 agent;
wf_815de99b4a45:background,1 agent;
wf_5d38d5d3e879:background,10 agents,运行约 8 分钟。
当时没有用 PI_DEBUG_REDRAW=1 留下 renderer receipt,因此不能声称已经证明“用户看到的那一次跳转”对应哪一个具体 redraw tick;但下面的运行链路、Pi 清 scrollback 行为和 OpenPI 的持续历史更新均已独立证实。
已证实的运行链路
1. Workflow V2 让历史工具卡在 tool call 返回后继续存活
PR #92 把交互式 Workflow 改为默认 detached:工具先返回 launch receipt,run 在后台继续执行。
这是正确的生命周期方向,但也改变了 UI 的时间关系:Workflow 工具卡会先进入 transcript 历史区,而 run 仍可能继续数分钟。
2. OpenPI 每 120ms 改写这张历史工具卡
当前 renderResult 不只为 Pi 仍标记为 partial 的工具调用开 spinner;只要 activeRuns 里仍存在该 run,也会启动定时 invalidation:
这意味着一张已经稳定写进 transcript 的旧卡片仍以约 8.3 FPS 变化。它违反了 regular 主屏很重要的 locality 条件:历史区最好是 append/finalize 后不再按时钟变化。
3. Pi regular renderer 会为 viewport 之上的变化清空 scrollback
Pi regular 模式不是 app-owned transcript viewport;滚轮操作的是终端原生 scrollback。Pi 的差分 renderer 无法修改已经离开可见 viewport 的旧行,因此当 firstChanged < prevViewportTop 时只能走全量重绘:
所以确定性的危险链路是:
detached Workflow 已返回 launch receipt
↓
工具卡进入 transcript 历史区,但 run 仍 active
↓
OpenPI 每 120ms 改 spinner,并 invalidate 历史卡
↓
Pi regular renderer:firstChanged < prevViewportTop
↓
fullRender(true) → CSI 3J 清 scrollback
↓
用户上滚位置丢失 / 视觉上突然回到底部
Pi 上游已经有高度同构的真实报告:
上游给出的现有规避方式是切到 fullscreen;但 OpenPI 不应要求所有用户切换 Pi 的全局 TUI 模式,尤其这条高频触发是我们自己可以消除的。
为什么现在更容易出现
Pi regular renderer 的限制并不是 PR #92 新增的。新的地方在于:
过去 blocking Workflow 的活动卡通常仍是当前 in-flight 工具调用,靠近 transcript 底部;
V2 默认 detached 后,工具调用先结束,用户可以继续对话,而同一张卡仍跟随后台 run 动画;
随着后续消息进入 transcript,这张仍每 120ms 变化的卡自然成为“历史区中的活动组件”,更容易命中 Pi 的 full redraw 分支。
因此应把它描述为:Pi regular 主屏的既有边界,被 OpenPI Workflow V2 的历史动画放大成了高概率用户问题。
建议方案
第一阶段:在 OpenPI 内切断高频触发链路
把 transcript 视为已提交的历史投影:
isPartial === true 时,Workflow 工具卡仍可显示实时 spinner;这是当前仍在执行的 tool call,通常位于底部。
detached Workflow 返回 launch receipt 后,历史工具卡冻结为静态 running/launched 状态,不再按时钟 invalidate。
后台实时状态继续由现有的 below-editor strip 和 /workflows dashboard 承担。
run 终态由现有 completion follow-up 投递,不靠回写旧 transcript 卡片表达。
实现边界应类似:
Pi tool result 仍 partial
→ 可以读取 active run + 动画
Pi tool result 已 settled / launch receipt 已进入 transcript
→ 冻结当时 details
→ 不再启动 transcript spinner
→ live 状态只存在于 strip/dashboard
这同时符合 OpenPI 的投影原则:canonical run state、model-visible completion 和 operator-facing live UI 是不同投影;不应让对象生命周期持续泄漏进已提交的历史 transcript。
第二阶段:用真实 receipt 判断是否还有 layout shrink 路径
Workflow 完成、用户确认 settled 状态、widget/status 移除时,也可能造成文档高度收缩并触发 Pi full redraw。它是否参与本次现场尚未证实。
先完成第一阶段并启用 PI_DEBUG_REDRAW=1 做真实 smoke:
若周期性的 firstChanged < viewportTop 消失且上滚稳定,则问题已经被最小修复覆盖;
若只在 widget/status 消失时仍出现一次 full redraw,再单独设计 height-stable settlement/placeholder;
不要一开始就为所有 dock 组件加入复杂 viewport 状态机。
不建议的方案
只让用户改 Ghostty/iTerm 设置 :本机 Ghostty 默认已经是 no-output;CSI 3J 清 scrollback 不是普通 output follow。
强制所有用户切 fullscreen :fullscreen 的 app-owned ScrollView 确实更容易正确实现 scroll lock,但 TUI 模式属于 Pi 用户选择,OpenPI 不应静默改写。
仅把 120ms 改成 500ms/1s :只能降低发生频率,历史卡仍会周期性触发同一 destructive redraw。
OpenPI 自造 regular-mode scroll lock :regular 模式的 viewport 由终端拥有,扩展拿不到可靠滚动位置;这应留在 Pi renderer seam。
立即修改 Pi 全量重绘算法 :这是更大的上游问题;OpenPI 先消除自己制造的非必要历史动画,收益更确定、风险更低。
验收标准
确定性测试
detached/background Workflow 返回 launch receipt 后,即使对应 run 仍 active,历史 renderResult 也不再建立时钟驱动的 invalidation;
fake timer 前进多个 SPINNER_INTERVAL_MS,settled tool result 的 context.invalidate 计数保持为 0;
isPartial === true 的当前工具调用仍可动画并显示真实进度;
below-editor strip、dashboard 和 completion follow-up 的状态表达不退化;
wait: true 路径继续按当前同步语义工作。
真实 TUI smoke
在 Ghostty + Pi 0.84.2 下分别运行:
regular:启动一个持续至少 30 秒的 detached Workflow,让 launch card 离开当前 viewport,向上滚动并保持阅读 10 秒;
修复前记录是否出现周期性的 fullRender: firstChanged < viewportTop;
修复后同场景不应再由 Workflow transcript spinner 产生该日志,阅读 anchor 不应移动;
fullscreen:同一场景中手动上滚后,内容增长不得恢复 follow;
若 regular 仍在终态时单次跳转,记录 exact redraw reason,再另行处理 widget/status shrink。
对同类产品的启示
Codex CLI 主对话同样主要依赖终端原生 scrollback,所以会尽量让已经提交的历史保持稳定;
Claude Code fullscreen、Hermes 和 OpenCode 使用 app-owned viewport,手动上滚会显式退出 follow,只有滚回底部或执行明确动作才恢复;
OpenPI 当前运行在 Pi 之上,正确做法不是复制另一套 viewport,而是遵守当前 renderer 的 locality:历史 transcript 不做无业务状态变化的时钟动画,实时状态放在专门的 live projection。
问题级别
建议标记为 bug。它不会破坏 Workflow 执行结果,但会让用户在长 Workflow/多 Agent 场景下无法稳定阅读历史,并且正是 detached Workflow 设计希望改善的“主会话可继续使用”体验的一部分。
问题概述
在 Pi
regularTUI 中运行 detached/background Workflow 时,用户向上滚动查看历史内容,视口可能突然被打回底部。这次现象是在 Workflow V2 的真实长任务中暴露的:Workflow 工具调用已经返回、主会话已经释放,但后台 run 仍在执行;此时 OpenPI 会继续让 transcript 中那张已经进入历史区的 Workflow 工具卡每
120ms更新 spinner。Pi regular renderer 发现“旧 viewport 之上的行发生变化”时,会走fullRender(true)并发送CSI 3J清除终端 scrollback。用户正在阅读的位置因此丢失。这不是一个单纯的终端“新输出自动滚到底”设置问题,也不能用 fullscreen
ScrollView的 follow 状态解释当前现场:本次运行明确使用tuiMode: regular。真实现场
cc92f05,由本地 checkout 单一加载;@earendil-works/* 0.84.2;regular;scroll-to-bottom = keystroke,no-output,普通新输出默认不会自动吸底;wf_490469b9fb68:background,1 agent;wf_815de99b4a45:background,1 agent;wf_5d38d5d3e879:background,10 agents,运行约 8 分钟。当时没有用
PI_DEBUG_REDRAW=1留下 renderer receipt,因此不能声称已经证明“用户看到的那一次跳转”对应哪一个具体 redraw tick;但下面的运行链路、Pi 清 scrollback 行为和 OpenPI 的持续历史更新均已独立证实。已证实的运行链路
1. Workflow V2 让历史工具卡在 tool call 返回后继续存活
PR #92 把交互式 Workflow 改为默认 detached:工具先返回 launch receipt,run 在后台继续执行。
这是正确的生命周期方向,但也改变了 UI 的时间关系:Workflow 工具卡会先进入 transcript 历史区,而 run 仍可能继续数分钟。
2. OpenPI 每 120ms 改写这张历史工具卡
当前
renderResult不只为 Pi 仍标记为 partial 的工具调用开 spinner;只要activeRuns里仍存在该 run,也会启动定时 invalidation:syncWorkflowSpinner()每个 cadence 调用context.invalidaterenderResult用isPartial || activeRuns.has(runId)保持历史卡动画这意味着一张已经稳定写进 transcript 的旧卡片仍以约 8.3 FPS 变化。它违反了 regular 主屏很重要的 locality 条件:历史区最好是 append/finalize 后不再按时钟变化。
3. Pi regular renderer 会为 viewport 之上的变化清空 scrollback
Pi regular 模式不是 app-owned transcript viewport;滚轮操作的是终端原生 scrollback。Pi 的差分 renderer 无法修改已经离开可见 viewport 的旧行,因此当
firstChanged < prevViewportTop时只能走全量重绘:fullRender(true)fullRender(true)明确发送CSI 2J + home + CSI 3J,其中CSI 3J清除 scrollback所以确定性的危险链路是:
Pi 上游已经有高度同构的真实报告:
上游给出的现有规避方式是切到 fullscreen;但 OpenPI 不应要求所有用户切换 Pi 的全局 TUI 模式,尤其这条高频触发是我们自己可以消除的。
为什么现在更容易出现
Pi regular renderer 的限制并不是 PR #92 新增的。新的地方在于:
因此应把它描述为:Pi regular 主屏的既有边界,被 OpenPI Workflow V2 的历史动画放大成了高概率用户问题。
建议方案
第一阶段:在 OpenPI 内切断高频触发链路
把 transcript 视为已提交的历史投影:
isPartial === true时,Workflow 工具卡仍可显示实时 spinner;这是当前仍在执行的 tool call,通常位于底部。running/launched状态,不再按时钟 invalidate。/workflowsdashboard 承担。实现边界应类似:
这同时符合 OpenPI 的投影原则:canonical run state、model-visible completion 和 operator-facing live UI 是不同投影;不应让对象生命周期持续泄漏进已提交的历史 transcript。
第二阶段:用真实 receipt 判断是否还有 layout shrink 路径
Workflow 完成、用户确认 settled 状态、widget/status 移除时,也可能造成文档高度收缩并触发 Pi full redraw。它是否参与本次现场尚未证实。
先完成第一阶段并启用
PI_DEBUG_REDRAW=1做真实 smoke:firstChanged < viewportTop消失且上滚稳定,则问题已经被最小修复覆盖;不建议的方案
no-output;CSI 3J清 scrollback 不是普通 output follow。ScrollView确实更容易正确实现 scroll lock,但 TUI 模式属于 Pi 用户选择,OpenPI 不应静默改写。验收标准
确定性测试
renderResult也不再建立时钟驱动的 invalidation;SPINNER_INTERVAL_MS,settled tool result 的context.invalidate计数保持为 0;isPartial === true的当前工具调用仍可动画并显示真实进度;wait: true路径继续按当前同步语义工作。真实 TUI smoke
在 Ghostty + Pi 0.84.2 下分别运行:
regular:启动一个持续至少 30 秒的 detached Workflow,让 launch card 离开当前 viewport,向上滚动并保持阅读 10 秒;fullRender: firstChanged < viewportTop;fullscreen:同一场景中手动上滚后,内容增长不得恢复 follow;对同类产品的启示
问题级别
建议标记为
bug。它不会破坏 Workflow 执行结果,但会让用户在长 Workflow/多 Agent 场景下无法稳定阅读历史,并且正是 detached Workflow 设计希望改善的“主会话可继续使用”体验的一部分。