青雲的博客

第八部:终端之上,工程边界之下

从行式组件、焦点和终端输入一路追到差分渲染与 InteractiveMode 事件投影,再把项目 trust、进程权限、外部容器和固定发布验证分层,说明 Pi 的可见界面、执行能力与安全边界分别由谁负责。

终端输入、组件焦点、差分渲染与外部隔离共同构成 Pi 的终端工程边界。
展开阅读路线与实验入口

第七部沿着 SDK、print、JSON、RPC、server 和 Evals,说明同一套 AgentSession 可以被不同宿主观察与控制。还剩一个最容易被低估的宿主:Pi 自带的终端交互界面。它看起来只是默认入口,实际上同时处理终端协议差异、输入分包、组件焦点、流式事件投影、ANSI 样式、宽字符、图片、滚动区和局部重绘。

这一部不会按 packages/tui 里的文件数量逐个介绍。更有用的读法是抓住两条相反方向的链:输入从终端进入 Agent,事件从 Agent 回到终端。组件、编辑器和差分渲染都能放进这两条链;安全与发布验证则回答链条外面的两个问题:这些动作拥有多大系统权限,我们凭什么确认书中描述的确实是固定版本行为。

flowchart LR
  accTitle: 第八部的输入、投影与边界地图
  accDescr: 终端字节经 StdinBuffer 和焦点到达编辑器,提交后进入 AgentSession;AgentSession 事件由 InteractiveMode 更新组件,TUI 将新旧文本行做差分后写回终端;工具在本地进程权限内运行,隔离边界位于 Pi 进程之外。
  KEY["terminal bytes"] --> BUFFER["StdinBuffer"] --> FOCUS["focused component"] --> EDITOR["Editor"]
  EDITOR --> SESSION["AgentSession"]
  SESSION --> EVENTS["session events"] --> VIEW["InteractiveMode components"]
  VIEW --> DIFF["TUI line diff"] --> TERM["terminal"]
  SESSION --> TOOLS["built-in / extension tools"] --> OS["user process permissions"]
  BOUNDARY["container / VM / policy sandbox"] -. "optional external boundary" .-> OS

先把“界面”拆成三个问题

第 43 章从 Component 开始。Pi 的组件只需按宽度返回若干文本行,Container 按顺序拼接。焦点不是组件树层级,而是 TUI 持有的唯一指针;overlay 也不是普通 child,而是在普通内容渲染后按位置与 focusOrder 合成的另一层。capturing overlay、non-capturing overlay、动态可见性和嵌套关闭之所以需要专门一章,是因为它们决定按键究竟到编辑器还是弹出的 selector。

第 44 章向终端外再走一步。ProcessTerminal 开 raw mode、启用 bracketed paste,协商 Kitty keyboard protocol,失败时退到 modifyOtherKeys;StdinBuffer 把可能跨多个 data event 的 CSI、OSC、DCS、APC 和 paste 重新拼成事件;TUI 再把事件交给当前 focused component。直到 Editor.handleInput() 命中 keybinding,Enter 才真正成为 submit。

第 45 章转向输出。每个流式 delta 都可能触发 render request,但 TUI 最快约 16ms 批处理一次,并比较 previousLines 与新结果。普通变化只重画 firstChanged 到 lastChanged;终端宽高改变、变化落在旧 viewport 之外或图片清理可能滚屏时,才回退到 full redraw。所谓“差分”不是 React reconciliation,而是最终终端行和硬件光标位置的比较。

Agent 事件不会自己长成聊天界面

第 46 章把前两条链接在一起。AgentSession 转发底层 message_start/update/endtool_execution_start/update/end,同时补充 queue、compaction、retry 和 settled 事件。InteractiveMode 持有 streaming assistant component 与 toolCallId -> ToolExecutionComponent map,事件到来时更新对象,再调用 requestRender()。这里的组件是事件的易失投影,不是会话的第二份真相;session replacement 或 compaction 后,界面可以清空并从当前消息重新构造。

这一区分也帮助定位故障。工具确实执行了但画面没有更新,先查 event projection、component state 和 render scheduling;session transcript 缺了消息,则应查 AgentSession persistence 与运行链,而不是终端样式。屏幕出现旧行,可能是 viewport 或 shrink 清理;不能据此断言 Agent 又执行了一次工具。

安全边界不在 TUI 的确认框里

