fix: fix keybindings (#406)

Co-authored-by: Letta <noreply@letta.com>
This commit is contained in:
Charles Packer
2025-12-28 19:31:01 -08:00
committed by GitHub
parent c576392db0
commit aca3d88800
6 changed files with 259 additions and 12 deletions

View File

@@ -14,6 +14,13 @@ import {
} from "../helpers/clipboard";
import { allocatePaste, resolvePlaceholders } from "../helpers/pasteRegistry";
// Global timestamp for forward delete coordination
// Use globalThis to ensure singleton across bundle
declare global {
// eslint-disable-next-line no-var
var __lettaForwardDeleteTimestamp: number | undefined;
}
interface PasteAwareTextInputProps {
value: string;
onChange: (value: string) => void;
@@ -340,19 +347,24 @@ export function PasteAwareTextInput({
caretOffsetRef.current = wordStart;
};
const forwardDeleteAtCursor = () => {
const curPos = caretOffsetRef.current;
if (curPos >= displayValueRef.current.length) return;
// Forward delete: delete character AFTER cursor
const forwardDeleteAtCursor = (cursorPos: number) => {
if (cursorPos >= displayValueRef.current.length) return;
const newDisplay =
displayValueRef.current.slice(0, curPos) +
displayValueRef.current.slice(curPos + 1);
displayValueRef.current.slice(0, cursorPos) +
displayValueRef.current.slice(cursorPos + 1);
const resolvedActual = resolvePlaceholders(newDisplay);
// Update refs synchronously for consecutive operations
displayValueRef.current = newDisplay;
caretOffsetRef.current = cursorPos;
setDisplayValue(newDisplay);
setActualValue(resolvedActual);
onChangeRef.current(newDisplay);
// Cursor stays in place
// Cursor stays in place, sync it
setNudgeCursorOffset(cursorPos);
};
const insertNewlineAtCursor = () => {
@@ -433,10 +445,54 @@ export function PasteAwareTextInput({
}
}
// 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: Arrow keys
// Format: ESC[1;modifier:event_typeX where X is A/B/C/D for up/down/right/left
// Event types: 1=press, 2=repeat, 3=release
// Handle press AND repeat events, ignore release
{
// Match ESC[1;N:1X or ESC[1;N:2X (press or repeat)
// biome-ignore lint/suspicious/noControlCharactersInRegex: ESC sequence matching
const arrowMatch = sequence.match(/^\x1b\[1;\d+:[12]([ABCD])$/);
if (arrowMatch) {
// Emit standard arrow key sequence
internal_eventEmitter.emit("input", `\x1b[${arrowMatch[1]}`);
return;
}
// Ignore arrow key release events only
// biome-ignore lint/suspicious/noControlCharactersInRegex: ESC sequence matching
if (/^\x1b\[1;\d+:3[ABCD]$/.test(sequence)) {
return;
}
}
// fn+Delete (forward delete): ESC[3~ - standard ANSI escape sequence
// This deletes the character AFTER the cursor (unlike regular backspace)
if (sequence === "\x1b[3~") {
forwardDeleteAtCursor();
// Also handle kitty extended format: ESC[3;modifier:event_type~
// Event types: 1=press, 2=repeat, 3=release
// Use caretOffsetRef which is updated synchronously via onCursorOffsetChange
// biome-ignore lint/suspicious/noControlCharactersInRegex: ESC sequence matching
if (sequence === "\x1b[3~" || /^\x1b\[3;\d+:[12]~$/.test(sequence)) {
// Set timestamp so ink-text-input skips its delete handling
globalThis.__lettaForwardDeleteTimestamp = Date.now();
forwardDeleteAtCursor(caretOffsetRef.current);
return;
}
// Ignore forward delete release events
// biome-ignore lint/suspicious/noControlCharactersInRegex: ESC sequence matching
if (/^\x1b\[3;\d+:3~$/.test(sequence)) {
return;
}
@@ -576,12 +632,13 @@ export function PasteAwareTextInput({
}
// Normal typing/edits - update display and compute actual by substituting placeholders
// Update displayValueRef synchronously for raw input handlers
displayValueRef.current = newValue;
setDisplayValue(newValue);
const resolved = resolvePlaceholders(newValue);
setActualValue(resolved);
onChange(newValue);
// Default: cursor moves to end (most common case)
caretOffsetRef.current = newValue.length;
// Note: caretOffsetRef is updated by onCursorOffsetChange callback (called before onChange)
};
const handleSubmit = () => {