キーバインドはつらいよ

わたしは普段の日本語入力で SKK (とりわけ AquaSKK) なる IME を利用しています.この IME は自動で形態素解析を行わず,ユーザーが文節を明示的に指定して変換する少し癖の強い仕組みですが,慣れてしまえばとても快適です (これなしではもうやっていけません1).もし変換候補が存在しなければその場で手早く辞書登録できるし,普段使いしている長いコマンドのエイリアスや「:thinking: → 🤔」といったスニペットの登録も柔軟にやれます.日本語モードでひらがなを入力すればそのまま確定されるので,漢字変換するわけでもないのにいちいち Return キーで入力を確定させる不毛な営みからも解放されます.すばらしいですね!

はい.前述した通り SKK にはモードの概念があります.デフォルトでは Ctrl+J キーで日本語モード,L キーで ASCII モードに切り替わるよう設定されていますが,わたしは Ctrl+L を ASCII モードに敢えて割り当てています.これは「ぁ ぃ ぅ ぇ ぉ」を「xa xi xu xe xo」ではなく「la li lu le lo」で入力したかったという海よりも深い理由からです.

このようにして,個人的なこだわりからアレンジされたキーバインドですが,Ctrl+L というものは (面倒なことに) ターミナルで動作するプログラムにとって由緒あるキーでした.一般的には「画面のクリア」に割り当てられており,なにも対策しなければ ASCII モードに切り替えようとするたび,画面の表示が消し飛ぶわけですね.Claude Code が一世を風靡していますが,これもフルスクリーンで起動しているときに Ctrl+L を 2 秒以内に 2 回押してしまうと問答無用で /clear される仕様なので,冪等性があると思ってガチャガチャ連打すると詰み重ねた会話が消滅してしまいます.なかなか邪悪です (まあ落ち着いて --resume すれば復帰できるのでよいのですが).

zsh で完結するなら bindkey で調整できる

普段のシェル (わたしは zsh を利用) での作業であれば,プロンプトから割り当てられたキーを確認したり設定を上書きしたりできます.zsh の行編集機能は ZLE (Zsh Line Editor) なる独自の実装で,デフォルトでは ^Lclear-screen が割り当てられています.

bindkey '^L'
# => "^L" clear-screen

bindkey-r オプションで割り当てを外すことができるので,これを .zshrc で設定します.

bindkey -r '^L'

これにて zsh のプロンプトでは画面が消し飛ばなくなりました.よかった.

ところが,適当な対話アプリケーション (たとえば Gemini CLI や Claude Code) を起動して確認すると Ctrl+L による画面クリアは生きています.あくまでも bindkey は zsh における設定なので,当然ながら zsh がキーを読んでいなければ意味がありません.zsh からコマンドを入力してアプリが動き始めると,キーボードから入ってきたバイト列は zsh ではなく,その子プロセスとして起動したアプリ自身が読むことになります (zsh はこのアプリが終了するのを待ち構えている).

プログラミング言語の REPL など,これは以前からありふれた仕組みではありますが,生成 AI に対する命令を自然言語で入力する昨今は目立つようになりました.こういったアプリは,ターミナルから入ってきた入力を raw mode2 で 1 バイトずつ読み,これの解釈はアプリ自身で決定しています.行編集のレイヤーを通らないので,設定したところでどうにもなりません3

ターミナルで握り潰すと IME に到達しない

キー入力はシェルに到達する前,ターミナルエミュレータを通過します.ターミナル自身も自前のキーバインドを管理していて,割り当てられたキーを処理します.ここで Ctrl+L を握り潰してしまえば,キー入力が個々のアプリまで届くことはありません.

わたしは WezTerm を利用していますが,このターミナルには,何もせずにキーを消費する Nop というアクションが定義されています.これを Ctrl+L に割り当てたとしましょう.

config.keys = {
  { key = 'l', mods = 'CTRL', action = act.Nop },
}

こうすることで,Ctrl+L を押下しても画面が消し飛ぶことは無くなります.めでたしめでたし.ただし,お前が Ctrl+L をアプリで使わないならば.

冒頭でも書いていたように,わたしは Ctrl+L を AquaSKK の ASCII モード切替に利用しています.これをターミナルで握り潰すと,モードを切り替える機能が使えなくなってしまって本末転倒です.

そもそも Ctrl+L が AquaSKK に届いているのも,実はターミナルの設定によるものです.macOS において,修飾キーを伴う入力を IME に渡すかどうかはターミナルが決めており,WezTerm の場合は macos_forward_to_ime_modifier_mask でキーを指定します.デフォルトでは SHIFT のみがこれに相当し,Ctrl を IME で認識させるには明示的な設定が必要です.

config.use_ime = true
config.macos_forward_to_ime_modifier_mask = 'SHIFT|CTRL'

ところが WezTerm は始めにキーバインドを処理し,それから IME に渡すかどうかを判断しています.ここで Nop を割り当ててしまうと,キー入力を IME に渡す判断の前に入力自体が消えてしまうわけです.

アプリが読む前に入力を捨てる

ターミナルの段階で握り潰すと IME が巻き込まれてしまい,シェルの設定を試みてもアプリには届かないことを見てきました.打つ手が限られてきましたが,別のプログラムからアプリを起動して,キーの入力をそのプログラムが受け取る方法がまだ残っています.

そもそもターミナルというのは,画面に文字を描画するプログラムで,その上で動いているシェルやアプリとは別のプロセスです.これらは pty4 を介してデータのやりとりをしています (ファイルとしての実体を tty コマンドで確認できる).

tty
# => /dev/ttys004 (たとえば)

別のターミナルを開いてこのパスに書き込んでみましょう.書き込んだメッセージが元の画面に出てきます.

echo hello > /dev/ttys004

アプリが文字を画面に出力するのと同じ向きにメッセージが流れています.キーを押下したときは,これと逆方向にデータが流れ,ターミナルが書き込んだバイト列をアプリが読み取ります.今回は,ここに割り込んで入力を加工するわけですね.ターミナルの設定ではないので IME には干渉しないし,(アプリに届く前に処理するため) アプリ自体がキー設定の機能を持っている必要もありません.

このあたりを上手いことやってくれるのが expect です.対話による SSH ログインなどを自動化するのに使ったことがある方もいるのかなと思います.spawn で pty を作成してアプリを起動し interact でユーザーとアプリを中継させます.tty コマンドで pty が増えていることも確認できます.

expect -c 'spawn -noecho tty; expect eof'
# => /dev/ttys019 (別の名前の pty ができる)

やりとりされるバイト列に対してパターンマッチすることで,この処理の中身を空にしてしまえば,マッチした入力はアプリに渡ることなく捨てられます.Ctrl+L は制御文字で 0x0C の 1 バイト5 なので,これにマッチしたら捨てます.たとえば Gemini CLI6 については,プロセス置換でこんなシェル関数を定義できます.

# gemini があるときだけ関数を定義する
if (( $+commands[gemini] )); then
  gemini() {
    expect -f <(cat <<< '
      spawn -noecho gemini {*}$argv
      interact {
        # Ctrl+L を捨てる (8 進法)
        \014 { }
      }
    ') "$@"
  }
fi

ここで spawn{*}expect の記述言語 (Tcl) において,引数のリストを個々の引数として展開しています (これがないと gemini --model foo の引数部分が一つの引数として渡ってしまう).これにて Gemini CLI の画面はクリアされなくなりました.

そのままでは Claude Code に流用できない

同じことを Claude Code で試してみると,こちらは相変わらず /clear が発火して会話が消し飛びます.確かに 0x0C は捨てているはずなのに,どうやら効果がないようです.ということは,Ctrl+L が 0x0C として届いていないわけですね.

そもそも従来の制御文字には「どの修飾キーと一緒に押したか」を厳密に区別できない弱点があります.たとえば Tab と Ctrl+I はどちらも 0x09 で,Enter と Ctrl+M はどちらも 0x0D です.ターミナル側から送られてきたバイト列を見ただけでは違いが分かりません.Ctrl と数字の組み合わせや「キーを離した」といったイベントに至っては,表現すらありません.

こういった問題を解決するために,近年のアプリケーションは拡張キーボードプロトコルによって,リッチなキー入力をやりとりするようになりました.代表的なものとして xterm の modifyOtherKeys や kitty keyboard protocol があります.対話型のアプリは起動時に,ターミナルに対して「これからは拡張したエスケープシーケンスとして送信してほしい」と要求することができます.Gemini CLI はこれを要求しないため素の 0x0C が送信されていましたが,Claude Code は要求を出していたと考えられます.

具体的に Ctrl+L がどう変換されるかというと,modifyOtherKeys の場合は CSI 27;5;108~ と,kitty keyboard protocol の場合は CSI 108;5u となります.ここで CSIESC [ の 2 バイト,108l の文字コード,5 は Ctrl を表します.

わたしの使っている WezTerm には enable_kitty_keyboard という設定項目があり,公式ドキュメントによればデフォルトは false (アプリが要求しても無視する) とされています.特に設定していないので,WezTerm は kitty keyboard protocol の要求を無視し,Claude Code とは modifyOtherKeys でバイト列 (\033[27;5;108~) をやりとりしていたことになります.これを expect でキャッチして捨てればよいでしょう.

最終的に完成したもの

そういうわけで,いろいろ考えて盛り込むと Claude Code はこんな設定になります.

if (( $+commands[claude] )); then
  claude() {
    # パイプや非対話はそのまま (claude -p, echo ... | claude など)
    if [[ ! -t 0 || ! -t 1 ]] || (( $@[(I)-p|--print] )); then
      command claude "$@"
      return
    fi
    expect -f <(cat <<< '
      spawn -noecho claude {*}$argv
      # 外側でのリサイズを内側の pty にコピー
      trap {
        set rows [stty rows]
        set cols [stty columns]
        stty rows $rows columns $cols < $spawn_out(slave,name)
      } WINCH
      interact {
        \014 { }               # Ctrl+L
        "\033\[108;5u" { }     # kitty keyboard protocol
        "\033\[27;5;108~" { }  # modifyOtherKeys
      }
      # wait は {pid spawn_id os_error exit_code} を返す
      catch wait result
      exit [lindex $result 3]
    ') "$@"
  }
fi

わたしの WezTerm と Claude Code は modifyOtherKeys でやりとりしますが,実装していないターミナルでは素の制御文字にフォールバックされるので残しておきます.WezTerm の設定変更や別ターミナルへの移行可能性も加味して,ついでに kitty keyboard protocol のケースも書いています.

あとは pty を挟んだ後始末として,非対話ではそのまま通したり,ウィンドウサイズをコピーしたり,終了コードを引き継がせたりしています (どれも Ctrl+L とは関係ないが丁寧に).

こういうのってアプリ側でサポートするものだと思うんだが (おや?)

試行錯誤でいろいろ試しましたが,ハイカロリーな解決策になってしまいました.どのような形でバイト列が届くのかは,アプリとターミナルの組み合わせ次第で安定しません.パイプやらウィンドウサイズやら終了コードやら,Ctrl+L の設定をしたいだけなのに pty 絡みで脈絡の無い箇所をハックすることにもなりました.たまたま上手くいっていますが,まだ踏んでいない問題もありそうです.

そもそもキー入力は単なる信号 (バイト列) で,その意味はアプリが与えるのが筋というものです.それを飛び道具でパターンマッチしてアドホックに機能を打ち消しているので,アプリの都合が変われば辻褄が合わなくなってしまいます.本来はアプリ側の設定でキーマップを編集できてほしいわけですね.… んん??

Customize keyboard shortcuts - Claude Code Docs
Customize keyboard shortcuts in Claude Code with a keybindings configuration file.
Customize keyboard shortcuts - Claude Code Docs favicon code.claude.com
Customize keyboard shortcuts - Claude Code Docs

なんか Claude Code にキーバインドを差し替える方法が用意されていました (草).~/.claude/keybindings.json で context ごとに割り当てることができます.

{
  "$schema": "https://www.schemastore.org/claude-code-keybindings.json",
  "bindings": [{ "context": "Chat", "bindings": { "ctrl+l": null } }]
}

null の割り当てで Ctrl+L は無事に無効化されました.さっきまでの expect ハックはもう要りません.バイト列の形式に関わらず Claude Code 側で意味を与えているのでロバストです.まあ,こういうこともあるよね.

脚注

  1. SKK が無いとタイピングが赤ちゃんになってしまうので CBT 試験みたいに他人の PC でキー入力しなければならないイベントは不安で仕方がない.ネスペが無くなる前に受験したいなと思っていたけれど,どんな感じになるんだろう.

  2. ターミナルドライバの設定です.デフォルトだと行単位で入力がグルーピングされており,Enter を押すまではプログラムに届きません.これが raw mode だと (行単位でまとめずに) 1 バイトずつ渡します.これは stty -a で確認できます.

  3. 行編集の割り当てを外部から差し替える枠組みとして,たとえば readline の .inputrc があり,bash の行編集や rlwrap で readline の機能を付与したプログラムの挙動を設定します.アプリ自身が画面全体を自分で描画する場合は,行編集そのものが無いので効果はありません.

  4. 疑似端末 (pseudo terminal) を指す略称です.実際のシリアル端末が無くても端末として振る舞う仕組みで,ターミナルエミュレータや ssh はこれを用いてシェルを動かします.

  5. もともとはプリンタの改ページ (form feed) でした.新しいページで印刷を始める動きが画面クリアとして引き継がれています.

  6. Gemini CLI の一般提供は終了していて,後継の Antigravity CLI に移行するようですね.このまえ出たばかりだと思ったら,あっという間にお役御免なのすごい.わたしはもう Claude Code と Cursor しか最近は触ってないのですが.