fix(cjk): forward Ctrl/Alt-modified navigation keys to the CLI

claude advertises "Jump to bottom (ctrl+End)", so that chord has to actually
reach it. But PASSTHROUGH_KEYS carried only the bare forms (End -> \x1b[F) and
CTRL_KEYS held just six letters (c/d/l/z/a/e), which cannot express End. Ctrl+End
therefore failed in both directions:

- with an empty composer it went out as a bare \x1b[F, the modifier silently
  dropped, so the CLI received a plain End;
- with text in the composer the forwarding branch requires empty, so nothing was
  forwarded and the browser default applied — the caret jumped to the end of the
  draft, which is the "the shortcut now edits my input box" the user saw.

Encode them as CSI 1;<mod><final> instead, and forward Ctrl/Alt-modified
navigation keys whether or not the composer is empty: they are commands for the
CLI, and the composer has no editing semantics for them worth preserving (bare
Home/End still use the old table and edit locally).

⚠️ Bare Shift is deliberately excluded: Shift+arrow selects text in the composer,
a real editing gesture that must stay local. Shift held together with Ctrl/Alt is
still encoded into the modifier mask.
This commit is contained in:
d fei
2026-09-03 02:01:44 -07:00
parent 08442dfee1
commit 3fbaadadfb
2 changed files with 112 additions and 0 deletions
+46
View File
@@ -120,6 +120,41 @@ const CjkInput = (() => {
c: '\x03', d: '\x04', l: '\x0c', z: '\x1a', a: '\x01', e: '\x05',
};
/** CSI final byte per navigation key, for the modifier-carrying forms below. */
const CSI_NAV_FINAL = {
ArrowUp: 'A',
ArrowDown: 'B',
ArrowRight: 'C',
ArrowLeft: 'D',
End: 'F',
Home: 'H',
};
/**
* The `CSI 1 ; <mod> <final>` form for a Ctrl/Alt-modified navigation key, or
* null when this key is not one.
*
* A modified navigation key is a terminal COMMAND, not text editing — claude's
* own "Jump to bottom (ctrl+End)" is one. PASSTHROUGH_KEYS carries only the
* plain forms, so Ctrl+End used to fail in BOTH directions: with an empty
* field it was sent as a bare `\x1b[F` (the modifier silently dropped, so the
* CLI saw a plain End), and with any text in the field it was not forwarded at
* all and the browser's default moved the caret to the end of the composer,
* which is what the user sees as "the shortcut does something to the input box
* instead".
*
* ⚠️ Shift ALONE is deliberately excluded: Shift+arrow selects text inside the
* composer, which is a real editing gesture worth keeping local. Shift is still
* encoded when it accompanies Ctrl or Alt.
*/
function _modifiedNavSequence(e) {
const final = CSI_NAV_FINAL[e.key];
if (!final) return null;
if (!e.ctrlKey && !e.altKey) return null;
const mod = 1 + (e.shiftKey ? 1 : 0) + (e.altKey ? 2 : 0) + (e.ctrlKey ? 4 : 0);
return `\x1b[1;${mod}${final}`;
}
function _strip(str) {
return str.replace(/​/g, '');
}
@@ -321,6 +356,17 @@ const CjkInput = (() => {
return;
}
// Ctrl/Alt-modified navigation keys go to the PTY REGARDLESS of whether
// the field has text: they are commands for the CLI, and the composer has
// no editing behaviour for them worth preserving (plain Home/End still
// edit locally through the table below).
const modNav = _modifiedNavSequence(e);
if (modNav) {
e.preventDefault();
_send(modNav);
return;
}
// Arrow/function keys: forward to PTY when no real text
if (PASSTHROUGH_KEYS[e.key] && _isEffectivelyEmpty()) {
e.preventDefault();