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:
161
.notes/csi-u-keyboard-protocol.md
Normal file
161
.notes/csi-u-keyboard-protocol.md
Normal 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
|
||||
@@ -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
|
||||
|
||||
10
vendor/ink/build/hooks/use-input.js
vendored
10
vendor/ink/build/hooks/use-input.js
vendored
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user