Skip to content

Commit 3ecf135

Browse files
committed
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
1 parent cd8036d commit 3ecf135

File tree

9 files changed

+304
-6
lines changed

9 files changed

+304
-6
lines changed

packages/types/src/global-settings.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,7 @@ export const globalSettingsSchema = z.object({
148148
includeTaskHistoryInEnhance: z.boolean().optional(),
149149
historyPreviewCollapsed: z.boolean().optional(),
150150
reasoningBlockCollapsed: z.boolean().optional(),
151+
sendMessageOnEnter: z.boolean().optional(),
151152
profileThresholds: z.record(z.string(), z.number()).optional(),
152153
hasOpenedModeSelector: z.boolean().optional(),
153154
lastModeExportPath: z.string().optional(),

src/core/webview/webviewMessageHandler.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1621,6 +1621,10 @@ export const webviewMessageHandler = async (
16211621
await updateGlobalState("reasoningBlockCollapsed", message.bool ?? true)
16221622
// No need to call postStateToWebview here as the UI already updated optimistically
16231623
break
1624+
case "setSendMessageOnEnter":
1625+
await updateGlobalState("sendMessageOnEnter", message.bool ?? true)
1626+
// No need to call postStateToWebview here as the UI already updated optimistically
1627+
break
16241628
case "toggleApiConfigPin":
16251629
if (message.text) {
16261630
const currentPinned = getGlobalState("pinnedApiConfigs") ?? {}

src/shared/WebviewMessage.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,7 @@ export interface WebviewMessage {
195195
| "profileThresholds"
196196
| "setHistoryPreviewCollapsed"
197197
| "setReasoningBlockCollapsed"
198+
| "setSendMessageOnEnter"
198199
| "openExternal"
199200
| "filterMarketplaceItems"
200201
| "marketplaceButtonClicked"

webview-ui/src/components/chat/ChatTextArea.tsx

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,7 @@ export const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
8989
clineMessages,
9090
commands,
9191
cloudUserInfo,
92+
sendMessageOnEnter,
9293
} = useExtensionState()
9394

9495
// Find the ID and display text for the currently selected API configuration.
@@ -467,12 +468,22 @@ export const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
467468
return
468469
}
469470

470-
if (event.key === "Enter" && !event.shiftKey && !isComposing) {
471-
event.preventDefault()
471+
// Handle Enter key based on user preference
472+
const isEnterToSend = sendMessageOnEnter ?? true // Default to true (current behavior)
472473

473-
// Always call onSend - let ChatView handle queueing when disabled
474-
resetHistoryNavigation()
475-
onSend()
474+
if (!isComposing) {
475+
if (isEnterToSend && event.key === "Enter" && !event.shiftKey) {
476+
// Enter sends, Shift+Enter for newline
477+
event.preventDefault()
478+
resetHistoryNavigation()
479+
onSend()
480+
} else if (!isEnterToSend && event.key === "Enter" && (event.shiftKey || event.ctrlKey)) {
481+
// Shift+Enter or Ctrl+Enter sends, Enter for newline
482+
event.preventDefault()
483+
resetHistoryNavigation()
484+
onSend()
485+
}
486+
// If neither condition matches, let the default behavior happen (newline)
476487
}
477488

478489
if (event.key === "Backspace" && !isComposing) {
@@ -536,6 +547,7 @@ export const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
536547
handleHistoryNavigation,
537548
resetHistoryNavigation,
538549
commands,
550+
sendMessageOnEnter,
539551
],
540552
)
541553

webview-ui/src/components/chat/__tests__/ChatTextArea.spec.tsx

Lines changed: 236 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ describe("ChatTextArea", () => {
7272
},
7373
taskHistory: [],
7474
cwd: "/test/workspace",
75+
sendMessageOnEnter: true, // Default to true for backward compatibility
7576
})
7677
})
7778

