feat: add configurable keyboard shortcut for sending messages

- Add sendMessageOnEnter setting to global settings schema
- Update ExtensionStateContext to manage the new setting
- Add UI toggle in Settings panel under UI section
- Modify ChatTextArea to respect the new setting for keyboard behavior
- Add comprehensive tests for the new functionality
- Add translation keys for the new setting

When enabled (default), Enter sends messages and Shift+Enter creates newlines.
When disabled, Enter creates newlines and Shift/Ctrl+Enter sends messages.

This helps users who frequently use CJK input methods or prefer traditional
text editor behavior to avoid accidental message submissions.

Fixes #8555
This commit is contained in:
Roo Code 2025-10-07 23:27:39 +00:00
parent cd8036d2d8
commit 3ecf13564e
9 changed files with 304 additions and 6 deletions

View file

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

View file

@ -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") ?? {}

View file

@ -195,6 +195,7 @@ export interface WebviewMessage {
| "profileThresholds"
| "setHistoryPreviewCollapsed"
| "setReasoningBlockCollapsed"
| "setSendMessageOnEnter"
| "openExternal"
| "filterMarketplaceItems"
| "marketplaceButtonClicked"

View file

@ -89,6 +89,7 @@ export const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
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<HTMLTextAreaElement, ChatTextAreaProps>(
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<HTMLTextAreaElement, ChatTextAreaProps>(
handleHistoryNavigation,
resetHistoryNavigation,
commands,
sendMessageOnEnter,
],
)

View file

@ -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<typeof vi.fn>).mockReturnValue({
filePaths: [],
openedTabs: [],
apiConfiguration: {
apiProvider: "anthropic",
},
taskHistory: [],
cwd: "/test/workspace",
sendMessageOnEnter: true,
})
const { container } = render(<ChatTextArea {...defaultProps} onSend={onSend} inputValue="Test message" />)
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<typeof vi.fn>).mockReturnValue({
filePaths: [],
openedTabs: [],
apiConfiguration: {
apiProvider: "anthropic",
},
taskHistory: [],
cwd: "/test/workspace",
sendMessageOnEnter: false,
})
const { container } = render(<ChatTextArea {...defaultProps} onSend={onSend} inputValue="Test message" />)
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<typeof vi.fn>).mockReturnValue({
filePaths: [],
openedTabs: [],
apiConfiguration: {
apiProvider: "anthropic",
},
taskHistory: [],
cwd: "/test/workspace",
sendMessageOnEnter: false,
})
const { container } = render(<ChatTextArea {...defaultProps} onSend={onSend} inputValue="Test message" />)
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<typeof vi.fn>).mockReturnValue({
filePaths: [],
openedTabs: [],
apiConfiguration: {
apiProvider: "anthropic",
},
taskHistory: [],
cwd: "/test/workspace",
sendMessageOnEnter: false,
})
const { container } = render(<ChatTextArea {...defaultProps} onSend={onSend} inputValue="Test message" />)
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<typeof vi.fn>).mockReturnValue({
filePaths: [],
openedTabs: [],
apiConfiguration: {
apiProvider: "anthropic",
},
taskHistory: [],
cwd: "/test/workspace",
sendMessageOnEnter: true,
})
const { container } = render(<ChatTextArea {...defaultProps} onSend={onSend} inputValue="Test message" />)
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<typeof vi.fn>).mockReturnValue({
filePaths: [],
openedTabs: [],
apiConfiguration: {
apiProvider: "anthropic",
},
taskHistory: [],
cwd: "/test/workspace",
sendMessageOnEnter: true,
})
const { container } = render(<ChatTextArea {...defaultProps} onSend={onSend} inputValue="Test message" />)
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<typeof vi.fn>).mockReturnValue({
filePaths: [],
openedTabs: [],
apiConfiguration: {
apiProvider: "anthropic",
},
taskHistory: [],
cwd: "/test/workspace",
sendMessageOnEnter: undefined,
})
const { container } = render(<ChatTextArea {...defaultProps} onSend={onSend} inputValue="Test message" />)
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<typeof vi.fn>).mockReturnValue({
filePaths: [],
openedTabs: [],
apiConfiguration: {
apiProvider: "anthropic",
},
taskHistory: [],
cwd: "/test/workspace",
sendMessageOnEnter: true,
})
const { container } = render(<ChatTextArea {...defaultProps} onSend={onSend} inputValue="" />)
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<typeof vi.fn>).mockReturnValue({
filePaths: [],
openedTabs: [],
apiConfiguration: {
apiProvider: "anthropic",
},
taskHistory: [],
cwd: "/test/workspace",
sendMessageOnEnter: true,
})
const { container } = render(
<ChatTextArea {...defaultProps} onSend={onSend} inputValue="Test message" sendingDisabled={true} />,
)
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()
})
})
})

View file

@ -195,6 +195,7 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone, t
openRouterImageApiKey,
openRouterImageGenerationSelectedModel,
reasoningBlockCollapsed,
sendMessageOnEnter,
} = cachedState
const apiConfiguration = useMemo(() => cachedState.apiConfiguration ?? {}, [cachedState.apiConfiguration])
@ -384,6 +385,7 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ 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<SettingsViewRef, SettingsViewProps>(({ onDone, t
{activeTab === "ui" && (
<UISettings
reasoningBlockCollapsed={reasoningBlockCollapsed ?? true}
sendMessageOnEnter={sendMessageOnEnter ?? true}
setCachedStateField={setCachedStateField}
/>
)}

View file

@ -11,10 +11,16 @@ import { ExtensionStateContextType } from "@/context/ExtensionStateContext"
interface UISettingsProps extends HTMLAttributes<HTMLDivElement> {
reasoningBlockCollapsed: boolean
sendMessageOnEnter: boolean
setCachedStateField: SetCachedStateField<keyof ExtensionStateContextType>
}
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 (
<div {...props}>
<SectionHeader>
@ -49,6 +64,19 @@ export const UISettings = ({ reasoningBlockCollapsed, setCachedStateField, ...pr
{t("settings:ui.collapseThinking.description")}
</div>
</div>
{/* Send Message on Enter Setting */}
<div className="flex flex-col gap-1">
<VSCodeCheckbox
checked={sendMessageOnEnter}
onChange={(e: any) => handleSendMessageOnEnterChange(e.target.checked)}
data-testid="send-message-on-enter-checkbox">
<span className="font-medium">{t("settings:ui.sendMessageOnEnter.label")}</span>
</VSCodeCheckbox>
<div className="text-vscode-descriptionForeground text-sm ml-5 mt-1">
{t("settings:ui.sendMessageOnEnter.description")}
</div>
</div>
</div>
</Section>
</div>

View file

@ -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<ExtensionStateContextType | undefined>(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 <ExtensionStateContext.Provider value={contextValue}>{children}</ExtensionStateContext.Provider>

View file

@ -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": {