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.

(cherry picked from commit 3fbaadadfb)
This commit is contained in:
d fei
2026-09-14 23:56:19 +02:00
committed by Codeman maintainer
parent b3a6ba2eb6
commit 631386d3f7
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();
+66
View File
@@ -146,6 +146,72 @@ describe('CJK input module', () => {
expect(sent).toEqual(['中文', ...committed]);
});
it('forwards Ctrl/Alt-modified navigation keys to the PTY, modifier intact', () => {
// claude prints "Jump to bottom (ctrl+End)" and the shortcut has to REACH it.
// PASSTHROUGH_KEYS carries only the plain forms, so Ctrl+End used to fail in
// both directions: with an empty field it went out as a bare `\x1b[F` (a
// plain End), and with any text in the field it was not forwarded at all —
// the browser default then moved the caret to the end of the composer, which
// is what the user sees as "the shortcut acts on the input box instead".
const { textarea, sent } = loadCjkHarness();
const preventDefault = vi.fn();
textarea.fire('keydown', {
key: 'End',
ctrlKey: true,
altKey: false,
shiftKey: false,
metaKey: false,
preventDefault,
});
expect(preventDefault).toHaveBeenCalled();
expect(sent).toEqual(['\x1b[1;5F']);
});
it('forwards a modified navigation key even when the composer has text', () => {
// The empty-field rule belongs to PLAIN navigation (which really is local
// editing); a Ctrl-modified one is a command for the CLI either way.
const { textarea, sent } = loadCjkHarness();
textarea.value = PHANTOM + '未发送的草稿';
textarea.fire('keydown', {
key: 'Home',
ctrlKey: true,
altKey: false,
shiftKey: false,
metaKey: false,
preventDefault: vi.fn(),
});
expect(sent).toEqual(['\x1b[1;5H']);
});
it('encodes the modifier bitmask, Shift included when it rides along', () => {
const { textarea, sent } = loadCjkHarness();
textarea.fire('keydown', {
key: 'ArrowUp',
ctrlKey: true,
shiftKey: true,
altKey: false,
metaKey: false,
preventDefault: vi.fn(),
});
expect(sent).toEqual(['\x1b[1;6A']); // 1 + shift(1) + ctrl(4)
});
it('leaves Shift-ALONE navigation local, so selecting in the composer still works', () => {
const { textarea, sent } = loadCjkHarness();
textarea.value = PHANTOM + '草稿';
const preventDefault = vi.fn();
textarea.fire('keydown', {
key: 'ArrowLeft',
shiftKey: true,
ctrlKey: false,
altKey: false,
metaKey: false,
preventDefault,
});
expect(sent).toEqual([]);
expect(preventDefault).not.toHaveBeenCalled();
});
it('recovers committed text when compositionend never fires (stuck composition)', () => {
const { textarea, sent } = loadCjkHarness();