From aca3d888009480207018097cb75fedd2b0ba7a03 Mon Sep 17 00:00:00 2001 From: Charles Packer Date: Sun, 28 Dec 2025 19:31:01 -0800 Subject: [PATCH] fix: fix keybindings (#406) Co-authored-by: Letta --- package.json | 2 +- src/cli/components/PasteAwareTextInput.tsx | 79 ++++++++-- src/cli/utils/terminalKeybindingInstaller.ts | 150 +++++++++++++++++++ src/index.ts | 29 ++++ src/settings.ts | 3 + vendor/ink-text-input/build/index.js | 8 + 6 files changed, 259 insertions(+), 12 deletions(-) diff --git a/package.json b/package.json index 2cc89e1..9db2a58 100644 --- a/package.json +++ b/package.json @@ -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" }, diff --git a/src/cli/components/PasteAwareTextInput.tsx b/src/cli/components/PasteAwareTextInput.tsx index e361d80..6a02418 100644 --- a/src/cli/components/PasteAwareTextInput.tsx +++ b/src/cli/components/PasteAwareTextInput.tsx @@ -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 = () => { diff --git a/src/cli/utils/terminalKeybindingInstaller.ts b/src/cli/utils/terminalKeybindingInstaller.ts index 8876ed6..9396b00 100644 --- a/src/cli/utils/terminalKeybindingInstaller.ts +++ b/src/cli/utils/terminalKeybindingInstaller.ts @@ -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}`, + }; + } +} diff --git a/src/index.ts b/src/index.ts index b4ecef3..9a7c378 100755 --- a/src/index.ts +++ b/src/index.ts @@ -690,7 +690,36 @@ async function main(): Promise { 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 diff --git a/src/settings.ts b/src/settings.ts index 2f29a19..17d2333 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -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 { diff --git a/vendor/ink-text-input/build/index.js b/vendor/ink-text-input/build/index.js index 3363592..fe0d379 100644 --- a/vendor/ink-text-input/build/index.js +++ b/vendor/ink-text-input/build/index.js @@ -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; }