fix(tui): restore Shift+letter case in the composer for extended-key terminals (#90674)
Ghostty (and any terminal reporting modified letters via the kitty CSI-u or xterm modifyOtherKeys protocols) sends Shift+R as a lowercase keycode with a shift modifier. keycodeToName() lowercases the printable ASCII range, so the composer received 'r' instead of 'R' — uppercase input was silently destroyed. The shift flag was parsed correctly but discarded at the input layer. Re-apply shift to a single lowercase letter in inputForSpecialSequence so the inserted text is case-restored while keybinding consumers still see the lowercase canonical name via key.name. Covers both the CSI-u and modifyOtherKeys paths. Fixes #90663
This commit is contained in:
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user