feat: shift+enter multi-line input support (#405)

Co-authored-by: Letta <noreply@letta.com>
This commit is contained in:
Charles Packer
2025-12-28 00:55:31 -08:00
committed by GitHub
parent 1f2b91b043
commit 2d61f335af
9 changed files with 703 additions and 1 deletions

View File

@@ -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<MessageCreate | ApprovalCreate>,

View File

@@ -192,6 +192,58 @@ export const commands: Record<string, Command> = {
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": {

View File

@@ -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)",

View File

@@ -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)

View File

@@ -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<void> {
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 ? <flags> 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 ? <attrs> 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[<u");
kittyEnabled = false;
}
} catch {
// Ignore errors
}
}

View File

@@ -0,0 +1,326 @@
/**
* Terminal keybinding installer for VS Code/Cursor/Windsurf
* Installs Shift+Enter keybinding that sends ESC+CR for multi-line input
*/
import {
copyFileSync,
existsSync,
mkdirSync,
readFileSync,
writeFileSync,
} from "node:fs";
import { homedir, platform } from "node:os";
import { dirname, join } from "node:path";
export type TerminalType = "vscode" | "cursor" | "windsurf" | null;
interface VSCodeKeybinding {
key: string;
command: string;
args?: Record<string, unknown>;
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);
}

View File

@@ -584,6 +584,17 @@ async function main(): Promise<void> {
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<void> {
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<void> {
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<void> {
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;

View File

@@ -12,6 +12,9 @@ export interface Settings {
globalSharedBlockIds: Record<string, string>; // label -> blockId mapping (persona, human; style moved to project settings)
permissions?: PermissionRules;
env?: Record<string, string>;
// 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 {

View File

@@ -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;