Skip to content
Closed
Show file tree
Hide file tree
Changes from 17 commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
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 @@ -83,6 +83,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 @@ -1905,6 +1905,7 @@ export class ClineProvider
terminalCompressProgressBar,
historyPreviewCollapsed,
reasoningBlockCollapsed,
requireCtrlEnterToSend,
cloudUserInfo,
cloudIsAuthenticated,
sharingEnabled,
Expand Down Expand Up @@ -2058,6 +2059,7 @@ export class ClineProvider
hasSystemPromptOverride,
historyPreviewCollapsed: historyPreviewCollapsed ?? false,
reasoningBlockCollapsed: reasoningBlockCollapsed ?? true,
requireCtrlEnterToSend: requireCtrlEnterToSend ?? false,
cloudUserInfo,
cloudIsAuthenticated: cloudIsAuthenticated ?? false,
cloudOrganizations,
Expand Down Expand Up @@ -2287,6 +2289,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
3 changes: 3 additions & 0 deletions src/shared/ExtensionMessage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,13 +131,15 @@ export interface ExtensionMessage {
| "interactionRequired"
| "browserSessionUpdate"
| "browserSessionNavigate"
| "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"
| "settingsButtonClicked"
Expand Down Expand Up @@ -288,6 +290,7 @@ export type ExtensionState = Pick<
| "includeCurrentTime"
| "includeCurrentCost"
| "maxGitStatusFiles"
| "requireCtrlEnterToSend"
> & {
version: string
clineMessages: ClineMessage[]
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 @@ -94,6 +95,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 @@ -473,6 +475,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 @@ -541,6 +548,7 @@ export const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
handleHistoryNavigation,
resetHistoryNavigation,
commands,
requireCtrlEnterToSend,
],
)

Expand Down Expand Up @@ -1000,11 +1008,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 @@ -1159,7 +1164,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
20 changes: 11 additions & 9 deletions webview-ui/src/components/chat/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -789,7 +789,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 @@ -858,6 +859,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
handleSecondaryButtonClick,
setCheckpointWarning,
playSound,
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],
)

useEffect(() => {
Expand Down Expand Up @@ -1216,7 +1219,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro

const itemContent = useCallback(
(index: number, messageOrGroup: ClineMessage) => {
const hasCheckpoint = modifiedMessages.some((message) => message.say === "checkpoint_saved")
const _hasCheckpoint = modifiedMessages.some((message) => message.say === "checkpoint_saved")

// Check if this is a browser action message
if (messageOrGroup.type === "say" && messageOrGroup.say === "browser_action") {
Expand Down Expand Up @@ -1252,12 +1255,12 @@ const ChatViewComponent: React.ForwardRefRenderFunction<ChatViewRef, ChatViewPro
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}
isFollowUpAnswered={messageOrGroup.isAnswered === true || messageOrGroup.ts === currentFollowUpTs}
editable={
Expand All @@ -1278,7 +1281,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 @@ -213,6 +213,7 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone, t
includeCurrentTime,
includeCurrentCost,
maxGitStatusFiles,
requireCtrlEnterToSend,
} = cachedState

const apiConfiguration = useMemo(() => cachedState.apiConfiguration ?? {}, [cachedState.apiConfiguration])
Expand Down Expand Up @@ -416,6 +417,7 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone, t
includeCurrentTime: includeCurrentTime ?? true,
includeCurrentCost: includeCurrentCost ?? true,
maxGitStatusFiles: maxGitStatusFiles ?? 0,
requireCtrlEnterToSend: requireCtrlEnterToSend ?? false,
profileThresholds,
imageGenerationProvider,
openRouterImageApiKey,
Expand Down Expand Up @@ -833,6 +835,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
Loading