From 631386d3f7d7267f805e74c3051059c36819506e Mon Sep 17 00:00:00 2001 From: d fei Date: Tue, 1 Sep 2026 07:05:43 -0700 Subject: [PATCH] fix(cjk): forward Ctrl/Alt-modified navigation keys to the CLI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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; 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 3fbaadadfb6eeee7d1c66933f7024062b7069cfd) --- src/web/public/input-cjk.js | 46 ++++++++++++++++++++++++++ test/input-cjk.test.ts | 66 +++++++++++++++++++++++++++++++++++++ 2 files changed, 112 insertions(+) diff --git a/src/web/public/input-cjk.js b/src/web/public/input-cjk.js index d2bf72bf..4f5420b7 100644 --- a/src/web/public/input-cjk.js +++ b/src/web/public/input-cjk.js @@ -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 ; ` 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(); diff --git a/test/input-cjk.test.ts b/test/input-cjk.test.ts index 993ad5a3..6da9dd71 100644 --- a/test/input-cjk.test.ts +++ b/test/input-cjk.test.ts @@ -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();