diff --git a/packages/types/src/global-settings.ts b/packages/types/src/global-settings.ts index a56a00fc355..b8212139762 100644 --- a/packages/types/src/global-settings.ts +++ b/packages/types/src/global-settings.ts @@ -148,6 +148,7 @@ export const globalSettingsSchema = z.object({ includeTaskHistoryInEnhance: z.boolean().optional(), historyPreviewCollapsed: z.boolean().optional(), reasoningBlockCollapsed: z.boolean().optional(), + sendMessageOnEnter: z.boolean().optional(), profileThresholds: z.record(z.string(), z.number()).optional(), hasOpenedModeSelector: z.boolean().optional(), lastModeExportPath: z.string().optional(), diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index e7cc9f96aaa..4dbd7f1440e 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -1621,6 +1621,10 @@ export const webviewMessageHandler = async ( await updateGlobalState("reasoningBlockCollapsed", message.bool ?? true) // No need to call postStateToWebview here as the UI already updated optimistically break + case "setSendMessageOnEnter": + await updateGlobalState("sendMessageOnEnter", message.bool ?? true) + // No need to call postStateToWebview here as the UI already updated optimistically + break case "toggleApiConfigPin": if (message.text) { const currentPinned = getGlobalState("pinnedApiConfigs") ?? {} diff --git a/src/shared/WebviewMessage.ts b/src/shared/WebviewMessage.ts index 97c52ef718e..cb5ef11205e 100644 --- a/src/shared/WebviewMessage.ts +++ b/src/shared/WebviewMessage.ts @@ -195,6 +195,7 @@ export interface WebviewMessage { | "profileThresholds" | "setHistoryPreviewCollapsed" | "setReasoningBlockCollapsed" + | "setSendMessageOnEnter" | "openExternal" | "filterMarketplaceItems" | "marketplaceButtonClicked" diff --git a/webview-ui/src/components/chat/ChatTextArea.tsx b/webview-ui/src/components/chat/ChatTextArea.tsx index c7813372fa7..e528d64f15c 100644 --- a/webview-ui/src/components/chat/ChatTextArea.tsx +++ b/webview-ui/src/components/chat/ChatTextArea.tsx @@ -89,6 +89,7 @@ export const ChatTextArea = forwardRef( clineMessages, commands, cloudUserInfo, + sendMessageOnEnter, } = useExtensionState() // Find the ID and display text for the currently selected API configuration. @@ -467,12 +468,22 @@ export const ChatTextArea = forwardRef( return } - if (event.key === "Enter" && !event.shiftKey && !isComposing) { - event.preventDefault() + // Handle Enter key based on user preference + const isEnterToSend = sendMessageOnEnter ?? true // Default to true (current behavior) - // Always call onSend - let ChatView handle queueing when disabled - resetHistoryNavigation() - onSend() + if (!isComposing) { + if (isEnterToSend && event.key === "Enter" && !event.shiftKey) { + // Enter sends, Shift+Enter for newline + event.preventDefault() + resetHistoryNavigation() + onSend() + } else if (!isEnterToSend && event.key === "Enter" && (event.shiftKey || event.ctrlKey)) { + // Shift+Enter or Ctrl+Enter sends, Enter for newline + event.preventDefault() + resetHistoryNavigation() + onSend() + } + // If neither condition matches, let the default behavior happen (newline) } if (event.key === "Backspace" && !isComposing) { @@ -536,6 +547,7 @@ export const ChatTextArea = forwardRef( handleHistoryNavigation, resetHistoryNavigation, commands, + sendMessageOnEnter, ], ) diff --git a/webview-ui/src/components/chat/__tests__/ChatTextArea.spec.tsx b/webview-ui/src/components/chat/__tests__/ChatTextArea.spec.tsx index af7704aa1b7..807d9534fd3 100644 --- a/webview-ui/src/components/chat/__tests__/ChatTextArea.spec.tsx +++ b/webview-ui/src/components/chat/__tests__/ChatTextArea.spec.tsx @@ -72,6 +72,7 @@ describe("ChatTextArea", () => { }, taskHistory: [], cwd: "/test/workspace", + sendMessageOnEnter: true, // Default to true for backward compatibility }) }) @@ -1139,4 +1140,239 @@ describe("ChatTextArea", () => { expect(sendButton).toHaveClass("pointer-events-auto") }) }) + + describe("sendMessageOnEnter setting", () => { + it("should send message on Enter when sendMessageOnEnter is true (default)", () => { + const onSend = vi.fn() + ;(useExtensionState as ReturnType).mockReturnValue({ + filePaths: [], + openedTabs: [], + apiConfiguration: { + apiProvider: "anthropic", + }, + taskHistory: [], + cwd: "/test/workspace", + sendMessageOnEnter: true, + }) + + const { container } = render() + + const textarea = container.querySelector("textarea")! + + // Simulate Enter key press + fireEvent.keyDown(textarea, { key: "Enter" }) + + expect(onSend).toHaveBeenCalled() + }) + + it("should create newline on Enter when sendMessageOnEnter is false", () => { + const onSend = vi.fn() + ;(useExtensionState as ReturnType).mockReturnValue({ + filePaths: [], + openedTabs: [], + apiConfiguration: { + apiProvider: "anthropic", + }, + taskHistory: [], + cwd: "/test/workspace", + sendMessageOnEnter: false, + }) + + const { container } = render() + + const textarea = container.querySelector("textarea")! + + // Simulate Enter key press + fireEvent.keyDown(textarea, { key: "Enter" }) + + // Should not send message + expect(onSend).not.toHaveBeenCalled() + }) + + it("should send message on Shift+Enter when sendMessageOnEnter is false", () => { + const onSend = vi.fn() + ;(useExtensionState as ReturnType).mockReturnValue({ + filePaths: [], + openedTabs: [], + apiConfiguration: { + apiProvider: "anthropic", + }, + taskHistory: [], + cwd: "/test/workspace", + sendMessageOnEnter: false, + }) + + const { container } = render() + + const textarea = container.querySelector("textarea")! + + // Simulate Shift+Enter key press + fireEvent.keyDown(textarea, { key: "Enter", shiftKey: true }) + + expect(onSend).toHaveBeenCalled() + }) + + it("should send message on Ctrl+Enter when sendMessageOnEnter is false", () => { + const onSend = vi.fn() + ;(useExtensionState as ReturnType).mockReturnValue({ + filePaths: [], + openedTabs: [], + apiConfiguration: { + apiProvider: "anthropic", + }, + taskHistory: [], + cwd: "/test/workspace", + sendMessageOnEnter: false, + }) + + const { container } = render() + + const textarea = container.querySelector("textarea")! + + // Simulate Ctrl+Enter key press + fireEvent.keyDown(textarea, { key: "Enter", ctrlKey: true }) + + expect(onSend).toHaveBeenCalled() + }) + + it("should create newline on Shift+Enter when sendMessageOnEnter is true", () => { + const onSend = vi.fn() + ;(useExtensionState as ReturnType).mockReturnValue({ + filePaths: [], + openedTabs: [], + apiConfiguration: { + apiProvider: "anthropic", + }, + taskHistory: [], + cwd: "/test/workspace", + sendMessageOnEnter: true, + }) + + const { container } = render() + + const textarea = container.querySelector("textarea")! + + // Simulate Shift+Enter key press + fireEvent.keyDown(textarea, { key: "Enter", shiftKey: true }) + + // Should not send message + expect(onSend).not.toHaveBeenCalled() + }) + + it("should not send message during IME composition regardless of setting", () => { + const onSend = vi.fn() + ;(useExtensionState as ReturnType).mockReturnValue({ + filePaths: [], + openedTabs: [], + apiConfiguration: { + apiProvider: "anthropic", + }, + taskHistory: [], + cwd: "/test/workspace", + sendMessageOnEnter: true, + }) + + const { container } = render() + + const textarea = container.querySelector("textarea")! + + // Create a proper KeyboardEvent with isComposing property + const composingEvent = new KeyboardEvent("keydown", { + key: "Enter", + bubbles: true, + cancelable: true, + }) + // Override the isComposing property + Object.defineProperty(composingEvent, "isComposing", { + value: true, + writable: false, + }) + + // Dispatch the event directly + textarea.dispatchEvent(composingEvent) + + // Should not send message during composition + expect(onSend).not.toHaveBeenCalled() + + // Now Enter should work without composition + fireEvent.keyDown(textarea, { key: "Enter" }) + expect(onSend).toHaveBeenCalled() + }) + + it("should use default value (true) when sendMessageOnEnter is undefined", () => { + const onSend = vi.fn() + ;(useExtensionState as ReturnType).mockReturnValue({ + filePaths: [], + openedTabs: [], + apiConfiguration: { + apiProvider: "anthropic", + }, + taskHistory: [], + cwd: "/test/workspace", + sendMessageOnEnter: undefined, + }) + + const { container } = render() + + const textarea = container.querySelector("textarea")! + + // Simulate Enter key press + fireEvent.keyDown(textarea, { key: "Enter" }) + + // Should send message (default behavior) + expect(onSend).toHaveBeenCalled() + }) + + it("should call onSend even with empty message (actual behavior)", () => { + const onSend = vi.fn() + ;(useExtensionState as ReturnType).mockReturnValue({ + filePaths: [], + openedTabs: [], + apiConfiguration: { + apiProvider: "anthropic", + }, + taskHistory: [], + cwd: "/test/workspace", + sendMessageOnEnter: true, + }) + + const { container } = render() + + const textarea = container.querySelector("textarea")! + + // Simulate Enter key press with empty input + fireEvent.keyDown(textarea, { key: "Enter" }) + + // The actual implementation calls onSend regardless of empty input + // The parent component (ChatView) is responsible for checking if message is empty + expect(onSend).toHaveBeenCalled() + }) + + it("should call onSend even when sendingDisabled is true (actual behavior)", () => { + const onSend = vi.fn() + ;(useExtensionState as ReturnType).mockReturnValue({ + filePaths: [], + openedTabs: [], + apiConfiguration: { + apiProvider: "anthropic", + }, + taskHistory: [], + cwd: "/test/workspace", + sendMessageOnEnter: true, + }) + + const { container } = render( + , + ) + + const textarea = container.querySelector("textarea")! + + // Simulate Enter key press + fireEvent.keyDown(textarea, { key: "Enter" }) + + // The actual implementation calls onSend regardless of sendingDisabled + // The parent component is responsible for checking if sending is disabled + expect(onSend).toHaveBeenCalled() + }) + }) }) diff --git a/webview-ui/src/components/settings/SettingsView.tsx b/webview-ui/src/components/settings/SettingsView.tsx index 93b1b39e506..3f9cb74565a 100644 --- a/webview-ui/src/components/settings/SettingsView.tsx +++ b/webview-ui/src/components/settings/SettingsView.tsx @@ -195,6 +195,7 @@ const SettingsView = forwardRef(({ onDone, t openRouterImageApiKey, openRouterImageGenerationSelectedModel, reasoningBlockCollapsed, + sendMessageOnEnter, } = cachedState const apiConfiguration = useMemo(() => cachedState.apiConfiguration ?? {}, [cachedState.apiConfiguration]) @@ -384,6 +385,7 @@ const SettingsView = forwardRef(({ onDone, t vscode.postMessage({ type: "updateSupportPrompt", values: customSupportPrompts || {} }) vscode.postMessage({ type: "includeTaskHistoryInEnhance", bool: includeTaskHistoryInEnhance ?? true }) vscode.postMessage({ type: "setReasoningBlockCollapsed", bool: reasoningBlockCollapsed ?? true }) + vscode.postMessage({ type: "setSendMessageOnEnter", bool: sendMessageOnEnter ?? true }) vscode.postMessage({ type: "upsertApiConfiguration", text: currentApiConfigName, apiConfiguration }) vscode.postMessage({ type: "telemetrySetting", text: telemetrySetting }) vscode.postMessage({ type: "profileThresholds", values: profileThresholds }) @@ -782,6 +784,7 @@ const SettingsView = forwardRef(({ onDone, t {activeTab === "ui" && ( )} diff --git a/webview-ui/src/components/settings/UISettings.tsx b/webview-ui/src/components/settings/UISettings.tsx index 2de16e68822..71ea806884f 100644 --- a/webview-ui/src/components/settings/UISettings.tsx +++ b/webview-ui/src/components/settings/UISettings.tsx @@ -11,10 +11,16 @@ import { ExtensionStateContextType } from "@/context/ExtensionStateContext" interface UISettingsProps extends HTMLAttributes { reasoningBlockCollapsed: boolean + sendMessageOnEnter: boolean setCachedStateField: SetCachedStateField } -export const UISettings = ({ reasoningBlockCollapsed, setCachedStateField, ...props }: UISettingsProps) => { +export const UISettings = ({ + reasoningBlockCollapsed, + sendMessageOnEnter, + setCachedStateField, + ...props +}: UISettingsProps) => { const { t } = useAppTranslation() const handleReasoningBlockCollapsedChange = (value: boolean) => { @@ -26,6 +32,15 @@ export const UISettings = ({ reasoningBlockCollapsed, setCachedStateField, ...pr }) } + const handleSendMessageOnEnterChange = (value: boolean) => { + setCachedStateField("sendMessageOnEnter", value) + + // Track telemetry event + telemetryClient.capture("ui_settings_send_message_on_enter_changed", { + enabled: value, + }) + } + return (
@@ -49,6 +64,19 @@ export const UISettings = ({ reasoningBlockCollapsed, setCachedStateField, ...pr {t("settings:ui.collapseThinking.description")}
+ + {/* Send Message on Enter Setting */} +
+ handleSendMessageOnEnterChange(e.target.checked)} + data-testid="send-message-on-enter-checkbox"> + {t("settings:ui.sendMessageOnEnter.label")} + +
+ {t("settings:ui.sendMessageOnEnter.description")} +
+
diff --git a/webview-ui/src/components/settings/__tests__/UISettings.spec.tsx b/webview-ui/src/components/settings/__tests__/UISettings.spec.tsx index 43bb013a08f..74f51dd1c79 100644 --- a/webview-ui/src/components/settings/__tests__/UISettings.spec.tsx +++ b/webview-ui/src/components/settings/__tests__/UISettings.spec.tsx @@ -5,6 +5,7 @@ import { UISettings } from "../UISettings" describe("UISettings", () => { const defaultProps = { reasoningBlockCollapsed: false, + sendMessageOnEnter: true, setCachedStateField: vi.fn(), } diff --git a/webview-ui/src/context/ExtensionStateContext.tsx b/webview-ui/src/context/ExtensionStateContext.tsx index 542b2385c02..cded6aa87d4 100644 --- a/webview-ui/src/context/ExtensionStateContext.tsx +++ b/webview-ui/src/context/ExtensionStateContext.tsx @@ -158,6 +158,8 @@ export interface ExtensionStateContextType extends ExtensionState { setMaxDiagnosticMessages: (value: number) => void includeTaskHistoryInEnhance?: boolean setIncludeTaskHistoryInEnhance: (value: boolean) => void + sendMessageOnEnter?: boolean + setSendMessageOnEnter: (value: boolean) => void } export const ExtensionStateContext = createContext(undefined) @@ -285,6 +287,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode global: {}, }) const [includeTaskHistoryInEnhance, setIncludeTaskHistoryInEnhance] = useState(true) + const [sendMessageOnEnter, setSendMessageOnEnter] = useState(true) // Default to true (current behavior) const setListApiConfigMeta = useCallback( (value: ProviderSettingsEntry[]) => setState((prevState) => ({ ...prevState, listApiConfigMeta: value })), @@ -322,6 +325,10 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode if ((newState as any).includeTaskHistoryInEnhance !== undefined) { setIncludeTaskHistoryInEnhance((newState as any).includeTaskHistoryInEnhance) } + // Update sendMessageOnEnter if present in state message + if ((newState as any).sendMessageOnEnter !== undefined) { + setSendMessageOnEnter((newState as any).sendMessageOnEnter) + } // Handle marketplace data if present in state message if (newState.marketplaceItems !== undefined) { setMarketplaceItems(newState.marketplaceItems) @@ -559,6 +566,8 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode }, includeTaskHistoryInEnhance, setIncludeTaskHistoryInEnhance, + sendMessageOnEnter, + setSendMessageOnEnter, } return {children} diff --git a/webview-ui/src/i18n/locales/en/settings.json b/webview-ui/src/i18n/locales/en/settings.json index dfccc49cc4c..9e4798fee48 100644 --- a/webview-ui/src/i18n/locales/en/settings.json +++ b/webview-ui/src/i18n/locales/en/settings.json @@ -42,6 +42,10 @@ "collapseThinking": { "label": "Collapse Thinking messages by default", "description": "When enabled, thinking blocks will be collapsed by default until you interact with them" + }, + "sendMessageOnEnter": { + "label": "Send message with Enter key", + "description": "When enabled, pressing Enter sends messages and Shift+Enter creates new lines. When disabled, Enter creates new lines and Shift/Ctrl+Enter sends messages." } }, "prompts": {