mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
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:
committed by
Codeman maintainer
parent
b3a6ba2eb6
commit
631386d3f7
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user