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:
Franci Penov
2026-09-07 18:43:31 -07:00
committed by GitHub
parent 966637323e
commit c3ce41645c
2 changed files with 44 additions and 4 deletions
@@ -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