@@ -1139,4 +1140,239 @@ describe("ChatTextArea", () => {
11391140
expect(sendButton).toHaveClass("pointer-events-auto")
11401141
})
11411142
})
1143+
1144+
describe("sendMessageOnEnter setting", () => {
1145+
it("should send message on Enter when sendMessageOnEnter is true (default)", () => {
1146+
const onSend = vi.fn()
1147+
;(useExtensionState as ReturnType<typeof vi.fn>).mockReturnValue({
1148+
filePaths: [],
1149+
openedTabs: [],
1150+
apiConfiguration: {
1151+
apiProvider: "anthropic",
1152+
},
1153+
taskHistory: [],
1154+
cwd: "/test/workspace",
1155+
sendMessageOnEnter: true,
1156+
})
1157+
1158+
const { container } = render(<ChatTextArea {...defaultProps} onSend={onSend} inputValue="Test message" />)
1159+
1160+
const textarea = container.querySelector("textarea")!
1161+
1162+
// Simulate Enter key press
1163+
fireEvent.keyDown(textarea, { key: "Enter" })
1164+
1165+
expect(onSend).toHaveBeenCalled()
1166+
})
1167+
1168+
it("should create newline on Enter when sendMessageOnEnter is false", () => {
1169+
const onSend = vi.fn()
1170+
;(useExtensionState as ReturnType<typeof vi.fn>).mockReturnValue({
1171+
filePaths: [],
1172+
openedTabs: [],
1173+
apiConfiguration: {
1174+
apiProvider: "anthropic",
1175+
},
1176+
taskHistory: [],
1177+
cwd: "/test/workspace",
1178+
sendMessageOnEnter: false,
1179+
})
1180+
1181+
const { container } = render(<ChatTextArea {...defaultProps} onSend={onSend} inputValue="Test message" />)
1182+
1183+
const textarea = container.querySelector("textarea")!
1184+
1185+
// Simulate Enter key press
1186+
fireEvent.keyDown(textarea, { key: "Enter" })
1187+
1188+
// Should not send message
1189+
expect(onSend).not.toHaveBeenCalled()
1190+
})
1191+
1192+
it("should send message on Shift+Enter when sendMessageOnEnter is false", () => {
1193+
const onSend = vi.fn()
1194+
;(useExtensionState as ReturnType<typeof vi.fn>).mockReturnValue({
1195+
filePaths: [],
1196+
openedTabs: [],
1197+
apiConfiguration: {
1198+
apiProvider: "anthropic",
1199+
},
1200+
taskHistory: [],
1201+
cwd: "/test/workspace",
1202+
sendMessageOnEnter: false,
1203+
})
1204+
1205+
const { container } = render(<ChatTextArea {...defaultProps} onSend={onSend} inputValue="Test message" />)
1206+
1207+
const textarea = container.querySelector("textarea")!
1208+
1209+
// Simulate Shift+Enter key press
1210+
fireEvent.keyDown(textarea, { key: "Enter", shiftKey: true })
1211+
1212+
expect(onSend).toHaveBeenCalled()
1213+
})
1214+
1215+
it("should send message on Ctrl+Enter when sendMessageOnEnter is false", () => {
1216+
const onSend = vi.fn()
1217+
;(useExtensionState as ReturnType<typeof vi.fn>).mockReturnValue({
1218+
filePaths: [],
1219+
openedTabs: [],
1220+
apiConfiguration: {
1221+
apiProvider: "anthropic",
1222+
},
1223+
taskHistory: [],
1224+
cwd: "/test/workspace",
1225+
sendMessageOnEnter: false,
1226+
})
1227+
1228+
const { container } = render(<ChatTextArea {...defaultProps} onSend={onSend} inputValue="Test message" />)
1229+
1230+
const textarea = container.querySelector("textarea")!
1231+
1232+
// Simulate Ctrl+Enter key press
1233+
fireEvent.keyDown(textarea, { key: "Enter", ctrlKey: true })
1234+
1235+
expect(onSend).toHaveBeenCalled()
1236+
})
1237+
1238+
it("should create newline on Shift+Enter when sendMessageOnEnter is true", () => {
1239+
const onSend = vi.fn()
1240+
;(useExtensionState as ReturnType<typeof vi.fn>).mockReturnValue({
1241+
filePaths: [],
1242+
openedTabs: [],
1243+
apiConfiguration: {
1244+
apiProvider: "anthropic",
1245+
},
1246+
taskHistory: [],
1247+
cwd: "/test/workspace",
1248+
sendMessageOnEnter: true,
1249+
})
1250+
1251+
const { container } = render(<ChatTextArea {...defaultProps} onSend={onSend} inputValue="Test message" />)
1252+
1253+
const textarea = container.querySelector("textarea")!
1254+
1255+
// Simulate Shift+Enter key press
1256+
fireEvent.keyDown(textarea, { key: "Enter", shiftKey: true })
1257+
1258+
// Should not send message
1259+
expect(onSend).not.toHaveBeenCalled()
1260+
})
1261+
1262+
it("should not send message during IME composition regardless of setting", () => {
1263+
const onSend = vi.fn()
1264+
;(useExtensionState as ReturnType<typeof vi.fn>).mockReturnValue({
1265+
filePaths: [],
1266+
openedTabs: [],
1267+
apiConfiguration: {
1268+
apiProvider: "anthropic",
1269+
},
1270+
taskHistory: [],
1271+
cwd: "/test/workspace",
1272+
sendMessageOnEnter: true,
1273+
})
1274+
1275+
const { container } = render(<ChatTextArea {...defaultProps} onSend={onSend} inputValue="Test message" />)
1276+
1277+
const textarea = container.querySelector("textarea")!
1278+
1279+
// Create a proper KeyboardEvent with isComposing property
1280+
const composingEvent = new KeyboardEvent("keydown", {
1281+
key: "Enter",
1282+
bubbles: true,
1283+
cancelable: true,
1284+
})
1285+
// Override the isComposing property
1286+
Object.defineProperty(composingEvent, "isComposing", {
1287+
value: true,
1288+
writable: false,
1289+
})
1290+
1291+
// Dispatch the event directly
1292+
textarea.dispatchEvent(composingEvent)
1293+
1294+
// Should not send message during composition
1295+
expect(onSend).not.toHaveBeenCalled()
1296+
1297+
// Now Enter should work without composition
1298+
fireEvent.keyDown(textarea, { key: "Enter" })
1299+
expect(onSend).toHaveBeenCalled()
1300+
})
1301+
1302+
it("should use default value (true) when sendMessageOnEnter is undefined", () => {
1303+
const onSend = vi.fn()
1304+
;(useExtensionState as ReturnType<typeof vi.fn>).mockReturnValue({
1305+
filePaths: [],
1306+
openedTabs: [],
1307+
apiConfiguration: {
1308+
apiProvider: "anthropic",
1309+
},
1310+
taskHistory: [],
1311+
cwd: "/test/workspace",
1312+
sendMessageOnEnter: undefined,
1313+
})
1314+
1315+
const { container } = render(<ChatTextArea {...defaultProps} onSend={onSend} inputValue="Test message" />)
1316+
1317+
const textarea = container.querySelector("textarea")!
1318+
1319+
// Simulate Enter key press
1320+
fireEvent.keyDown(textarea, { key: "Enter" })
1321+
1322+
// Should send message (default behavior)
1323+
expect(onSend).toHaveBeenCalled()
1324+
})
1325+
1326+
it("should call onSend even with empty message (actual behavior)", () => {
1327+
const onSend = vi.fn()
1328+
;(useExtensionState as ReturnType<typeof vi.fn>).mockReturnValue({
1329+
filePaths: [],
1330+
openedTabs: [],
1331+
apiConfiguration: {
1332+
apiProvider: "anthropic",
1333+
},
1334+
taskHistory: [],
1335+
cwd: "/test/workspace",
1336+
sendMessageOnEnter: true,
1337+
})
1338+
1339+
const { container } = render(<ChatTextArea {...defaultProps} onSend={onSend} inputValue="" />)
1340+
1341+
const textarea = container.querySelector("textarea")!
1342+
1343+
// Simulate Enter key press with empty input
1344+
fireEvent.keyDown(textarea, { key: "Enter" })
1345+
1346+
// The actual implementation calls onSend regardless of empty input
1347+
// The parent component (ChatView) is responsible for checking if message is empty
1348+
expect(onSend).toHaveBeenCalled()
1349+
})
1350+
1351+
it("should call onSend even when sendingDisabled is true (actual behavior)", () => {
1352+
const onSend = vi.fn()
1353+
;(useExtensionState as ReturnType<typeof vi.fn>).mockReturnValue({
1354+
filePaths: [],
1355+
openedTabs: [],
1356+
apiConfiguration: {
1357+
apiProvider: "anthropic",
1358+
},
1359+
taskHistory: [],
1360+
cwd: "/test/workspace",
1361+
sendMessageOnEnter: true,
1362+
})
1363+
1364+
const { container } = render(
1365+
<ChatTextArea {...defaultProps} onSend={onSend} inputValue="Test message" sendingDisabled={true} />,
1366+
)
1367+
1368+
const textarea = container.querySelector("textarea")!
1369+
1370+
// Simulate Enter key press
1371+
fireEvent.keyDown(textarea, { key: "Enter" })
1372+
1373+
// The actual implementation calls onSend regardless of sendingDisabled
1374+
// The parent component is responsible for checking if sending is disabled
1375+
expect(onSend).toHaveBeenCalled()
1376+
})
1377+
})
11421378
})

