跳到主要内容

数据截至 (上游 commit 3667151744e3)

渲染管线:服务端画好帧,再决定发多少字节给谁

30 秒导读: herdr 的服务端是个没有屏幕的进程,却要负责把整个 TUI 画出来。它的办法是:在内存里开一块假的终端缓冲区画完一整屏,拿到一个结构化的「帧」,然后针对每个连上来的客户端分别决定——是把这一屏的所有单元格原样寄过去,还是在服务端先跟上一帧比对、只寄变化部分的 ANSI 转义字节。本章讲这条管线怎么搭,以及在 agent 疯狂刷屏、多个客户端尺寸还不一样时,它靠哪几道闸门不被压垮。

前置背景在 01-server-client-runtime(为什么有个常驻服务端和一群瘦客户端)与 02-terminal-core(PTY 和内嵌 VT 怎么产出可渲染的终端状态)。本章只讲「画」和「发」。


1. 先搞清楚问题:没有终端,怎么画?

普通 TUI 程序的渲染很直接:程序跑在你的终端里,ratatui 把界面画进一块缓冲区,再把差异写到 stdout,你就看见了。

herdr 把这件事拆开了。真正持有工作区、tab、PTY 的是一个后台服务端进程,它没有 stdout 可画——甚至可能是从 systemd/launchd 拉起来的、根本没有控制终端。看界面的是客户端进程,它有真终端,但没有任何应用状态。

于是渲染要回答三个问题:

问题herdr 的答案
没有终端,往哪画?画进内存里的一块假后端缓冲区(ratatui::backend::TestBackend)
画完的东西怎么过网络?序列化成 FrameData(整屏单元格),或先 diff 成 ANSI 字节
一个 agent 每秒刷几百行,怎么办?16ms 渲染节流 + 「上一帧打补丁」的保留渲染 + 每客户端只留一帧的渲染队列

一句话直觉: 把它想成一个没有显示器的游戏服务器——它照样跑完整的渲染逻辑得到一张「画面」,然后按每个玩家的带宽,决定是发整张位图,还是发压缩过的帧间差分。


2. 顶层全景:一帧的生命周期

先看整条流水线。从左到右是时间顺序,每一格都可以提前退出(下面章节会讲每道闸门)。

[PTY 有新输出] [用户按了键 / API 改了状态]
| |
v v
RenderSignal.request_pty RenderSignal.request_generic
\_______________ _______________/
\/
服务端事件循环每轮取一次信号
|
+---------v---------+ 不到 16ms / 没人看得见
| ① 节流与可见性闸门 |------------------> 跳过,睡到下个截止时间
+---------+---------+
| 该画了
+---------v---------+ 只有 PTY 脏 & 状态干净
| ② 选渲染计划 |----> 保留帧局部打补丁(不重画 UI)
+---------+---------+
| 需要全画
+---------v---------+
| ③ 虚拟渲染出一帧 | compute_view -> render -> TestBackend Buffer
+---------+---------+
|
v Buffer -> FrameData(cells/cursor/hyperlinks/graphics)
+---------------------+
| ④ 按客户端分别编码 |
+----+-----------+----+
| |
SemanticFrame TerminalAnsi
(整帧 CellData) (服务端 diff 后的 ANSI 字节)
| |
v v
客户端自己 blit 客户端直接 write_all 到 stdout

怎么读这张图: 第 ①②④ 步是本章的三个重点,分别对应「什么时候画」「画多少」「发多少字节」。

各部件一句话职责:

部件干什么在哪
RenderSignal把散落各处的「该重画了」合并成一个待处理请求,并记住是谁触发的src/render_signal.rs:17
compute_view算几何、顺带把 pane 尺寸对齐(会改 AppState)src/ui.rs:111
render只读 AppStateFrame 上画,不改任何状态src/ui.rs:391
render_virtual_with_runtime_registry在内存 backend 上跑一遍上面两步,产出 Buffer + 光标src/server/render_stream.rs:304
ClientRenderState每个客户端一份的「上一帧基线」,决定这次发什么src/server/render_stream.rs:13
BlitEncoder把两帧 FrameData 的差异编码成终端 ANSI 字节src/protocol/render_ansi.rs:57
render_and_stream全渲染 + 遍历所有渲染目标发帧src/server/headless.rs:4432

3. 纯渲染约定:算几何的和画画的必须分家

3.1 它要解决的小问题

ratatui 的绘制回调拿到的是 &mut Frame,很容易顺手在里面改应用状态(比如"发现侧栏放不下了,把滚动位置改一下")。一旦这么写,同一份状态在不同尺寸的客户端下会互相踩——因为服务端要为每个客户端各画一遍。

3.2 herdr 的做法:两个函数,签名就把规矩定死了

pub fn compute_view(app: &mut AppState, area: Rect) // src/ui.rs:111 —— 可变,算几何
pub fn render(app: &AppState, frame: &mut Frame) // src/ui.rs:391 —— 只读,只画

