@@ -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}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user