This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

fix: encode shift+non-letter keys using CSI u / modified CSI sequences (#14)

resolveKey("shift+return") previously returned plain "\r" because
shift was silently ignored for non-letter keys. Now:

- CSI sequence keys (arrows, home/end, delete, pageup/down) get the
modifier parameter inserted: e.g. shift+up → \x1b[1;2A
- Control char keys (return, tab, escape, space, backspace) use
Kitty keyboard protocol CSI u encoding: e.g. shift+return → \x1b[13;2u
- shift+tab produces the legacy backtab sequence \x1b[Z
- Combined modifiers (ctrl+shift, alt+shift, ctrl+alt+shift) are
supported using the standard xterm modifier bitmask

Fixes #13

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

authored by

Johannes Schickling
Claude Opus 4.6 (1M context)
and committed by
GitHub
(Apr 10, 2026, 1:17 PM +0200) ab8757ce 91d5ec3f

+117 -22
+66 -21
src/keys.ts
··· 19 19 20 20 const MODIFIERS = new Set(["ctrl", "alt", "shift"]); 21 21 22 + /** Keycodes for CSI u encoding (Kitty keyboard protocol). */ 23 + const CSI_U_KEYCODES: Record<string, number> = { 24 + return: 13, 25 + enter: 13, 26 + tab: 9, 27 + escape: 27, 28 + esc: 27, 29 + space: 32, 30 + backspace: 127, 31 + }; 32 + 33 + /** Compute xterm modifier parameter: 1 + bitmask(shift=1, alt=2, ctrl=4). */ 34 + function modifierParam(mods: Set<string>): number { 35 + return ( 36 + 1 + 37 + (mods.has("shift") ? 1 : 0) + 38 + (mods.has("alt") ? 2 : 0) + 39 + (mods.has("ctrl") ? 4 : 0) 40 + ); 41 + } 42 + 22 43 /** Parse a key spec like `ctrl+c`, `return`, `alt+x` into bytes. */ 23 44 export function resolveKey(spec: string): string { 24 45 const parts = spec.toLowerCase().split("+"); ··· 32 53 } 33 54 } 34 55 35 - let result: string; 56 + const isLetter = base.length === 1 && base >= "a" && base <= "z"; 57 + const hasModifiers = mods.size > 0; 58 + const mapped = KEY_MAP[base]; 36 59 37 - if (KEY_MAP[base] !== undefined) { 38 - result = KEY_MAP[base]; 39 - } else if (base.length === 1 && base >= "a" && base <= "z") { 40 - result = base; 41 - } else { 60 + if (mapped === undefined && !isLetter) { 42 61 throw new Error(`Unknown key: "${base}" in key spec "${spec}"`); 43 62 } 44 63 45 - // Apply shift (only meaningful for single letters) 46 - if (mods.has("shift")) { 47 - if (result.length === 1 && result >= "a" && result <= "z") { 64 + // Single letter keys 65 + if (isLetter) { 66 + let result = base; 67 + 68 + if (mods.has("shift")) { 48 69 result = result.toUpperCase(); 49 70 } 50 - // shift on non-letters is silently ignored (e.g. shift+return = return) 51 - } 52 71 53 - // Apply ctrl (only meaningful for single letters) 54 - if (mods.has("ctrl")) { 55 - if (result.length === 1) { 72 + if (mods.has("ctrl")) { 56 73 const code = result.toLowerCase().charCodeAt(0); 57 - if (code >= 97 && code <= 122) { 58 - result = String.fromCharCode(code - 96); 59 - } 74 + result = String.fromCharCode(code - 96); 75 + } 76 + 77 + if (mods.has("alt")) { 78 + result = "\x1b" + result; 60 79 } 80 + 81 + return result; 61 82 } 62 83 63 - // Apply alt (prefix with ESC) 64 - if (mods.has("alt")) { 65 - result = "\x1b" + result; 84 + // Named keys without modifiers: return the mapped value directly 85 + if (!hasModifiers) { 86 + return mapped; 87 + } 88 + 89 + const mod = modifierParam(mods); 90 + 91 + // Special case: shift+tab produces legacy backtab sequence 92 + if (base === "tab" && mod === 2) { 93 + return "\x1b[Z"; 94 + } 95 + 96 + // CSI sequences: \x1b[N~ (e.g. delete, pageup) or \x1b[X (e.g. arrows, home, end) 97 + const csiTilde = mapped.match(/^\x1b\[(\d+)~$/); 98 + if (csiTilde) { 99 + return `\x1b[${csiTilde[1]};${mod}~`; 100 + } 101 + 102 + const csiLetter = mapped.match(/^\x1b\[([A-Z])$/); 103 + if (csiLetter) { 104 + return `\x1b[1;${mod}${csiLetter[1]}`; 105 + } 106 + 107 + // Control char keys (return, tab, escape, space, backspace): use CSI u encoding 108 + const keycode = CSI_U_KEYCODES[base]; 109 + if (keycode !== undefined) { 110 + return `\x1b[${keycode};${mod}u`; 66 111 } 67 112 68 - return result; 113 + return mapped; 69 114 } 70 115 71 116 /** If value starts with `key:`, resolve the key name; otherwise return the literal string. */
+51 -1
tests/keys.test.ts
··· 39 39 expect(resolveKey("alt+a")).toBe("\x1ba"); 40 40 }); 41 41 42 - it("resolves shift chords", () => { 42 + it("resolves shift chords for letters", () => { 43 43 expect(resolveKey("shift+a")).toBe("A"); 44 44 expect(resolveKey("shift+z")).toBe("Z"); 45 + }); 46 + 47 + it("resolves shift+return via CSI u encoding", () => { 48 + expect(resolveKey("shift+return")).toBe("\x1b[13;2u"); 49 + expect(resolveKey("shift+enter")).toBe("\x1b[13;2u"); 50 + }); 51 + 52 + it("resolves shift+tab as legacy backtab", () => { 53 + expect(resolveKey("shift+tab")).toBe("\x1b[Z"); 54 + }); 55 + 56 + it("resolves shift+escape and shift+space via CSI u", () => { 57 + expect(resolveKey("shift+escape")).toBe("\x1b[27;2u"); 58 + expect(resolveKey("shift+space")).toBe("\x1b[32;2u"); 59 + expect(resolveKey("shift+backspace")).toBe("\x1b[127;2u"); 60 + }); 61 + 62 + it("resolves shift+arrow keys with modifier parameter", () => { 63 + expect(resolveKey("shift+up")).toBe("\x1b[1;2A"); 64 + expect(resolveKey("shift+down")).toBe("\x1b[1;2B"); 65 + expect(resolveKey("shift+right")).toBe("\x1b[1;2C"); 66 + expect(resolveKey("shift+left")).toBe("\x1b[1;2D"); 67 + }); 68 + 69 + it("resolves shift+navigation keys with modifier parameter", () => { 70 + expect(resolveKey("shift+home")).toBe("\x1b[1;2H"); 71 + expect(resolveKey("shift+end")).toBe("\x1b[1;2F"); 72 + expect(resolveKey("shift+pageup")).toBe("\x1b[5;2~"); 73 + expect(resolveKey("shift+pagedown")).toBe("\x1b[6;2~"); 74 + expect(resolveKey("shift+delete")).toBe("\x1b[3;2~"); 75 + }); 76 + 77 + it("resolves ctrl+shift combinations", () => { 78 + expect(resolveKey("ctrl+shift+up")).toBe("\x1b[1;6A"); 79 + expect(resolveKey("ctrl+shift+return")).toBe("\x1b[13;6u"); 80 + }); 81 + 82 + it("resolves alt+shift combinations", () => { 83 + expect(resolveKey("alt+shift+up")).toBe("\x1b[1;4A"); 84 + expect(resolveKey("alt+shift+return")).toBe("\x1b[13;4u"); 85 + }); 86 + 87 + it("resolves ctrl+alt on named keys", () => { 88 + expect(resolveKey("ctrl+alt+up")).toBe("\x1b[1;7A"); 89 + expect(resolveKey("ctrl+alt+delete")).toBe("\x1b[3;7~"); 90 + }); 91 + 92 + it("resolves all three modifiers combined", () => { 93 + expect(resolveKey("ctrl+alt+shift+up")).toBe("\x1b[1;8A"); 94 + expect(resolveKey("ctrl+alt+shift+return")).toBe("\x1b[13;8u"); 45 95 }); 46 96 47 97 it("resolves composed modifiers", () => {