Skip to content
Open
Show file tree
Hide file tree
Changes from 15 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 @@ -82,6 +82,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 @@ -1858,6 +1858,7 @@ export class ClineProvider
terminalCompressProgressBar,
historyPreviewCollapsed,
reasoningBlockCollapsed,
requireCtrlEnterToSend,
cloudUserInfo,
cloudIsAuthenticated,
sharingEnabled,
Expand Down Expand Up @@ -1995,6 +1996,7 @@ export class ClineProvider
hasSystemPromptOverride,
historyPreviewCollapsed: historyPreviewCollapsed ?? false,
reasoningBlockCollapsed: reasoningBlockCollapsed ?? true,
requireCtrlEnterToSend: requireCtrlEnterToSend ?? false,
cloudUserInfo,
cloudIsAuthenticated: cloudIsAuthenticated ?? false,
cloudOrganizations,
Expand Down Expand Up @@ -2213,6 +2215,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 @@ -1684,6 +1684,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
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 @@ -127,13 +127,15 @@ export interface ExtensionMessage {
| "insertTextIntoTextarea"
| "dismissedUpsells"
| "organizationSwitchResult"
| "requireCtrlEnterToSend"
text?: string
payload?: any // Add a generic payload for now, can refine later
// Checkpoint warning message
checkpointWarning?: {
type: "WAIT_TIMEOUT" | "INIT_TIMEOUT"
timeout: number
}
bool?: boolean
action?:
| "chatButtonClicked"
| "mcpButtonClicked"
Expand Down Expand Up @@ -296,6 +298,7 @@ export type ExtensionState = Pick<
| "reasoningBlockCollapsed"
| "includeCurrentTime"
| "includeCurrentCost"
| "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 @@ -199,6 +199,7 @@ export interface WebviewMessage {
| "profileThresholds"
| "setHistoryPreviewCollapsed"
| "setReasoningBlockCollapsed"
| "requireCtrlEnterToSend"
| "openExternal"
| "filterMarketplaceItems"
| "marketplaceButtonClicked"
Expand Down
32 changes: 31 additions & 1 deletion webview-ui/src/components/chat/ChatTextArea.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { ExtensionMessage } from "@roo/ExtensionMessage"
import { vscode } from "@src/utils/vscode"
import { useExtensionState } from "@src/context/ExtensionStateContext"
import { useAppTranslation } from "@src/i18n/TranslationContext"
import { getSendMessageKeyCombination } from "@src/utils/platform"
import {
ContextMenuOptionType,
getContextMenuOptions,
Expand Down Expand Up @@ -89,6 +90,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 +470,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 +543,7 @@ export const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
handleHistoryNavigation,
resetHistoryNavigation,
commands,
requireCtrlEnterToSend,
],
)

Expand Down Expand Up @@ -1154,7 +1162,13 @@ export const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
</button>
</StandardTooltip>
)}
<StandardTooltip content={t("chat:sendMessage")}>
<StandardTooltip
content={
requireCtrlEnterToSend
? t("chat:sendMessage") +
` (${t("chat:pressToSend", { keyCombination: getSendMessageKeyCombination() })})`
: t("chat:sendMessage")
}>
<button
aria-label={t("chat:sendMessage")}
disabled={false}
Expand Down Expand Up @@ -1194,6 +1208,22 @@ export const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
{placeholderBottomText}
</div>
)}
{requireCtrlEnterToSend && (
<div
className={cn(
"absolute left-2 z-30 flex items-center h-8 font-vscode-font-family text-vscode-editor-font-size leading-vscode-editor-line-height",
isEditMode ? "pr-20" : "pr-9",
)}
style={{
bottom: "0.25rem",
color: "color-mix(in oklab, var(--vscode-descriptionForeground) 70%, transparent)",
userSelect: "none",
pointerEvents: "none",
fontSize: "0.75rem",
}}>
{t("chat:pressToSend", { keyCombination: getSendMessageKeyCombination() })}
</div>
)}
</div>
</div>
</div>
Expand Down
9 changes: 6 additions & 3 deletions webview-ui/src/components/chat/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -790,7 +790,8 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
case "action":
switch (message.action!) {
case "didBecomeVisible":
if (!isHidden && !sendingDisabled && !enableButtons) {
// Do not grab focus during follow-up questions
if (!isHidden && !sendingDisabled && !enableButtons && clineAsk !== "followup") {
textAreaRef.current?.focus()
}
break
Expand Down Expand Up @@ -855,6 +856,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
handlePrimaryButtonClick,
handleSecondaryButtonClick,
setCheckpointWarning,
clineAsk,
],
)

