# 输入框 · 补全、粘贴与快捷键

> 底部输入框是 TUI 里最复杂的状态机：BottomPane 在输入框与弹窗栈之间分派按键，ChatComposer 管补全、粘贴、历史与提交，TextArea 是自己实现的编辑缓冲。本篇看斜杠命令的匹配规则、@ 文件搜索的双线程会话、大段粘贴与无括号粘贴的识别，以及三层优先级、支持双键组合的可配置快捷键。

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

# 输入框 · 补全、粘贴与快捷键

[上一篇](https://daiw.net/manual/codex-source/tui-architecture)讲到，按键先经过 `App`，再交给 `ChatWidget`。这一篇顺着按键往下走，看屏幕底部那块：`bottom_pane/` 目录下一百多个文件，核心是输入框。

## 用户看到的样子

行首 `/` 打开命令菜单，`@` 搜索文件与插件，`$` 提及技能和应用，行首 `!` 执行本地命令；`Ctrl+V` 粘贴剪贴板图片，`↑`、`↓` 翻历史，`Ctrl+R` 搜历史；Codex 工作时按 `Enter` 把话插进当前这一轮，按 `Tab` 排到下一轮；`/keymap` 可以重新绑定大多数快捷键。完整列表见手册[斜杠命令](https://daiw.net/manual/codex/slash-commands)的“快捷键与输入技巧”一节。

## 三层结构

- **`BottomPane`**（`bottom_pane/mod.rs`）拥有输入框 `ChatComposer` 和一个视图栈 `view_stack`：选择列表、审批框这类弹窗实现 `BottomPaneView` trait，压栈后暂时顶替输入框，输入框本身一直保留，关掉弹窗草稿还在。它只决定按键给谁；中断、退出这类意图由 `ChatWidget` 决定。以 `Ctrl+C` 为例，先给栈顶视图（通常是关掉自己），再给正在进行的历史搜索（当作取消），都不要才由 `ChatWidget` 当成中断，或者“再按一次退出”的第一下。
- **`ChatComposer`**（`bottom_pane/chat_composer.rs`）是真正的状态机：补全弹窗、粘贴、历史、提交。文件里约 5000 行实现，后面跟着近 8000 行内联测试，模块文档本身就是一份自顶向下的行为说明；`bottom_pane/AGENTS.md` 要求改动这些状态机时同步更新文档。
- **`TextArea`**（`bottom_pane/textarea.rs`）是自己写的编辑缓冲，不是现成组件。它存 UTF-8 文本、光标与折行缓存，外加一组“元素”：粘贴占位符、图片占位符、提及这类必须整体移动、整体删除的区间。它还有一个只存一项的 kill buffer（`Ctrl+K` 剪、`Ctrl+Y` 贴），提交、执行斜杠命令后清空草稿时刻意保留它，换线程、`/new` 时也带到新的输入框里。Vim 模式同样实现在这一层。
- **`footer.rs`** 只负责画：输入框决定当前的 `FooterMode`（空草稿、有草稿、快捷键帮助、“再按一次退出”等），footer 按宽度挑出放得下的那一行提示。

按键在这几层之间的走向如下图，输入框最后交回上层的是一个 `InputResult`：

```mermaid
flowchart TB
  K[TuiEvent Key] --> APP[App 全局快捷键<br/>Ctrl+T、Ctrl+G 等]
  APP --> CW[ChatWidget]
  CW --> BP{BottomPane<br/>有弹窗吗}
  BP -->|有| V[栈顶 BottomPaneView]
  BP -->|没有| CC{ChatComposer<br/>有补全弹窗吗}
  CC -->|有| POP[弹窗专用处理<br/>命令、提及、文件]
  CC -->|没有| ED[handle_key_event_without_popup<br/>提交、排队、历史、编辑]
  POP --> SY[sync_popups<br/>按新文本与光标重算弹窗]
  ED --> SY
  ED --> IR[InputResult]
  IR --> H[ChatWidget 提交、排队<br/>或分派斜杠命令]
```

```rust
pub enum InputResult {
    Submitted {
        text: String,
        text_elements: Vec<TextElement>,
    },
    Queued {
        text: String,
        text_elements: Vec<TextElement>,
        action: QueuedInputAction,
        pending_pastes: Vec<(String, String)>,
    },
// ...
    Command(SlashCommand),
// ...
    CommandWithArgs(SlashCommand, String, Vec<TextElement>),
// ...
    ParentOwnedInputBlocked,
    None,
}
```

（`codex-rs/tui/src/bottom_pane/chat_composer.rs:457`）

`Enter` 产生 `Submitted`：`ChatWidget::handle_composer_input_result` 把它变成 `AppCommand::UserTurn` 发出去，有轮次在跑时 `App` 会改走 `turn/steer`。`Tab` 在有任务运行时产生 `Queued`，消息留在 `ChatWidget` 的输入队列里，这一轮结束（`TurnCompleted`）后由 `maybe_send_next_queued_input` 逐条送出；没有任务时 `Tab` 等同 `Enter`（`!` 开头的 shell 命令除外），不会吞掉输入。提交前，输入框会把粘贴占位符展开成原文，并把 `TextElement` 的字节区间跟着重新定位，单条消息上限是 `MAX_USER_INPUT_TEXT_CHARS`（2 的 20 次方个字符）。

## 斜杠命令：前缀匹配，枚举顺序就是菜单顺序

内置命令是一个 62 个变体的枚举：

```rust
/// Commands that can be invoked by starting a message with a leading slash.
#[derive(
    Debug, Clone, Copy, PartialEq, Eq, Hash, EnumString, EnumIter, AsRefStr, IntoStaticStr,
)]
#[strum(serialize_all = "kebab-case")]
pub enum SlashCommand {
    // DO NOT ALPHA-SORT! Enum order is presentation order in the popup, so
    // more frequently used commands should be listed first.
    Model,
    Ide,
    Permissions,
```

（`codex-rs/tui/src/slash_command.rs:7`）

命令名由 strum 按 kebab-case 生成，个别变体用属性改名或加别名（`AutoReview` 叫 `/approve`，`Stop` 另有别名 `/clean`）。每个变体上挂着一组判定：`supports_inline_args`（能不能带参数，如 `/review ...`）、`available_during_task`（任务运行中能否使用）、`available_in_side_conversation`、`is_visible`（`/rollout`、`/test-approval` 只在调试构建里出现，`/app` 只在 macOS 与 Windows 上出现）。

菜单的过滤在 `CommandPopup::filtered`（`codex-rs/tui/src/bottom_pane/command_popup.rs:147`）：取 `/` 后第一个词，不分大小写，先列完全相等的，再列前缀匹配的，两组内部都保持枚举顺序。没输入过滤词时隐藏 `/quit`、`/btw` 这两个别名；`debug-` 开头的命令和 `/apps` 从不进菜单，但直接敲出来照样能执行。斜杠命令这条路上，模糊匹配（`codex_utils_fuzzy_match`，按子序列）只用在一处：判断光标是否还停在命令名上、要不要继续显示菜单；`@` 提及菜单才真正按模糊匹配筛选，并按插件、技能、任务、文件的分组排序。解析在输入框，受理在 `ChatWidget`（`chatwidget/slash_dispatch.rs`）：输入框清空前把命令原文暂存起来，`ChatWidget` 分派完再写进历史，让 `↑` 回翻斜杠命令与回翻普通消息遵循同一条“提交过才算”的规则，带参数的命令也只记一次原始调用。

## @ 提及：一个会话，两个线程

功能开关 `mentions_v2`（默认开）让 `@` 弹出一个统一菜单，同时列插件、文件和目录、技能；`$` 列技能和应用。文件这一路是这样的：`@` 后面的词每变一次，输入框就发一个 `AppEvent::StartFileSearch(query)`；`App` 持有的 `FileSearchManager`（`codex-rs/tui/src/file_search.rs`）为当前工作目录只维护一个 `codex-file-search` 会话，每次按键只调 `update_query`，查询清空就丢掉会话；结果以 `AppEvent::FileSearchResult` 回到主循环，会话令牌保证过期的结果被丢弃。

`codex-file-search`（`codex-rs/file-search/src/lib.rs`）在会话里起两个线程：遍历线程用 `ignore` crate 的并行遍历器走目录树，包括隐藏文件、跟随符号链接，`.gitignore` 只在 git 仓库里才生效（`require_git(true)`，免得家目录下一个写着 `*` 的 `.gitignore` 把一切都挡掉），每个路径塞进 `nucleo` 的注入器；匹配线程在查询变化时重新打分，节流后报告前 N 名（默认 `limit` 20、`threads` 2）。改查询不必重新遍历，注释说它“should be cheap relative to re-walking”。当前线程支持任务工具时，`@` 还能提及别的任务：`App` 同时经 `task_mentions::spawn_search` 向 app-server 发 `thread/search`（防抖 100 毫秒，最多 50 条），命中的任务一起列进菜单。

## 粘贴：大段、图片与没有括号的粘贴

终端支持括号粘贴（bracketed paste）时，粘贴内容作为一个 `TuiEvent::Paste` 到达，`App` 先把 `\r\n` 和单独的 `\r` 统一成 `\n`，再交给输入框：

```rust
    pub(super) fn apply_paste(&mut self, pasted: String) -> bool {
        let pasted = pasted.replace("\r\n", "\n").replace('\r', "\n");
        let pasted = sanitize_user_text(pasted.into());
// ...
        let char_count = pasted.chars().count();
        if char_count > LARGE_PASTE_CHAR_THRESHOLD {
            let placeholder = self.next_large_paste_placeholder(char_count);
            self.draft.textarea.insert_element(&placeholder);
            self.draft
                .pending_pastes
                .push((placeholder, pasted.into_owned()));
        } else if char_count > 1
            && self.image_paste_enabled()
            && self.handle_paste_image_path(&pasted)
        {
            let cursor = self.draft.textarea.cursor();
            self.draft.textarea.insert_str_at(cursor, " ");
        } else {
            self.insert_str(&pasted);
        }
```

（`codex-rs/tui/src/bottom_pane/chat_composer/paste_input.rs:118`）

超过 `LARGE_PASTE_CHAR_THRESHOLD`（1000 个字符）的粘贴在输入框里只显示一个 `[Pasted Content N chars]` 元素，原文存进 `pending_pastes`，同样大小的第二段加 `#2` 后缀，提交时再展开。粘贴的若是一个图片文件路径（Windows 路径会先转成 WSL 路径），能读出图片尺寸就当作图片附件。`Ctrl+V` 与 `Ctrl+Alt+V` 是固定键：`ChatWidget::paste_image` 用 `arboard` 读剪贴板，编码成 PNG 写进名为 `codex-clipboard-*.png` 的临时文件，再以 `[Image #N]` 占位符附上。

难的是没有括号粘贴的终端，尤其是 Windows：一次粘贴会变成一串飞快的 `Char`、`Enter`、`Tab` 按键。`PasteBurst`（`codex-rs/tui/src/bottom_pane/paste_burst.rs`）是一个不碰文本的纯状态机，字符间隔不超过 8 毫秒就算“快”。ASCII 字符的第一个先扣住不显示，8 毫秒内来了第二个就开始缓冲，免得先显示再被收回造成闪烁；非 ASCII 字符（输入法）不扣，因为扣住会让人觉得丢了字，连续 3 个快字符才开始缓冲，并把已经插入的前缀“追回”进缓冲。最后一个字符之后静默 8 毫秒（Windows 上 60 毫秒，注释说那里观察到更慢的粘贴），缓冲内容作为一次粘贴整体提交。缓冲期间及之后 120 毫秒内的 `Enter` 当作换行而不是发送，粘贴里的 `?` 也不会弹出快捷键帮助。不想要这套启发式，可以设 `tui.disable_paste_burst`。

## 历史：两个来源，一个偏移空间

`ChatComposerHistory` 把两种历史排进同一个偏移空间：跨会话的持久历史，即 `CODEX_HOME` 下的 `history.jsonl`，每行一条 JSON，只存文本；本会话内的历史，带完整的元素与图片附件。持久历史由 `App` 经 `codex-message-history` 读写：追加时整行一次写入，按 `↑` 时按偏移逐条异步读取，结果经 `AppEvent` 送回输入框；`Ctrl+R` 反向搜索则改为按批读取，同一次搜索里按提示词原文去重。上一篇说过，这个文件在 TUI 所在的机器上，连远程 app server 时也一样。

## 快捷键：三层优先级与双键组合

配置里的 `[tui.keymap]` 按场景分组：`global`、`chat`、`composer`、`editor`、`pager`、`list`、`agents`、`approval` 以及几组 Vim 场景（`TuiKeymap`，`codex-rs/config/src/tui_keymap.rs`）。`keymap.rs` 把它解析成运行时用的 `RuntimeKeymap`，优先级是“本场景 → `global` → 内置默认”：

```rust
fn resolve_bindings_with_global_fallback(
    configured: Option<&KeybindingsSpec>,
    global: Option<&KeybindingsSpec>,
    fallback: &[KeyBinding],
    path: &str,
) -> Result<Vec<KeyBinding>, String> {
    if let Some(configured) = configured {
        return parse_bindings(configured, path);
    }
    if let Some(global) = global {
        return parse_bindings(global, path);
    }
    Ok(fallback.to_vec())
}
```

（`codex-rs/tui/src/keymap.rs:2510`）

配置成空列表也算“配置过”，于是等于解绑，不会回落到默认。解析时还做冲突检查：同一条输入路径上一个键不能触发两个动作，报错信息直接给出配置路径和改法。`MAIN_RESERVED_BINDINGS` 列出不许占用的固定键：`Ctrl+C`、`Ctrl+D`、`Ctrl+V`、`Ctrl+Alt+V`、`Shift+Tab`、`Esc`、`Alt+←`、`Alt+→`，以及 `/`、`!`、`@`、`$`。一个绑定最多两击（如 `ctrl-x ctrl-s`），第一击之后等待的组合在 1 秒后（`KEY_CHORD_TIMEOUT`）或场景切换时作废；完成的组合被翻译成一个内部的功能键记号追加到目标动作上，原有的按键处理函数因此仍是唯一的分派表。`/keymap`（`keymap_setup.rs`）是一个三步向导：选动作，选替换、添加还是删除，再捕获一个键或一个组合；它复用同一套解析做校验，只发出 `AppEvent`，写不写配置由 `App` 决定。

## 和《从 LLM 到 Coding Agent》对照

那本书的[中断](https://daiw.net/manual/llm-to-agent/interrupt)一篇把“转向”描述为“打断 + 追加”：干净地结束当前回合，再把新话作为下一轮输入，常配一个消息队列。Codex 把这两件事拆给了两个键：`Enter` 插话不打断，经 `turn/steer` 把输入并进正在跑的这一轮，模型在同一轮里就能看到；`Tab` 才是那本书里的队列，等这一轮结束再发；真要停，按 `Esc` 走 `turn/interrupt`。

对照 Grok Build 的[输入与补全](https://daiw.net/manual/grok-build/input-and-completions)：它的斜杠补全用 `nucleo` 模糊匹配并按最近使用排序，快捷键内置、不能自定义；Codex 的命令菜单只做前缀匹配、顺序写死在枚举里，把灵活性留给了可配置、带双键组合的快捷键。

---

上一篇：[TUI 架构 · 事件、线程路由与渲染](https://daiw.net/manual/codex-source/tui-architecture) · 下一篇：[历史记录单元 · 流式 Markdown 与终端排版](https://daiw.net/manual/codex-source/history-cells)
