Skip to content
Closed
Show file tree
Hide file tree
Changes from 10 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/Cmd+Enter to send messages",
"description": "When enabled, you must press Ctrl or Cmd+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