diff --git a/ui-tui/packages/hermes-ink/src/ink/events/cmd-shortcuts.test.ts b/ui-tui/packages/hermes-ink/src/ink/events/cmd-shortcuts.test.ts index 3f1c5109be..88aa02ecde 100644 --- a/ui-tui/packages/hermes-ink/src/ink/events/cmd-shortcuts.test.ts +++ b/ui-tui/packages/hermes-ink/src/ink/events/cmd-shortcuts.test.ts @@ -62,4 +62,32 @@ describe('enhanced keyboard modifier parsing', () => { expect(right.key.rightArrow).toBe(true) expect(right.key.super).toBe(true) }) + + it('restores uppercase for Shift+letter via kitty CSI u (Ghostty) so the composer gets "R"', () => { + // Ghostty with TERM=xterm-ghostty reports Shift+R as CSI 82;2u. The + // parser lowercases the keycode to name 'r' and sets shift=true; the + // input layer must re-capitalize so the composer receives 'R', not 'r'. + const shiftR = new InputEvent(parseOne('\u001b[82;2u')) + expect(shiftR.key.shift).toBe(true) + expect(shiftR.input).toBe('R') + + const shiftA = new InputEvent(parseOne('\u001b[65;2u')) + expect(shiftA.input).toBe('A') + + // Plain lowercase must stay lowercase. + const plainR = new InputEvent(parseOne('\u001b[114;1u')) + expect(plainR.key.shift).toBe(false) + expect(plainR.input).toBe('r') + }) + + it('restores uppercase for Shift+letter via xterm modifyOtherKeys so the composer gets "R"', () => { + // modifyOtherKeys form of the same bug: Shift+R is [27;2;82~. + const shiftR = new InputEvent(parseOne('\u001b[27;2;82~')) + expect(shiftR.key.shift).toBe(true) + expect(shiftR.input).toBe('R') + + const plainR = new InputEvent(parseOne('\u001b[27;1;114~')) + expect(plainR.key.shift).toBe(false) + expect(plainR.input).toBe('r') + }) }) diff --git a/ui-tui/packages/hermes-ink/src/ink/events/input-event.ts b/ui-tui/packages/hermes-ink/src/ink/events/input-event.ts index 900f0042c0..36184e29bf 100644 --- a/ui-tui/packages/hermes-ink/src/ink/events/input-event.ts +++ b/ui-tui/packages/hermes-ink/src/ink/events/input-event.ts @@ -2,8 +2,20 @@ import { nonAlphanumericKeys, type ParsedKey } from '../parse-keypress.js' import { Event } from './event.js' -const inputForSpecialSequence = (name: string): string => - name === 'space' ? ' ' : name === 'return' || name === 'escape' ? '' : name +const inputForSpecialSequence = (name: string, shift: boolean): string => { + const input = name === 'space' ? ' ' : name === 'return' || name === 'escape' ? '' : name + + // Extended-key protocols (CSI u / xterm modifyOtherKeys) report printable + // letters as their lowercase keycode, so Shift+R arrives as name 'r' with + // shift=true. Re-apply shift to a single lowercase letter so the composer + // receives 'R', not 'r'. Keybinding consumers still see the lowercase + // canonical name via key.name — only the inserted text is case-restored. + if (shift && input.length === 1 && input >= 'a' && input <= 'z') { + return input.toUpperCase() + } + + return input +} export type Key = { upArrow: boolean @@ -112,7 +124,7 @@ function parseKey(keypress: ParsedKey): [Key, string] { // so the raw "[57358u" doesn't leak into the prompt. See #38781. input = '' } else { - input = inputForSpecialSequence(keypress.name) + input = inputForSpecialSequence(keypress.name, keypress.shift) } processedAsSpecialSequence = true @@ -130,7 +142,7 @@ function parseKey(keypress: ParsedKey): [Key, string] { // guards against future terminal behavior. input = '' } else { - input = inputForSpecialSequence(keypress.name) + input = inputForSpecialSequence(keypress.name, keypress.shift) } processedAsSpecialSequence = true