跳转至

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 把输入状态绑定到当前控制客户端:

  1. foreground client 获得 lease;
  2. 只有 lease owner 的输入改变服务器输入状态;
  3. owner 断线/被 takeover 时清空其 pressed/partial decoder state;
  4. 新 owner 从干净状态开始;
  5. 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 风格的绘制逻辑:

  1. 创建/维护 virtual buffer;
  2. 用 client area 运行 compute_view;
  3. 调用纯 render 写 cells;
  4. 提取 FrameData 或交给 ANSI blitter;
  5. 与该 client baseline 比较;
  6. 发送完整或增量 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 本章结论

  1. 输入先标准化、再过 UI 状态机、最后由权威 VT 编码;
  2. 控制客户端的 lease 同时保护写权限和输入状态生命周期;
  3. compute_view 集中所有允许的派生 mutation,render 保持只读;
  4. terminal dirty patch 与 client frame diff 是两级独立优化;
  5. 隐藏只跳过呈现,绝不能停止 PTY 协议与 Agent 检测。

下一章进入 Herdr 最有辨识度的功能:它如何从进程、屏幕、OSC 和 Agent 主动 Hook 中判断 Working/Blocked/Idle,并在证据冲突和迟到时保持状态可信。