xterm.js で Shift+Enter を改行にする — customKeyEventHandler が false を返すだけでは keypress で \r が漏れる

xterm.js で Shift+Enter を改行にする — customKeyEventHandler が false を返すだけでは keypress で \r が漏れる

ブラウザのターミナル(xterm.js)で Claude Code を動かしているアプリで、「Shift+Enter で改行が入る」ように直したはずが、ある日から Shift+Enter が普通の Enter と同じく送信されてしまうという報告が来ました。キーハンドラのコードは以前のまま、いじっていません。

結論から言うと、xterm の customKeyEventHandlerfalse を返しても、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" を送信
}

_keyDownHandledfalse(早期 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;
  };
}

コピペする前に知っておくと良いこと

一度踏むと忘れない類の罠

「custom handler で false を返せば xterm は何もしない」と思い込むと、送信が漏れる一点にたどり着くまで時間を溶かします。keydown を横取りするハンドラでは preventDefault まで添える——これだけ覚えておけば、xterm に限らず「キーを握ったのに default が漏れる」系はだいたい早く切り分けられます。

この記事をシェア

関連記事

記事一覧に戻る