Skip to content
Closed
Show file tree
Hide file tree
Changes from 8 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 @@ -67,6 +67,7 @@ export const globalSettingsSchema = z.object({
alwaysAllowFollowupQuestions: z.boolean().optional(),
followupAutoApproveTimeoutMs: z.number().optional(),
alwaysAllowUpdateTodoList: z.boolean().optional(),
requireCtrlEnterToSend: z.boolean().optional(),
allowedCommands: z.array(z.string()).optional(),
deniedCommands: z.array(z.string()).optional(),
commandExecutionTimeout: z.number().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 @@ -1793,6 +1793,7 @@ export class ClineProvider
terminalCompressProgressBar,
historyPreviewCollapsed,
reasoningBlockCollapsed,
requireCtrlEnterToSend,
cloudUserInfo,
cloudIsAuthenticated,
sharingEnabled,
Expand Down Expand Up @@ -1927,6 +1928,7 @@ export class ClineProvider
hasSystemPromptOverride,
historyPreviewCollapsed: historyPreviewCollapsed ?? false,
reasoningBlockCollapsed: reasoningBlockCollapsed ?? true,
requireCtrlEnterToSend: requireCtrlEnterToSend ?? false,
cloudUserInfo,
cloudIsAuthenticated: cloudIsAuthenticated ?? false,
cloudOrganizations,
Expand Down Expand Up @@ -2142,6 +2144,7 @@ export class ClineProvider
maxConcurrentFileReads: stateValues.maxConcurrentFileReads ?? 5,
historyPreviewCollapsed: stateValues.historyPreviewCollapsed ?? false,
reasoningBlockCollapsed: stateValues.reasoningBlockCollapsed ?? true,
requireCtrlEnterToSend: stateValues.requireCtrlEnterToSend ?? false,
cloudUserInfo,
cloudIsAuthenticated,
sharingEnabled,
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 "requireCtrlEnterToSend":
await updateGlobalState("requireCtrlEnterToSend", message.bool)
// No need to call postStateToWebview here as the UI already updated optimistically
break
Comment on lines +1624 to +1627
Copy link

Choose a reason for hiding this comment

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

[P1] Setting change doesn't propagate to the webview. The extension updates global state but doesn't notify the frontend, so Chat won't reflect the new behavior until a later state refresh. After updating, also post a message back to the webview so ExtensionStateContext can update immediately.

Suggested change
case "requireCtrlEnterToSend":
await updateGlobalState("requireCtrlEnterToSend", message.bool)
// No need to call postStateToWebview here as the UI already updated optimistically
break
case "requireCtrlEnterToSend":
await updateGlobalState("requireCtrlEnterToSend", message.bool)
await provider.postMessageToWebview({ type: "requireCtrlEnterToSend", bool: message.bool ?? false })
break

Copy link
Author

Choose a reason for hiding this comment

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

I am following the other UI checkbox; the other doesn't do this, so I believe this one shouldn't either.

case "toggleApiConfigPin":
if (message.text) {
const currentPinned = getGlobalState("pinnedApiConfigs") ?? {}
Expand Down
3 changes: 3 additions & 0 deletions src/shared/ExtensionMessage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,9 +126,11 @@ export interface ExtensionMessage {
| "insertTextIntoTextarea"
| "dismissedUpsells"
| "organizationSwitchResult"
| "requireCtrlEnterToSend"
| "usagePreviewData"
text?: string
payload?: any // Add a generic payload for now, can refine later
bool?: boolean
action?:
| "chatButtonClicked"
| "mcpButtonClicked"
Expand Down Expand Up @@ -290,6 +292,7 @@ export type ExtensionState = Pick<
| "openRouterImageGenerationSelectedModel"
| "includeTaskHistoryInEnhance"
| "reasoningBlockCollapsed"
| "requireCtrlEnterToSend"
> & {
version: string
clineMessages: ClineMessage[]
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"
| "requireCtrlEnterToSend"
| "openExternal"
| "filterMarketplaceItems"
| "marketplaceButtonClicked"
Expand Down
7 changes: 7 additions & 0 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,
requireCtrlEnterToSend,
} = useExtensionState()

// Find the ID and display text for the currently selected API configuration.
Expand Down Expand Up @@ -468,6 +469,11 @@ export const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
}

if (event.key === "Enter" && !event.shiftKey && !isComposing) {
// If Ctrl+Enter is required but neither Ctrl nor Meta (Cmd) key is pressed, don't send
if (requireCtrlEnterToSend && !event.ctrlKey && !event.metaKey) {
return
}

event.preventDefault()

// Always call onSend - let ChatView handle queueing when disabled
Expand Down Expand Up @@ -536,6 +542,7 @@ export const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
handleHistoryNavigation,
resetHistoryNavigation,
commands,
requireCtrlEnterToSend,
],
)

Expand Down
79 changes: 79 additions & 0 deletions webview-ui/src/components/chat/__tests__/ChatTextArea.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1058,6 +1058,85 @@ describe("ChatTextArea", () => {
})
})

