TUI 架构 · 事件、线程路由与渲染
codex-tui 是整个仓库最大的 crate,却不碰 agent 内核:会话里的每个动作都变成发给 app-server 的 JSON-RPC 请求,服务端的通知再按线程分流回界面。本篇从启动顺序讲起,拆开 App 的事件循环、按线程缓冲的事件通道,以及合并重绘请求、最高 120 帧每秒的帧调度。
TUI 架构 · 事件、线程路由与渲染
从这一篇起进入第 6 部分:你每天盯着的那个终端界面。codex-rs/tui(crate 名 codex-tui)去掉测试约 20.6 万行,是整个 workspace 最大的 crate,比 codex-core 还大将近一倍。体量大,却不是因为 agent 逻辑藏在里面。一条消息的生命周期和 app-server 两篇已经点过:TUI 只是 app-server 的一个客户端。这一篇把这句话落到代码上:启动时怎么选服务端,一次按键怎么变成 JSON-RPC 请求,通知怎么按线程分流回来,屏幕又是怎么重绘的。
用户看到的样子
codex 不带子命令就进入这个界面,codex resume、codex fork、codex agents 也是它,只是开场不同。连哪个服务端由启动参数决定:默认自动启动或复用本机的后台服务(app-server 守护进程),--no-daemon 改为在进程里内嵌一份,--remote 连远程的 app server,细节见手册命令行参数与 SDK 与 App Server。主干模块的分工如下(src/ 下):
| 模块 | 职责 |
|---|---|
lib.rs、startup_orchestration.rs、startup_draft.rs | 启动:参数校验、选服务端、登录引导、会话选择 |
app.rs 与 app/(约 90 个文件) | App:事件循环、线程路由、会话切换、配置持久化 |
chatwidget.rs 与 chatwidget/(约 100 个文件) | ChatWidget:一个线程的聊天界面,把通知变成历史记录单元 |
bottom_pane/ | 输入框与各种弹窗,见下一篇 |
app_server_session.rs | 发往 app-server 的类型化 JSON-RPC 门面 |
tui.rs、tui/、custom_terminal.rs、insert_history.rs | 终端封装、事件流、帧调度、写入终端回滚 |
app.rs 本身只有 1255 行,chatwidget.rs 2079 行,功能都拆进了同名目录。这是仓库根 AGENTS.md 的要求:模块以 500 行为目标,超过 800 行就另开模块;app.rs、chatwidget.rs 等被点名为高频改动文件,chatwidget.rs 只负责编排。
启动:先让输入框能打字
入口 run_main(codex-rs/tui/src/lib.rs:1086)做完不需要终端的校验就进入 raw 模式,马上画出一个临时输入框 StartupDraft。之后加载配置、连接服务端、读账号、拉模型目录这些慢操作都包在 startup_draft.run_until(...) 里:一个 select! 同时等这个 future 和终端输入,等待期间敲的字进入临时输入框,按回车只记下“要提交”,等登录、目录信任这些受保护的步骤走完,才把草稿连同光标、粘贴占位符移交给正式的输入框。服务端的选择是三选一:
pub(crate) enum AppServerTarget {
Embedded,
LocalDaemon {
endpoint: RemoteAppServerEndpoint,
allow_embedded_fallback: bool,
},
Remote {
endpoint: RemoteAppServerEndpoint,
},
}(codex-rs/tui/src/lib.rs:312)
判定在 app_server_target_for_launch(lib.rs:1013)与 startup_orchestration.rs:494 的自动启动里:--remote 得到 Remote;daemon_startup::exclusion 命中任一排除条件(--no-daemon、--oss、--profile、环境变量 CODEX_EXEC_SERVER_URL、多数 -c 覆盖、--strict-config 等,共享的守护进程没法采用这一次调用私有的配置)就用 Embedded;否则功能开关 daemon_auto_start(默认开)调用 codex_app_server_daemon::start_with_features 启动或复用守护进程,得到 allow_embedded_fallback 为 false 的 LocalDaemon,连不上就报错并提示加 --no-daemon,不会悄悄退回内嵌。关掉这个开关时,TUI 只用 50 毫秒(AUTO_CONNECT_DAEMON_CONNECT_TIMEOUT)探一下默认 socket,有进程在听才连。守护进程本身见守护进程与传输。
之后 run_ratatui_app 依次做:start_app_server 建立连接并包成 AppServerSession,读登录状态,需要时跑登录引导,按参数打开恢复或派生选择器、代理总览,确认目录信任,bootstrap 读账号并并发拉取模型目录与配置要求,最后进入 App::run(codex-rs/tui/src/app/startup.rs:158)。这些界面见其它界面。
TUI 是客户端
AppServerSession(codex-rs/tui/src/app_server_session.rs:312)是 TUI 这一侧的门面,包着上面选出的 AppServerClient(进程内或远程),对外是 start_thread、turn_start、turn_steer、thread_read 这样一排方法,每个都是一次 request_typed。发送用户消息最能说明这种身份:ChatWidget 不判断该“开始”还是“插话”,只发出 AppCommand::UserTurn;App 在 try_submit_active_thread_op_via_app_server(codex-rs/tui/src/app/thread_routing.rs:659)里查这个线程有没有正在跑的轮次,有就发 turn/steer 把输入并进当前这一轮,服务端回报那一轮已经不在了再退回 turn/start;中断则是一次 turn/interrupt。连状态栏里的 git 分支这种小查询,也经 WorkspaceCommandRunner 走 app-server 的 command/exec,工作区在远程时照样能用。
留在本机的只有三类:客户端自己的偏好(LocalSettings,如主题、下次启动的界面模式)用 ConfigEditsBuilder 直接写本机 config.toml,归服务端管的设置走 config/value/write、config/batchWrite;提示词历史文件 ~/.codex/history.jsonl;@ 文件搜索遍历本机目录。排查“某功能在 --remote 下不灵”时,这条边界是第一个要看的地方。
事件循环:一个不带 biased 的 select!
App::run 的主体是一个 loop 包着的 select!,同时等待内部事件、当前线程的事件、终端输入、app-server 事件、断线重连,以及用量轮询、终端标题动画、流式提交节拍三个按需武装的定时器。节选前四个分支:
let control = select! {
Some(event) = app_event_rx.recv() => {
// ...
active = async {
if let Some(rx) = app.active_thread_rx.as_mut() {
rx.recv().await
} else {
None
}
}, if App::should_handle_active_thread_events(
waiting_for_initial_session_configured,
app.active_thread_rx.is_some()
) && !has_pending_app_events && !app.reconnect.offline => {
// ...
event = tui_events.next(), if app.pending_thread_switch_resets == 0
&& (app.reconnect.offline || !block_terminal_input_for_pending_startup_events) => {
// ...
app_server_event = app_server.next_event(), if listen_for_app_server_events && !app.reconnect.offline
&& (matches!(app.app_server_target, AppServerTarget::Embedded) || !has_pending_app_events) => {(codex-rs/tui/src/app/startup.rs:1109)
- 优先级写在守卫里:内部事件队列
app_event_rx非空时先不收当前线程的事件,连守护进程或远程服务时连 app-server 事件也先等着。源码注释的理由是回放会排队插入历史和操作,切换界面之前必须先让它们落地。 AppEvent(codex-rs/tui/src/app_event.rs:279)是 TUI 内部的消息总线,两百五十多个变体,让组件请求打开选择器、持久化配置、关闭 agent 这类应用层动作,又不必碰App的内部;handle_event(codex-rs/tui/src/app/event_dispatch.rs:28)是一个穷举的match,大块逻辑再转交app/下的子模块。- 要等网络的请求(MCP 清单、技能、插件、用量等)由
app/background_requests.rs甩给后台任务,结果包成AppEvent送回,注释写的是“so the main event loop remains single-threaded”。
一条消息的来回,按这些分支串起来是这样:
线程路由:每个线程一条带缓冲的通道
一个 TUI 里可能同时有主线程、子代理线程、/side 开出的旁支对话,屏幕上只显示一个,ChatWidget 也只对应这一个。通知混在一条流里到达,enqueue_thread_notification(codex-rs/tui/src/app/thread_routing.rs:1142)按线程 id 投进各自的 ThreadEventChannel:一条容量 32768(THREAD_EVENT_CHANNEL_CAPACITY)的 mpsc 通道,外加一个 ThreadEventStore,记着会话信息、轮次、正在跑的轮次 id、待答复的审批和一个有界的回放缓冲(同一条消息的连续增量合并成至多 4 KiB 一段,增量总量超过 256 KiB 就从头淘汰)。每条通知都先进 store,再看这个线程是不是当前显示的那个:
let notification = if guard.active {
guard.push_notification_ref(¬ification);
Some(notification)
} else {
// ...
guard.push_notification(notification);
None
};(codex-rs/tui/src/app/thread_routing.rs:1261)
当前线程的通知还会被 try_send 进通道,接收端被 App 拿在 active_thread_rx 里,由主循环第二个分支取出、交给 ChatWidget::handle_server_notification;其他线程的通知只进 store。切换线程时(/subagents、Alt+←、代理总览),App 把旧接收端和输入框草稿还给旧线程,新建一个 ChatWidget,取新线程 store 的快照按 ReplayKind::ThreadSnapshot 回放,再接着消费实时事件。后台线程的审批留在 store 里,底部栏会提示哪些线程在等你答复。多 agent 本身见多 agent。
帧调度与绘制
没有谁直接“画一帧”。组件手里只有一个可以随便克隆的 FrameRequester,想重绘就调 schedule_frame(),请求进入专门的 FrameScheduler 任务,它只记最早的截止时间:
let draw_at = self.rate_limiter.clamp_deadline(draw_at);
next_deadline = Some(next_deadline.map_or(draw_at, |cur| cur.min(draw_at)));
// Do not send a draw immediately here. By continuing the loop,
// we recompute the sleep target so the draw fires once via the
// sleep branch, coalescing multiple requests into a single draw.
continue;(codex-rs/tui/src/tui/frame_requester.rs:110)
多次请求合并成一次;FrameRateLimiter 再保证两帧间隔不小于 MIN_FRAME_INTERVAL(8,333,334 纳秒,最高 120 帧每秒)。到点后的通知在 TuiEventStream 里变成 TuiEvent::Draw,与 crossterm 的键盘鼠标事件轮流被取(注释:“approximate fairness + no starvation”)。流式输出的提交节拍 COMMIT_ANIMATION_TICK 也取这个值,见历史记录单元。收到 Draw 后,App 用 Renderable 接口(render、desired_height、cursor_pos)问出 ChatWidget 要多高,交给 Tui::draw_with_resize_reflow:
- 只占屏幕底部。终端回滚模式下 ratatui 只管底部一块内联视口;定稿的历史用转义序列插到视口上方,成为终端自己的回滚记录(
insert_history.rs开头写明“Codex uses the terminal scrollback itself for finalized chat history”)。全屏模式下整屏由 Codex 绘制,见全屏对话记录。 - 整帧同步输出。每帧包在 crossterm 的
sync_update里:先把排队的历史行写进回滚,再画视口,终端一次性刷新。custom_terminal.rs文件头注明派生自 ratatui 的Terminal(MIT 许可),在它的基础上改了视口管理,并让 OSC 8 超链接参与输出。workspace 用 ratatui 0.30.2;crossterm 声明为 0.29.0,但被[patch.crates-io]换成了openai-oss-forks下的分叉。 - 交出终端。
Ctrl+G打开外部编辑器、Unix 上按Ctrl+Z挂起时,EventBroker丢掉底层的 crossterm 事件流,回来再重建;注释解释只停止轮询不够,crossterm 的读线程仍可能读 stdin,抢走编辑器的输入。
和《从 LLM 到 Coding Agent》对照
那本书的流式处理一篇,界面就是 process.stdout.write 把增量一段段打出来,界面与 agent 循环在同一段代码里。Codex 在两者之间隔了一条协议:TUI 只看得到 item/agentMessage/delta 这样的通知,看不到循环本身。代价是多出一整层客户端代码,换来同一个界面能挂在内嵌服务、本机守护进程或远程服务器上,断线还能重新加入线程、按快照回放。中断一篇讲的“转向”在这里也成了协议动作:有轮次在跑时,新消息走 turn/steer。
对照 Grok Build 的 TUI 架构:它是 Elm 式的 Action→dispatch→Effect,事件循环用 biased 的 select! 靠分支顺序排优先级;Codex 的 AppEvent 同样是“组件发意图、顶层统一处理”的总线,但优先级写在分支守卫上。
上一篇:从别的 agent 迁移 · 导入 Claude Code 与 Cursor 的配置 · 下一篇:输入框 · 补全、粘贴与快捷键