第 47 章处理全册最需要措辞克制的结论:v0.83.0 没有内置沙箱。Project trust 保护的是启动前的资源加载,防止未批准的项目设置与 extension 自动进入进程;它不限制模型在会话开始后调用 read、write、edit 或 bash。默认 Bash backend 使用当前 cwd、shell environment 与 Pi 进程权限启动本地子进程。扩展本身也是拥有同等权限的 TypeScript 模块。

真正的隔离要由 OS、容器、VM 或 policy sandbox 提供。源码文档列出三种不同所有权方案:整个 Pi 放进 Docker/OpenShell;或保留 host Pi,把内置工具和 ! 命令转进 Gondolin。后一种方案不会自动隔离其他 host extension tool,bind mount 也仍可把写操作传回宿主。第 47 章会用能力矩阵说明每种方案隔离了什么、仍暴露什么。

最后一章不靠“我看过源码”收尾

第 48 章给整本书建立可重复的证据层级。固定对象是 tag v0.83.0 和 commit 845d6ff1f6643aba440341cce877ce1c43ebbc39。普通 git checkout 故意没有被 .gitignore 排除的 provider data,因此完整离线构建要使用 release workflow 生成的 pi-0.83.0-source.tar.gz,先核对 SHA256,再在一次性目录执行安装、offline build 与固定测试集。

这一步也会重新摆正 Harness v2。固定 tag 里已经有 durability、lanes、operation log 和 crash resume 的设计文档,但当前实现仍是第 19 至 24 章读到的 AgentHarness 与 Session storage。文档里的 Goals、Non-goals 和未来 builder 不能倒灌成 v0.83.0 的 shipped API。以后更新本册,应重新固定版本、验证源码引用和实验,而不是悄悄用 main 分支修正旧结论。

查阅这一部时,可以从症状反推入口:按键异常先读第 44 章;弹窗抢焦点读第 43 章;闪烁、旧行或 resize 问题读第 45 章;流式工具块不更新读第 46 章;部署权限与无人值守运行读第 47 章;准备复核或升级版本则直接从第 48 章执行验证清单。

深入浅出 Pi 第八部:终端之上,工程边界之下 第 43 章

TUI 组件、焦点与覆盖层怎样配合

从 Component、Container、Focusable 与 overlay stack 进入 Pi 的终端界面,解释静态组件树、唯一输入焦点和屏幕覆盖层为何是三套彼此配合却不能混为一谈的机制。

源码版本
v0.83.0
验证日期
Commit
845d6ff1f6643aba440341cce877ce1c43ebbc39

第 42 章把 Pi Evals 的结果收缩成可断言的 output、events 和 usage。真实用户在终端里看到的却不是这些对象本身:聊天记录、编辑器、状态栏和弹窗都要先变成一组带 ANSI 控制序列的文本行,再由终端解释。要看懂这段投影,第一步不是从颜色或 Markdown 开始,而是确认画面、焦点和输入分别由谁持有。

Pi 的 TUI 有意把组件合同压得很薄,但它并不等于一棵浏览器 DOM。普通组件按纵向顺序组成内容,覆盖层在这份内容上二次合成,键盘输入只交给一个 focused component。三者恰好在 TUI 中汇合,所以界面看起来是一体的;源码里的状态所有权却是分开的。

Component 只承诺“给我宽度,还你几行”

Component 必须实现 render(width)invalidate(),可以选择实现 handleInput()。返回值是字符串数组,一项就是终端中的一行。Container 没有布局算法,它只是按 children 顺序调用 render(width),再把所有行首尾拼接。组件若想缓存 Markdown、图片或截断结果,可以在自己内部缓存;主题变化时,invalidate() 递归通知它重新计算。

InteractiveMode 利用这个简单合同搭出稳定的纵向骨架:header、已加载资源、chat、pending messages、status、上方 widgets、editor、下方 widgets、footer。编辑器单独包在 editorContainer 中,是因为 selector、登录框和 extension input 可以临时替换这一格,而不必重建整棵聊天树。初始化结束时,setFocus(editor) 明确把键盘所有权交给编辑器。

因此,chatContainer.addChild() 只会把新内容接到聊天区末尾;它不会自动获得输入,也不会浮在编辑器之上。反过来,调用 setFocus(component) 也不会改变组件在普通内容中的位置。组件树回答“渲染在哪一段”,焦点回答“下一次输入给谁”。

焦点还是一个指针,但恢复规则不简单