Expand Down Expand Up @@ -976,12 +978,13 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro

useDebounceEffect(
() => {
if (!isHidden && !sendingDisabled && !enableButtons) {
// Do not grab focus during follow-up questions
if (!isHidden && !sendingDisabled && !enableButtons && clineAsk !== "followup") {
textAreaRef.current?.focus()
}
},
50,
[isHidden, sendingDisabled, enableButtons],
[isHidden, sendingDisabled, enableButtons, clineAsk],
)

const isReadOnlyToolAction = useCallback((message: ClineMessage | undefined) => {
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 @@ -198,6 +198,7 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone, t
reasoningBlockCollapsed,
includeCurrentTime,
includeCurrentCost,
requireCtrlEnterToSend,
} = cachedState

const apiConfiguration = useMemo(() => cachedState.apiConfiguration ?? {}, [cachedState.apiConfiguration])
Expand Down Expand Up @@ -390,6 +391,7 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone, t
vscode.postMessage({ type: "setReasoningBlockCollapsed", bool: reasoningBlockCollapsed ?? true })
vscode.postMessage({ type: "includeCurrentTime", bool: includeCurrentTime ?? true })
vscode.postMessage({ type: "includeCurrentCost", bool: includeCurrentCost ?? 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 @@ -791,6 +793,7 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone, t
{activeTab === "ui" && (
<UISettings
reasoningBlockCollapsed={reasoningBlockCollapsed ?? true}
requireCtrlEnterToSend={requireCtrlEnterToSend ?? false}
setCachedStateField={setCachedStateField}
/>
)}
Expand Down
35 changes: 34 additions & 1 deletion webview-ui/src/components/settings/UISettings.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { HTMLAttributes } from "react"
import { useAppTranslation } from "@/i18n/TranslationContext"
import { getPrimaryModifierKey } from "@/utils/platform"
import { VSCodeCheckbox } from "@vscode/webview-ui-toolkit/react"
import { Glasses } from "lucide-react"
import { telemetryClient } from "@/utils/TelemetryClient"
Expand All @@ -11,10 +12,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 +33,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 +65,23 @@ 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", { primaryMod: getPrimaryModifierKey() })}
</span>
</VSCodeCheckbox>
<div className="text-vscode-descriptionForeground text-sm ml-5 mt-1">
{t("settings:ui.requireCtrlEnterToSend.description", {
primaryMod: getPrimaryModifierKey(),
})}
</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 @@ -165,6 +165,8 @@ export interface ExtensionStateContextType extends ExtensionState {
setIncludeCurrentTime: (value: boolean) => void
includeCurrentCost?: boolean
setIncludeCurrentCost: (value: boolean) => void
requireCtrlEnterToSend?: boolean
setRequireCtrlEnterToSend: (value: boolean) => void
}

export const ExtensionStateContext = createContext<ExtensionStateContextType | undefined>(undefined)
Expand Down Expand Up @@ -272,6 +274,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
alwaysAllowUpdateTodoList: true,
includeDiagnosticMessages: true,
maxDiagnosticMessages: 50,
requireCtrlEnterToSend: false, // Default to expected value
openRouterImageApiKey: "",
openRouterImageGenerationSelectedModel: "",
includeCurrentTime: true,
Expand Down Expand Up @@ -421,6 +424,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 @@ -596,6 +603,10 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
setIncludeCurrentTime,
includeCurrentCost,
setIncludeCurrentCost,
requireCtrlEnterToSend: state.requireCtrlEnterToSend,
setRequireCtrlEnterToSend: (value) => {
setState((prevState) => ({ ...prevState, requireCtrlEnterToSend: value }))
},
}

return <ExtensionStateContext.Provider value={contextValue}>{children}</ExtensionStateContext.Provider>
Expand Down
Loading