compute_view 的注释写得很直白:"Called before render to separate mutation from drawing"(src/ui.rs:109-110)。真正干活的是 compute_view_internal(src/ui.rs:215),它做三类事:

  1. 切版面 —— 侧栏宽度、tab 栏位置、终端区,靠 Layout::horizontal 切(src/ui.rs:238-239)。
  2. 夹紧滚动量 —— 把 workspace_scrollagent_panel_scrolltab_scroll 修正到合法范围(src/ui.rs:245-262)。
  3. 对齐 pane 尺寸 —— 只在 resize_panes = true 时,把每个 pane 的 VT 尺寸 resize 到它该有的大小(src/ui.rs:288-291)。

render(src/ui.rs:391,实体是 render_with_runtime_registry,src/ui.rs:396)则完全不碰状态:读 app.view 里已经算好的矩形,依次画导航区、tab 栏、pane 表面、通知、弹出 pane,最后按 app.mode 画一个覆盖层。

3.3 关键分叉:resize_panes 这个开关

多客户端时,pane 的 VT 只有一份(见 02-terminal-core),它不能被每个客户端各 resize 一次。于是有第三个入口:

pub(crate) fn compute_view_without_resizing_panes(...) // src/ui.rs:144

注释点明了用途:非前台客户端需要按自己的尺寸算几何,但共享的 pane runtime 要钉在前台客户端的尺寸上(src/ui.rs:139-143)。这条规则在服务端的落地见 §7。

3.4 什么时候画:RenderSignal + 16ms

RenderSignal(src/render_signal.rs:17)是一个合并器。它不只记「脏了」,还记谁弄脏的:

字段含义谁写
generic非 PTY 的普通状态变化request_generic(src/render_signal.rs:37)
pty_sources哪些 pane 的 PTY 有新输出request_pty(src/render_signal.rs:47)
terminal_title_sources哪些 pane 改了终端标题request_terminal_title(src/render_signal.rs:81)

分开记的价值在结构体注释里:让服务端能"丢弃对所有客户端都不可见的、纯 PTY 的更新"(src/render_signal.rs:14-15)。一个后台 tab 里的 agent 在疯狂刷屏,没人看得见,那就不必渲染——这条判据在 §6 展开。

节流常量只有一行:

const MIN_RENDER_INTERVAL: Duration = Duration::from_millis(16); // src/app/mod.rs:39

16ms ≈ 60fps 上限。但它被拆成两个闸门:

  • can_render_now(src/app/runtime.rs:530)—— 距上次任何渲染尝试是否满 16ms。
  • can_present_now(src/app/runtime.rs:537)—— 距上次真的把画面呈现给人看是否满 16ms。

record_render_attempt(now, presentation)(src/app/runtime.rs:546)按第二个参数决定要不要同时推进呈现时间戳。为什么要分两个?因为"分类一次隐藏 PTY 的输出然后什么都不发"也是一次渲染尝试,不该把真正要给人看的帧挡在门外——这条不变量有专门的测试:hidden_render_attempt_keeps_presentation_cadence_available(src/app/runtime.rs:677)。

顺带说明:同一套 compute_view / render 也被进程内直跑模式用着(App::run,src/app/mod.rs:935;绘制在 src/app/mod.rs:1098-1127)。服务端模式只是把 terminal.draw 的后端从真终端换成了内存缓冲区。


4. 虚拟渲染:在内存里造一个假终端

4.1 思路

ratatui 自带一个测试用后端 TestBackend——它把"写入终端"变成"写入一块 Buffer"。herdr 直接把这个测试设施当生产设施用:服务端每次渲染就 new 一个 TestBackend,画完把 Buffer 拿走。

4.2 为什么还要包一层 CursorTrackingBackend

TestBackend 记不住光标最终停在哪,而客户端需要知道光标该画在哪一格。所以 herdr 包了一层(src/server/render_stream.rs:200),只做一件事:拦下 set_cursor_position / hide_cursor,把最后一次位置记进 rendered_cursor

4.3 主流程

// src/server/render_stream.rs:304 render_virtual_with_runtime_registry
if resize_panes { compute_view_with_cell_size(...) } else { compute_view_without_resizing_panes(...) }
let backend = CursorTrackingBackend::new(area.width, area.height);
let mut terminal = ratatui::Terminal::new(backend).expect(...);
terminal.draw(|frame| crate::ui::render_with_runtime_registry(app_state, terminal_runtimes, frame));
let buffer = terminal.backend().buffer().clone();

拿到 buffer 之后,光标要走一段优先级选择(src/server/render_stream.rs:322-338):弹出 pane 的光标 > 聚焦终端自己的光标 > 后端记下的位置;并且当聚焦终端处于同步输出(synchronized output)中或已滚回历史,光标要被抑制。这不是小事——远程终端上一个乱跳的光标会把输入法的候选框拽到屏幕另一头。

