Skip to content
Merged
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
7 changes: 7 additions & 0 deletions packages/types/src/global-settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,13 @@ export const globalSettingsSchema = z.object({
includeTaskHistoryInEnhance: z.boolean().optional(),
historyPreviewCollapsed: z.boolean().optional(),
reasoningBlockCollapsed: z.boolean().optional(),
/**
* Controls the keyboard behavior for sending messages in the chat input.
* - "send": Enter sends message, Shift+Enter creates newline (default)
* - "newline": Enter creates newline, Shift+Enter/Ctrl+Enter sends message
* @default "send"
*/
enterBehavior: z.enum(["send", "newline"]).optional(),
profileThresholds: z.record(z.string(), z.number()).optional(),
hasOpenedModeSelector: z.boolean().optional(),
lastModeExportPath: z.string().optional(),
Expand Down
3 changes: 3 additions & 0 deletions src/core/webview/ClineProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1872,6 +1872,7 @@ export class ClineProvider
terminalCompressProgressBar,
historyPreviewCollapsed,
reasoningBlockCollapsed,
enterBehavior,
cloudUserInfo,
cloudIsAuthenticated,
sharingEnabled,
Expand Down Expand Up @@ -2025,6 +2026,7 @@ export class ClineProvider
hasSystemPromptOverride,
historyPreviewCollapsed: historyPreviewCollapsed ?? false,
reasoningBlockCollapsed: reasoningBlockCollapsed ?? true,
enterBehavior: enterBehavior ?? "send",
cloudUserInfo,
cloudIsAuthenticated: cloudIsAuthenticated ?? false,
cloudOrganizations,
Expand Down Expand Up @@ -2254,6 +2256,7 @@ export class ClineProvider
maxConcurrentFileReads: stateValues.maxConcurrentFileReads ?? 5,
historyPreviewCollapsed: stateValues.historyPreviewCollapsed ?? false,
reasoningBlockCollapsed: stateValues.reasoningBlockCollapsed ?? true,
enterBehavior: stateValues.enterBehavior ?? "send",
cloudUserInfo,
cloudIsAuthenticated,
sharingEnabled,
Expand Down
1 change: 1 addition & 0 deletions src/shared/ExtensionMessage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -286,6 +286,7 @@ export type ExtensionState = Pick<
| "openRouterImageGenerationSelectedModel"
| "includeTaskHistoryInEnhance"
| "reasoningBlockCollapsed"
| "enterBehavior"
| "includeCurrentTime"
| "includeCurrentCost"
| "maxGitStatusFiles"
Expand Down
42 changes: 34 additions & 8 deletions webview-ui/src/components/chat/ChatTextArea.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ export const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
clineMessages,
commands,
cloudUserInfo,
enterBehavior,
} = useExtensionState()

// Find the ID and display text for the currently selected API configuration.
Expand Down Expand Up @@ -257,6 +258,17 @@ export const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
return inputValue.trim().length > 0 || selectedImages.length > 0
}, [inputValue, selectedImages])

// Compute the key combination text for the send button tooltip based on enterBehavior
const sendKeyCombination = useMemo(() => {
if (enterBehavior === "newline") {
// When Enter = newline, Ctrl/Cmd+Enter sends
const isMac = navigator.platform.toUpperCase().indexOf("MAC") >= 0
return isMac ? "⌘+Enter" : "Ctrl+Enter"
}
// Default: Enter sends
return "Enter"
}, [enterBehavior])

