feat: shift+enter multi-line input support (#405)
Co-authored-by: Letta <noreply@letta.com>
This commit is contained in:
@@ -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>,
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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)
|
||||
|
||||
147
src/cli/utils/kittyProtocolDetector.ts
Normal file
147
src/cli/utils/kittyProtocolDetector.ts
Normal 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
|
||||
}
|
||||
}
|
||||
326
src/cli/utils/terminalKeybindingInstaller.ts
Normal file
326
src/cli/utils/terminalKeybindingInstaller.ts
Normal 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);
|
||||
}
|
||||
63
src/index.ts
63
src/index.ts
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
3
vendor/ink-text-input/build/index.js
vendored
3
vendor/ink-text-input/build/index.js
vendored
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user