在网页聊天框和代码编辑器里,我们已经习惯了:
Enter:提交Shift + Enter:只插入换行,不提交
这项语义由应用层定义,传统终端并未给 Shift + Enter 规定统一行为。在 Windows Terminal 中运行 Codex、Claude Code 或 Pi 时,同一个 Shift + Enter 可能表现为换行、直接提交,甚至输入一串 [13;2u。要理解这些现象,需要先弄清楚按键是怎样到达终端程序的。
此文章撰写时的环境:
- Windows Terminal 1.24.11911.0
- PowerShell 7.6.4
- git version 2.54.0.vfs.0.4
- codex cli 1.125.1
- claude code 2.1.217
- pi 0.81.1
Shift + Enter 只是一种交互语义
键盘产生的是“按下 Enter 时 Shift 也处于按下状态”这个事件;“将它解释为换行”则是聊天应用、编辑器或 TUI 自己定义的语义。
GUI 应用通常可以直接拿到按键及修饰键,例如 key = Enter, shift = true。传统终端程序面对的情况不同:终端模拟器先接收系统的键盘事件,再把它编码成字符或转义序列,通过 PTY/ConPTY 交给前台程序。程序通常读取一串字节,原始键盘事件中的部分信息可能已经丢失。
问题在于,传统终端编码没有为所有修饰键组合提供唯一表示。默认情况下,Enter 和 Shift + Enter 往往都会被编码成同一个 CR。应用只看到 [13],自然无法知道 Shift 是否按下过。
用 Node.js 查看程序实际收到的字节
可以在 Windows Terminal 中运行下面的命令。它会将标准输入切换到 raw mode,并打印每次输入对应的字节;按 Ctrl + C 退出。
node -e "process.stdin.setRawMode(true); process.stdin.resume(); process.stdin.on('data',b=>{console.log([...b]);if(b[0]===3)process.exit()})"
测试结果如下:
| 按键 | 字节 | ASCII 控制字符 |
|---|---|---|
Enter | [13] | CR,即 \r |
Shift + Enter | [13] | CR,即 \r |
Ctrl + Enter | [10] | LF,即 \n |
Ctrl + J | [10] | LF,即 \n |
这也解释了为什么默认情况下 Pi 会把 Shift + Enter 当成普通 Enter:组合键到达应用时只剩下字节 13,信息已经在终端这一层丢失了。
CR、LF 和 CRLF
CR 与 LF 来自机械打字机时代的两个动作:
CR(Carriage Return,回车):0x0D、十进制13、JavaScript 字符串中的\rLF(Line Feed,换行):0x0A、十进制10、JavaScript 字符串中的\n
Unix 文本文件通常用 LF 表示换行,Windows 文本文件通常使用 CRLF(\r\n)。“文件的行尾格式”和“Enter 键发送什么”属于不同层面:传统终端中的 Enter 通常发送 CR;终端驱动是否再做转换,则取决于当前模式。上面的 Node.js 程序启用了 raw mode,所以看到的是未经规范行处理的输入字节。
在传统 ASCII 控制键映射中,Ctrl + J 对应 10,也就是 LF;类似地,Ctrl + M 对应 13,因此它通常与 Enter 无法区分。
什么是“转义序列”、CSI 和 CSI u
单个字节表达不了 Shift + Enter,终端就需要用多个字符组成的转义序列来编码它。方向键也采用类似的处理方式,应用会收到一串以 ESC 开始的字节。
CSI 是 Control Sequence Introducer。常见的 7-bit 写法是:
ESC [
对应字节:
27 91
所以我们看到的 \u001b[ 就是 CSI:\u001b 是 ESC(十进制 27),后面紧跟字符 [。
Kitty keyboard protocol 在 CSI 的基础上定义了较完整的键盘事件编码,常见形式为:
CSI key-code ; modifiers u
因此 Shift + Enter 可以写成:
ESC [ 13 ; 2 u
也就是:
\u001b[13;2u
完整字节为:
[27, 91, 49, 51, 59, 50, 117]
其中:
13:Enter 的键码2:修饰键参数;协议使用“修饰键位图 + 1”,所以2表示 Shiftu:CSI u 序列的结束字符
CSI 是控制序列的通用前缀;CSI u 则是使用 u 结尾、用来无歧义报告键盘事件的一套编码。应用必须显式解析这套协议,否则它只会把后面的 [13;2u 当成普通文本。
Pi、Claude Code 和 Codex 为什么表现不同
Pi:解析终端输入序列
Pi 采用典型的终端处理方式:从标准输入读取字节,再按照 Kitty keyboard protocol 等规则解析按键。Pi 官方的 Terminal Setup 文档 因此建议 Windows Terminal 将 Shift + Enter 映射成:
\u001b[13;2u
Pi 能把它还原成逻辑上的 shift+enter,再匹配 tui.input.newLine。Pi 的默认换行键还包括 Ctrl + J,这一点在其 Keybindings 文档 中也有说明。
Claude Code:同样依赖终端编码,但版本兼容性并不一致
Claude Code 在 Windows Terminal/WSL 上也遇到过 Enter 与 Shift + Enter 无法区分的问题。相关 Issue #1262 中常见的解决方式,就是让 Windows Terminal 主动发送 CSI u 序列;另一个 Issue #28785 还记录了 /terminal-setup 已识别 Windows Terminal、却无法自动完成配置的问题。
不过不能笼统地认为所有 Claude Code 版本都能正确解析 CSI u。Issue #11192 记录过 Claude Code 2.0.35 将 [13;2u 直接插入输入框的兼容性问题。换句话说,它仍然走“终端发送序列、应用解析序列”这条路线,但具体协议支持会随版本和终端环境变化。
Codex:输入层先产生结构化 KeyEvent
Codex 的 TUI 使用 Rust 终端输入库,将输入转换成带 code 和 modifiers 的 KeyEvent,再交给内部 keymap 匹配。例如 shift-enter、ctrl-j 和 ctrl-enter 都可以作为编辑器动作的快捷键。
这里所说的“Codex 直接监听快捷键”,指 Codex 的输入库在当前 Windows 控制台输入路径中先得到结构化键盘事件,再由业务层处理 KeyEvent。它没有注册系统级全局键盘钩子,也没有像 Pi 一样在业务层直接匹配原始输入字符串。
这也解释了一个看起来奇怪的现象:
- Windows Terminal 默认发送原生
Shift + Enter时,Codex 可以拿到KeyCode::Enter + SHIFT,所以能够正常换行; - 强制 Windows Terminal 发送
\u001b[13;2u后,当前输入路径没有把这段手工注入的 CSI u 重新解码成同一个 KeyEvent; ESC被单独消费,剩余的[13;2u变成了可见文本。
真实按键和 sendInput 注入的终端协议序列会经过不同的输入路径,需要分别解码。Codex 的 Issue #20555 也展示了另一种情况:某些终端会把 Shift + Enter 退化成 LF/U+000A,Codex 需要把这个 C0 控制字符规范化为 Ctrl + J 或 Ctrl + Enter,才能让 keymap 匹配成功。
Windows Terminal 到底做了什么
Windows Terminal 的 sendInput action 可以拦截一个快捷键,并向当前终端会话发送指定文本。它会改变应用最终收到的输入字节,具体功能仍由应用解释这些字节后决定。
按照 Pi 官方文档配置:
{
"actions": [
{
"command": {
"action": "sendInput",
"input": "\u001b[13;2u"
},
"keys": "shift+enter"
}
]
}
结果是:
- Pi:解析 CSI u,得到
shift+enter,正常换行; - 支持该序列的 Claude Code 版本:正常换行;
- 我测试的 Codex/Windows 输入路径:
ESC被单独消费,其余字符[13;2u被插入输入框。
因此,界面中只会显示 [13;2u。Windows Terminal 仍然发送了序列开头的 \u001b,Codex 将它作为 ESC 处理了。
同时兼容 Codex、Pi 和 Claude Code 的配置
如果主要目标是让三个工具里的 Shift + Enter 都换行,可以让 Windows Terminal 统一发送 LF:
{
"actions": [
{
"command": {
"action": "sendInput",
"input": "\u000A"
},
"keys": "shift+enter"
}
]
}
\u000A 就是 LF,也等价于传统终端中的 Ctrl + J。应用此时只会看到 [10],无法知道用户原本按的是 Shift + Enter。这个方案保留了“插入换行”的语义,并提高了不同工具之间的兼容性。
然后在 Codex 中执行 /keymap,为 Editor → Insert new line(界面版本不同也可能显示为 Editor new line)增加 Ctrl + Enter。可以再用 /keymap debug 确认 Codex 最终将 Windows Terminal 发来的输入识别成什么。这个方案也来自 Codex #20555 中针对 Windows 终端输入规范化的讨论。
Pi 默认已经把 Ctrl + J 绑定到换行,因此无需额外修改;Claude Code 通常也能将 LF 用作多行输入。如果某个 Claude Code 版本不响应 LF,则应优先检查它自己的 keybinding 和对应版本 Issue。
需要注意:不要同时保留另一条同为 shift+enter、但发送 \u001b[13;2u 的 action。两个配置会冲突,最终生效项还可能受到 Windows Terminal 配置合并顺序影响。修改后最好完全关闭并重新打开 Windows Terminal。
总结
Shift + Enter 从物理按键到插入换行,需要跨越几层不同抽象:
物理按键
→ Windows 键盘事件
→ Windows Terminal 快捷键/action
→ 字符、C0 控制字符或 CSI u 序列
→ 应用输入库
→ 应用 keymap
→ 插入换行
传统终端默认把 Enter 和 Shift + Enter 都压缩成 CR,所以应用无法区分;CSI u 通过 ESC[13;2u 保留了完整修饰键信息,但前提是应用输入路径支持解析它;发送 LF 则主动放弃“这是 Shift + Enter”这一身份,只保留“我要换行”的语义。
对于同时使用 Codex、Pi 和 Claude Code 的 Windows Terminal 用户,Shift + Enter → \u000A 是更朴素、也更兼容的方案。