跳转至

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 …"]

这个分层解释了两个乍看奇怪的事实:

  1. Done 不在原始 AgentState 枚举里;它是 Idle + 未读 的呈现投影。
  2. 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 时,先问四个问题:

  1. 这段代码属于呈现、控制、运行时还是持久化/分布平面?
  2. 它操作的是事实、派生缓存,还是活跃资源?
  3. 谁是唯一写者,跨边界靠什么 ID 或 sequence 对齐?
  4. 失败后要回滚什么,哪些进程必须继续活着?

下一章从 main 开始,解释一个 herdr 命令怎样选择 daemon、client、remote、update 或兼容的 monolithic TUI。