ブラウザのターミナル(xterm.js)で Claude Code を動かしているアプリで、「Shift+Enter で改行が入る」ように直したはずが、ある日から Shift+Enter が普通の Enter と同じく送信されてしまうという報告が来ました。キーハンドラのコードは以前のまま、いじっていません。
結論から言うと、xterm の customKeyEventHandler が false を返しても、xterm 側は preventDefault を呼ばずに早期 return するだけなので、ブラウザが後続の keypress を発火し、それを xterm が Enter の \r として送ってしまう、という罠でした。ハンドラの中で e.preventDefault() を呼べば直ります。
そもそもなぜ横取りしているか
xterm は Enter も Shift+Enter も同じ \r を送るので、PTY 側では区別できません。だから customKeyEventHandler で Shift+Enter を横取りし、Claude Code が改行として解釈する Meta/Alt+Enter(ESC + CR = \x1b\r)を自前で送っています。
export const NEWLINE_SEQUENCE = "\x1b\r"; // Meta/Alt+Enter — Claude Code reads this as a newline
export function shiftEnterNewline(e: ModifierKeyEvent): string | null {
const isShiftEnter = e.type === "keydown" && e.key === "Enter" && e.shiftKey && !e.altKey && !e.ctrlKey && !e.metaKey;
return isShiftEnter ? NEWLINE_SEQUENCE : null;
}
なぜ return false だけでは漏れるか
xterm 6.0.0 のソース(CoreBrowserTerminal)を追うと、_keyDown は要約するとこうなっています。
_keyDown(e) {
this._keyDownHandled = false;
// custom handler が false を返すと、ここで早期 return する。
// このとき preventDefault も this._keyDownHandled = true も呼ばれない。
if (this._customKeyEventHandler && this._customKeyEventHandler(e) === false) return false;
// …通常キーはこの先で処理され、最後に preventDefault + _keyDownHandled = true …
}
ポイントは、custom handler が false を返した経路では preventDefault を呼ばないこと。preventDefault されないので、ブラウザは keydown に続けて keypress を発火します。その keypress を受ける _keyPress は、
_keyPress(e) {
if (this._keyDownHandled) return false; // ← false のまま通過してしまう
// …
if (e.charCode) t = e.charCode; // Enter の charCode 13 = "\r" を送信
}
_keyDownHandled が false(早期 return したので立っていない)なので素通りし、Enter の charCode = \r を送ってしまう。つまり Shift+Enter では「自前で送った \x1b\r(改行)」に加えて「keypress 由来の \r(送信)」も飛び、結果として送信されます。通常の Enter は handler が true を返すので _keyDown が最後まで進み、preventDefault + _keyDownHandled = true するため keypress は無視されます。Shift+Enter だけ、早期 return のせいで keypress が漏れるわけです。
直し方:ハンドラで preventDefault する
修正は 1 行、ハンドラで e.preventDefault() を呼ぶだけです。keydown で default をキャンセルすれば、ブラウザは後続の keypress を発火しません。
export function makeShiftEnterHandler(send: (data: string) => void): (e: ShiftEnterEvent) => boolean {
return (e) => {
const newline = shiftEnterNewline(e);
if (newline === null) return true;
e.preventDefault(); // ← 肝。keypress の発火自体を止め、漏れの \r を防ぐ
send(newline);
return false;
};
}
コピペする前に知っておくと良いこと
return falseだけでは不十分。xterm のドキュメントの例もreturn falseのみですが、Enter のように keypress を伴うキーを横取りする場合はこの漏れが起きます。keydown を横取りするならpreventDefaultまでがワンセット。- 切り分けの小ネタ:Option+Enter は改行になるのに Shift+Enter だけ送信、という状態なら「
\x1b\r自体は改行として通っている(バイト経路は正常)」「壊れているのは Shift+Enter の横取り経路だけ」と一発で切り分けられます。macOptionIsMeta: trueを有効にしていると Option+Enter は xterm がネイティブに\x1b\rを送るので、良い対照実験になります。 - バージョンで内部実装は変わりうる。今回は xterm 6.0.0 で確認しました。挙動が怪しいときは
_keyDown/_keyPressの該当ロジックを直接見て、false経路でpreventDefaultを呼んでいるかを確かめるのが速いです。
一度踏むと忘れない類の罠
「custom handler で false を返せば xterm は何もしない」と思い込むと、送信が漏れる一点にたどり着くまで時間を溶かします。keydown を横取りするハンドラでは preventDefault まで添える——これだけ覚えておけば、xterm に限らず「キーを握ったのに default が漏れる」系はだいたい早く切り分けられます。