Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/types/src/global-settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
4 changes: 4 additions & 0 deletions src/core/webview/webviewMessageHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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") ?? {}
Expand Down
1 change: 1 addition & 0 deletions src/shared/WebviewMessage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,7 @@ export interface WebviewMessage {
| "profileThresholds"
| "setHistoryPreviewCollapsed"
| "setReasoningBlockCollapsed"
| "setSendMessageOnEnter"
| "openExternal"
| "filterMarketplaceItems"
| "marketplaceButtonClicked"
Expand Down
22 changes: 17 additions & 5 deletions webview-ui/src/components/chat/ChatTextArea.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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)) {
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] macOS Cmd+Enter not supported when sendMessageOnEnter is false. Current logic only checks shiftKey/ctrlKey; on macOS users expect metaKey (Cmd) to work too. Consider including event.metaKey to preserve parity across platforms and common editor conventions.

Suggestion:

Suggested change
} else if (!isEnterToSend && event.key === "Enter" && (event.shiftKey || event.ctrlKey)) {
} else if (!isEnterToSend && event.key === \"Enter\" && (event.shiftKey || event.ctrlKey || event.metaKey)) {

// 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) {
Expand Down Expand Up @@ -536,6 +547,7 @@ export const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
handleHistoryNavigation,
resetHistoryNavigation,
commands,
sendMessageOnEnter,
],
)

Expand Down
236 changes: 236 additions & 0 deletions webview-ui/src/components/chat/__tests__/ChatTextArea.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ describe("ChatTextArea", () => {
},
taskHistory: [],
cwd: "/test/workspace",
sendMessageOnEnter: true, // Default to true for backward compatibility
})
})

Expand Down Expand Up @@ -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()
})
})
})
3 changes: 3 additions & 0 deletions webview-ui/src/components/settings/SettingsView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,7 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone, t
openRouterImageApiKey,
openRouterImageGenerationSelectedModel,
reasoningBlockCollapsed,
sendMessageOnEnter,
} = cachedState

const apiConfiguration = useMemo(() => cachedState.apiConfiguration ?? {}, [cachedState.apiConfiguration])
Expand Down Expand Up @@ -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 })
Expand Down Expand Up @@ -782,6 +784,7 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone, t
{activeTab === "ui" && (
<UISettings
reasoningBlockCollapsed={reasoningBlockCollapsed ?? true}
sendMessageOnEnter={sendMessageOnEnter ?? true}
setCachedStateField={setCachedStateField}
/>
)}
Expand Down
30 changes: 29 additions & 1 deletion webview-ui/src/components/settings/UISettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand All @@ -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>
Expand All @@ -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>
Expand Down
Loading
Loading