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

@@ -52,7 +52,7 @@
"typecheck": "tsc --noEmit",
"check": "bun run scripts/check.js",
"dev": "bun --loader:.md=text --loader:.mdx=text --loader:.txt=text run src/index.ts",
"build": "bun run build.js",
"build": "node scripts/postinstall-patches.js && bun run build.js",
"prepare": "bun run build",
"postinstall": "node scripts/postinstall-patches.js"
},

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 = () => {

View File

@@ -324,3 +324,153 @@ export function removeKeybindingForCurrentTerminal(): InstallResult {
return removeKeybinding(path);
}
// ============================================================================
// WezTerm keybinding support
// WezTerm has a bug where Delete key sends 0x08 (backspace) instead of ESC[3~
// when kitty keyboard protocol is enabled. This keybinding fixes it.
// WezTerm auto-reloads config, so the fix takes effect immediately.
// See: https://github.com/wez/wezterm/issues/3758
// ============================================================================
/**
* Check if running in WezTerm
*/
export function isWezTerm(): boolean {
return process.env.TERM_PROGRAM === "WezTerm";
}
/**
* Get WezTerm config path
*/
export function getWezTermConfigPath(): string {
// WezTerm looks for config in these locations (in order):
// 1. $WEZTERM_CONFIG_FILE
// 2. $XDG_CONFIG_HOME/wezterm/wezterm.lua
// 3. ~/.config/wezterm/wezterm.lua
// 4. ~/.wezterm.lua
if (process.env.WEZTERM_CONFIG_FILE) {
return process.env.WEZTERM_CONFIG_FILE;
}
const xdgConfig = process.env.XDG_CONFIG_HOME;
if (xdgConfig) {
const xdgPath = join(xdgConfig, "wezterm", "wezterm.lua");
if (existsSync(xdgPath)) return xdgPath;
}
const configPath = join(homedir(), ".config", "wezterm", "wezterm.lua");
if (existsSync(configPath)) return configPath;
// Default to ~/.wezterm.lua
return join(homedir(), ".wezterm.lua");
}
/**
* The Lua code to fix Delete key in WezTerm
*/
const WEZTERM_DELETE_FIX = `
-- Letta Code: Fix Delete key sending wrong sequence with kitty keyboard protocol
-- See: https://github.com/wez/wezterm/issues/3758
local wezterm = require 'wezterm'
local keys = config.keys or {}
table.insert(keys, {
key = 'Delete',
mods = 'NONE',
action = wezterm.action.SendString '\\x1b[3~',
})
config.keys = keys
`;
/**
* Check if WezTerm config already has our Delete key fix
*/
export function wezTermDeleteFixExists(configPath: string): boolean {
if (!existsSync(configPath)) return false;
try {
const content = readFileSync(configPath, { encoding: "utf-8" });
// Check if our fix or equivalent already exists
return (
content.includes("Letta Code: Fix Delete key") ||
(content.includes("key = 'Delete'") &&
content.includes("SendString") &&
content.includes("\\x1b[3~"))
);
} catch {
return false;
}
}
/**
* Install WezTerm Delete key fix
*/
export function installWezTermDeleteFix(): InstallResult {
const configPath = getWezTermConfigPath();
try {
// Check if already installed
if (wezTermDeleteFixExists(configPath)) {
return { success: true, alreadyExists: true };
}
let content = "";
let backupPath: string | null = null;
if (existsSync(configPath)) {
backupPath = `${configPath}.letta-backup`;
copyFileSync(configPath, backupPath);
content = readFileSync(configPath, { encoding: "utf-8" });
}
// For simple configs that return a table directly, we need to modify them
// to use a config variable. Check if it's a simple "return {" style config.
if (content.includes("return {") && !content.includes("local config")) {
// Convert simple config to use config variable
content = content.replace(/return\s*\{/, "local config = {");
// Add return config at the end if not present
if (!content.includes("return config")) {
content = `${content.trimEnd()}\n\nreturn config\n`;
}
}
// If config doesn't exist or is empty, create a basic one
if (!content.trim()) {
content = `-- WezTerm configuration
local config = {}
return config
`;
}
// Insert our fix before "return config"
if (content.includes("return config")) {
content = content.replace(
"return config",
`${WEZTERM_DELETE_FIX}\nreturn config`,
);
} else {
// Append to end as fallback
content = `${content.trimEnd()}\n${WEZTERM_DELETE_FIX}\n`;
}
// Ensure parent directory exists
const parentDir = dirname(configPath);
if (!existsSync(parentDir)) {
mkdirSync(parentDir, { recursive: true });
}
writeFileSync(configPath, content, { encoding: "utf-8" });
return {
success: true,
backupPath: backupPath ?? undefined,
};
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return {
success: false,
error: `Failed to install WezTerm Delete key fix: ${message}`,
};
}
}

View File

@@ -690,7 +690,36 @@ async function main(): Promise<void> {
setShowKeybindingSetup(false);
}
async function autoInstallWezTermFix() {
const {
isWezTerm,
wezTermDeleteFixExists,
getWezTermConfigPath,
installWezTermDeleteFix,
} = await import("./cli/utils/terminalKeybindingInstaller");
const { loadSettings, updateSettings } = await import("./settings");
if (!isWezTerm()) return;
const settings = await loadSettings();
if (settings.wezTermDeleteFixInstalled) return;
const configPath = getWezTermConfigPath();
if (wezTermDeleteFixExists(configPath)) {
await updateSettings({ wezTermDeleteFixInstalled: true });
return;
}
// Silently install the fix
const result = installWezTermDeleteFix();
if (result.success) {
await updateSettings({ wezTermDeleteFixInstalled: true });
}
}
autoInstallKeybinding();
autoInstallWezTermFix();
}, []);
// Initialize on mount - check if we should show global agent selector

View File

@@ -15,6 +15,9 @@ export interface Settings {
// Shift+Enter keybinding state (for VS Code/Cursor/Windsurf)
// Tracks if we've auto-installed the keybinding (or if user already had it)
shiftEnterKeybindingInstalled?: boolean;
// WezTerm Delete key fix state
// Tracks if we've auto-installed the fix for kitty keyboard protocol bug
wezTermDeleteFixInstalled?: boolean;
}
export interface ProjectSettings {

View File

@@ -31,6 +31,14 @@ function isControlSequence(input, key) {
// CSI sequences (ESC[...), Option+Delete (ESC + DEL), and other multi-char escape sequences
if (input && typeof input === 'string' && input.startsWith('\x1b') && input.length > 1) return true;
// Forward delete (fn+Delete on macOS): handled by parent's raw input handler
// Check timestamp to avoid double-processing (globalThis.__lettaForwardDeleteTimestamp)
// Only forward delete sets this; regular backspace doesn't, so backspace still works here
if (key.delete && globalThis.__lettaForwardDeleteTimestamp &&
(Date.now() - globalThis.__lettaForwardDeleteTimestamp) < 100) {
return true;
}
return false;
}