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

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

作者 David更新于 第 45 篇(共 57 篇)

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

上一篇讲到,按键先经过 App,再交给 ChatWidget。这一篇顺着按键往下走,看屏幕底部那块:bottom_pane/ 目录下一百多个文件,核心是输入框。

用户看到的样子

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

三层结构

  • 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:

图表加载中…
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 个变体的枚举:

/// 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,再交给输入框:

    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 → 内置默认”:

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》对照

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

对照 Grok Build 的输入与补全:它的斜杠补全用 nucleo 模糊匹配并按最近使用排序,快捷键内置、不能自定义;Codex 的命令菜单只做前缀匹配、顺序写死在枚举里,把灵活性留给了可配置、带双键组合的快捷键。


上一篇:TUI 架构 · 事件、线程路由与渲染 · 下一篇:历史记录单元 · 流式 Markdown 与终端排版

本页目录