const queryItems = useMemo(() => {
return [
{ type: ContextMenuOptionType.Problems, value: "problems" },
Expand Down Expand Up @@ -472,12 +484,24 @@ export const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
return
}

if (event.key === "Enter" && !event.shiftKey && !isComposing) {
event.preventDefault()

// Always call onSend - let ChatView handle queueing when disabled
resetHistoryNavigation()
onSend()
// Handle Enter key based on enterBehavior setting
if (event.key === "Enter" && !isComposing) {
if (enterBehavior === "newline") {
// New behavior: Enter = newline, Shift+Enter or Ctrl+Enter = send
if (event.shiftKey || event.ctrlKey || event.metaKey) {
event.preventDefault()
resetHistoryNavigation()
onSend()
}
// Otherwise, let Enter create newline (don't preventDefault)
} else {
// Default behavior: Enter = send, Shift+Enter = newline
if (!event.shiftKey) {
event.preventDefault()
resetHistoryNavigation()
onSend()
}
}
}

if (event.key === "Backspace" && !isComposing) {
Expand Down Expand Up @@ -541,6 +565,7 @@ export const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
handleHistoryNavigation,
resetHistoryNavigation,
commands,
enterBehavior,
],
)

Expand Down Expand Up @@ -1159,9 +1184,10 @@ export const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
</button>
</StandardTooltip>
)}
<StandardTooltip content={t("chat:sendMessage")}>
<StandardTooltip
content={t("chat:pressToSend", { keyCombination: sendKeyCombination })}>
<button
aria-label={t("chat:sendMessage")}
aria-label={t("chat:pressToSend", { keyCombination: sendKeyCombination })}
disabled={false}
onClick={onSend}
className={cn(
Expand Down
66 changes: 66 additions & 0 deletions webview-ui/src/components/chat/__tests__/ChatTextArea.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1056,6 +1056,72 @@ describe("ChatTextArea", () => {
const apiConfigDropdown = getApiConfigDropdown()
expect(apiConfigDropdown).toHaveAttribute("disabled")
})

describe("enter key behavior", () => {
it("should send on Enter and allow newline on Shift+Enter in default mode", () => {
const onSend = vi.fn()

;(useExtensionState as ReturnType<typeof vi.fn>).mockReturnValue({
filePaths: [],
openedTabs: [],
taskHistory: [],
cwd: "/test/workspace",
})

const { container } = render(<ChatTextArea {...defaultProps} onSend={onSend} />)

const textarea = container.querySelector("textarea")!

fireEvent.keyDown(textarea, { key: "Enter" })
expect(onSend).toHaveBeenCalledTimes(1)

const shiftEnterEvent = new KeyboardEvent("keydown", { key: "Enter", shiftKey: true, bubbles: true })
fireEvent(textarea, shiftEnterEvent)
expect(onSend).toHaveBeenCalledTimes(1)
expect(shiftEnterEvent.defaultPrevented).toBe(false)
})

it("should treat Ctrl/Cmd/Shift+Enter as send and plain Enter as newline in newline mode", () => {
const onSend = vi.fn()

;(useExtensionState as ReturnType<typeof vi.fn>).mockReturnValue({
filePaths: [],
openedTabs: [],
taskHistory: [],
cwd: "/test/workspace",
enterBehavior: "newline",
})

const { container } = render(<ChatTextArea {...defaultProps} onSend={onSend} />)

const textarea = container.querySelector("textarea")!

const plainEnterEvent = new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true })
fireEvent(textarea, plainEnterEvent)
expect(onSend).not.toHaveBeenCalled()
expect(plainEnterEvent.defaultPrevented).toBe(false)

const ctrlEnterEvent = new KeyboardEvent("keydown", {
key: "Enter",
ctrlKey: true,
bubbles: true,
cancelable: true,
})
fireEvent(textarea, ctrlEnterEvent)
expect(onSend).toHaveBeenCalledTimes(1)
expect(ctrlEnterEvent.defaultPrevented).toBe(true)

const shiftEnterEvent = new KeyboardEvent("keydown", {
key: "Enter",
shiftKey: true,
bubbles: true,
cancelable: true,
})
fireEvent(textarea, shiftEnterEvent)
expect(onSend).toHaveBeenCalledTimes(2)
expect(shiftEnterEvent.defaultPrevented).toBe(true)
})
})
})

