07 · 输入、视图计算与增量渲染¶
输入与渲染看似是两条方向相反的管线,实际上共享同一条约束:客户端设备状态不能悄悄污染服务器领域状态,呈现过程也不能反过来制造不可控副作用。
7.1 输入不是 match key¶
Herdr 接收的输入可能来自:
- Unix raw terminal bytes;
- Windows console event;
- 本地 app client 的规范化消息;
- SSH bridge 后的远程 client;
- mouse/focus/paste/IME;
- API 的
pane.send或 Agent prompt。
最终目标也不同:
- modal/overlay;
- 全局导航与 prefix;
- copy/resize/rename/settings mode;
- pane 内运行的终端应用;
- context menu 或 sidebar。
因此输入管线要先标准化,再按 UI 优先级路由,最后才做 terminal protocol 编码。
7.2 路由层次¶
flowchart TD
Raw["raw bytes / console / client message"] --> Normalize["规范化 key mouse paste focus IME"]
Normalize --> Lease{"当前 client 有输入 lease?"}
Lease -->|否| Drop["拒绝或只允许 takeover"]
Lease -->|是| Overlay{"modal / overlay 消费?"}
Overlay -->|是| UIAction["更新 UI state"]
Overlay -->|否| Mode{"当前 Mode"]
Mode -->|Navigate/Resize/Copy/...| UIAction
Mode -->|Terminal| Hit{"sidebar/border/context 命中?"}
Hit -->|UI| UIAction
Hit -->|pane| Encode["按 VT mode 编码"]
Encode --> Pty["PTY actor input"]
UIAction --> Render["安排 render"]
overlay 优先非常重要:确认框已经盖住终端时,Enter 必须确认当前操作,不能同时发给底下的 shell。
7.3 Mode 是交互状态机¶
Mode 在固定快照中覆盖:
- Onboarding、ReleaseNotes;
- Navigate、Prefix、Terminal、Copy、Resize;
- workspace/tab/pane rename;
- worktree 创建/移除等 modal;
- ContextMenu、Settings、GlobalMenu、KeybindHelp、Navigator。
这些 mode 不是页面路由;它们定义“下一次输入的语义”。例如数字键在 Navigate 中可能选择 pane,在 Rename 中是文本,在 Terminal 中必须传给 child。
安全默认的 ASCII/IME 策略¶
Mode::wants_ascii_input 使用显式 allowlist。新加 mode 默认不会自动接收文本输入,直到开发者确认它需要。这样能避免新 modal 把 IME commit 或普通字母意外穿透到底层 terminal。
这是一个通用的安全模式:对副作用输入采用 opt-in,而不是对每个未来 overlay 期待开发者记得 opt-out。
7.4 输入 lease 防止“幽灵按键”¶
多客户端时,按键可能有 press/repeat/release 生命周期。若 client A 按下修饰键后失去 foreground,client B 接管时服务端仍保留 A 的输入状态,会出现卡住的 Ctrl/Alt 或错误 repeat。
输入 lease 把输入状态绑定到当前控制客户端:
- foreground client 获得 lease;
- 只有 lease owner 的输入改变服务器输入状态;
- owner 断线/被 takeover 时清空其 pressed/partial decoder state;
- 新 owner 从干净状态开始;
- focus out 也按 capability 决定是否通知 child。
7.5 物理键与终端字节分开¶
客户端应传“键是什么”,服务器根据 Ghostty 当前 mode 决定“发哪些字节”。原因包括:
- Kitty keyboard protocol 可被 child 动态打开/关闭;
- application cursor/keypad mode 改变方向键序列;
modifyOtherKeys改变修饰键编码;- 远端 client 不拥有权威 VT state;
- server 需要先让 global keybinding 有机会消费。
只有 raw passthrough 类客户端才可能直接发送 terminal bytes;即便如此也必须通过明确 role/capability,而不是绕过所有权。
7.6 Mouse 的双重坐标系¶
client mouse event 使用 client viewport 坐标。server 必须先用该 client 的 ViewState 命中:
- sidebar;
- tab/workspace chrome;
- split border;
- overlay/context menu;
- pane content rect。
只有落入 pane content 后,才把坐标转换为 pane-local cell,再依据 VT mouse tracking mode 编码。非前台客户端的 view 可与共享 PTY 尺寸不同,因此绝不能直接把全局坐标写入 child。
7.7 为什么分开 compute_view 与 render¶
compute_view 接受 &mut AppState,render 只接受 &AppState。
flowchart LR
State["权威 state"] --> Compute["compute_view(area, policy)"]
Compute --> Derived["ViewState: rects, visible panes, hit targets"]
Compute --> Resize["仅在允许时 resize PTY"]
Derived --> Render["render(&state, frame)"]
Render --> Cells["virtual cell grid"]
compute_view 可以做什么¶
- 根据 desktop/mobile layout 分配区域;
- 递归计算 pane rect;
- 更新 sidebar、overlay、popup 命中区域;
- 标记 visible terminals;
- 在 foreground policy 下触发必要 PTY resize;
- 准备 render 只读所需的派生值。
render 不应该做什么¶
- 运行
git或进程树查询; - 改变 selection/focus;
- resize PTY;
- 构造整个 terminal history 字符串;
- 读写磁盘;
- 等待 channel/lock;
- 因“顺便发现状态”而发业务事件。
纯 render 让测试可以对同一 state 重复绘制而不改变系统,也让 retained rendering 的前提更可靠。
7.8 Desktop 与 Mobile View¶
ViewLayout 区分 Desktop/Mobile。它不是简单 breakpoint CSS,因为 terminal cell grid、sidebar 和输入命中都在服务器计算。
小 viewport 下可能需要:
- 隐藏或折叠 sidebar/chrome;
- 只展示 focused pane;
- 改变 navigator/menu 呈现;
- 避免把 PTY resize 到不可用的极小内容区;
- 保持 overlay 在有限 cell 网格中可操作。
同一 AppState 可以为不同 client 计算不同 ViewState,但只有 foreground view 允许改变共享 runtime 尺寸。
7.9 虚拟 backend¶
Headless server 没有物理 terminal,仍复用 ratatui 风格的绘制逻辑:
- 创建/维护 virtual buffer;
- 用 client area 运行
compute_view; - 调用纯
render写 cells; - 提取
FrameData或交给 ANSI blitter; - 与该 client baseline 比较;
- 发送完整或增量 frame。
这个适配层让 UI widget 无需知道输出是本地 crossterm 还是网络 frame,同时也暴露了一个约束:任何直接调用物理 terminal API 的 widget 都会破坏 headless 复用。
7.10 两级增量¶
Herdr 的增量不是单一 diff,而是两级:
flowchart LR
Bytes["PTY bytes"] --> VT["VT dirty rows"]
VT --> PanePatch["pane 内容局部 patch"]
PanePatch --> Virtual["virtual full view buffer"]
Virtual --> ClientDiff["per-client semantic/ANSI diff"]
ClientDiff --> Wire["send"]
- 终端内部增量避免每批 output 重建整个 pane snapshot;
- 客户端帧增量避免每次发送整个 app 屏幕。
两层各自有 baseline 和 fallback。不能因为 VT 只脏一行,就假设 app frame 只变一行:sidebar badge、pane border 或 overlay 也可能因同一事件改变。
7.11 Render impact 与 full fallback¶
状态动作会产生不同呈现影响。典型 full-render 条件包括:
- area/layout/sidebar 变化;
- mode/overlay 打开关闭;
- focus/border/title 变化;
- client 新连接或 repaint 请求;
- terminal resize/alternate screen;
- graphics/hyperlink 使 patch 难以安全组合。
纯终端内容更新且布局稳定时,retained plan 才能走快速路径。关键不是 enum 名称本身,而是每个改变状态的动作必须诚实声明它破坏了哪些呈现假设。
7.12 隐藏 pane 的优化边界¶
隐藏 tab/workspace 中的 terminal 仍必须:
- 持续读取 PTY,避免 child 阻塞;
- 解析 VT,保持协议和 history 正确;
- 处理终端 query responses;
- 更新 Agent detection/content sequence;
- 发布重要 lifecycle/blocked 事件。
可以跳过的是:
- 为当前 client 生成 cell patch;
- 把隐藏 pane 合成 app frame;
- 逐帧比较其呈现输出。
这叫“跳过 presentation”,不是“暂停 terminal”。
7.13 架构守卫¶
scripts/test_ui_hot_path_architecture.py 以静态检查保护热路径,禁止或约束:
- 聚合式 input state 重新混入热路径;
- render 中构造格式化 terminal snapshot;
- render 中做 foreground process inspection;
- 已知的跨层调用回流。
这类测试不证明性能数值,却能防止结构回归。它把“render 应该纯”从口头原则变成 CI 可执行契约。
7.14 错误和极端输入¶
| 场景 | 处理原则 |
|---|---|
| 非 UTF-8 raw bytes | 保留终端协议语义,不强行当文本 |
| 不完整 escape sequence | decoder 跨 read 保留 partial state |
| 超大 paste | 分块/有界排队,不能阻塞控制消息 |
| client takeover 中有 pressed key | 释放旧 lease 并清理状态 |
| 0×0/极小 viewport | clamp/跳过无效 resize,保持 server 可恢复 |
| mouse 落在 overlay | overlay 优先,不穿透 terminal |
| diff baseline 不可信 | 请求/发送 full repaint |
7.15 本章结论¶
- 输入先标准化、再过 UI 状态机、最后由权威 VT 编码;
- 控制客户端的 lease 同时保护写权限和输入状态生命周期;
compute_view集中所有允许的派生 mutation,render保持只读;- terminal dirty patch 与 client frame diff 是两级独立优化;
- 隐藏只跳过呈现,绝不能停止 PTY 协议与 Agent 检测。
下一章进入 Herdr 最有辨识度的功能:它如何从进程、屏幕、OSC 和 Agent 主动 Hook 中判断 Working/Blocked/Idle,并在证据冲突和迟到时保持状态可信。