Recognize Esc and other control keys in kitty keyboard mode
Enabling the kitty keyboard protocol for Shift+Enter made terminals report Esc as CSI 27 u, which the CSI-u parser dropped as KeyUnknown, so Esc stopped aborting the agent. Map kitty control codepoints (Esc=27, Tab=9, Backspace=127/8) back to their dedicated keys.
- commit
- 1cc654ebbfceb15f4f56e61011d1a02ea1825a10
- author
- patriceckhart · · 2026-06-16 05:46 UTC
- parent
- ab7fb37
- signature
- unsigned
- refs
- tag: v0.2.34
2 files · +32 -0
M packages/tui/input.go +13 -0
| @@ -342,9 +342,22 @@ func keyFromModifiedCode(code, mod int) (Key, bool) { | |||
| 342 | 342 | shift := bits&1 != 0 | |
| 343 | 343 | alt := bits&2 != 0 | |
| 344 | 344 | ctrl := bits&4 != 0 | |
| 345 | + | // Kitty keyboard protocol (CSI ... u) reports control keys as their | |
| 346 | + | // codepoints: Esc=27, Enter=13, Tab=9, Backspace=127. Without the | |
| 347 | + | // enhanced-mode handling these arrive as raw bytes; with it enabled | |
| 348 | + | // they come through here, so map them back to their dedicated keys. | |
| 345 | 349 | switch code { | |
| 346 | 350 | case 13: | |
| 347 | 351 | return Key{Kind: KeyEnter, Shift: shift, Alt: alt, Ctrl: ctrl}, true | |
| 352 | + | case 27: | |
| 353 | + | return Key{Kind: KeyEsc, Shift: shift, Alt: alt, Ctrl: ctrl}, true | |
| 354 | + | case 9: | |
| 355 | + | if shift { | |
| 356 | + | return Key{Kind: KeyShiftTab, Alt: alt, Ctrl: ctrl}, true | |
| 357 | + | } | |
| 358 | + | return Key{Kind: KeyTab, Shift: shift, Alt: alt, Ctrl: ctrl}, true | |
| 359 | + | case 127, 8: | |
| 360 | + | return Key{Kind: KeyBackspace, Shift: shift, Alt: alt, Ctrl: ctrl}, true | |
| 348 | 361 | } | |
| 349 | 362 | if ctrl { | |
| 350 | 363 | switch code { | |
| @@ -342,9 +342,22 @@ func keyFromModifiedCode(code, mod int) (Key, bool) { | |||||
| 342 | shift := bits&1 != 0 | 342 | shift := bits&1 != 0 | ||
| 343 | alt := bits&2 != 0 | 343 | alt := bits&2 != 0 | ||
| 344 | ctrl := bits&4 != 0 | 344 | ctrl := bits&4 != 0 | ||
| 345 | + | // Kitty keyboard protocol (CSI ... u) reports control keys as their | |||
| 346 | + | // codepoints: Esc=27, Enter=13, Tab=9, Backspace=127. Without the | |||
| 347 | + | // enhanced-mode handling these arrive as raw bytes; with it enabled | |||
| 348 | + | // they come through here, so map them back to their dedicated keys. | |||
| 345 | switch code { | 349 | switch code { | ||
| 346 | case 13: | 350 | case 13: | ||
| 347 | return Key{Kind: KeyEnter, Shift: shift, Alt: alt, Ctrl: ctrl}, true | 351 | return Key{Kind: KeyEnter, Shift: shift, Alt: alt, Ctrl: ctrl}, true | ||
| 352 | + | case 27: | |||
| 353 | + | return Key{Kind: KeyEsc, Shift: shift, Alt: alt, Ctrl: ctrl}, true | |||
| 354 | + | case 9: | |||
| 355 | + | if shift { | |||
| 356 | + | return Key{Kind: KeyShiftTab, Alt: alt, Ctrl: ctrl}, true | |||
| 357 | + | } | |||
| 358 | + | return Key{Kind: KeyTab, Shift: shift, Alt: alt, Ctrl: ctrl}, true | |||
| 359 | + | case 127, 8: | |||
| 360 | + | return Key{Kind: KeyBackspace, Shift: shift, Alt: alt, Ctrl: ctrl}, true | |||
| 348 | } | 361 | } | ||
| 349 | if ctrl { | 362 | if ctrl { | ||
| 350 | switch code { | 363 | switch code { | ||
M packages/tui/input_test.go +19 -0
| @@ -30,6 +30,25 @@ func TestReaderParsesModifyOtherKeysCtrlC(t *testing.T) { | |||
| 30 | 30 | } | |
| 31 | 31 | } | |
| 32 | 32 | ||
| 33 | + | func TestReaderParsesCSIUEsc(t *testing.T) { | |
| 34 | + | k := readKey(t, "\x1b[27u") | |
| 35 | + | if k.Kind != KeyEsc { | |
| 36 | + | t.Fatalf("Read kind=%v, want esc", k.Kind) | |
| 37 | + | } | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | func TestReaderParsesCSIUTabAndBackspace(t *testing.T) { | |
| 41 | + | if k := readKey(t, "\x1b[9u"); k.Kind != KeyTab { | |
| 42 | + | t.Fatalf("Read kind=%v, want tab", k.Kind) | |
| 43 | + | } | |
| 44 | + | if k := readKey(t, "\x1b[9;2u"); k.Kind != KeyShiftTab { | |
| 45 | + | t.Fatalf("Read kind=%v, want shift-tab", k.Kind) | |
| 46 | + | } | |
| 47 | + | if k := readKey(t, "\x1b[127u"); k.Kind != KeyBackspace { | |
| 48 | + | t.Fatalf("Read kind=%v, want backspace", k.Kind) | |
| 49 | + | } | |
| 50 | + | } | |
| 51 | + | ||
| 33 | 52 | func TestReaderParsesSGRMouseWheel(t *testing.T) { | |
| 34 | 53 | cases := []struct { | |
| 35 | 54 | seq string | |
| @@ -30,6 +30,25 @@ func TestReaderParsesModifyOtherKeysCtrlC(t *testing.T) { | |||||
| 30 | } | 30 | } | ||
| 31 | } | 31 | } | ||
| 32 | 32 | ||||
| 33 | + | func TestReaderParsesCSIUEsc(t *testing.T) { | |||
| 34 | + | k := readKey(t, "\x1b[27u") | |||
| 35 | + | if k.Kind != KeyEsc { | |||
| 36 | + | t.Fatalf("Read kind=%v, want esc", k.Kind) | |||
| 37 | + | } | |||
| 38 | + | } | |||
| 39 | + | ||||
| 40 | + | func TestReaderParsesCSIUTabAndBackspace(t *testing.T) { | |||
| 41 | + | if k := readKey(t, "\x1b[9u"); k.Kind != KeyTab { | |||
| 42 | + | t.Fatalf("Read kind=%v, want tab", k.Kind) | |||
| 43 | + | } | |||
| 44 | + | if k := readKey(t, "\x1b[9;2u"); k.Kind != KeyShiftTab { | |||
| 45 | + | t.Fatalf("Read kind=%v, want shift-tab", k.Kind) | |||
| 46 | + | } | |||
| 47 | + | if k := readKey(t, "\x1b[127u"); k.Kind != KeyBackspace { | |||
| 48 | + | t.Fatalf("Read kind=%v, want backspace", k.Kind) | |||
| 49 | + | } | |||
| 50 | + | } | |||
| 51 | + | ||||
| 33 | func TestReaderParsesSGRMouseWheel(t *testing.T) { | 52 | func TestReaderParsesSGRMouseWheel(t *testing.T) { | ||
| 34 | cases := []struct { | 53 | cases := []struct { | ||
| 35 | seq string | 54 | seq string | ||