はじめに
Enter キーでプロンプトを送信するのがどうしても我慢できない。
チャット UI でも AI CLI でも、Enter は改行であるべきだ。
送信は Ctrl+Enter。そう決めている。コードを書いていて思考の途中に誤って送信してしまうストレスを、もう味わいたくない。
この「Enter = 改行、Ctrl+Enter = 送信」をあらゆる環境で統一することが今回の目標だった。
Claude Code(claude)と OpenAI Codex(codex)をtmux, neovim+sidekickから使い倒す中で、Mac+SSH+tmux 環境の codex だけ Ctrl+Enter がまったく効かないという壁にぶつかった。
調査と試行錯誤の記録を残しておく。
調査の90%はClaude CodeでSonnet 4.6が実施。この記事の90%もClaude Sonnet4.6が書いています。
TL;DR
- Mac+SHH+tmux環境下、Claude(Node.js)は動く、Codex(Rust/crossterm)だけCtrl+Enter送信が動かないという非対称な現象
- 原因は SSH レイテンシによる kitty keyboard protocol ネゴシエーションのタイムアウト
- 解決策は「Codex が kitty mode なしで認識できるキー(Alt+Enter)を使う」4層構成
環境
| 項目 | 内容 |
|---|---|
| ターミナル | Ghostty |
| マルチプレクサ | tmux |
| エディタ | Neovim + sidekick.nvim |
| AI CLI | Claude Code(claude)/ OpenAI Codex(codex) |
| 接続形態 | Mac → SSH → リモート Mac mini サーバ |
sidekick.nvim は Neovim 内から AI CLI をサイドペインに起動するプラグイン。tmux バックエンドで pane を管理する。
現象
Neovim の sidekick.nvim 経由で claude と codex を起動したとき:
-
claude→ Ctrl+Enter でプロンプト送信 ✅ -
codex→ Ctrl+Enter が改行として機能 ❌
同様のコンフィグでも 別環境Windows+WSL+Neovim+sidekick では codex の Ctrl+Enter が動く。
Ghostty で直接 tmux + codex を起動(Neovim 経由なし)しても Ctrl+Enter は効かない。
原因を掘り下げる
Ctrl+Enter は「普通の文字」ではない
ターミナルに Ctrl+Enter を送るとき、どんなバイト列になるかはターミナルの実装による。
レガシーモード(旧来の VT エスケープ):
Ctrl+Enter は特別なシーケンスを持たない。\r(キャリッジリターン = 普通の Enter)として届くことが多い。
kitty keyboard protocol(拡張モード):
\033[13;5u ← CSI-u シーケンス。13 = Enter、5 = Ctrl modifier
このモードでは Enter・Ctrl+Enter・Shift+Enter がすべて区別できる。
crossterm と kitty keyboard protocol
Codex の TUI は Rust の crossterm ライブラリを使っている。crossterm が Ctrl+Enter を認識するためには、起動時にターミナルに対して「kitty keyboard protocol を使う」というネゴシエーションを完了する必要がある。
具体的には、crossterm がエスケープシーケンスをターミナルに送り、ターミナルが応答するまで待つ。
SSH レイテンシがネゴシエーションを殺す
ここが核心。
crossterm → エスケープシーケンスを送信
↓ SSH の RTT がかかる
ターミナル(Ghostty)が応答
↓ SSH の RTT がかかる
crossterm が受信
ローカル(WSL など) では RTT がほぼゼロ → ネゴシエーション成功 → kitty mode ON → \033[13;5u を Ctrl+Enter と認識 ✅
SSH 越し では RTT が数十〜数百 ms → crossterm のクエリがタイムアウト → kitty mode に入れない → \033[13;5u は未知のシーケンスとして無視される ❌
なぜ Claude は動くのか
claude CLI は Node.js 実装。Claude は CSI-u シーケンスをネゴシエーションなしで直接バイト列としてパースする。そのため SSH 越しでも \033[13;5u を受け取れば Ctrl+Enter と認識できる。
なぜ Windows Terminal では動くのか
Windows Terminal には sendInput というキーバインドアクションがあり、物理キー入力をバイパスして任意のバイト列を直接注入できる:
{
"command": {
"action": "sendInput",
"input": "[13;5u"
}
}
Ctrl+Enter を押すと Windows Terminal が \033[13;5u を強制送信すル設定を追加してあった。
WSL はローカル接続なので kitty mode ネゴシエーションが成功しており、crossterm がこのバイト列を正しく認識できる。
解決策
kitty mode ネゴシエーションを SSH 越しで修正するのは困難なため、方針を転換:
「Codex が kitty mode なしで認識できるキーを使う」
そのキーが Alt+Enter(\033\r = ESC + CR)。レガシーモードで確立された古典的なエンコーディングで、ネゴシエーション不要。
ただしそのまま alt-enter を Codex の config に追加すると:
Error: invalid `tui.keymap`
エラーの原因:デフォルトバインドとの競合
Codex の alt-enter はデフォルトで editor.insert_newline(改行挿入)にバインドされている。同じキーを submit にも割り当てようとするとコンフリクトで設定全体が無効になる。
修正:insert_newline から alt-enter を明示的に除外してから submit に追加。
# ~/.codex/config.toml
[tui.keymap.editor]
insert_newline = ["enter", "ctrl-j", "ctrl-m", "shift-enter"] # alt-enter を除去
[tui.keymap.composer]
submit = ["ctrl-enter", "alt-enter"] # alt-enter を追加
実装:4層の変更
1. Ghostty(最外層)
Windows Terminal の sendInput と同じ設定を Ghostty にも追加。text: アクションは \x 記法でバイト列を直接送出できる:
# ghostty/config
keybind = ctrl+enter=text:\x1b[13;5u
これで Ctrl+Enter を押したとき Ghostty が \033[13;5u を強制送信する。
2. tmux(中間層)
tmux が \033[13;5u を受け取ったとき、アクティブな pane のコマンドによって送り先を変える:
# tmux.conf
set -g extended-keys on # "always" ではなく "on"
bind-key -n C-Enter run-shell "~/.config/tmux/ctrl-enter.sh '#{pane_id}' '#{pane_current_command}'"
#!/bin/bash
# ~/.config/tmux/ctrl-enter.sh
pane_id="$1"
pane_cmd="$2"
case "$pane_cmd" in
codex)
if [[ -f /proc/sys/fs/binfmt_misc/WSLInterop ]]; then
# WSL: Codex は kitty mode で動作 → CSI-u をそのまま送る
tmux send-keys -t "$pane_id" -l $'\x1b[13;5u'
else
# Mac+SSH: kitty mode なし → Alt+Enter で submit
tmux send-keys -t "$pane_id" -l $'\x1b\r'
fi
;;
nvim|vim)
# Neovim には CSI-u をそのまま渡す(Neovim 側でマッピングを処理)
tmux send-keys -t "$pane_id" -l $'\x1b[13;5u'
;;
*)
# Claude 等:CSI-u をそのまま
tmux send-keys -t "$pane_id" -l $'\x1b[13;5u'
;;
esac
ポイント:send-keys -l フラグでキー名解釈をスキップし、生バイトを直接 pane の stdin に注入する。
なお、検討の途中で -l なしで send-keys C-Enter を使い、tmux が独自解釈した別のシーケンスを送る事象が発生した。
3. Neovim / sidekick.lua(Sidekick 経由の場合)
sidekick.nvim が Codex の job に送るバイト列を、SSH_TTY 環境変数で切り替える:
local is_ssh = vim.fn.getenv("SSH_TTY") ~= vim.NIL
vim.keymap.set("t", "<C-CR>", function()
local job = vim.b[args.buf].terminal_job_id
if job then
local seq
if tool.name == "codex" and is_ssh then
seq = "\027\r" -- Alt+Enter(SSH = kitty mode なし)
else
seq = "\027[13;5u" -- Ctrl+Enter CSI-u(kitty mode あり)
end
vim.api.nvim_chan_send(job, seq)
end
end, { buffer = args.buf })
SSH_TTY は SSH セッション中のみ設定される環境変数。WSL のローカル接続では未設定なので、Windows+WSL の既存動作を壊さない。
4. Codex config(keymap 設定)
前述の TOML 設定を適用。
動作マトリクス
| 環境 | 経路 | 送信バイト | 認識 |
|---|---|---|---|
| Mac+SSH | tmux → Codex 直接 |
\x1b\r(Alt+Enter) |
✅ |
| Mac+SSH | Neovim → sidekick → Codex |
\x1b\r(SSH 検出) |
✅ |
| Mac+SSH | tmux → Claude 直接 |
\x1b[13;5u(CSI-u) |
✅ |
| Mac+SSH | Neovim → sidekick → Claude | \x1b[13;5u |
✅ |
| Windows+WSL | Neovim → sidekick → Codex |
\x1b[13;5u(kitty mode) |
✅ |
| Windows+WSL | tmux → Codex 直接 |
\x1b[13;5u(WSLInterop 検出) |
✅ |
まとめ
| レイヤー | 変更内容 | 目的 |
|---|---|---|
| Ghostty | ctrl+enter=text:\x1b[13;5u |
バイト列の強制注入(Windows Terminal 方式) |
| Codex config |
alt-enter を submit に追加、insert_newline から除去 |
コンフリクト解消 |
| tmux スクリプト | pane コマンドで送信先を分岐 | Codex には Alt+Enter、他には CSI-u |
| sidekick.lua |
SSH_TTY で SSH 検出 |
Windows+WSL の既存動作を保護 |
SSH 環境での TUI ツールのキー入力は、ターミナルエミュレータ・SSH・tmux・アプリ(TUI ライブラリ)の4層がそれぞれのプロトコルを持っており、どこか一箇所でも食い違うと失敗する。今回の調査で得た最大の教訓は「なぜ同じ設定が別の環境で動くのか、動作原理を追う」ことの重要性だった。
とは、この検討に1時間を費やしたClaude Sonnet 4.6さんの弁