TUI 内部只有一个 focusedComponent。切换时,旧组件若实现了 Focusable,其 focused 置为 false;新组件对应置为 true。编辑器利用这个标记决定是否在光标位置输出零宽 CURSOR_MARKER,TUI 在最终画面中找到并移除 marker,再移动真正的硬件光标。这样中文输入法的候选窗能跟随编辑位置,而界面仍可以保留自己绘制的反色假光标。

单个指针不意味着随便保存一个 previous value 就够了。Pi 允许 overlay 上再打开 selector,overlay 也可能因终端变窄而由 visible() 动态隐藏。setFocusInternal() 因而维护 eligible、blocked、inactive 三种 overlay 恢复状态:普通组件暂时抢走焦点时,原 overlay 可能等待恢复;overlay 已经不可见或被移除时,等待状态必须失效;显式 unfocus({target}) 又能把恢复目标改成指定组件。

这也是为什么 extension 自定义界面不能只 editorContainer.clear() 后忘记收尾。普通替换模式结束时,showExtensionCustom() 会放回原 editor、恢复保存的文字、重新 setFocus;overlay 模式则交给 OverlayHandlehideOverlay() 恢复。显示与关闭必须成对,焦点才不会留在一个已卸载对象上。

Overlay 不是普通 child,也不是“天然弹窗”

showOverlay() 为组件记录 preFocus、hidden 和递增的 focusOrder,再压入独立的 overlay stack。默认 overlay 在可见时会捕获焦点;nonCapturing: true 则只加入画面,编辑器继续接收按键。handle 的 focus() 可以让 non-capturing overlay 后来主动接管输入,unfocus()setHidden()hide() 则按当前栈和 preFocus 决定焦点去向。

输入到来时,TUI 还会重新检查 focused overlay 是否仍可见。若 visible(width,height) 因 resize 返回 false,它会优先选择视觉上最靠前的 capturing overlay;没有候选时才回到这层 overlay 的 preFocus。non-capturing overlay 不参与这个 fallback,因为它从未声明自己拥有输入。

画面组合发生得更晚。普通 children 已经产出完整 lines 后,TUI 才按 focusOrder 排序可见 overlays,计算 width、anchor、row、col 和 maxHeight,将 overlay 每一行裁到声明宽度,最后拼入 base line。合成过程会在片段边界补 ANSI reset,且再次检查 CJK 宽字符与终端总宽度。这解释了一个看似反常的现象:overlay 可以显示在 chat 上面,却完全不在 chatContainer.children 中。

flowchart TD
  accTitle: Pi TUI 的三个相互独立的所有权层
  accDescr: 普通组件树生成纵向内容;overlay stack 生成屏幕覆盖层;focusedComponent 决定单一输入接收者。最终渲染把普通内容和覆盖层合成,但不会借此改变输入焦点。
  TREE["Container children"] --> LINES["base lines"]
  STACK["overlay stack<br/>focusOrder + position"] --> COMPOSE["composite overlays"]
  LINES --> COMPOSE --> SCREEN["terminal screen"]
  INPUT["terminal input"] --> FOCUS["focusedComponent"]
  FOCUS --> EDITOR["editor or capturing overlay"]
  FOCUS -. "CURSOR_MARKER" .-> SCREEN

用虚拟终端检查“看得见”和“拿到输入”

固定版本已经把焦点恢复写成可复现实验。下面只跑 overlay 相关的 Node test,不访问网络,也不需要真实终端:

repo="${PI_RELEASE_SOURCE_DIR:-/path/to/pi-0.83.0}"
cd "$repo"

node --test --test-reporter=spec \
  packages/tui/test/overlay-non-capturing.test.ts \
  packages/tui/test/overlay-options.test.ts

重点不是“测试绿色”四个字,而是读三个断言:non-capturing overlay 创建后 editor 的 focused 仍为 true;对 handle 调用 focus() 后输入转给 overlay;关闭 nested overlay 后,再送一个 x,只有 editor 的 inputs 增加。VirtualTerminal 用 headless xterm 接收 ANSI 输出和模拟输入,验证的是 TUI 自己的路由与画面,不是假设某款终端的 UI 行为。

组件树、overlay stack 和 focused component 分开以后,下一条链就可以逐层追踪了:用户按下 Enter 时,Pi 接收到的并不是“提交”这个高层动作,而是一串可能被拆包、重编码或带修饰键的终端字节。第 44 章从 raw stdin 开始,看这些字节怎样最后变成编辑器的一次 submit。