Skip to content
Open
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
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
23 changes: 17 additions & 6 deletions 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 @@ -995,11 +1003,8 @@ export const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
"font-vscode-font-family",
"text-vscode-editor-font-size",
"leading-vscode-editor-line-height",
isFocused
? "border border-vscode-focusBorder outline outline-vscode-focusBorder"
: isDraggingOver
? "border-2 border-dashed border-vscode-focusBorder"
: "border border-transparent",
"border-none",
"outline-none",
"pl-2",
"py-2",
isEditMode ? "pr-20" : "pr-9",
Expand Down Expand Up @@ -1154,7 +1159,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
21 changes: 12 additions & 9 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 Expand Up @@ -1516,20 +1519,21 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
/>
)
}
const hasCheckpoint = modifiedMessages.some((message) => message.say === "checkpoint_saved")
// Lint: keep underscore to mark intentionally unused (prop removed)
const _hasCheckpoint = modifiedMessages.some((message) => message.say === "checkpoint_saved")

// regular message
return (
<ChatRow
key={messageOrGroup.ts}
message={messageOrGroup}
isExpanded={expandedRows[messageOrGroup.ts] || false}
onToggleExpand={toggleRowExpansion} // This was already stabilized
lastModifiedMessage={modifiedMessages.at(-1)} // Original direct access
isLast={index === groupedMessages.length - 1} // Original direct access
onToggleExpand={toggleRowExpansion}
lastModifiedMessage={modifiedMessages.at(-1)}
isLast={index === groupedMessages.length - 1}
onHeightChange={handleRowHeightChange}
isStreaming={isStreaming}
onSuggestionClick={handleSuggestionClickInRow} // This was already stabilized
onSuggestionClick={handleSuggestionClickInRow}
onBatchFileResponse={handleBatchFileResponse}
onFollowUpUnmount={handleFollowUpUnmount}
isFollowUpAnswered={messageOrGroup.isAnswered === true || messageOrGroup.ts === currentFollowUpTs}
Expand All @@ -1551,7 +1555,6 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
return tool.tool === "updateTodoList" && enableButtons && !!primaryButtonText
})()
}
hasCheckpoint={hasCheckpoint}
/>
)
},
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
39 changes: 38 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,27 @@ 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(),
interpolation: { prefix: "{", suffix: "}" },
})}
</span>
</VSCodeCheckbox>
<div className="text-vscode-descriptionForeground text-sm ml-5 mt-1">
{t("settings:ui.requireCtrlEnterToSend.description", {
primaryMod: getPrimaryModifierKey(),
interpolation: { prefix: "{", suffix: "}" },
})}
</div>
</div>
</div>
</Section>
</div>
Expand Down
Loading