webview-ui/src/components/settings/SettingsView.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,7 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone, t
195195
openRouterImageApiKey,
196196
openRouterImageGenerationSelectedModel,
197197
reasoningBlockCollapsed,
198+
sendMessageOnEnter,
198199
} = cachedState
199200

200201
const apiConfiguration = useMemo(() => cachedState.apiConfiguration ?? {}, [cachedState.apiConfiguration])
@@ -384,6 +385,7 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone, t
384385
vscode.postMessage({ type: "updateSupportPrompt", values: customSupportPrompts || {} })
385386
vscode.postMessage({ type: "includeTaskHistoryInEnhance", bool: includeTaskHistoryInEnhance ?? true })
386387
vscode.postMessage({ type: "setReasoningBlockCollapsed", bool: reasoningBlockCollapsed ?? true })
388+
vscode.postMessage({ type: "setSendMessageOnEnter", bool: sendMessageOnEnter ?? true })
387389
vscode.postMessage({ type: "upsertApiConfiguration", text: currentApiConfigName, apiConfiguration })
388390
vscode.postMessage({ type: "telemetrySetting", text: telemetrySetting })
389391
vscode.postMessage({ type: "profileThresholds", values: profileThresholds })
@@ -782,6 +784,7 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone, t
782784
{activeTab === "ui" && (
783785
<UISettings
784786
reasoningBlockCollapsed={reasoningBlockCollapsed ?? true}
787+
sendMessageOnEnter={sendMessageOnEnter ?? true}
785788
setCachedStateField={setCachedStateField}
786789
/>
787790
)}