4.4 每客户端一份的基线:ClientRenderState

画出来的帧要不要发、怎么发,取决于这个客户端的基线状态:

pub(crate) enum ClientRenderState { // src/server/render_stream.rs:13
Semantic { last_frame: Option<FrameData> },
TerminalAnsi { blit_encoder: BlitEncoder, seq: u64, repaint_pending: bool },
}

三个操作定义了它的全部生命周期:

方法干什么什么时候用
prepare_frame(:65)跟基线比;一样就返回 None(整帧不发)每次要发帧时
reset_baseline(:36)把基线整个清空,当作从没发过客户端切换成直连终端模式时(src/server/headless.rs:1901:2880)
request_repaint(:50)保留结构、但要求下一帧走全量重绘共享 runtime 尺寸变了、客户端 resize 了(src/server/headless.rs:1143:3259)

还有一个细节值得单独拎出来:reset_semantic_input_baseline(:59)只清语义客户端的基线。源码注释解释了为什么不能一视同仁——ANSI 客户端如果每次按键都清基线,就等于每敲一个字符全屏重绘一次,远程会慢到不能用(src/server/headless.rs:2937-2940)。


5. 两种线上编码:发单元格,还是发 ANSI

5.1 协议里只有两个选项

pub enum RenderEncoding { // src/protocol/wire.rs:39
SemanticFrame, // 发完整的 FrameData
TerminalAnsi, // 发已经 diff 好的终端 ANSI 字节
}

客户端在握手时提出诉求(ClientMessage::Hello { requested_encoding, .. }),服务端在 ServerEvent::ClientConnected 里原样收下并建好对应的 ClientRenderState(src/server/headless.rs:3010:3042)。客户端这边的选择极其朴素——读环境变量:

fn requested_render_encoding() -> RenderEncoding { // src/client/mod.rs:694
match std::env::var("HERDR_RENDER_ENCODING").ok().as_deref() {
Some("terminal-ansi" | "terminal_ansi" | "ansi") => RenderEncoding::TerminalAnsi,
_ => RenderEncoding::SemanticFrame,
}
}

5.2 两种编码的对比

维度SemanticFrameTerminalAnsi
服务端发什么整屏 FrameData(width × heightCellData)只有变化部分的转义字节
diff 在哪做客户端(客户端也有一个 BlitEncoder)服务端
客户端要干的活反序列化 + 自己 blit(src/client/mod.rs:1692-1718)stdout.write_all(&frame.bytes),几乎零成本(src/client/mod.rs:1720-1726)
帧大小上限MAX_FRAME_SIZE = 2 MiB(src/protocol/wire.rs:20)同上(含图形时放宽到 32 MiB)
默认用在哪本地 socket 客户端远程 attach
客户端能否重排/加工帧能(拿到的是结构化数据)不能(拿到的是字节流)

5.3 SemanticFrame 长什么样

pub struct FrameData { // src/protocol/wire.rs:527
pub cells: Vec<CellData>, // 行优先,长度必须等于 width * height
pub width: u16,
pub height: u16,
pub cursor: Option<CursorState>,
pub hyperlinks: Vec<String>, // OSC 8 目标,cell 里存下标
pub graphics: Vec<u8>, // 文字帧之后要应用的 Kitty 图形字节
}

单个 CellData(src/protocol/wire.rs:476)含 symbol: Stringfg: u32bg: u32modifier: u16skip: boolhyperlink: Option<u32>。注意 symbol堆上的 String,一屏 200×50 就是一万个 String。这就是为什么整帧编码在慢链路上不合算——成本跟屏幕面积成正比,跟"这一帧到底变了多少"完全无关

超链接用了一个小技巧:URI 去重后存进 FrameData::hyperlinks,单元格只存下标(from_ratatui_buffer_with_hyperlinks,src/protocol/wire.rs:556-596)。一个满屏都是同一个链接的日志页,URI 只传一份。

5.4 TerminalAnsi 的 blit 策略

BlitEncoder(src/protocol/render_ansi.rs:57)只有三个字段:上一帧、上次可见光标位置、上次光标形状。encode(:68)是不可变的——它算出 EncodedBlit 但不改自己;只有确认发出去之后才 commit(:128)。这个「先算后提交」的分离,让"序列化失败/通道满了"这类情况不会污染基线。

模块顶部注释列出的 6 步策略(src/protocol/render_ansi.rs:1-18),对应实现在 blit_frame_to_with_cursor_memory_and_clear_policy(:447):

做什么为什么
1第一帧写整个缓冲区没有基线可 diff
2之后只写变化的单元格这是省字节的主力
3整帧包在同步输出里支持的终端不会看到画到一半的中间态
4写任何单元格前先隐藏光标否则光标会在中间的 CUP 位置上留下残影
5全部写完再恢复光标可见性与位置先显示后移动会让慢终端/输入法看到光标停在最后画的那格
6需要的平台在结束同步输出后再补一次光标锚点有些原生输入法看不到同步块内的光标移动;Windows Terminal 会把这次重复显示成可见抖动,所以 Windows 跳过