describe("keyboard handling with requireCtrlEnterToSend", () => {
beforeEach(() => {
;(useExtensionState as ReturnType<typeof vi.fn>).mockReturnValue({
filePaths: [],
openedTabs: [],
apiConfiguration: {
apiProvider: "anthropic",
},
taskHistory: [],
cwd: "/test/workspace",
requireCtrlEnterToSend: true,
})
})

it("should send message with Ctrl+Enter when requireCtrlEnterToSend is enabled", () => {
const onSend = vi.fn()
const { container } = render(<ChatTextArea {...defaultProps} onSend={onSend} inputValue="Test message" />)

const textarea = container.querySelector("textarea")!
fireEvent.keyDown(textarea, { key: "Enter", ctrlKey: true })

expect(onSend).toHaveBeenCalled()
})

it("should send message with Cmd+Enter when requireCtrlEnterToSend is enabled", () => {
const onSend = vi.fn()
const { container } = render(<ChatTextArea {...defaultProps} onSend={onSend} inputValue="Test message" />)

const textarea = container.querySelector("textarea")!
fireEvent.keyDown(textarea, { key: "Enter", metaKey: true })

expect(onSend).toHaveBeenCalled()
})

it("should not send message with regular Enter when requireCtrlEnterToSend is enabled", () => {
const onSend = vi.fn()
const { container } = render(<ChatTextArea {...defaultProps} onSend={onSend} inputValue="Test message" />)

const textarea = container.querySelector("textarea")!
fireEvent.keyDown(textarea, { key: "Enter" })

expect(onSend).not.toHaveBeenCalled()
})

it("should insert newline with Shift+Enter when requireCtrlEnterToSend is enabled", () => {
const setInputValue = vi.fn()
const { container } = render(
<ChatTextArea {...defaultProps} setInputValue={setInputValue} inputValue="Test message" />,
)

const textarea = container.querySelector("textarea")!
fireEvent.keyDown(textarea, { key: "Enter", shiftKey: true })

// Should not call onSend, allowing default behavior (insert newline)
expect(setInputValue).not.toHaveBeenCalled()
})

it("should send message with regular Enter when requireCtrlEnterToSend is disabled", () => {
;(useExtensionState as ReturnType<typeof vi.fn>).mockReturnValue({
filePaths: [],
openedTabs: [],
apiConfiguration: {
apiProvider: "anthropic",
},
taskHistory: [],
cwd: "/test/workspace",
requireCtrlEnterToSend: false,
})

const onSend = vi.fn()
const { container } = render(<ChatTextArea {...defaultProps} onSend={onSend} inputValue="Test message" />)

const textarea = container.querySelector("textarea")!
fireEvent.keyDown(textarea, { key: "Enter" })

expect(onSend).toHaveBeenCalled()
})
})

describe("send button visibility", () => {
it("should show send button when there are images but no text", () => {
const { container } = render(
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 @@ -195,6 +195,7 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone, t
openRouterImageApiKey,
openRouterImageGenerationSelectedModel,
reasoningBlockCollapsed,
requireCtrlEnterToSend,
} = 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: "requireCtrlEnterToSend", bool: requireCtrlEnterToSend ?? false })
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}
requireCtrlEnterToSend={requireCtrlEnterToSend ?? false}
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
requireCtrlEnterToSend?: boolean
setCachedStateField: SetCachedStateField<keyof ExtensionStateContextType>
}

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

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

const handleRequireCtrlEnterToSendChange = (value: boolean) => {
setCachedStateField("requireCtrlEnterToSend", value)

// Track telemetry event
telemetryClient.capture("ui_settings_ctrl_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>

{/* Require Ctrl+Enter to Send Setting */}
<div className="flex flex-col gap-1">
<VSCodeCheckbox
checked={requireCtrlEnterToSend ?? false}
onChange={(e: any) => handleRequireCtrlEnterToSendChange(e.target.checked)}
data-testid="ctrl-enter-checkbox">
<span className="font-medium">{t("settings:ui.requireCtrlEnterToSend.label")}</span>
</VSCodeCheckbox>
<div className="text-vscode-descriptionForeground text-sm ml-5 mt-1">
{t("settings:ui.requireCtrlEnterToSend.description")}
</div>
</div>
</div>
</Section>
</div>
Expand Down
11 changes: 11 additions & 0 deletions webview-ui/src/context/ExtensionStateContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,8 @@ export interface ExtensionStateContextType extends ExtensionState {
setMaxDiagnosticMessages: (value: number) => void
includeTaskHistoryInEnhance?: boolean
setIncludeTaskHistoryInEnhance: (value: boolean) => void
requireCtrlEnterToSend?: boolean
setRequireCtrlEnterToSend: (value: boolean) => void
}

export const ExtensionStateContext = createContext<ExtensionStateContextType | undefined>(undefined)
Expand Down Expand Up @@ -264,6 +266,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
alwaysAllowUpdateTodoList: true,
includeDiagnosticMessages: true,
maxDiagnosticMessages: 50,
requireCtrlEnterToSend: false, // Default to expected value
openRouterImageApiKey: "",
openRouterImageGenerationSelectedModel: "",
})
Expand Down Expand Up @@ -400,6 +403,10 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
}
break
}
case "requireCtrlEnterToSend": {
setState((prevState) => ({ ...prevState, requireCtrlEnterToSend: message.bool ?? false }))
break
}
}
},
[setListApiConfigMeta],
Expand Down Expand Up @@ -559,6 +566,10 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
},
includeTaskHistoryInEnhance,
setIncludeTaskHistoryInEnhance,
requireCtrlEnterToSend: state.requireCtrlEnterToSend,
setRequireCtrlEnterToSend: (value) => {
setState((prevState) => ({ ...prevState, requireCtrlEnterToSend: value }))
},
}

return <ExtensionStateContext.Provider value={contextValue}>{children}</ExtensionStateContext.Provider>
Expand Down
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.

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.

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 @@ -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"
},
"requireCtrlEnterToSend": {
"label": "Require Ctrl+Enter to send messages",
"description": "When enabled, you must press Ctrl+Enter to send messages instead of just Enter"
}
},
"prompts": {
Expand Down
4 changes: 4 additions & 0 deletions webview-ui/src/i18n/locales/es/settings.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/fr/settings.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/hi/settings.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/id/settings.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/it/settings.json

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

Loading