# TUI 架构 · 事件、线程路由与渲染

> codex-tui 是整个仓库最大的 crate，却不碰 agent 内核：会话里的每个动作都变成发给 app-server 的 JSON-RPC 请求，服务端的通知再按线程分流回界面。本篇从启动顺序讲起，拆开 App 的事件循环、按线程缓冲的事件通道，以及合并重绘请求、最高 120 帧每秒的帧调度。

- 作者：David（道雾轩）
- 专栏：Codex 源码解读（https://daiw.net/manual/codex-source.md）
- 最后更新：2026-09-29
- 原文：https://daiw.net/manual/codex-source/tui-architecture
- 转载与引用：请注明出处并附原文链接（https://daiw.net/about/copyright）

# TUI 架构 · 事件、线程路由与渲染

从这一篇起进入第 6 部分：你每天盯着的那个终端界面。`codex-rs/tui`（crate 名 `codex-tui`）去掉测试约 20.6 万行，是整个 workspace 最大的 crate，比 `codex-core` 还大将近一倍。体量大，却不是因为 agent 逻辑藏在里面。[一条消息的生命周期](https://daiw.net/manual/codex-source/message-lifecycle)和 [app-server](https://daiw.net/manual/codex-source/app-server-architecture) 两篇已经点过：TUI 只是 app-server 的一个客户端。这一篇把这句话落到代码上：启动时怎么选服务端，一次按键怎么变成 JSON-RPC 请求，通知怎么按线程分流回来，屏幕又是怎么重绘的。

## 用户看到的样子

`codex` 不带子命令就进入这个界面，`codex resume`、`codex fork`、`codex agents` 也是它，只是开场不同。连哪个服务端由启动参数决定：默认自动启动或复用本机的后台服务（app-server 守护进程），`--no-daemon` 改为在进程里内嵌一份，`--remote` 连远程的 app server，细节见手册[命令行参数](https://daiw.net/manual/codex/cli-flags)与 [SDK 与 App Server](https://daiw.net/manual/codex/sdk-app-server)。主干模块的分工如下（`src/` 下）：

| 模块 | 职责 |
| --- | --- |
| `lib.rs`、`startup_orchestration.rs`、`startup_draft.rs` | 启动：参数校验、选服务端、登录引导、会话选择 |
| `app.rs` 与 `app/`（约 90 个文件） | `App`：事件循环、线程路由、会话切换、配置持久化 |
| `chatwidget.rs` 与 `chatwidget/`（约 100 个文件） | `ChatWidget`：一个线程的聊天界面，把通知变成历史记录单元 |
| `bottom_pane/` | 输入框与各种弹窗，见[下一篇](https://daiw.net/manual/codex-source/chat-composer) |
| `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 和终端输入，等待期间敲的字进入临时输入框，按回车只记下“要提交”，等登录、目录信任这些受保护的步骤走完，才把草稿连同光标、粘贴占位符移交给正式的输入框。服务端的选择是三选一：

```rust
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，有进程在听才连。守护进程本身见[守护进程与传输](https://daiw.net/manual/codex-source/daemon-and-transport)。

之后 `run_ratatui_app` 依次做：`start_app_server` 建立连接并包成 `AppServerSession`，读登录状态，需要时跑登录引导，按参数打开恢复或派生选择器、代理总览，确认目录信任，`bootstrap` 读账号并并发拉取模型目录与配置要求，最后进入 `App::run`（`codex-rs/tui/src/app/startup.rs:158`）。这些界面见[其它界面](https://daiw.net/manual/codex-source/tui-surfaces)。

## 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 事件、断线重连，以及用量轮询、终端标题动画、流式提交节拍三个按需武装的定时器。节选前四个分支：

```rust
                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”。

一条消息的来回，按这些分支串起来是这样：

```mermaid
sequenceDiagram
  participant K as 终端
  participant W as ChatWidget
  participant A as App 事件循环
  participant S as AppServerSession
  participant SV as app-server
  K->>A: TuiEvent Key 回车
  A->>W: handle_key_event
  W->>A: AppEvent CodexOp UserTurn
  A->>S: 有轮次在跑则 turn_steer，否则 turn_start
  S->>SV: JSON-RPC 请求
  SV-->>A: item/agentMessage/delta 等通知
  A->>A: 按线程放进 ThreadEventChannel
  A->>W: 从 active_thread_rx 取出，handle_server_notification
  W->>A: AppEvent InsertHistoryCell
  A->>K: 写进回滚或全屏记录，请求重绘
```

## 线程路由：每个线程一条带缓冲的通道

一个 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，再看这个线程是不是当前显示的那个：

```rust
            let notification = if guard.active {
                guard.push_notification_ref(&notification);
                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](https://daiw.net/manual/codex-source/multi-agent)。

## 帧调度与绘制

没有谁直接“画一帧”。组件手里只有一个可以随便克隆的 `FrameRequester`，想重绘就调 `schedule_frame()`，请求进入专门的 `FrameScheduler` 任务，它只记最早的截止时间：

```rust
                    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` 也取这个值，见[历史记录单元](https://daiw.net/manual/codex-source/history-cells)。收到 `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 绘制，见[全屏对话记录](https://daiw.net/manual/codex-source/fullscreen-transcript)。
- **整帧同步输出**。每帧包在 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》对照

那本书的[流式处理](https://daiw.net/manual/llm-to-agent/streaming)一篇，界面就是 `process.stdout.write` 把增量一段段打出来，界面与 agent 循环在同一段代码里。Codex 在两者之间隔了一条协议：TUI 只看得到 `item/agentMessage/delta` 这样的通知，看不到循环本身。代价是多出一整层客户端代码，换来同一个界面能挂在内嵌服务、本机守护进程或远程服务器上，断线还能重新加入线程、按快照回放。[中断](https://daiw.net/manual/llm-to-agent/interrupt)一篇讲的“转向”在这里也成了协议动作：有轮次在跑时，新消息走 `turn/steer`。

对照 Grok Build 的 [TUI 架构](https://daiw.net/manual/grok-build/tui-architecture)：它是 Elm 式的 Action→dispatch→Effect，事件循环用 `biased` 的 `select!` 靠分支顺序排优先级；Codex 的 `AppEvent` 同样是“组件发意图、顶层统一处理”的总线，但优先级写在分支守卫上。

---

上一篇：[从别的 agent 迁移 · 导入 Claude Code 与 Cursor 的配置](https://daiw.net/manual/codex-source/external-agent-migration) · 下一篇：[输入框 · 补全、粘贴与快捷键](https://daiw.net/manual/codex-source/chat-composer)
