01 · 全景与阅读地图¶
Herdr 最容易被低估的地方,是把它看成“带分屏的终端 UI”。如果只沿着渲染组件阅读,会看见很多 mode、pane 和 widget,却解释不了为什么项目需要后台服务、二进制协议、PTY FD 交接、Agent 状态仲裁和 3000 多个测试。
更准确的起点是:Herdr 是一个长寿命 Agent 进程的本地控制平面,终端 UI 只是其中一个客户端。
1.1 它真正管理什么¶
普通终端模拟器重点管理字符网格和窗口。Herdr 还要管理:
| 对象 | 生命周期 | 主要风险 |
|---|---|---|
| shell / Agent 子进程 | 分钟到数天 | UI 退出或升级时不能误杀 |
| PTY master 与 reader | 跟随子进程 | 阻塞、丢字节、交接期间重复读取 |
| VT 状态 | 持续随字节变化 | escape sequence、alternate screen、图片、超链接 |
| workspace / tab / pane 布局 | 跨重启 | 内部 ID、公开编号和终端身份不能混淆 |
| Agent 工作状态 | 秒级变化 | 多来源冲突、迟到事件、误判阻塞 |
| 本地/远程客户端 | 随时加入离开 | 尺寸、焦点、输入所有权和慢消费者 |
| 自动化请求 | 毫秒到无限等待 | 不能阻塞主状态循环,不能丢事件窗口 |
| Git worktree | 比 UI 更长寿 | 异步完成乱序、脏目录、错误删除 |
因此,源码里的复杂度主要来自生命周期交叉,不是来自画边框。
1.2 四个平面¶
把 22 万行 Rust 压缩成心智模型,可以分为四个平面。
flowchart TB
subgraph Presentation["呈现平面"]
Input["键盘 / 鼠标 / 粘贴 / IME"]
View["ViewState / Mode"]
Render["compute_view / render / frame diff"]
end
subgraph Control["控制平面"]
Loop["App 或 HeadlessServer 主循环"]
State["AppState / Workspace / Tab / PaneState"]
API["JSON API / EventHub / Plugins"]
end
subgraph Runtime["运行时平面"]
Registry["TerminalRuntimeRegistry"]
Pty["PTY actors"]
VT["Ghostty VT"]
Detect["进程 + 屏幕 + OSC + Hook"]
end
subgraph Durability["持久化与分布平面"]
Snapshot["SessionSnapshot / history"]
Handoff["Live Handoff"]
Remote["SSH bridge / client protocol"]
Git["worktree / config / updates"]
end
Input --> Loop
Loop --> State
Loop --> Registry
Registry --> Pty --> VT
VT --> Detect --> State
State --> View --> Render
API <--> Loop
State --> Snapshot
Registry --> Handoff
Render --> Remote
Git --> Loop
四个平面不是四个独立包;它们是在同一调用链上承担不同责任的边界。
呈现平面¶
决定“当前客户端看见什么、输入发给谁”。核心入口在 src/ui.rs、src/input/ 和 src/app/state.rs。它包含派生几何和瞬时 mode,但不应该承担进程发现或磁盘 I/O。
控制平面¶
把所有状态变化串行化。默认运行时由 HeadlessServer::run 担任协调者;兼容的单进程模式由 App::run 承担相似职责。
运行时平面¶
拥有不能直接序列化的活跃资源:PTY、子进程、终端模拟器、reader task 和检测 sequence。入口集中在 src/terminal/runtime_registry.rs、src/pane.rs 和 src/pty/。
持久化与分布平面¶
处理“跨重启、跨进程、跨机器之后仍然指向同一件事”。它包括 snapshot、命名 session、Live Handoff、SSH bridge、客户端协议、Git worktree 和 updater。
1.3 默认部署拓扑¶
默认启动不是一个 TUI 进程独占一切,而是后台服务拥有真实状态,前台进程作为客户端连接。
flowchart LR
subgraph Host["同一台主机 / 同一个 session namespace"]
S["herdr server\n状态 + PTY + API"]
C1["app client A"]
C2["app client B"]
O["terminal observer"]
CLI["herdr CLI / plugin"]
P1["Agent PTY 1"]
P2["Agent PTY 2"]
C1 <-->|"binary socket"| S
C2 <-->|"binary socket"| S
O <-->|"observe/control"| S
CLI <-->|"JSON lines API"| S
S <-->|"PTY bytes"| P1
S <-->|"PTY bytes"| P2
end
这里有两个刻意分开的 socket:
- 客户端 socket传输握手、按键、尺寸、语义帧或 ANSI 帧;
- API socket传输行分隔 JSON 请求/响应,供 CLI 和自动化使用。
两者都操作同一份服务器状态,但流量形状和安全边界完全不同。把它们混成一个协议,会让高频帧、长等待 API 和交互输入互相阻塞。
1.4 一次按键的完整旅程¶
以用户在 Agent 终端里按下 Enter 为例:
sequenceDiagram
participant U as User
participant C as Client input
participant S as HeadlessServer
participant M as Mode router
participant T as PaneTerminal
participant P as PTY actor
participant A as Agent process
participant V as Ghostty VT
participant R as Render stream
U->>C: physical key / bytes
C->>S: ClientMessage::Input
S->>M: normalize + route
M->>T: terminal key event
T->>T: encode by active terminal mode
T->>P: bounded input command
P->>A: write PTY master
A-->>P: output bytes
P-->>V: process_pty_bytes
V-->>S: dirty + callbacks + content sequence
S->>R: coalesced render signal
R-->>C: semantic or ANSI diff frame
C-->>U: draw terminal
关键点不是箭头数量,而是每段都使用适合自己的语义:
- 客户端发的是规范化输入消息,不直接持有 PTY;
PaneTerminal按当前 VT keyboard mode 编码,不能把所有键都当 UTF-8;- PTY 输入有界,避免慢进程无限吞内存;
- PTY 输出不会为每个 byte 都排一条 UI 事件,而是更新 VT 后发合并信号;
- 渲染只在帧成功进入发送路径后推进客户端基线。
1.5 一次 Agent 状态变化的旅程¶
Agent 状态并不是单个 parser 的输出。
flowchart LR
Proc["前台进程组 / 进程树"] --> Identity["Agent identity"]
Screen["底部检测文本"] --> Manifest["TOML rules"]
OSC["OSC title / progress"] --> Manifest
Hook["官方 integration hook"] --> Arbiter["TerminalState 仲裁"]
Identity --> Arbiter
Manifest --> Arbiter
Seq["sequence + session generation"] --> Arbiter
Arbiter --> Raw["Idle / Working / Blocked / Unknown"]
Seen["PaneState.seen"] --> View["UI projection"]
Raw --> View
View --> Display["Working / Blocked / Done …"]
这个分层解释了两个乍看奇怪的事实:
Done不在原始AgentState枚举里;它是Idle + 未读的呈现投影。- pane 的“是否看过”不能塞进 terminal runtime;同一终端可能换布局位置,而未读语义属于视图中的附着关系。
1.6 三条最重要的不变量¶
不变量 A:状态不是运行时¶
结构化、可恢复的事实和活跃 OS 资源必须分开。TerminalId 是两边的连接键,而不是把 Arc<Mutex<PTY>> 塞进 snapshot。
不变量 B:渲染不制造外部副作用¶
compute_view 可以更新派生几何和触发必要 resize;render 只读取已经准备好的状态。进程查询、Git、持久化和格式化大快照都不能潜入逐帧路径。
不变量 C:异步结果必须证明自己仍然新鲜¶
Herdr 重复使用三种证据:
- sequence:同一来源中,拒绝旧报告;
- generation / identity:证明事件仍属于同一 Agent 会话或同一 pane;
- operation id / key:证明后台 worktree 结果仍对应当前请求。
这不是重复设计,而是不同边界上对同一类时间竞态的局部解法。
1.7 模块地图¶
| 目录/文件 | 主要职责 | 建议先读的符号 |
|---|---|---|
src/main.rs |
参数预处理与运行模式分流 | main |
src/server/ |
daemon、客户端、虚拟渲染、handoff | HeadlessServer::run |
src/app/ |
领域状态与动作协调 | App, AppState, Mode |
src/workspace/ |
tab、公开编号、git 元数据 | Workspace, Tab |
src/layout.rs |
二叉分割树与几何 | Node, TileLayout |
src/terminal/ |
终端身份、状态、runtime registry | TerminalState, TerminalRuntime |
src/pane/ + src/pane.rs |
pane 附着状态和传统 runtime | PaneState, PaneRuntime |
src/pty/ |
Unix/Windows PTY actor | 平台 reader/control loop |
src/ghostty/ |
libghostty-vt FFI 封装 | Terminal |
src/detect/ |
Agent 身份与屏幕规则 | AgentDetection, manifest evaluator |
src/protocol/ |
客户端二进制 wire model | ClientMessage, ServerMessage |
src/api/ |
JSON API、wait、schema | Method, Request |
src/persist/ |
snapshot、history、原子写 | SessionSnapshot |
src/integrations/ |
Agent 配置注入与 hook | IntegrationTarget |
src/plugins/ |
manifest、registry、action/event/pane | plugin manifest types |
src/remote/ |
SSH bridge、远端二进制发现 | remote launch flow |
src/platform/ |
macOS/Linux/Windows 差异 | platform capabilities |
完整到文件级的入口见源码地图。
1.8 规模告诉我们的事¶
固定快照中,src/ 有 243 个 Rust 文件、约 220,337 行 Rust。最大的几个文件包括:
| 文件 | 约行数 | 暗示 |
|---|---|---|
src/server/headless.rs |
10,971 | server 仍承担大量协调与迁移兼容逻辑 |
src/pane/terminal.rs |
6,627 | VT 输入、输出、回调、快照和渲染高度耦合 |
src/app/mod.rs |
6,471 | 单进程路径和共享动作仍有历史重量 |
src/app/actions.rs |
6,023 | 用户动作的领域分支非常广 |
src/terminal/state.rs |
5,746 | Agent 状态仲裁是核心复杂域,而非辅助功能 |
文件大不自动等于设计差。这里更有价值的判断是:源码正在从“pane 拥有一切的单体 TUI”迁向“server 拥有 terminal state/runtime 的多客户端系统”,因此包装类型和双路径是演进中的接缝。
1.9 本章结论¶
阅读 Herdr 时,先问四个问题:
- 这段代码属于呈现、控制、运行时还是持久化/分布平面?
- 它操作的是事实、派生缓存,还是活跃资源?
- 谁是唯一写者,跨边界靠什么 ID 或 sequence 对齐?
- 失败后要回滚什么,哪些进程必须继续活着?
下一章从 main 开始,解释一个 herdr 命令怎样选择 daemon、client、remote、update 或兼容的 monolithic TUI。