describe("send button visibility", () => {
Expand Down
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 @@ -210,6 +210,7 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone, t
openRouterImageApiKey,
openRouterImageGenerationSelectedModel,
reasoningBlockCollapsed,
enterBehavior,
includeCurrentTime,
includeCurrentCost,
maxGitStatusFiles,
Expand Down Expand Up @@ -413,6 +414,7 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone, t
condensingApiConfigId: condensingApiConfigId || "",
includeTaskHistoryInEnhance: includeTaskHistoryInEnhance ?? true,
reasoningBlockCollapsed: reasoningBlockCollapsed ?? true,
enterBehavior: enterBehavior ?? "send",
includeCurrentTime: includeCurrentTime ?? true,
includeCurrentCost: includeCurrentCost ?? true,
maxGitStatusFiles: maxGitStatusFiles ?? 0,
Expand Down Expand Up @@ -833,6 +835,7 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone, t
{activeTab === "ui" && (
<UISettings
reasoningBlockCollapsed={reasoningBlockCollapsed ?? true}
enterBehavior={enterBehavior ?? "send"}
setCachedStateField={setCachedStateField}
/>
)}
Expand Down
41 changes: 39 additions & 2 deletions webview-ui/src/components/settings/UISettings.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { HTMLAttributes } from "react"
import { HTMLAttributes, useMemo } from "react"
import { useAppTranslation } from "@/i18n/TranslationContext"
import { VSCodeCheckbox } from "@vscode/webview-ui-toolkit/react"
import { Glasses } from "lucide-react"
Expand All @@ -11,12 +11,24 @@ import { ExtensionStateContextType } from "@/context/ExtensionStateContext"

interface UISettingsProps extends HTMLAttributes<HTMLDivElement> {
reasoningBlockCollapsed: boolean
enterBehavior: "send" | "newline"
setCachedStateField: SetCachedStateField<keyof ExtensionStateContextType>
}

export const UISettings = ({ reasoningBlockCollapsed, setCachedStateField, ...props }: UISettingsProps) => {
export const UISettings = ({
reasoningBlockCollapsed,
enterBehavior,
setCachedStateField,
...props
}: UISettingsProps) => {
const { t } = useAppTranslation()

// Detect platform for dynamic modifier key display
const primaryMod = useMemo(() => {
const isMac = navigator.platform.toUpperCase().indexOf("MAC") >= 0
return isMac ? "⌘" : "Ctrl"
}, [])

const handleReasoningBlockCollapsedChange = (value: boolean) => {
setCachedStateField("reasoningBlockCollapsed", value)

Expand All @@ -26,6 +38,16 @@ export const UISettings = ({ reasoningBlockCollapsed, setCachedStateField, ...pr
})
}

const handleEnterBehaviorChange = (requireCtrlEnter: boolean) => {
const newBehavior = requireCtrlEnter ? "newline" : "send"
setCachedStateField("enterBehavior", newBehavior)

// Track telemetry event
telemetryClient.capture("ui_settings_enter_behavior_changed", {
behavior: newBehavior,
})
}

return (
<div {...props}>
<SectionHeader>
Expand All @@ -49,6 +71,21 @@ export const UISettings = ({ reasoningBlockCollapsed, setCachedStateField, ...pr
{t("settings:ui.collapseThinking.description")}
</div>
</div>

{/* Enter Key Behavior Setting */}
<div className="flex flex-col gap-1">
<VSCodeCheckbox
checked={enterBehavior === "newline"}
onChange={(e: any) => handleEnterBehaviorChange(e.target.checked)}
data-testid="enter-behavior-checkbox">
<span className="font-medium">
{t("settings:ui.requireCtrlEnterToSend.label", { primaryMod })}
</span>
</VSCodeCheckbox>
<div className="text-vscode-descriptionForeground text-sm ml-5 mt-1">
{t("settings:ui.requireCtrlEnterToSend.description", { primaryMod })}
</div>
</div>
</div>
</Section>
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { UISettings } from "../UISettings"
describe("UISettings", () => {
const defaultProps = {
reasoningBlockCollapsed: false,
enterBehavior: "send" as const,
setCachedStateField: vi.fn(),
}

Expand Down
5 changes: 5 additions & 0 deletions webview-ui/src/context/ExtensionStateContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,8 @@ export interface ExtensionStateContextType extends ExtensionState {
setTerminalCompressProgressBar: (value: boolean) => void
setHistoryPreviewCollapsed: (value: boolean) => void
setReasoningBlockCollapsed: (value: boolean) => void
enterBehavior?: "send" | "newline"
setEnterBehavior: (value: "send" | "newline") => void
autoCondenseContext: boolean
setAutoCondenseContext: (value: boolean) => void
autoCondenseContextPercent: number
Expand Down Expand Up @@ -251,6 +253,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
terminalCompressProgressBar: true, // Default to compress progress bar output
historyPreviewCollapsed: false, // Initialize the new state (default to expanded)
reasoningBlockCollapsed: true, // Default to collapsed
enterBehavior: "send", // Default: Enter sends, Shift+Enter creates newline
cloudUserInfo: null,
cloudIsAuthenticated: false,
cloudOrganizations: [],
Expand Down Expand Up @@ -571,6 +574,8 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
setState((prevState) => ({ ...prevState, historyPreviewCollapsed: value })),
setReasoningBlockCollapsed: (value) =>
setState((prevState) => ({ ...prevState, reasoningBlockCollapsed: value })),
enterBehavior: state.enterBehavior ?? "send",
setEnterBehavior: (value) => setState((prevState) => ({ ...prevState, enterBehavior: value })),
setHasOpenedModeSelector: (value) => setState((prevState) => ({ ...prevState, hasOpenedModeSelector: value })),
setAutoCondenseContext: (value) => setState((prevState) => ({ ...prevState, autoCondenseContext: value })),
setAutoCondenseContextPercent: (value) =>
Expand Down
1 change: 1 addition & 0 deletions webview-ui/src/i18n/locales/ca/chat.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions webview-ui/src/i18n/locales/ca/settings.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions webview-ui/src/i18n/locales/de/chat.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions webview-ui/src/i18n/locales/de/settings.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions webview-ui/src/i18n/locales/en/chat.json
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@
"enhancePromptDescription": "The 'Enhance Prompt' button helps improve your prompt by providing additional context, clarification, or rephrasing. Try typing a prompt in here and clicking the button again to see how it works.",
"addImages": "Add images to message",
"sendMessage": "Send message",
"pressToSend": "Press {{keyCombination}} to send",
"stopTts": "Stop text-to-speech",
"typeMessage": "Type a message...",
"typeTask": "Type your task here...",
Expand Down
4 changes: 4 additions & 0 deletions webview-ui/src/i18n/locales/en/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,10 @@
"collapseThinking": {
"label": "Collapse Thinking messages by default",
"description": "When enabled, thinking blocks will be collapsed by default until you interact with them"
},
"requireCtrlEnterToSend": {
"label": "Require {{primaryMod}}+Enter to send messages",
"description": "When enabled, you must press {{primaryMod}}+Enter to send messages instead of just Enter"
}
},
"prompts": {
Expand Down
1 change: 1 addition & 0 deletions webview-ui/src/i18n/locales/es/chat.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading