はじめに
Neovim(以下nvim)で日本語入力のモードに応じてカーソルの色を変えたい。ひらがなモードではオレンジ、英字モードでは緑——それだけのことに、3日かかりました。
問題は単純に見えました。IMEの状態を検知して、カーソル色を切り替えるだけ。しかし実際に手を動かすと、nvimのTUI描画、ターミナルエミュレータのIME処理、macOSの入力ソースAPIという4つのレイヤーにまたがるバグに行き当たり、10種類の回避策をすべて試して全滅し、最終的にはターミナルエミュレータ(WezTerm)のGitHubにissueを報告するところまで辿り着きました。
この記事では、そのデバッグの過程を追体験できるように書いています。題材はニッチですが、ここで使ったデバッグの方法論——「推測で直すな、観測しろ」「レイヤーを分離して犯人を特定する」「試行の記録がそのままOSSへの貢献になる」——は、どんな技術領域にも持ち帰れるものだと考えています。
なお、このデバッグはAIコーディングアシスタント(Claude Code)との協働で行いました。macOSのネイティブAPIをLuaJIT FFIで呼び出す実装も、10種のワークアラウンドの試行も、WezTermのRustソースコードの分析も、実務はAIが担当しています。自分はデバッグの方向性を判断し、AIの提案を取捨選択する役割でした。「自分の知識・技量だけでは難しかったことが、AIとの協働で可能になる」——この記事はその実例でもあります。
第1部: デバッグ編 — 4つのレイヤーを掘り下げる
やりたかったこと
nvimのインサートモードで、日本語IME(MacSKK)の入力モードに応じてカーソル色を動的に変更する。
| IMEモード | カーソル色 |
|---|---|
| ひらがな | オレンジ |
| 直接入力(ASCII) | 緑 |
| 全角英字 | 深緑 |
nvimにはguicursorというオプションがあり、ハイライトグループを指定することでカーソルの色を制御できます。ターミナル上のnvimでは、これがOSC 12というエスケープシーケンスに変換され、ターミナルエミュレータがカーソルの描画色を変更します。
つまり、IMEモードを検知 → ハイライトグループを切り替え → nvimがOSC 12を送信 → ターミナルがカーソル色を更新、という流れです。
壁1: IMEモードをどう検知するか
最初の壁は「nvimの中からmacOSのIME状態をどう取得するか」でした。
以前のセッションで「nvimからSKKのサブモード(ひらがな/英字)を区別する方法はない」と結論していたのですが、AIがim-selectというコマンドラインツールを調査したところ、MacSKKのサブモードが区別可能であることがわかりました。ここで道が開けます。
ただし、im-selectは外部プロセスです。カーソル色を素早く反映するには高頻度のポーリングが必要で、毎回プロセスを起動するコストが気になります。
そこでAIが提案したのが、LuaJIT FFIでmacOSのText Input Services API(TIS API)を直接呼び出す方法でした。プロセス起動なしでIMEモードを取得でき、オーバーヘッドはマイクロ秒単位。
ところが、ここで予想外の問題が発生します。FFIでTISCopyCurrentKeyboardInputSource()を呼んでも、IMEモードを切り替えた後の値が更新されない。古い値がずっと返ってくるのです。
観測と原因特定
推測で修正を試みたくなる場面ですが、ここでデバッグの基本に立ち返ります。まず観測。
デバッグログを仕込み、FFIの返り値を毎ポーリングサイクルで記録しました。すると、im-selectコマンド(外部プロセス)では正しい値が返るのに、FFI呼び出し(同一プロセス内)では古い値が返り続けることがわかりました。
この事実から仮説が立ちます。macOSのTIS APIはプロセス内にキャッシュを持ち、IMEモード変更の通知はCFRunLoop経由の分散通知で配信される。nvimプロセスはCFRunLoopを回していないため通知が処理されず、キャッシュが古いまま残る。im-selectが毎回正しいのは、新プロセスとして起動されるからだ——と。
AIがCFRunLoopRunInMode(kCFRunLoopDefaultMode, 0, true)をTIS API呼び出し前に実行するコードを書きました。ペンディング中の通知を処理してキャッシュを更新する、たった1行の追加です。これで解決しました。
持ち帰りポイント: キャッシュの存在を疑うとき、「外部プロセスでは正しい/同一プロセスでは古い」という観測が決定的な手がかりになる。推測ではなく、観測の差分から仮説を立てる。
壁2: nvimのターミナル出力への直接書き込みがTUIを壊す
IMEモード検知が解決し、次はカーソル色の反映です。当初はOSC 1337(WezTerm独自のエスケープシーケンス)でnvimからWezTermにユーザー変数を送り、WezTermのLuaイベントハンドラでカーソル色を変更する設計を試みました。
ところが、io.writeでOSCシーケンスをターミナルに書き込んだ途端、nvimのTUI描画が壊れ始めます。カーソル移動が継続的に遅延する。InsertEnterで1回書き込むだけで、以降のすべての操作が重くなるのです。
段階的有効化テストで犯人を特定
ここでもデバッグの基本——「レイヤーを分離して犯人を特定する」。
コードを5つのパーツに分解し、1つずつ有効化して影響を確認しました。
- FFI初期化のみ → 問題なし
- InsertEnter/Leaveイベントのみ → 問題なし
- guicursor変更のみ → 問題なし
- OSC送信のみ → 遅延発生
- OSC送信を無効化 → 問題解消
30分で犯人をピンポイント特定。nvimのTUIレイヤーが予期しない出力を検知して回復モードに入るためだと推測されます。この設計は断念し、guicursor経由でnvimに正規のOSC 12を送信させるアプローチに切り替えました。
持ち帰りポイント: 複数の要素が絡む問題では、「全部入り」でテストしても犯人はわからない。段階的有効化テスト(1つずつ足して、どこで壊れるかを見る)は、レイヤーをまたぐデバッグの基本技法。
壁3: guicursorの再設定でOSC 12が再送信されない
guicursor経由のアプローチに切り替えたものの、今度は「コードは正しく動いているのに、視覚的にカーソル色が変わらない」という現象に遭遇しました。
デバッグログではguicursorの値が正しく変更されていることが確認できます。それなのに画面上のカーソル色は変わらない。
これはnvimの最適化が原因でした。guicursorに同じ文字列を再設定した場合、nvimはOSC 12の送信をスキップします。ハイライトグループの色だけ変えても、guicursor文字列自体が変わらなければnvimは「変更なし」と判断するのです。
解決策は、モードごとに異なるハイライトグループ名を使うこと。CursorInsert、CursorInsertHiragana、CursorInsertEisuと3つ定義し、guicursor文字列自体を物理的に変えることで、nvimに確実にOSC 12を再送信させました。
持ち帰りポイント: 「コードは正しいのに反映されない」ときは、フレームワークやランタイムの最適化を疑う。ログで「正しく動いている」ことが確認できるなら、問題はその先(出力経路)にある。
壁4: IMEモード切替後、カーソル色が固着する
ここまでで基本的なカーソル色変更は動くようになりました。C-jでひらがなモードに切り替えるとオレンジに変わる。しかし、lで直接入力に戻してもオレンジのまま。Escでノーマルモードに戻してもオレンジのまま。
一度オレンジになると、次に文字を入力するまで色が変わらないのです。
誤診、そして誤診の訂正
最初はWezTermのcompose_cursor(IME入力中のカーソル色設定)が犯人だと考えました。前セッションでの推測です。
しかし次セッションでcompose_cursorをnilにしても問題は変わらず、WezTermのLua APIでcomposition_status()をログ出力したところ、MacSKKのモード切替がcomposition状態を経由しないことが判明しました。前回の診断は誤りだった。
次に、tmuxのterminal-features設定を疑いましたが、ユーザーからtmuxなしの環境でテストしていると聞かされ、この仮説も棄却。テスト環境の前提を確認するという基本を怠っていました。
タイマートグルテスト — 問題を分離する実験を設計する
仮説を2回外した時点で、もっと根本的な切り分けが必要だと判断しました。AIが設計したのが「タイマートグルテスト」です。
— 2秒ごとにカーソル色をオレンジ↔緑にトグルする
local toggle = false
vim.uv.new_timer():start(0, 2000, vim.schedule_wrap(function()
toggle = not toggle
local hl = toggle and "CursorInsertHiragana" or "CursorInsert"
vim.o.guicursor = "…,i-ci-ve:block-" .. hl .. ",…"
end))
このテストで2つの事実が確定しました。
- IME操作なしでは、トグルは完璧に双方向で動作する
- C-j(IMEモード切替)を押した瞬間、トグルが永続的に停止し、色が固着する
IMEイベントがカーソル色の描画パイプラインを壊している——問題をここまで絞り込めました。
持ち帰りポイント: 複雑な条件が絡む問題では、「自動で動くテスト」を設計して人間の操作を最小限にする。再現条件を1つずつ足して、どこで壊れるかを見る。
10種のアプローチ、全滅
IMEイベントが原因とわかった上で、10種類の回避策を試行しました。
| # | アプローチ | 結果 |
|---|---|---|
| 1 | vim.cmd("redraw!") |
効果なし |
| 2 | vim.api.nvim_win_set_cursor() |
効果なし |
| 3 | vim.cmd("mode")(ターミナル再初期化) |
効果なし |
| 4 | FFIで/dev/ttyにOSC 12を直接書き込み |
WezTermに到達せず |
| 5 | FFIでstderr(fd 2)にOSC 12を書き込み | 到達するが色変わらず |
| 6 | WezTerm Luaのwindow:invalidate() |
効果なし |
| 7 | pane:inject_output()でOSC 12を注入 |
効果なし |
| 8 | カーソル表示のトグル | 効果なし |
| 9 | pane:send_text(" \b") |
効果なし |
| 10 | set_config_overridesでuse_imeをトグル |
効果なし |
全滅です。nvim側(1-5)もWezTerm Lua API側(6-10)も、どちらからも状態をリセットできない。
転換点: 「ネット検索してみたら?」
10種全滅の後、長時間のデバッグで視野が狭くなっていた状況を、自分が「袋小路にはまってる感じがする。気分を変えてネット検索してみたら?」とAIに提案しました。
結果として辿り着いたのが、最もシンプルなテスト——WezTermのuse_ime設定をfalseにすること。
use_ime = falseにすると、C-j後もタイマートグルが正常に継続しました。trueに戻すと再発。WezTermのmacOS IME処理(NSTextInputClient実装)がカーソル色の描画を壊している——これで真因が特定できました。
ただし、use_ime = falseは解決策ではありません。これを無効にすると日本語入力そのものが使えなくなるからです。あくまで「犯人がWezTermのIME処理にいる」ことを証明するための切り分けテストでした。
つまり現状はこうです。IMEモード切替でカーソル色が固着し、次に文字を入力するまで色が戻らない。nvim側の実装は正しく動いている(デバッグログで証明済み)。しかしWezTerm側のIME処理がOSC 12の反映を阻害しており、nvim側からもWezTerm Lua API側からも回避できない。
問題がWezTermのRustコード内にある以上、自分たちにできることは一つ——この調査結果をWezTermの開発者に届けることです。これが第2部につながります。
持ち帰りポイント: 10種の複雑な回避策を試した後に、
use_ime = falseという1行の設定変更で真因が特定できた。デバッグの渦中では「もっと複雑な方法」に目が向きがちだが、機能丸ごとのON/OFFが最も強力な切り分け手段であることは多い。そして、その視点の切り替えは渦中にいる本人(AI含む)より、一歩引いた位置にいる人間の方が得意なことがある。
第2部: OSSイシュー報告編 — デバッグの記録を贈り物に変える
「動きません」で終わらせない
デバッグの結果、問題がWezTermのRustコード内にあることが確定しました。自分たちの側(nvim、Lua)からは回避できない。ならば、この調査結果をWezTermの開発者に届ける必要があります。
ここで重要なのは、デバッグの過程で蓄積した情報がそのままissue報告の材料になるということです。改めて調査し直す必要はありません。
OSSのissueトラッカーには「動きません」だけの報告が溢れています。近年ではAIが生成した表面的なissueやPR(いわゆる「Slop」)も問題になっています。メンテナのデバッグコストを最小化する報告を目指しました。
issue報告に盛り込んだ要素
実際にwezterm/wezterm#7695として投稿したissueには、以下の要素を含めています。
1. 最小再現手順
問題を再現するための設定と手順を、コピペで試せるレベルで記述しました。特にタイマートグルテストのコードは、IMEの有無で挙動が変わることを誰でも確認できる独立した再現手法です。
2. 決定的な切り分け証拠
「use_ime = falseで解消する」という一点が最も重要な情報です。これにより、メンテナは問題の所在をIME処理のコードパスに即座に絞り込めます。
3. 試行済みワークアラウンドの一覧
10種のアプローチとその結果を表形式で記載しました。これは「報告者がすでに試したこと」をメンテナに伝える意味があります。「redraw!は試しましたか?」というやり取りを事前に省略できます。
4. ソースコード分析
WezTermのRustソースコードを読み、問題がwindow/src/os/macos/window.rsのNSTextInputClient実装にあると推測される旨を記載しました。具体的なファイル名・関数名を挙げることで、メンテナが問題箇所を探す手間を減らします。
5. 関連issueとの差異
既存の関連issue(#2708, #7494)を引用し、本issueとの違いを明記しました。「重複ではない」ことを報告者側が説明する責任です。
重複チェックの戦略
issueを投稿する前に、5パターンの検索キーワードで既存issueを網羅的に検索しました。
IME cursor colorOSC 12 IMEuse_ime cursorcursor color stuckOSC 12
重複issueがないことを確認した上で、関連する3件のissue/PRを特定し、本文中で言及しています。
持ち帰りポイント: 良いissue報告の要件は、(1)誰でも再現できる手順、(2)問題を絞り込む決定的な証拠、(3)すでに試したことの一覧、(4)可能なら原因箇所の推測、(5)既存issueとの差異の説明。デバッグを丁寧にやっていれば、これらの材料は自然と揃っている。
まとめ: デバッグの過程にこそ価値がある
この3日間で得た教訓を、一般化可能な形でまとめます。
デバッグの方法論
- 「推測で直すな、観測しろ」——デバッグログを1行入れるだけで、「正しく動いているのに反映されない」のか「そもそも動いていない」のかが判別できる。推測に基づく修正は、当たっても学びが少なく、外れると時間を浪費する
- 「レイヤーを分離して犯人を特定する」——複数のレイヤーにまたがる問題では、段階的有効化テスト(1つずつ足す)やタイマートグルテスト(自動実行で人間の操作を排除する)で、問題のあるレイヤーを特定する
- 「誤診を恐れるな、ただし訂正しろ」——仮説は外れることがある。重要なのは、観測結果に基づいて速やかに仮説を棄却し、次の仮説に移ること。誤診を引きずる方が危険
- 「最もシンプルなテストを忘れるな」——10種の複雑な回避策を試した後に、
use_ime = falseという1行で決着がついた。渦中にいると「もっと複雑な方法」に目が向きがちだが、機能丸ごとのON/OFFが最も強力な切り分け手段であることは多い
AIとの協働
- AIは実務を担い、人間は判断を担う——macOS TIS APIのFFI呼び出し、10種のワークアラウンドの実装、WezTermのRustソースコード分析。これらの実務はAIが高速にこなした。一方、「袋小路を抜けるための視点の切り替え」は人間側から提案した。AIと人間の得意領域は異なり、その組み合わせが個人の技量を超えた問題解決を可能にする
- デバッグの記録がOSSへの貢献になる——丁寧にデバッグした記録は、そのままissue報告の材料になる。「動きません」ではなく「これだけ試してダメでした」と言える報告は、OSSメンテナへの敬意であり、問題解決への最短距離でもある
この記事で紹介したデバッグは、AIコーディングアシスタント(Claude Code)との協働で行いました。macOSネイティブAPIの呼び出しからOSSへのissue報告まで、AIは強力なパートナーでした。ただし最終的な判断——何を試し、何を諦め、いつ視点を切り替えるか——は人間が担っています。AIは「不可能を可能にする」道具ですが、その道具をどこに向けるかを決めるのは、今のところまだ人間の仕事です。