From e56a112fba198cfebff138e2c6d40656c7563c53 Mon Sep 17 00:00:00 2001 From: lellansin Date: Wed, 13 May 2026 15:44:26 +0800 Subject: [PATCH] feat: support Shift+Enter prompt newlines Enable terminal extended key reporting while the prompt is active so terminals can distinguish Shift+Enter from plain Enter. Treat shifted return sequences as newline insertion while preserving plain Enter submission, and add unit coverage for the return-key behavior and terminal escape helpers. --- src/tests/promptInputKeys.test.ts | 29 ++++++++++++++++++++++++++++ src/ui/PromptInput.tsx | 32 ++++++++++++++++++++++--------- src/ui/index.ts | 3 ++- src/ui/prompt/cursor.ts | 21 ++++++++++++++++++++ src/ui/prompt/index.ts | 1 + src/ui/prompt/useTerminalInput.ts | 2 +- 6 files changed, 77 insertions(+), 11 deletions(-) diff --git a/src/tests/promptInputKeys.test.ts b/src/tests/promptInputKeys.test.ts index a17d7a4..704d44a 100644 --- a/src/tests/promptInputKeys.test.ts +++ b/src/tests/promptInputKeys.test.ts @@ -12,12 +12,15 @@ import { formatImageAttachmentStatus, formatSelectedSkillsStatus, getPromptCursorPlacement, + getPromptReturnKeyAction, isClearImageAttachmentsShortcut, parseTerminalInput, removeCurrentSlashToken, toggleSkillSelection, renderBufferWithCursor, buildInitPromptSubmission, + disableTerminalExtendedKeys, + enableTerminalExtendedKeys, } from "../ui"; import type { SkillInfo } from "../session"; @@ -83,6 +86,32 @@ test("parseTerminalInput recognizes shifted return sequences", () => { assert.equal(key.meta, false); }); +test("prompt return key action submits on plain enter", () => { + const { key } = parseTerminalInput("\r"); + assert.equal(getPromptReturnKeyAction(key), "submit"); +}); + +test("prompt return key action inserts newline on shift+enter", () => { + const { key } = parseTerminalInput("\u001B[13;2u"); + assert.equal(key.return, true); + assert.equal(key.shift, true); + assert.equal(getPromptReturnKeyAction(key), "newline"); +}); + +test("parseTerminalInput recognizes alternate shifted return sequences", () => { + for (const sequence of ["\u001B[13;2~", "\u001B[27;2;13~"]) { + const { key } = parseTerminalInput(sequence); + assert.equal(key.return, true); + assert.equal(key.shift, true); + assert.equal(getPromptReturnKeyAction(key), "newline"); + } +}); + +test("terminal extended key helpers request and restore modifyOtherKeys mode", () => { + assert.equal(enableTerminalExtendedKeys(), "\u001B[>4;1m"); + assert.equal(disableTerminalExtendedKeys(), "\u001B[>4;0m"); +}); + test("parseTerminalInput recognizes terminal focus events", () => { const focusIn = parseTerminalInput("\u001B[I"); const focusOut = parseTerminalInput("\u001B[O"); diff --git a/src/ui/PromptInput.tsx b/src/ui/PromptInput.tsx index 24560e2..b878961 100644 --- a/src/ui/PromptInput.tsx +++ b/src/ui/PromptInput.tsx @@ -32,7 +32,7 @@ export type { InputKey } from "./prompt"; import { useTerminalInput } from "./prompt"; import type { InputKey } from "./prompt"; -import { useHiddenTerminalCursor, useTerminalFocusReporting } from "./prompt"; +import { useHiddenTerminalCursor, useTerminalExtendedKeys, useTerminalFocusReporting } from "./prompt"; import SlashCommandMenu from "./SlashCommandMenu"; import type { ModelConfigSelection, ReasoningEffort } from "../settings"; @@ -140,6 +140,7 @@ export const PromptInput = React.memo(function PromptInput({ : "esc to interrupt · ctrl+c to cancel input" : "enter send · shift+enter newline · ctrl+v image · / commands · ctrl+d exit"; useTerminalFocusReporting(stdout, !disabled); + useTerminalExtendedKeys(stdout, !disabled); useHiddenTerminalCursor(stdout, !disabled); useEffect(() => { @@ -324,7 +325,8 @@ export const PromptInput = React.memo(function PromptInput({ } const noModifier = !key.shift && !key.ctrl && !key.meta; - const isPlainReturn = key.return && !key.shift && !key.meta; + const returnAction = getPromptReturnKeyAction(key); + const isPlainReturn = returnAction === "submit"; if (showMenu) { if (key.upArrow) { @@ -335,7 +337,7 @@ export const PromptInput = React.memo(function PromptInput({ setMenuIndex((idx) => (idx + 1) % slashMenu.length); return; } - if (key.tab || (key.return && !key.shift && !key.meta)) { + if (key.tab || returnAction === "submit") { const selected = slashMenu[menuIndex]; if (selected) { handleSlashSelection(selected); @@ -349,12 +351,12 @@ export const PromptInput = React.memo(function PromptInput({ return; } - if (key.return) { - const isShiftEnter = key.shift || key.meta; - if (isShiftEnter) { - updateBuffer((s) => insertText(s, "\n")); - return; - } + if (returnAction === "newline") { + updateBuffer((s) => insertText(s, "\n")); + return; + } + + if (returnAction === "submit") { submitCurrentBuffer(); return; } @@ -844,6 +846,18 @@ export function isClearImageAttachmentsShortcut(input: string, key: Pick): PromptReturnKeyAction { + if (!key.return) { + return null; + } + if (key.shift || key.meta) { + return "newline"; + } + return "submit"; +} + export function renderBufferWithCursor(state: PromptBufferState, isFocused: boolean, placeholder?: string): string { const text = state.text || ""; const cursor = Math.max(0, Math.min(state.cursor, text.length)); diff --git a/src/ui/index.ts b/src/ui/index.ts index a74e330..56e6ea2 100644 --- a/src/ui/index.ts +++ b/src/ui/index.ts @@ -18,6 +18,7 @@ export { toggleSkillSelection, removeCurrentSlashToken, isClearImageAttachmentsShortcut, + getPromptReturnKeyAction, renderBufferWithCursor, buildInitPromptSubmission, getThinkingOptionIndex, @@ -28,7 +29,7 @@ export { type PromptSubmission, type InputKey, } from "./PromptInput"; -export { getPromptCursorPlacement } from "./prompt/cursor"; +export { disableTerminalExtendedKeys, enableTerminalExtendedKeys, getPromptCursorPlacement } from "./prompt/cursor"; export { SessionList, formatSessionTitle } from "./SessionList"; export { ThemedGradient } from "./ThemedGradient"; export { UpdatePrompt, type UpdatePromptChoice } from "./UpdatePrompt"; diff --git a/src/ui/prompt/cursor.ts b/src/ui/prompt/cursor.ts index 8ccdc60..2668470 100644 --- a/src/ui/prompt/cursor.ts +++ b/src/ui/prompt/cursor.ts @@ -40,6 +40,14 @@ function disableTerminalFocusReporting(): string { return "\u001B[?1004l"; } +export function enableTerminalExtendedKeys(): string { + return "\u001B[>4;1m"; +} + +export function disableTerminalExtendedKeys(): string { + return "\u001B[>4;0m"; +} + export function getPromptCursorPlacement( state: PromptBufferState, screenWidth: number, @@ -239,3 +247,16 @@ export function useTerminalFocusReporting(stdout: NodeJS.WriteStream | undefined }; }, [isActive, stdout]); } + +export function useTerminalExtendedKeys(stdout: NodeJS.WriteStream | undefined, isActive: boolean): void { + useLayoutEffect(() => { + if (!isActive || !stdout?.isTTY) { + return; + } + + stdout.write(enableTerminalExtendedKeys()); + return () => { + stdout.write(disableTerminalExtendedKeys()); + }; + }, [isActive, stdout]); +} diff --git a/src/ui/prompt/index.ts b/src/ui/prompt/index.ts index 857b8ac..a33172c 100644 --- a/src/ui/prompt/index.ts +++ b/src/ui/prompt/index.ts @@ -3,6 +3,7 @@ export type { InputKey } from "./useTerminalInput"; export { useHiddenTerminalCursor, + useTerminalExtendedKeys, usePromptTerminalCursor, useTerminalFocusReporting, getPromptCursorPlacement, diff --git a/src/ui/prompt/useTerminalInput.ts b/src/ui/prompt/useTerminalInput.ts index a0f454f..af830e5 100644 --- a/src/ui/prompt/useTerminalInput.ts +++ b/src/ui/prompt/useTerminalInput.ts @@ -26,7 +26,7 @@ const BACKSPACE_BYTES = new Set(["\u007F", "\b"]); const FORWARD_DELETE_SEQUENCES = new Set(["\u001B[3~", "\u001B[P"]); const HOME_SEQUENCES = new Set(["\u001B[H", "\u001B[1~", "\u001B[7~", "\u001BOH"]); const END_SEQUENCES = new Set(["\u001B[F", "\u001B[4~", "\u001B[8~", "\u001BOF"]); -const SHIFT_RETURN_SEQUENCES = new Set(["\u001B\r", "\u001B[13;2u"]); +const SHIFT_RETURN_SEQUENCES = new Set(["\u001B\r", "\u001B[13;2u", "\u001B[13;2~", "\u001B[27;2;13~"]); const META_RETURN_SEQUENCES = new Set(["\u001B[13;3u", "\u001B[13;4u"]); const CTRL_LEFT_SEQUENCES = new Set(["\u001B[1;5D", "\u001B[5D"]); const CTRL_RIGHT_SEQUENCES = new Set(["\u001B[1;5C", "\u001B[5C"]);