第 6 步的平台分叉就是两个函数:repeat_ime_anchor_after_sync() 在 Windows 返回 false、其他平台返回 true(src/protocol/render_ansi.rs:513-521)。

用到的转义序列(注释在 :20-25,实际写入在 :464-500):

序列作用
CSI H(CUP)移动光标到 (行, 列)
CSI m(SGR)设置颜色/加粗等
CSI ? 2026 h / l开始/结束同步输出
CSI Ps SP q(DECSCUSR)光标形状
CSI ? 25 l隐藏光标(第 4 步)
OSC 8 ;;每帧开头清空超链接状态,防止未加链接的格子继承上一次的 URI
CSI 2 J仅在从未画过时清屏(clear_before_full_redraw)
OSC 52剪贴板写入

省字节的关键在 write_changed_cells(:773):按行扫描,用 last_sgr 记住上一次的样式串避免重复发 SGR;用 next_inline_col 记住"如果下一格正好是当前列 +1,就不用再发一次 CUP"(:800-812)。宽字符和被覆盖的残留格用 to_skip / invalidated 两个计数器处理(:814-816)。

5.5 为什么远程要发 ANSI 而不是发 cell

答案在远程 attach 的启动代码里——它硬编码了编码方式:

// src/remote/attach.rs:2126,run_client_process
.env("HERDR_RENDER_ENCODING", "terminal-ansi")

道理很简单:远程链路上跑的每一个字节都要过 SSH。语义帧的体积由屏幕面积决定,而 ANSI 帧的体积由这一帧真的变了多少决定。一个 agent 只是在最后一行追加了一行日志,ANSI 编码可能只有几十字节,语义帧仍然是满屏一万个单元格。

代价也很明确:diff 的 CPU 成本从客户端搬到了服务端,而且服务端必须为每个 ANSI 客户端各维护一份 BlitEncoder(因为每个客户端看到的上一帧不同)。

5.6 图形字节要塞进同步块里面

Kitty 图形字节不能随便追加在末尾,否则会在同步输出块结束之后才落地,导致闪一下。所以:

// src/server/render_stream.rs:158 insert_graphics_before_sync_end
if let Some(sync_end) = crate::protocol::render_ansi::final_sync_output_end(encoded) {
encoded.splice(sync_end..sync_end, graphics.iter().copied());
} else {
encoded.extend_from_slice(graphics);
}

final_sync_output_end(src/protocol/render_ansi.rs:39)用 rposition 找最后一个 \x1b[?2026l 的位置,把图形字节插在它前面。客户端侧有对应的测试:graphics_bytes_are_written_inside_synchronized_blit_with_saved_cursor(src/client/mod.rs:2961)。


6. 增量与早退:能不重画,就绝不重画

这一节是整条管线里工程密度最高的部分。核心矛盾:一个 agent 的 PTY 可能每秒产生几百次输出,而全渲染要重新画侧栏、tab 栏、所有 pane 边框、状态栏——绝大部分内容根本没变。

6.1 先分级:这次改动有多严重

enum RenderImpact { None, Graphics, Full } // src/server/headless.rs:133

merge 就是取 max(:141-143),所以一批事件里只要有一个是 Full,整批就按 Full 算。record_render_impact(:182)把"是 API 请求还是客户端事件导致了全渲染"打成 profiling 事件,方便事后归因。

PTY 单独有个三态,因为它要回答"这次脏的 pane,有人看得见吗":

enum PtyRenderState { Clean, Hidden, Visible } // src/server/headless.rs:147

判定逻辑在 pty_sources_visible_to_any_render_target(:4142),它会问:有 app 客户端吗?脏的 pane 在当前工作区的当前 tab 里吗?被 zoom 挡住了吗(app_surface_contains_pane,:4178)?有没有哪个直连终端客户端正盯着这个 terminal?

6.2 再选计划

四种输入组合,四条出路,一个纯函数说了算:

fn retained_render_plan(input: RetainedRenderInput) -> RetainedRenderPlan { // src/server/headless.rs:168-181
if input.needs_full_render { Full }
else if input.needs_graphics_render && input.pty != Visible { Graphics }
else { match input.pty { Visible => Pty, Hidden => HiddenPty, Clean => Full } }
}
needs_full_render? --yes--> Full (走 render_and_stream,整条管线全跑)
|no
v
needs_graphics? & PTY 非可见 --yes--> Graphics (只重发图形层)
|no
v
PTY 状态?
Visible --> Pty (拿上一帧打补丁,不重画 UI)
Hidden --> HiddenPty (什么都不发,只推进节流时钟)
Clean --> Full (说不清,保守走全渲染)

Clean 落到 Full 是个保守兜底:走到这里说明有人喊了"要渲染"但没说清是什么脏了,那就重画。四条路径都有断言覆盖:retained_render_plan_covers_each_render_path(src/server/headless.rs:5317)。

HiddenPty 这条路的收益最直接——它返回 true(算作"渲染成功"),然后 record_render_attempt(now, false)(:789)把 presentation = false 传下去。也就是:分类一次隐藏输出会推进渲染节流,但不会占用呈现配额。这正是 §3.4 里那两个闸门存在的理由。

6.3 保留帧局部打补丁:Pty 路径怎么走

这是最省的一条路。思路:上一帧我还留着,PTY 只脏了几行,那就直接把那几行的单元格覆盖进去,其余原封不动。

拿到 client.render_state.last_frame() 的克隆
|
for 每个可见 pane:
runtime.collect_dirty_patch(w, h)
|-- Clean -> 跳过
|-- Fallback -> 放弃,退全渲染
`-- Patch(p) -> 检查是否碰到超链接 -> 覆盖进 frame
|
重新取一次聚焦终端的光标
|
没碰任何格子 且 光标没变 -> 直接算成功,一个字节都不发
|
否则 -> send_retained_frame_to_client

实现是 render_retained_pty_update_and_stream(src/server/headless.rs:4218)。三个辅助函数守着边界:

函数位置守什么
rect_fits_frame:193pane 的矩形必须完全落在帧内,否则任何切片都不安全
apply_terminal_dirty_patch:198逐行 clone_from_slice;行宽对不上、越界一律返回 false
dirty_patch_intersects_hyperlinks:222补丁覆盖的区域里若已有超链接格子,拒绝——因为补丁不带 OSC 8 信息,盖上去会留下指向旧 URI 的僵尸链接

dirty_patch_intersects_hyperlinks 的写法是刻意保守的:范围算不出来时(local_y >= area.heightend > cells.len())也返回 true,即"当作相交,放弃优化"。优化路径出错的代价远大于少优化一次。

6.4 哪些状态会让保留更新直接退化成全渲染

第一道闸门是一个纯谓词,读起来像一张清单:

fn retained_pty_update_allowed_by_app_state(&self) -> bool { // src/server/headless.rs:4332
self.app.state.mode == app::Mode::Terminal
&& self.app.state.popup_pane.is_none()
&& self.app.state.selection.is_none()
&& self.app.state.copy_mode.is_none()
&& self.app.state.context_menu.is_none()
&& self.app.state.toast.is_none()
&& self.app.state.copy_feedback.is_none()
&& !self.app.full_redraw_pending
}

共同点很清楚:凡是会在 pane 上面盖一层东西的状态,都不能用「直接把 PTY 行拍进上一帧」这招——因为补丁不知道自己头顶上压着一个弹窗或 toast,盖上去就把覆盖层抹花了。每一条都有测试作证:

判据对应测试行号
弹出 pane 可见retained_pty_update_declines_while_popup_is_visible:9834
toast 通知可见retained_pty_update_declines_while_toast_is_visible:10048
复制反馈可见retained_pty_update_declines_while_copy_feedback_is_visible:10090
非 Terminal 模式retained_pty_update_declines_unsafe_mode_without_consuming_dirty_rows:10230
补丁会让超链接过期retained_pty_update_declines_when_patch_would_stale_hyperlinks:10280
图形缓存里有内容retained_pty_update_declines_when_graphics_cache_has_content:10379

最后一个测试的名字里有个关键词 without_consuming_dirty_rows——退化时不能把脏行消费掉,否则退回全渲染时那些行已经没人记得脏过了。

过了状态闸门,还有一串针对客户端的闸门(:4225-4303),每一条都以 retained_fallback! 记一个 profiling 事件:

退化原因触发条件
multiple_or_no_target渲染目标不是恰好一个——多客户端时上一帧的语义不唯一
not_app_client目标是直连终端客户端,走别的路径
render_pending该客户端有一次被推迟的全渲染在排队
graphics_cache_active / visible_kitty_graphics屏幕上有图形,补丁不管图形层
no_last_frame / frame_size_mismatch没有基线,或基线尺寸跟客户端现在的尺寸对不上
dirty_patch_fallbackVT 自己说"这次的脏区我描述不了"

6.5 「什么都不发」也是一种成功

补丁流程末尾有一段容易被忽略但很重要的逻辑:

if !touched && !cursor_changed { retained_success!("clean_no_cursor_change"); } // src/server/headless.rs:4316-4318

以及在真的准备发送时,prepare_frame 返回 None 就直接算发送成功(:4358-4363)。整条管线里有三层"内容没变就别发":RenderSignal 的可见性判定、保留补丁的 touched 判定、编码器的 is_current 判定。


7. 多客户端 fanout:一份状态,N 种尺寸

7.1 每个客户端是什么

pub(crate) struct ClientConnection { ... } // src/server/clients.rs:31
pub(crate) enum ClientConnectionMode { // src/server/clients.rs:9
App,
TerminalAttach { terminal_id: String },
TerminalObserve { terminal_id: String },
}

ClientConnection 里跟渲染直接相关的字段:

字段作用
terminal_size / cell_size这个客户端的行列数和单元格像素尺寸
render_state它自己的帧基线(§4.4)
graphics_cache / graphics_surface_reset_pending它的宿主终端上已经放了哪些图
render_pending上次渲染因通道满被推迟了
writer发送通道

7.2 渲染目标列表

pub(crate) fn render_targets(clients, foreground_client_id) -> Vec<RenderTarget> // src/server/clients.rs:288

它筛出「有 writer 且是完整 app 客户端或直连终端客户端」的连接,产出 (client_id, (cols, rows), cell_size, is_foreground, mode) 五元组,并且排序把前台客户端排在最后(:314)。

这个排序不是审美问题。render_and_stream(src/server/headless.rs:4432)会按顺序逐个渲染,而只有 is_foreground = true 的那次会传 resize_panes = true(:4471)。前台放最后,意味着循环结束时共享 pane runtime 的尺寸停在前台客户端的尺寸上,不会被后面某个小窗口客户端改掉。

非前台客户端还要额外保护滚动状态——渲染前后手工存取一遍(:4459-4476):

let preserved_scroll = (!is_foreground).then_some((workspace_scroll, agent_panel_scroll, tab_scroll, mobile_switcher_scroll));
// ... render ...
if let Some((w, a, t, m)) = preserved_scroll { /* 写回 */ }

因为 compute_view 会按当前这个客户端的尺寸夹紧滚动量(§3.2),一个窄窗口客户端不该把宽窗口客户端的侧栏滚动位置压小。

7.3 前台客户端决定什么

sync_foreground_client_state(src/server/headless.rs:1169)把前台客户端的属性投影成全局状态:effective_sizeouter_terminal_focushost_cell_size、宿主终端主题、生效的键位配置(:1212-1224)。没有前台客户端时回落到 headless_size(:1173)。

尺寸一变,所有客户端的基线都作废:

// src/server/headless.rs:1132-1137
// Shared runtime size changes affect pane wrapping and foreground-driven
// rendering semantics. Force one fresh frame to every remaining client
for client in self.clients.values_mut() { client.request_repaint(); }

7.4 背压:渲染队列只有一格

pub(crate) struct ClientWriter { // src/server/client_transport.rs:49
pub(crate) control: ClientControlWriter, // 可靠:关机、通知、剪贴板
pub(crate) render: ClientRenderWriter, // 可丢弃:容量为一,慢客户端攒不出延迟
}

注释就是设计声明(:52)。发送失败时不是重试,而是标记推迟:

Err(TrySendError::Full(_)) => { client.defer_full_render(); } // src/server/headless.rs:4399-4400

DeferredRender(src/server/clients.rs:24)只有 None / Full 两态——被丢掉的帧不会被补发,只会被下一次全渲染覆盖。这是终端 UI 能接受的取舍:用户要的是"当前状态正确",不是"每一帧都到齐"。而且 deferred_render() != None 本身就是保留补丁的退化条件之一(:4241),避免在基线可疑时做增量。

7.5 事件从客户端回到服务端

反方向走 ServerEvent(src/server/client_transport.rs:307)。跟渲染相关的几个:ClientConnected(带 render_encoding、初始尺寸)、ClientInput / ClientInputEventsClientAttachTerminal / ClientObserveTerminal / ClientControlTerminal,以及 Kitty 直传的 GraphicsTransmissionStarted / GraphicsTransmissionResult

一个易被忽略的行为:即使一个客户端都没有,服务端照样虚拟渲染一次(src/server/headless.rs:4436-4455),只是不发给谁。这保证了 AppState::view 里的几何信息始终是新鲜的,新客户端接上来时不会看到一屏陈旧布局。


8. 直连终端模式:绕开整个 UI

有时候你不想要 herdr 的界面,只想看某一个 pane 里的终端——比如另一个 agent 想读某个 agent 的输出(见 04-agent-control-plane)。这条路完全绕开 §3–§6 的 UI 管线。

三个协议消息触发模式切换:

消息位置语义
ClientMessage::AttachTerminal { terminal_id, takeover }src/protocol/wire.rs:394可写接管一个终端
ClientMessage::ObserveTerminal { target }src/protocol/wire.rs:421只读观察
ClientMessage::ControlTerminal { target, takeover }src/protocol/wire.rs:427可写控制

服务端处理函数分别是 attach_terminal_client(src/server/headless.rs:2803)、observe_terminal_client(:1879)、control_terminal_client(:1905)。它们做的第一件事都一样:改 client.mode,然后 client.render_state.reset_baseline()(:1894:2880)——因为接下来这个连接看到的内容跟之前的 app 界面毫无关系,基线必须清零。

渲染走另一个入口:

pub(crate) fn render_terminal_virtual(runtime: &TerminalRuntime, area: Rect) // src/server/render_stream.rs:368

它直接 runtime.render(frame, area, true)——不跑 compute_view、没有侧栏和 tab 栏、没有覆盖层。render_and_stream 里这条分支只多做一件事:找不到对应 terminal 就给客户端发 ServerShutdown 并断开(src/server/headless.rs:4514-4525)。

terminal_stream_client_ids(src/server/clients.rs:270)按 terminal id 反查所有直连该终端的客户端,用于终端消失时统一遣散(shutdown_terminal_stream_clients,src/server/headless.rs:2749)。

诚实说明: src/server/terminal_attach.rs 这个文件本身只有 14 行,里面只有一个 paste_payload_for_runtime(:1)——按目标 VT 是否开了 bracketed paste 决定要不要给粘贴文本包上 \x1b[200~ … \x1b[201~。直连模式的主体逻辑不在这个文件里,而在 src/server/headless.rs 的上述几个方法中。


9. 图形:图片走的是另一条管道

终端里显示图片(Kitty 图形协议)跟画字符是两套东西——图片字节大、有服务端/宿主两端的 id 映射、还不能重复传。herdr 为此单开了三条支线。

9.1 服务端编码 + 缓存

src/kitty_graphics.rs 负责把 pane 上的图形编码成 Kitty 转义序列:

符号位置作用
HostCellSize:28单元格像素尺寸;is_known() 为假时整条图形路径直接停摆
HostGraphicsCache:133记住这个客户端的宿主终端上已经有哪些图,避免重传
encode_local_pane_graphics:215出字节,带 transaction 预算
has_visible_pane_graphics:279判断当前屏幕上有没有可见图形(保留补丁的退化判据之一)
HEADLESS_GRAPHICS_TRANSACTION_BUDGET:21MAX_GRAPHICS_FRAME_SIZE - MAX_FRAME_SIZE,给文字帧留出空间

9.2 只更新图形层

当只有图形变了、PTY 没变时走 Graphics 计划:

pub(super) enum RetainedGraphicsOutcome { Sent, Deferred, Fallback } // src/server/headless/pane_graphics.rs:7
pub(super) fn render_retained_graphics_update_and_stream(&mut self) -> RetainedGraphicsOutcome // :464

三态的意义:Sent = 发出去了;Deferred = 这轮先不发(事件循环 continue 掉,不推进节流);Fallback = 放弃优化,回全渲染。退化条件包括 full_redraw_pending(:466)和「多个 app 客户端尺寸不一致」(mixed_app_geometry,:478)——因为图形放置位置依赖版面几何。

图形字节还要包一层保存/恢复光标:frame_pane_graphics\x1b7\x1b8 夹住(src/server/headless/pane_graphics.rs:13-18)。

9.3 直传:让客户端自己读文件

如果客户端跟服务端在同一台机器上,把图片字节塞进 socket 是纯浪费。于是有 direct graphics:服务端发一个 ServerMessage::GraphicsFile { path, expected_len, image_id, transfer_id, leading, control },客户端自己校验并把本地文件喂给自己的终端(src/client/mod.rs:1743-1787),再回报结果。

客户端侧的状态机在 src/client/direct_graphics.rs:ResponseMatcher(:15)负责 armstartretire/cancel 的配对,带 3 秒超时(RESPONSE_TIMEOUT,:4);valid_control(:215)校验服务端给的控制串确实指向声明的 image id 与长度——服务端给的路径和控制串不被无条件信任

9.4 尺寸上限

pub const MAX_FRAME_SIZE: usize = 2 * 1024 * 1024; // src/protocol/wire.rs:20
pub const MAX_GRAPHICS_FRAME_SIZE: usize = 32 * 1024 * 1024; // src/protocol/wire.rs:25

含图形的帧按 32 MiB 上限走;超了就丢掉图形、保留文字帧并打 warning(src/server/headless.rs:4598-4607)——宁可少一张图,不能整屏卡死。


10. UI 组件目录(导航式列举)

render 内部只是把活分派给 src/ui/ 下的各个模块。要改界面的某一块,按下表跳:

文件负责的界面区域入口符号
src/ui/sidebar.rs(另有 src/ui/sidebar/tokens.rs)左侧工作区列表 + agent 详情面板,含滚动与拖放render_sidebarrender_workspace_list(:1200)、render_agent_detail(:1423)
src/ui/panes.rspane 边框、标题、选区高亮、复制模式光标与搜索高亮、弹出 panerender_popup_panerender_pane_borders(:484)、pane_inner_rect
src/ui/tabs.rstab 栏与其滚动几何render_tab_barcompute_tab_bar_view
src/ui/tab_surface.rstab 内的 pane 布局:几何与绘制分家的那一对compute_tab_surface(:30)、render_tab_surface(:66)
src/ui/dialogs.rs关闭确认、重命名、worktree 新建/打开/移除等模态框render_rename_overlayrender_confirm_close_overlay
src/ui/mobile.rs窄屏布局:移动头部、切换面板、toast 横幅is_mobile_widthrender_mobile_header(:204)、render_mobile_panel(:277)
src/ui/onboarding.rs首次启动引导页render_onboarding_overlay
src/ui/settings.rs设置弹窗(集成、主题、开关)render_settings_overlayrender_settings_theme(:373)
src/ui/menus.rs上下文菜单、导航/前缀/复制/缩放模式的模式条render_context_menurender_navigate_overlay
src/ui/status.rstoast 通知、复制反馈、配置诊断render_toast_notificationrender_copy_feedback
src/ui/scrollbar.rs滚动条几何与拖拽换算should_show_scrollbarscrollbar_offset_from_drag_row

这些模块普遍遵循 §3 的约定:名字带 compute_ 的算几何、带 render_ 的只画;_rect 结尾的函数同时给鼠标命中测试复用。


11. 边界与局限

  • 保留补丁只支持「恰好一个 app 客户端」。两个客户端同时看,multiple_or_no_target 立刻退化成全渲染(src/server/headless.rs:4245)。这是有意的简化:上一帧的语义在多客户端下不唯一。
  • 补丁不认识超链接和图形。碰到就退化(:4300:4244-4259),不做部分合并。
  • 编码方式只能靠环境变量选,且握手后不能改HERDR_RENDER_ENCODING 只在客户端启动时读一次(src/client/mod.rs:704),网络变慢了不会自动切编码。
  • 渲染帧可以被丢弃且不补发。容量为一的渲染通道意味着慢客户端会漏帧,只靠下一次全渲染收敛(src/server/client_transport.rs:52)。
  • compute_view / render 这对无参数版本在非测试构建里是死代码(#[cfg_attr(not(test), allow(dead_code))],src/ui.rs:110:390)。生产路径一律走带 TerminalRuntimeRegistry 的变体。
  • 16ms 是硬编码常量,没有配置项(src/app/mod.rs:39);低刷新率屏幕或极慢链路上不能调低渲染频率来省资源。

12. 代码地图(导航索引)

主题文件关键符号
几何计算(可变)src/ui.rscompute_viewcompute_view_internalcompute_view_without_resizing_panes
纯绘制(只读)src/ui.rsrenderrender_with_runtime_registry
渲染请求合并src/render_signal.rsRenderSignalRenderRequestrequest_ptytake
渲染节流src/app/mod.rs / src/app/runtime.rsMIN_RENDER_INTERVALcan_render_nowcan_present_nowrecord_render_attempt
虚拟渲染src/server/render_stream.rsrender_virtual_with_runtime_registryCursorTrackingBackendrender_terminal_virtual
每客户端帧基线src/server/render_stream.rsClientRenderStateprepare_framereset_baselinerequest_repaintPreparedRender
线上帧格式src/protocol/wire.rsRenderEncodingFrameDataCellDataCursorStateMAX_FRAME_SIZE
ANSI 差分编码src/protocol/render_ansi.rsBlitEncoderEncodedBlitblit_frame_to_with_cursor_memory_and_clear_policywrite_changed_cellsfinal_sync_output_end
渲染计划决策src/server/headless.rsRenderImpactPtyRenderStateRetainedRenderInputretained_render_plan
保留帧补丁src/server/headless.rsrender_retained_pty_update_and_streamretained_pty_update_allowed_by_app_stateapply_terminal_dirty_patchdirty_patch_intersects_hyperlinksrect_fits_frame
全渲染与分发src/server/headless.rsrender_and_streamsend_retained_frame_to_clienthas_pending_presentation_work
客户端连接与 fanoutsrc/server/clients.rsClientConnectionClientConnectionModerender_targetsterminal_stream_client_idsDeferredRender
传输与背压src/server/client_transport.rsClientWriterClientWriterQueueServerEvent
直连终端src/server/headless.rs / src/server/terminal_attach.rsattach_terminal_clientobserve_terminal_clientcontrol_terminal_clientpaste_payload_for_runtime
图形编码src/kitty_graphics.rsHostCellSizeHostGraphicsCacheencode_local_pane_graphicshas_visible_pane_graphics
图形增量src/server/headless/pane_graphics.rsRetainedGraphicsOutcomerender_retained_graphics_update_and_streamframe_pane_graphics
图形直传(客户端)src/client/direct_graphics.rsResponseMatchervalid_control
客户端收帧src/client/mod.rsrequested_render_encodingServerMessage::Frame / Terminal 分支
远程强制 ANSIsrc/remote/attach.rsrun_client_process(HERDR_RENDER_ENCODING)

接着读: 帧发出去之后,连接怎么在服务端换二进制、换机器时活下来,见 06-persistence-and-handoff;为什么服务端知道哪个 pane 里的 agent 值得渲染,见 03-agent-detection