Fix CSI u double-firing for Ctrl+C, Ctrl+V, and Shift+Enter (#431)

Co-authored-by: Letta <noreply@letta.com>
This commit is contained in:
Charles Packer
2025-12-30 19:51:51 -08:00
committed by GitHub
parent aeadf27938
commit 0d72e2bbe2
3 changed files with 173 additions and 58 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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,