diff --git a/src/cli/App.tsx b/src/cli/App.tsx index de26186..20cc230 100644 --- a/src/cli/App.tsx +++ b/src/cli/App.tsx @@ -939,7 +939,6 @@ export default function App({ ); // Core streaming function - iterative loop that processes conversation turns - // biome-ignore lint/correctness/useExhaustiveDependencies: refs read .current dynamically const processConversation = useCallback( async ( initialInput: Array, diff --git a/src/cli/commands/registry.ts b/src/cli/commands/registry.ts index 08165f5..8aacbe8 100644 --- a/src/cli/commands/registry.ts +++ b/src/cli/commands/registry.ts @@ -192,6 +192,58 @@ export const commands: Record = { return "Opening help..."; }, }, + "/terminal": { + desc: "Manage Shift+Enter keybinding [--revert]", + order: 36, + handler: async (args: string[]) => { + const { + detectTerminalType, + getKeybindingsPath, + installKeybinding, + removeKeybinding, + } = await import("../utils/terminalKeybindingInstaller"); + const { updateSettings } = await import("../../settings"); + + const isRevert = args.includes("--revert") || args.includes("--remove"); + const terminal = detectTerminalType(); + + if (!terminal) { + return "Not running in a VS Code-like terminal. Shift+Enter keybinding is not needed."; + } + + const terminalName = { + vscode: "VS Code", + cursor: "Cursor", + windsurf: "Windsurf", + }[terminal]; + + const keybindingsPath = getKeybindingsPath(terminal); + if (!keybindingsPath) { + return `Could not determine keybindings.json path for ${terminalName}`; + } + + if (isRevert) { + const result = removeKeybinding(keybindingsPath); + if (!result.success) { + return `Failed to remove keybinding: ${result.error}`; + } + await updateSettings({ shiftEnterKeybindingInstalled: false }); + return `Removed Shift+Enter keybinding from ${terminalName}`; + } + + const result = installKeybinding(keybindingsPath); + if (!result.success) { + return `Failed to install keybinding: ${result.error}`; + } + + if (result.alreadyExists) { + return `Shift+Enter keybinding already exists in ${terminalName}`; + } + + await updateSettings({ shiftEnterKeybindingInstalled: true }); + return `Installed Shift+Enter keybinding for ${terminalName}\nLocation: ${keybindingsPath}`; + }, + }, // === Session management (order 40-49) === "/connect": { diff --git a/src/cli/components/HelpDialog.tsx b/src/cli/components/HelpDialog.tsx index 689a625..ef47124 100644 --- a/src/cli/components/HelpDialog.tsx +++ b/src/cli/components/HelpDialog.tsx @@ -69,6 +69,8 @@ export function HelpDialog({ onClose }: HelpDialogProps) { { keys: "Tab", description: "Autocomplete command or file path" }, { keys: "↓", description: "Navigate down / next command in history" }, { keys: "↑", description: "Navigate up / previous command in history" }, + { keys: "Shift+Enter", description: "Insert newline (multi-line input)" }, + { keys: "Opt+Enter", description: "Insert newline (alternative)" }, { keys: "Ctrl+C", description: "Interrupt operation / exit (double press)", diff --git a/src/cli/components/PasteAwareTextInput.tsx b/src/cli/components/PasteAwareTextInput.tsx index 69479db..e361d80 100644 --- a/src/cli/components/PasteAwareTextInput.tsx +++ b/src/cli/components/PasteAwareTextInput.tsx @@ -175,6 +175,29 @@ export function PasteAwareTextInput({ // Intercept paste events and macOS fallback for image clipboard imports useInput( (input, key) => { + // Handle Shift/Option/Ctrl + Enter to insert newline + if (key.return && (key.shift || key.meta || key.ctrl)) { + const at = Math.max( + 0, + Math.min(caretOffsetRef.current, displayValueRef.current.length), + ); + + // Insert actual \n for visual newline (cursor moves to new line) + const newValue = + displayValueRef.current.slice(0, at) + + "\n" + + displayValueRef.current.slice(at); + + setDisplayValue(newValue); + setActualValue(newValue); // Display and actual are same (both have \n) + onChangeRef.current(newValue); + + const nextCaret = at + 1; + setNudgeCursorOffset(nextCaret); + caretOffsetRef.current = nextCaret; + return; + } + // Handle bracketed paste events emitted by vendored Ink const isPasted = (key as unknown as { isPasted?: boolean })?.isPasted; if (isPasted) { @@ -317,6 +340,41 @@ export function PasteAwareTextInput({ caretOffsetRef.current = wordStart; }; + const forwardDeleteAtCursor = () => { + const curPos = caretOffsetRef.current; + if (curPos >= displayValueRef.current.length) return; + + const newDisplay = + displayValueRef.current.slice(0, curPos) + + displayValueRef.current.slice(curPos + 1); + const resolvedActual = resolvePlaceholders(newDisplay); + + setDisplayValue(newDisplay); + setActualValue(resolvedActual); + onChangeRef.current(newDisplay); + // Cursor stays in place + }; + + const insertNewlineAtCursor = () => { + const at = Math.max( + 0, + Math.min(caretOffsetRef.current, displayValueRef.current.length), + ); + + const newValue = + displayValueRef.current.slice(0, at) + + "\n" + + displayValueRef.current.slice(at); + + setDisplayValue(newValue); + setActualValue(newValue); + onChangeRef.current(newValue); + + const nextCaret = at + 1; + setNudgeCursorOffset(nextCaret); + caretOffsetRef.current = nextCaret; + }; + const handleRawInput = (payload: unknown) => { if (!focusRef.current) return; @@ -333,6 +391,55 @@ export function PasteAwareTextInput({ } if (!sequence) return; + // Optional debug logging for raw input bytes + if (process.env.LETTA_DEBUG_INPUT === "1") { + const debugHex = [...sequence] + .map((c) => `0x${c.charCodeAt(0).toString(16).padStart(2, "0")}`) + .join(" "); + // eslint-disable-next-line no-console + console.error( + `[debug:raw-input] len=${sequence.length} hex: ${debugHex}`, + ); + } + + // Option+Enter (Alt+Enter): ESC + carriage return + // On macOS with "Option as Meta" enabled, this sends \x1b\r + // Also check for \x1b\n (ESC + newline) for compatibility + if (sequence === "\x1b\r" || sequence === "\x1b\n") { + insertNewlineAtCursor(); + return; + } + + // VS Code/Cursor terminal keybinding style: + // Often configured to send a literal "\\r" sequence for Shift+Enter. + // Treat it as newline. + if (sequence === "\\r") { + insertNewlineAtCursor(); + return; + } + + // Kitty keyboard protocol: Shift+Enter, Ctrl+Enter, Alt+Enter + // Format: CSI keycode ; modifiers u + // Enter keycode = 13, modifiers: 2=shift, 3=alt, 5=ctrl, 6=ctrl+shift, 7=alt+ctrl, 8=alt+ctrl+shift + // Examples: \x1b[13;2u (Shift+Enter), \x1b[13;5u (Ctrl+Enter), \x1b[13;3u (Alt+Enter) + { + const prefix = "\u001b[13;"; + if (sequence.startsWith(prefix) && sequence.endsWith("u")) { + const mod = sequence.slice(prefix.length, -1); + if (mod.length === 1 && mod >= "2" && mod <= "8") { + insertNewlineAtCursor(); + 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(); + return; + } + // Option+Delete sequences (check first as they're exact matches) // - iTerm2/some terminals: ESC + DEL (\x1b\x7f) // - Some terminals: ESC + Backspace (\x1b\x08) diff --git a/src/cli/utils/kittyProtocolDetector.ts b/src/cli/utils/kittyProtocolDetector.ts new file mode 100644 index 0000000..7dd715a --- /dev/null +++ b/src/cli/utils/kittyProtocolDetector.ts @@ -0,0 +1,147 @@ +/** + * Detects and enables Kitty keyboard protocol support. + * Based on gemini-cli's implementation. + * See: https://sw.kovidgoyal.net/kitty/keyboard-protocol/ + */ +import * as fs from "node:fs"; + +let detectionComplete = false; +let kittySupported = false; +let kittyEnabled = false; + +const DEBUG = process.env.LETTA_DEBUG_KITTY === "1"; + +/** + * Detects Kitty keyboard protocol support. + * This function should be called once at app startup, before rendering. + */ +export async function detectAndEnableKittyProtocol(): Promise { + if (detectionComplete) { + return; + } + + return new Promise((resolve) => { + if (!process.stdin.isTTY || !process.stdout.isTTY) { + detectionComplete = true; + resolve(); + return; + } + + const originalRawMode = process.stdin.isRaw; + if (!originalRawMode) { + process.stdin.setRawMode(true); + } + + let responseBuffer = ""; + let progressiveEnhancementReceived = false; + let timeoutId: NodeJS.Timeout | undefined; + + const finish = () => { + if (timeoutId !== undefined) { + clearTimeout(timeoutId); + timeoutId = undefined; + } + process.stdin.removeListener("data", handleData); + if (!originalRawMode) { + process.stdin.setRawMode(false); + } + + // If the terminal explicitly answered the progressive enhancement query, + // treat it as supported. + if (progressiveEnhancementReceived) kittySupported = true; + + // Best-effort: even when the query isn't supported (common in xterm.js), + // enabling may still work. So we enable whenever we're on a TTY. + // If unsupported, terminals will just ignore the escape. + if (process.stdout.isTTY) { + if (DEBUG) { + // eslint-disable-next-line no-console + console.error("[kitty] enabling protocol"); + } + + enableKittyKeyboardProtocol(); + process.on("exit", disableKittyKeyboardProtocol); + process.on("SIGTERM", disableKittyKeyboardProtocol); + process.on("SIGINT", disableKittyKeyboardProtocol); + } else if (DEBUG && !kittySupported) { + // eslint-disable-next-line no-console + console.error( + "[kitty] protocol query unsupported; enabled anyway (best-effort)", + ); + } + + detectionComplete = true; + resolve(); + }; + + const handleData = (data: Buffer) => { + if (timeoutId === undefined) { + // Race condition. We have already timed out. + return; + } + responseBuffer += data.toString(); + + if (DEBUG) { + // eslint-disable-next-line no-console + console.error("[kitty] rx:", JSON.stringify(data.toString())); + } + + // Check for progressive enhancement response (CSI ? u) + if (responseBuffer.includes("\x1b[?") && responseBuffer.includes("u")) { + progressiveEnhancementReceived = true; + // Give more time to get the full set of kitty responses + clearTimeout(timeoutId); + timeoutId = setTimeout(finish, 1000); + } + + // Check for device attributes response (CSI ? c) + if (responseBuffer.includes("\x1b[?") && responseBuffer.includes("c")) { + // If we also got progressive enhancement, we can be confident. + if (progressiveEnhancementReceived) kittySupported = true; + + finish(); + } + }; + + process.stdin.on("data", handleData); + + // Query progressive enhancement and device attributes. + // Many terminals (including VS Code/xterm.js) will only start reporting + // enhanced keys after this handshake. + if (DEBUG) { + // eslint-disable-next-line no-console + console.error("[kitty] querying support"); + } + fs.writeSync(process.stdout.fd, "\x1b[?u\x1b[c"); + + // Timeout after 200ms + timeoutId = setTimeout(finish, 200); + }); +} + +export function isKittyProtocolEnabled(): boolean { + return kittyEnabled; +} + +function enableKittyKeyboardProtocol() { + try { + // Enable keyboard progressive enhancement flags. + // Use 7 (=1|2|4): DISAMBIGUATE_ESCAPE_CODES | REPORT_EVENT_TYPES | REPORT_ALTERNATE_KEYS + // This matches what crossterm-based TUIs (e.g., codex) request. + fs.writeSync(process.stdout.fd, "\x1b[>7u"); + kittyEnabled = true; + } catch { + // Ignore errors + } +} + +function disableKittyKeyboardProtocol() { + try { + if (kittyEnabled) { + fs.writeSync(process.stdout.fd, "\x1b[; + when?: string; +} + +/** + * Detect terminal type from environment variables + */ +export function detectTerminalType(): TerminalType { + // Check for Cursor first (it sets TERM_PROGRAM=vscode for compatibility) + if (process.env.CURSOR_TRACE_ID || process.env.CURSOR_CHANNEL) { + return "cursor"; + } + + // Check for Windsurf + if (process.env.WINDSURF_TRACE_ID || process.env.WINDSURF_CHANNEL) { + return "windsurf"; + } + + const termProgram = process.env.TERM_PROGRAM?.toLowerCase(); + + if (termProgram === "vscode") return "vscode"; + if (termProgram === "cursor") return "cursor"; + if (termProgram === "windsurf") return "windsurf"; + + // Fallback checks + if (process.env.VSCODE_INJECTION === "1") return "vscode"; + + return null; +} + +/** + * Check if running in a VS Code-like terminal (xterm.js-based) + */ +export function isVSCodeLikeTerminal(): boolean { + return detectTerminalType() !== null; +} + +/** + * Get platform-specific path to keybindings.json + */ +export function getKeybindingsPath(terminal: TerminalType): string | null { + if (!terminal) return null; + + const appName = { + vscode: "Code", + cursor: "Cursor", + windsurf: "Windsurf", + }[terminal]; + + const os = platform(); + + if (os === "darwin") { + return join( + homedir(), + "Library", + "Application Support", + appName, + "User", + "keybindings.json", + ); + } + + if (os === "win32") { + const appData = process.env.APPDATA; + if (!appData) return null; + return join(appData, appName, "User", "keybindings.json"); + } + + if (os === "linux") { + return join(homedir(), ".config", appName, "User", "keybindings.json"); + } + + return null; +} + +/** + * The keybinding we install - Shift+Enter sends ESC+CR + */ +const SHIFT_ENTER_KEYBINDING: VSCodeKeybinding = { + key: "shift+enter", + command: "workbench.action.terminal.sendSequence", + args: { text: "\u001b\r" }, + when: "terminalFocus", +}; + +/** + * Strip single-line and multi-line comments from JSONC + * Also handles trailing commas + */ +function stripJsonComments(jsonc: string): string { + // Remove single-line comments (// ...) + let result = jsonc.replace(/\/\/.*$/gm, ""); + + // Remove multi-line comments (/* ... */) + result = result.replace(/\/\*[\s\S]*?\*\//g, ""); + + // Remove trailing commas before ] or } + result = result.replace(/,(\s*[}\]])/g, "$1"); + + return result; +} + +/** + * Parse keybindings.json (handles JSONC with comments) + */ +function parseKeybindings(content: string): VSCodeKeybinding[] | null { + try { + const stripped = stripJsonComments(content); + const parsed = JSON.parse(stripped); + if (!Array.isArray(parsed)) return null; + return parsed as VSCodeKeybinding[]; + } catch { + return null; + } +} + +/** + * Check if our Shift+Enter keybinding already exists + */ +export function keybindingExists(keybindingsPath: string): boolean { + if (!existsSync(keybindingsPath)) return false; + + try { + const content = readFileSync(keybindingsPath, { encoding: "utf-8" }); + const keybindings = parseKeybindings(content); + + if (!keybindings) return false; + + return keybindings.some( + (kb) => + kb.key?.toLowerCase() === "shift+enter" && + kb.command === "workbench.action.terminal.sendSequence" && + kb.when?.includes("terminalFocus"), + ); + } catch { + return false; + } +} + +/** + * Create backup of keybindings.json + */ +function createBackup(keybindingsPath: string): string | null { + if (!existsSync(keybindingsPath)) return null; + + const backupPath = `${keybindingsPath}.letta-backup`; + try { + copyFileSync(keybindingsPath, backupPath); + return backupPath; + } catch { + // Backup failed, but we can continue without it + return null; + } +} + +export interface InstallResult { + success: boolean; + error?: string; + backupPath?: string; + alreadyExists?: boolean; +} + +/** + * Install the Shift+Enter keybinding + */ +export function installKeybinding(keybindingsPath: string): InstallResult { + try { + // Check if already exists + if (keybindingExists(keybindingsPath)) { + return { success: true, alreadyExists: true }; + } + + // Ensure parent directory exists + const parentDir = dirname(keybindingsPath); + if (!existsSync(parentDir)) { + mkdirSync(parentDir, { recursive: true }); + } + + let keybindings: VSCodeKeybinding[] = []; + let backupPath: string | null = null; + + // Read existing keybindings if file exists + if (existsSync(keybindingsPath)) { + backupPath = createBackup(keybindingsPath); + + const content = readFileSync(keybindingsPath, { encoding: "utf-8" }); + const parsed = parseKeybindings(content); + + if (parsed === null) { + return { + success: false, + error: `Could not parse ${keybindingsPath}. Please fix syntax errors and try again.`, + }; + } + + keybindings = parsed; + } + + // Add our keybinding + keybindings.push(SHIFT_ENTER_KEYBINDING); + + // Write back + const newContent = `${JSON.stringify(keybindings, null, 2)}\n`; + writeFileSync(keybindingsPath, newContent, { 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 keybinding: ${message}`, + }; + } +} + +/** + * Remove the Shift+Enter keybinding we installed + */ +export function removeKeybinding(keybindingsPath: string): InstallResult { + try { + if (!existsSync(keybindingsPath)) { + return { success: true }; // Nothing to remove + } + + const content = readFileSync(keybindingsPath, { encoding: "utf-8" }); + const keybindings = parseKeybindings(content); + + if (!keybindings) { + return { + success: false, + error: `Could not parse ${keybindingsPath}`, + }; + } + + // Filter out our keybinding + const filtered = keybindings.filter( + (kb) => + !( + kb.key?.toLowerCase() === "shift+enter" && + kb.command === "workbench.action.terminal.sendSequence" && + kb.when?.includes("terminalFocus") + ), + ); + + // Write back + const newContent = `${JSON.stringify(filtered, null, 2)}\n`; + writeFileSync(keybindingsPath, newContent, { encoding: "utf-8" }); + + return { success: true }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { + success: false, + error: `Failed to remove keybinding: ${message}`, + }; + } +} + +/** + * Convenience function to install keybinding for current terminal + */ +export function installKeybindingForCurrentTerminal(): InstallResult { + const terminal = detectTerminalType(); + if (!terminal) { + return { + success: false, + error: "Not running in a VS Code-like terminal", + }; + } + + const path = getKeybindingsPath(terminal); + if (!path) { + return { + success: false, + error: `Could not determine keybindings.json path for ${terminal}`, + }; + } + + return installKeybinding(path); +} + +/** + * Convenience function to remove keybinding for current terminal + */ +export function removeKeybindingForCurrentTerminal(): InstallResult { + const terminal = detectTerminalType(); + if (!terminal) { + return { + success: false, + error: "Not running in a VS Code-like terminal", + }; + } + + const path = getKeybindingsPath(terminal); + if (!path) { + return { + success: false, + error: `Could not determine keybindings.json path for ${terminal}`, + }; + } + + return removeKeybinding(path); +} diff --git a/src/index.ts b/src/index.ts index a8184ff..b4ecef3 100755 --- a/src/index.ts +++ b/src/index.ts @@ -584,6 +584,17 @@ async function main(): Promise { return; } + // Enable enhanced key reporting (Shift+Enter, etc.) BEFORE Ink initializes. + // In VS Code/xterm.js this typically requires a short handshake (query + enable). + try { + const { detectAndEnableKittyProtocol } = await import( + "./cli/utils/kittyProtocolDetector" + ); + await detectAndEnableKittyProtocol(); + } catch { + // Best-effort: if this fails, the app still runs (Option+Enter remains supported). + } + // Interactive: lazy-load React/Ink + App const React = await import("react"); const { render } = await import("ink"); @@ -614,6 +625,9 @@ async function main(): Promise { skillsDirectory?: string; fromAfFile?: string; }) { + const [showKeybindingSetup, setShowKeybindingSetup] = useState< + boolean | null + >(null); const [loadingState, setLoadingState] = useState< | "selecting" | "selecting_global" @@ -635,6 +649,50 @@ async function main(): Promise { string | null >(null); + // Auto-install Shift+Enter keybinding for VS Code/Cursor/Windsurf (silent, no prompt) + useEffect(() => { + async function autoInstallKeybinding() { + const { + detectTerminalType, + getKeybindingsPath, + keybindingExists, + installKeybinding, + } = await import("./cli/utils/terminalKeybindingInstaller"); + const { loadSettings, updateSettings } = await import("./settings"); + + const terminal = detectTerminalType(); + if (!terminal) { + setShowKeybindingSetup(false); + return; + } + + const settings = await loadSettings(); + const keybindingsPath = getKeybindingsPath(terminal); + + // Skip if already installed or no valid path + if (!keybindingsPath || settings.shiftEnterKeybindingInstalled) { + setShowKeybindingSetup(false); + return; + } + + // Check if keybinding already exists (user might have added it manually) + if (keybindingExists(keybindingsPath)) { + await updateSettings({ shiftEnterKeybindingInstalled: true }); + setShowKeybindingSetup(false); + return; + } + + // Silently install keybinding (no prompt, just like Claude Code) + const result = installKeybinding(keybindingsPath); + if (result.success) { + await updateSettings({ shiftEnterKeybindingInstalled: true }); + } + + setShowKeybindingSetup(false); + } + autoInstallKeybinding(); + }, []); + // Initialize on mount - check if we should show global agent selector useEffect(() => { async function checkAndStart() { @@ -1095,6 +1153,11 @@ async function main(): Promise { selectedGlobalAgentId, ]); + // Wait for keybinding auto-install to complete before showing UI + if (showKeybindingSetup === null) { + return null; + } + // Don't render anything during initial "selecting" phase - wait for checkAndStart if (loadingState === "selecting") { return null; diff --git a/src/settings.ts b/src/settings.ts index ca92d0d..2f29a19 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -12,6 +12,9 @@ export interface Settings { globalSharedBlockIds: Record; // label -> blockId mapping (persona, human; style moved to project settings) permissions?: PermissionRules; env?: Record; + // 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; } export interface ProjectSettings { diff --git a/vendor/ink-text-input/build/index.js b/vendor/ink-text-input/build/index.js index a069969..3363592 100644 --- a/vendor/ink-text-input/build/index.js +++ b/vendor/ink-text-input/build/index.js @@ -14,6 +14,9 @@ function isControlSequence(input, key) { if (key.tab || (key.ctrl && input === 'c')) return true; if (key.shift && key.tab) return true; + // Modifier+Enter - handled by parent for newline insertion + if (key.return && (key.shift || key.meta || key.ctrl)) return true; + // Ctrl+W (delete word) - handled by parent component if (key.ctrl && (input === 'w' || input === 'W')) return true;