源码阅读 · 第一站 · 建议 20–30 分钟

第一个文件,今天只学一件事:
你的输入怎样交给下一层?

目标文件是 input_submission.rs。先只看其中从第 108 行开始的提交函数,沿普通文字输入走完五个位置。

依据本地 Codex 快照 a26f1806a4f4。这是 TUI(终端界面)的实现;桌面端日常操作用于帮助理解,不代表桌面界面使用同一份代码。

今天的场景:上一轮已结束,运行配置已经准备好,输入“帮我检查这段代码”并发送;不附图片、不引用技能或插件、不用 ! 命令。
接收输入 → 检查 → 整理成列表 → 带上设置 → 提交

你要认出每一步的职责。这层组织和转交请求,模型生成答案的主循环在后面。

位置 1 · 第 108 行

它拿到了什么?

fn submit_user_message_with_history_and_shell_escape_policy(
    &mut self,
    user_message: UserMessage,
    history_record: UserMessageHistoryRecord,
    shell_escape_policy: ShellEscapePolicy,
) -> (bool, Option<AppCommand>) {

fn 标记函数开头。括号里列出这段代码工作时要用的东西,今天重点看 user_message:本次用户输入。它已经是整理好的数据,不是原始键盘按键。

第一遍只要能说:“它接收本次输入,并处理提交”,就可以往下走。

其他参数与返回值,现在可以怎么读?
  • &mut self:当前聊天组件,代码可以更新它的状态;借用规则暂时跳过。
  • history_record:本次输入怎样记入输入历史,不是把整个 Thread 的历史传进来。
  • shell_escape_policy:是否允许把 ! 开头的输入走用户命令通道;今天不走这个分支。
  • (bool, Option<AppCommand>):返回两个信息——这一层是否接受/处理了输入,以及有没有生成的命令。它不返回模型的最终答案;排队路径也可能返回 (true, None)

源码:函数入口 · 上游怎样组成 UserMessage

位置 2 · 第 118、128 行

是不是每次输入都立刻往下交?

不是。配置尚未准备好时,会先排队;文字和两类图片都没有时,会直接返回。

if user_message.text.is_empty()
    && user_message.local_images.is_empty()
    && user_message.remote_image_urls.is_empty()
{
    return (false, None);
}

if 是“如果”;is_empty() 是“是否为空”;&& 是“同时成立”;return 是“结束当前函数并交回结果”。

这段检查的是“文字和图片都没有”,不是“没有文字就一定拒绝”。先学会沿着条件判断走流程。

校正:为什么 Codex 桌面端看起来可以“空白发送”?

因为本页读的是终端 TUI 的 ChatWidget,不是 Codex 桌面客户端自己的前端。桌面端不会调用这里的 submit_user_message...,所以不能拿这段 TUI 的界面检查直接推断桌面端按钮是否可点。

而且“编辑框没有可见文字”不等于“传给 Core 的整个 UserInput 列表为空”。即使列表真的为空,Core 对“空闲 Thread 启动新 Turn”和“向活动 Turn 追加输入”的处理也不同。完整分层规则见第 3 站:四种“空”

源码:未准备好时排队 · 空输入检查

位置 3 · 第 166、195 行

你的文字怎样变成程序能统一处理的内容?

let mut items: Vec<UserInput> = Vec::new();

// 中间省略其他分支;下面是普通文字处理
if !text.is_empty() {
    items.push(UserInput::Text {
        text: text.clone(),
        text_elements: app_server_text_elements(&text_elements),
    });
}

先准备一个叫 items 的空列表。text.is_empty() 问的是“文字是不是空的”;前面的 ! 把结果反过来,所以 if !text.is_empty() 是“如果文字不为空”。

text 来自本次传进来的 user_messagetext.clone() 复制的是本次的文字,不会读取或补入上次输入。items.push(...) 是把包装好的文字输入加进本次列表;真正交出去要等后面的 submit_op

items 装的是本次提交的输入内容,不是整段 Thread 的历史,也不是未来生成的回答。

小检验:上次输入“检查代码”,这次输入“补测试”,复制哪一句?

复制“补测试”,因为这里的 text 是本次输入。如果本次文字为空,这段 if 就不添加文字项;它不会拿“检查代码”来补。能否提交还要看其他检查,例如本次有没有图片。

今天需要懂 Vec 和 text_elements 吗?

Vec<UserInput> 先读成“装用户输入的列表”;let mut 先读成“建立一个可以改变的变量”。文字元素转换和 Rust 如何管理复制后的数据,暂时不用展开。

源码:本次文字的来源 · 准备列表 · 加入文字;Rust 官方说明:! 取反 · clone 复制

位置 4 · 第 356 行

为什么不能只交出那一句文字?

同样一句“检查代码”,还需要知道在哪个目录、用哪个模型、按哪些权限工作。AppCommand::user_turn(...) 把输入和这些设置组成一条内部命令,起名为 op

你在代码里找先这样理解
items本次要处理的内容
self.config.cwd工作目录:在哪个项目里做事
approval_policyactive_permission_profile审批与权限设置
effective_mode.model()选用的模型
reasoning_effort()推理强度设置

这里是在组装内部命令,不是在生成答案,也还不是把 JSON-RPC 文本直接写到网络上。

源码:组装命令 · user_turn 的实现

位置 5 · 第 394 行

哪里把东西真正交出去了?

if !self.submit_op(op.clone()) {
    return (false, None);
}

submit_op 将内部命令交到后续处理路径。若这一步没成功,当前函数就停止;成功提交也只表示这一层已交接,不表示模型已经完成工作。

submit_op 仍然在 TUI 内部:调用它的是 TUI 的 ChatWidget。在当前 AppEvent 路径中,它把 op 包装成 AppEvent::CodexOp,交给 TUI 自己的事件处理器;事件处理器随后才调用 App Server 的 turn/start 接口。

这里不是“submit_op 离开后又回到 TUI”,而是“同一个 TUI 内部,从 ChatWidget 子组件向上交给应用协调层”。跨到 App Server 的边界发生在后面的 turn_start 调用。

把交接后的主链路展开

先这样读:TUI / ChatWidget 的 submit_op → TUI / AppEvent::CodexOp → TUI / 事件处理器 → App Server 的 turn/start → Core 的 start_or_steer_turn。前三步都还在 TUI 内部;App Server 会把界面使用的输入格式转换成 Core 使用的输入格式,再交给 Core 启动或追加一轮工作。

这里的“消息类型”先读成“消息的分类标签”。程序收到消息后,会根据标签决定用哪段代码处理。在当前代码中,可以先把 :: 读成“某个大类中的一种”:AppEvent::CodexOp 是 AppEvent 大类中的 CodexOp 消息;ServerNotification::TurnCompleted 是 ServerNotification 大类中的 TurnCompleted 通知。

名字所在层作用
AppEvent::CodexOpTUIChatWidget 向 TUI 应用协调层发送内部消息
turn/startApp Server 接口TUI 请求 App Server 启动或追加一轮工作
Core 事件Core描述执行中的文字、工具和状态变化
ServerNotificationApp Server 协议把过程和完成状态通知给 TUI

Core 执行期间不是最后一次性返回一个结果,而是不断产生事件;App Server 将这些事件转换成通知,TUI 边接收边更新文字、工具调用和状态。TUI 收到 turn/completed(代码中的 TurnCompleted)时,才知道这轮已经结束。

日常使用中,如果回答正在逐字出现,或者工具仍在运行,可以先判断为“本次 Turn 正在进行”,对应状态 InProgress;眼前出现的内容是过程事件,不是最终完成信号。

“结束”还不一定等于“成功”:通知里的状态可能是 CompletedFailedInterrupted。只有 Completed 表示本次 Turn 成功完成;整个 Thread 仍然保留,以后还能追加新的 Turn。

继续追踪:ChatWidget.submit_op · TUI 的 AppEvent 定义 · TUI 接住内部命令 · 调用 turn/start · App Server 处理请求 · 交给 Core · 发出完成通知 · TUI 接收通知 · 通知名称映射 · Turn 状态

后面第 401 行起的 history 是什么?

这里会保存输入历史,便于以后重新调出曾经输入的内容;周围还处理用户消息显示和待提交状态。这些界面/输入历史工作,与整个 Thread 的持久化对话历史不是同一层概念。

在特定路径中,用户消息会先显示再提交,所以“界面出现了我的消息”也不能单独证明模型已经开始执行。

源码:提交位置 · submit_op 实现 · 后续 turn/start · 输入历史

第一遍明确跳过什么?

图片细节、Skill / Plugin / App 提及、! 命令、执行中追加输入、界面重绘和 Rust 借用/泛型细节。只要知道“这些是特殊输入或特殊状态的分支”,就继续看主线。

之后再分别学习这些分支,并不把它们当成已经理解。

怎样算这一课通过?

能找到上面五个位置,并解释“输入怎样被检查、整理、带上设置并提交”,就达到今天的目标。

练习 1:用自己的话解释 items.push(UserInput::Text { ... }),再展开参考答案。

把本次输入的文字整理成“文字类型的输入项”,追加到本次待提交的内容列表中。

练习 2:只有一张图片、没有文字,会被第 128 行的检查拒绝吗?先假设配置已准备好、模型支持图片。

不会因为“文字和图片全空”这条检查被拒绝:有图片,所以三个空条件不会同时成立。其他检查是另外的判断。

练习 3:submit_op 返回 true,能说明用户要求已经完成吗?

不能。它表示这里的提交路径通过了,不是后台 Turn 的完成结果。模型执行和进度回传是后续阶段。