From 0d72e2bbe269640aeed5576774d93a1101fab45a Mon Sep 17 00:00:00 2001 From: Charles Packer Date: Tue, 30 Dec 2025 19:51:51 -0800 Subject: [PATCH] Fix CSI u double-firing for Ctrl+C, Ctrl+V, and Shift+Enter (#431) Co-authored-by: Letta --- .notes/csi-u-keyboard-protocol.md | 161 +++++++++++++++++++++ src/cli/components/PasteAwareTextInput.tsx | 60 +------- vendor/ink/build/hooks/use-input.js | 10 +- 3 files changed, 173 insertions(+), 58 deletions(-) create mode 100644 .notes/csi-u-keyboard-protocol.md diff --git a/.notes/csi-u-keyboard-protocol.md b/.notes/csi-u-keyboard-protocol.md new file mode 100644 index 0000000..0597e44 --- /dev/null +++ b/.notes/csi-u-keyboard-protocol.md @@ -0,0 +1,161 @@ +# CSI u Keyboard Protocol Support + +## Background + +iTerm2 3.5+ (and other modern terminals like Kitty, Ghostty, WezTerm) send keyboard input using the CSI u (aka "fixterms" or "libtermkey") encoding format instead of traditional escape sequences. + +**Discovery**: A user reported Escape and Shift+Tab not working in iTerm2. We discovered they were on iTerm2 3.6.5 while our working setup used 3.4.19. The newer version sends CSI u encoded keys by default. + +## CSI u Format + +``` +ESC [ keycode ; modifier u +ESC [ keycode ; modifier : event u (with event type) +``` + +### Keycodes +| Key | Keycode | +|-----------|---------| +| Tab | 9 | +| Return | 13 | +| Escape | 27 | +| Backspace | 127 | +| Letters | ASCII (a=97, z=122, A=65, Z=90) | + +### Modifier Bits +The modifier value in CSI u is `(bits + 1)`: +- Shift: bit 0 (value 1) → modifier = 2 +- Alt/Meta: bit 1 (value 2) → modifier = 3 +- Ctrl: bit 2 (value 4) → modifier = 5 +- Combinations add up: Ctrl+Shift = bits 0+2 = 5 → modifier = 6 + +### Event Types +- 1 = key press +- 2 = key repeat +- 3 = key release (must be ignored to avoid double-firing) + +### Examples +| Key Combination | CSI u Sequence | +|-----------------|----------------| +| Escape | `ESC[27u` | +| Shift+Tab | `ESC[9;2u` | +| Ctrl+C | `ESC[99;5u` | +| Shift+Enter | `ESC[13;2u` | +| Ctrl+C release | `ESC[99;5:3u` | + +## The Problem + +Ink's `parseKeypress` (from enquirer) doesn't understand CSI u format. When iTerm2 3.5+ sends `ESC[9;2u` for Shift+Tab: + +```javascript +const keypress = parseKeypress(data); +// Returns: { name: '', ctrl: false, shift: false, ... } +``` + +This caused Escape and Shift+Tab (and other keys) to not work. + +## Prior Workaround + +Before this fix, we handled CSI u sequences in PasteAwareTextInput's raw stdin handler: + +```javascript +stdin.on("data", (payload) => { + // Intercept ESC[99;5u and convert to 0x03 + if (sequence === "\x1b[99;5u") { + internal_eventEmitter.emit("input", "\x03"); + return; + } + // ... similar for Ctrl+V, Shift+Enter, etc. +}); +``` + +**Limitation**: Raw handlers only work when PasteAwareTextInput is focused. Menus like `/memory` don't have focus, so Ctrl+C didn't work there. + +## The Fix + +### 1. CSI u Fallback in use-input.js + +Added CSI u parsing as a fallback in `vendor/ink/build/hooks/use-input.js`: + +```javascript +let keypress = parseKeypress(data); + +// CSI u fallback: if parseKeypress didn't recognize it +if (!keypress.name && typeof data === 'string') { + const csiUMatch = data.match(/^\x1b\[(\d+)(?:;(\d+))?(?::(\d+))?u$/); + if (csiUMatch) { + const keycode = parseInt(csiUMatch[1], 10); + const modifier = parseInt(csiUMatch[2] || '1', 10) - 1; + const event = csiUMatch[3] ? parseInt(csiUMatch[3], 10) : 1; + + // Ignore key release events (event=3) + if (event === 3) return; + + // Map keycodes to names + const csiUKeyMap = { 9: 'tab', 13: 'return', 27: 'escape', 127: 'backspace' }; + let name = csiUKeyMap[keycode] || ''; + + // Handle letter keycodes (a-z, A-Z) + if (!name && keycode >= 97 && keycode <= 122) { + name = String.fromCharCode(keycode); + } + + if (name) { + keypress = { + name, + ctrl: !!(modifier & 4), + meta: !!(modifier & 10), + shift: !!(modifier & 1), + // ... + }; + } + } +} +``` + +### 2. Remove Redundant Raw Handlers + +After adding CSI u fallback, we had **double-firing**: both the raw handler AND the useInput handler processed the same sequence. + +**Removed from PasteAwareTextInput.tsx**: +- Ctrl+C handler (`ESC[99;5u` → `0x03` conversion) +- Ctrl+V handler (`ESC[118;5u` clipboard handling) +- Modifier+Enter handler (`ESC[13;Nu` newline insertion) + +**Kept**: +- Option+Enter (`ESC + CR`) - not CSI u format +- VS Code keybinding style (`\\r`) - not CSI u format +- Arrow keys with event types - different format, still needed + +## Testing Matrix + +| Terminal | Version | Escape | Shift+Tab | Ctrl+C (menu) | Ctrl+C (main) | Shift+Enter | +|-------------|---------|--------|-----------|---------------|---------------|-------------| +| iTerm2 | 3.4.19 | ✓ | ✓ | ✓ | ✓ | ✓ | +| iTerm2 | 3.6.5 | ✓ | ✓ | ✓ | ✓ | ✓ | +| Kitty | - | ✓ | ✓ | ✓ | ✓ | ✓ | +| Ghostty | - | ✓ | ✓ | ✓ | ✓ | ✓ | +| WezTerm | - | ✓ | ✓ | ✓ | ✓ | ✓ | +| VS Code | - | ✓ | ✓ | ✓ | ✓ | ✓ | +| Mac Terminal| - | ✓ | ✓ | ✓ | ✓ | (no support)| + +## Debug Environment Variables + +- `LETTA_DEBUG_KEYS=1` - Log raw keypresses and parsed results in use-input.js +- `LETTA_DEBUG_INPUT=1` - Log raw stdin bytes in PasteAwareTextInput +- `LETTA_DISABLE_KITTY=1` - Skip enabling Kitty keyboard protocol (for debugging) + +## Key Insight + +Raw stdin handlers were a **workaround** for Ink not understanding CSI u. The proper fix is teaching Ink to parse CSI u natively: + +1. Works everywhere (focused and unfocused contexts) +2. Single code path for all key handling +3. No double-firing issues +4. Easier to maintain + +## References + +- [Kitty Keyboard Protocol](https://sw.kovidgoyal.net/kitty/keyboard-protocol/) +- [fixterms spec](http://www.leonerd.org.uk/hacks/fixterms/) +- Gemini CLI's KeypressContext.tsx - comprehensive CSI u parsing example diff --git a/src/cli/components/PasteAwareTextInput.tsx b/src/cli/components/PasteAwareTextInput.tsx index d57b4cf..ceed5a3 100644 --- a/src/cli/components/PasteAwareTextInput.tsx +++ b/src/cli/components/PasteAwareTextInput.tsx @@ -457,63 +457,9 @@ export function PasteAwareTextInput({ return; } - // Kitty keyboard protocol: Shift+Enter, Ctrl+Enter, Alt+Enter - // Format: CSI keycode ; modifiers u - // Enter keycode = 13, modifiers: 2=shift, 3=alt, 5=ctrl, 6=ctrl+shift, 7=alt+ctrl, 8=alt+ctrl+shift - // Examples: \x1b[13;2u (Shift+Enter), \x1b[13;5u (Ctrl+Enter), \x1b[13;3u (Alt+Enter) - { - const prefix = "\u001b[13;"; - if (sequence.startsWith(prefix) && sequence.endsWith("u")) { - const mod = sequence.slice(prefix.length, -1); - if (mod.length === 1 && mod >= "2" && mod <= "8") { - insertNewlineAtCursor(); - return; - } - } - } - - // Kitty keyboard protocol: Ctrl+C - // Format: ESC[99;5u (key=99='c', modifier=5=ctrl) - // Kitty also sends key release events: ESC[99;5:3u (:3 = release) - // Only handle key PRESS, not release (to avoid double-triggering) - if (sequence === "\x1b[99;5u") { - // Emit raw Ctrl+C byte for Ink to handle - internal_eventEmitter.emit("input", "\x03"); - return; - } - // Ignore Ctrl+C key release/repeat events - if (sequence.startsWith("\x1b[99;5:")) { - return; - } - - // Kitty keyboard protocol: Ctrl+V (for clipboard image paste) - // Format: ESC[118;5u (key=118='v', modifier=5=ctrl) - if (sequence === "\x1b[118;5u") { - // Check clipboard for images - const clip = tryImportClipboardImageMac(); - if (clip) { - const at = Math.max( - 0, - Math.min(caretOffsetRef.current, displayValueRef.current.length), - ); - const newDisplay = - displayValueRef.current.slice(0, at) + - clip + - displayValueRef.current.slice(at); - displayValueRef.current = newDisplay; - setDisplayValue(newDisplay); - setActualValue(newDisplay); - onChangeRef.current(newDisplay); - const nextCaret = at + clip.length; - setNudgeCursorOffset(nextCaret); - caretOffsetRef.current = nextCaret; - } - return; - } - // Ignore Ctrl+V key release/repeat events - if (sequence.startsWith("\x1b[118;5:")) { - return; - } + // CSI u modifier+Enter (ESC[13;Nu) is now handled by the CSI u fallback + // in use-input.js, which parses it as return + shift/ctrl/meta flags. + // The useInput handler at line 186 then handles the newline insertion. // Kitty keyboard protocol: Arrow keys // Format: ESC[1;modifier:event_typeX where X is A/B/C/D for up/down/right/left diff --git a/vendor/ink/build/hooks/use-input.js b/vendor/ink/build/hooks/use-input.js index 5a8609b..a8e33ac 100644 --- a/vendor/ink/build/hooks/use-input.js +++ b/vendor/ink/build/hooks/use-input.js @@ -80,7 +80,15 @@ const useInput = (inputHandler, options = {}) => { 127: 'backspace', }; - const name = csiUKeyMap[keycode] || ''; + let name = csiUKeyMap[keycode] || ''; + + // Handle letter keycodes (a-z: 97-122, A-Z: 65-90) + if (!name && keycode >= 97 && keycode <= 122) { + name = String.fromCharCode(keycode); // lowercase letter + } else if (!name && keycode >= 65 && keycode <= 90) { + name = String.fromCharCode(keycode + 32); // convert to lowercase + } + if (name) { keypress = { name,