@@ -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"
|
||||
},
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
29
src/index.ts
29
src/index.ts
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
8
vendor/ink-text-input/build/index.js
vendored
8
vendor/ink-text-input/build/index.js
vendored
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user