webview-ui/src/components/settings/UISettings.tsx

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,16 @@ import { ExtensionStateContextType } from "@/context/ExtensionStateContext"
1111

1212
interface UISettingsProps extends HTMLAttributes<HTMLDivElement> {
1313
reasoningBlockCollapsed: boolean
14+
sendMessageOnEnter: boolean
1415
setCachedStateField: SetCachedStateField<keyof ExtensionStateContextType>
1516
}
1617

17-
export const UISettings = ({ reasoningBlockCollapsed, setCachedStateField, ...props }: UISettingsProps) => {
18+
export const UISettings = ({
19+
reasoningBlockCollapsed,
20+
sendMessageOnEnter,
21+
setCachedStateField,
22+
...props
23+
}: UISettingsProps) => {
1824
const { t } = useAppTranslation()
1925

2026
const handleReasoningBlockCollapsedChange = (value: boolean) => {
@@ -26,6 +32,15 @@ export const UISettings = ({ reasoningBlockCollapsed, setCachedStateField, ...pr
2632
})
2733
}
2834

35+
const handleSendMessageOnEnterChange = (value: boolean) => {
36+
setCachedStateField("sendMessageOnEnter", value)
37+
38+
// Track telemetry event
39+
telemetryClient.capture("ui_settings_send_message_on_enter_changed", {
40+
enabled: value,
41+
})
42+
}
43+
2944
return (
3045
<div {...props}>
3146
<SectionHeader>
@@ -49,6 +64,19 @@ export const UISettings = ({ reasoningBlockCollapsed, setCachedStateField, ...pr
4964
{t("settings:ui.collapseThinking.description")}
5065
</div>
5166
</div>
67+
68+
{/* Send Message on Enter Setting */}
69+
<div className="flex flex-col gap-1">
70+
<VSCodeCheckbox
71+
checked={sendMessageOnEnter}
72+
onChange={(e: any) => handleSendMessageOnEnterChange(e.target.checked)}
73+
data-testid="send-message-on-enter-checkbox">
74+
<span className="font-medium">{t("settings:ui.sendMessageOnEnter.label")}</span>
75+
</VSCodeCheckbox>
76+
<div className="text-vscode-descriptionForeground text-sm ml-5 mt-1">
77+
{t("settings:ui.sendMessageOnEnter.description")}
78+
</div>
79+
</div>
5280
</div>
5381
</Section>
5482
</div>

0 commit comments

Comments
 (0)