mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-08-28 05:27:24 +00:00
feat: add configurable keyboard shortcut for sending messages
- Add sendMessageOnEnter setting to global settings schema - Update ExtensionStateContext to manage the new setting - Add UI toggle in Settings panel under UI section - Modify ChatTextArea to respect the new setting for keyboard behavior - Add comprehensive tests for the new functionality - Add translation keys for the new setting When enabled (default), Enter sends messages and Shift+Enter creates newlines. When disabled, Enter creates newlines and Shift/Ctrl+Enter sends messages. This helps users who frequently use CJK input methods or prefer traditional text editor behavior to avoid accidental message submissions. Fixes #8555
This commit is contained in:
parent
cd8036d2d8
commit
3ecf13564e
9 changed files with 304 additions and 6 deletions
|
|
@ -148,6 +148,7 @@ export const globalSettingsSchema = z.object({
|
|||
includeTaskHistoryInEnhance: z.boolean().optional(),
|
||||
historyPreviewCollapsed: z.boolean().optional(),
|
||||
reasoningBlockCollapsed: z.boolean().optional(),
|
||||
sendMessageOnEnter: z.boolean().optional(),
|
||||
profileThresholds: z.record(z.string(), z.number()).optional(),
|
||||
hasOpenedModeSelector: z.boolean().optional(),
|
||||
lastModeExportPath: z.string().optional(),
|
||||
|
|
|
|||
|
|
@ -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 "setSendMessageOnEnter":
|
||||
await updateGlobalState("sendMessageOnEnter", message.bool ?? true)
|
||||
// No need to call postStateToWebview here as the UI already updated optimistically
|
||||
break
|
||||
case "toggleApiConfigPin":
|
||||
if (message.text) {
|
||||
const currentPinned = getGlobalState("pinnedApiConfigs") ?? {}
|
||||
|
|
|
|||
|
|
@ -195,6 +195,7 @@ export interface WebviewMessage {
|
|||
| "profileThresholds"
|
||||
| "setHistoryPreviewCollapsed"
|
||||
| "setReasoningBlockCollapsed"
|
||||
| "setSendMessageOnEnter"
|
||||
| "openExternal"
|
||||
| "filterMarketplaceItems"
|
||||
| "marketplaceButtonClicked"
|
||||
|
|
|
|||
|
|
@ -89,6 +89,7 @@ export const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
|
|||
clineMessages,
|
||||
commands,
|
||||
cloudUserInfo,
|
||||
sendMessageOnEnter,
|
||||
} = useExtensionState()
|
||||
|
||||
// Find the ID and display text for the currently selected API configuration.
|
||||
|
|
@ -467,12 +468,22 @@ export const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
|
|||
return
|
||||
}
|
||||
|
||||
if (event.key === "Enter" && !event.shiftKey && !isComposing) {
|
||||
event.preventDefault()
|
||||
// Handle Enter key based on user preference
|
||||
const isEnterToSend = sendMessageOnEnter ?? true // Default to true (current behavior)
|
||||
|
||||
// Always call onSend - let ChatView handle queueing when disabled
|
||||
resetHistoryNavigation()
|
||||
onSend()
|
||||
if (!isComposing) {
|
||||
if (isEnterToSend && event.key === "Enter" && !event.shiftKey) {
|
||||
// Enter sends, Shift+Enter for newline
|
||||
event.preventDefault()
|
||||
resetHistoryNavigation()
|
||||
onSend()
|
||||
} else if (!isEnterToSend && event.key === "Enter" && (event.shiftKey || event.ctrlKey)) {
|
||||
// Shift+Enter or Ctrl+Enter sends, Enter for newline
|
||||
event.preventDefault()
|
||||
resetHistoryNavigation()
|
||||
onSend()
|
||||
}
|
||||
// If neither condition matches, let the default behavior happen (newline)
|
||||
}
|
||||
|
||||
if (event.key === "Backspace" && !isComposing) {
|
||||
|
|
@ -536,6 +547,7 @@ export const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
|
|||
handleHistoryNavigation,
|
||||
resetHistoryNavigation,
|
||||
commands,
|
||||
sendMessageOnEnter,
|
||||
],
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -72,6 +72,7 @@ describe("ChatTextArea", () => {
|
|||
},
|
||||
taskHistory: [],
|
||||
cwd: "/test/workspace",
|
||||
sendMessageOnEnter: true, // Default to true for backward compatibility
|
||||
})
|
||||
})
|
||||
|
||||
|
|
@ -1139,4 +1140,239 @@ describe("ChatTextArea", () => {
|
|||
expect(sendButton).toHaveClass("pointer-events-auto")
|
||||
})
|
||||
})
|
||||
|
||||
describe("sendMessageOnEnter setting", () => {
|
||||
it("should send message on Enter when sendMessageOnEnter is true (default)", () => {
|
||||
const onSend = vi.fn()
|
||||
;(useExtensionState as ReturnType<typeof vi.fn>).mockReturnValue({
|
||||
filePaths: [],
|
||||
openedTabs: [],
|
||||
apiConfiguration: {
|
||||
apiProvider: "anthropic",
|
||||
},
|
||||
taskHistory: [],
|
||||
cwd: "/test/workspace",
|
||||
sendMessageOnEnter: true,
|
||||
})
|
||||
|
||||
const { container } = render(<ChatTextArea {...defaultProps} onSend={onSend} inputValue="Test message" />)
|
||||
|
||||
const textarea = container.querySelector("textarea")!
|
||||
|
||||
// Simulate Enter key press
|
||||
fireEvent.keyDown(textarea, { key: "Enter" })
|
||||
|
||||
expect(onSend).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("should create newline on Enter when sendMessageOnEnter is false", () => {
|
||||
const onSend = vi.fn()
|
||||
;(useExtensionState as ReturnType<typeof vi.fn>).mockReturnValue({
|
||||
filePaths: [],
|
||||
openedTabs: [],
|
||||
apiConfiguration: {
|
||||
apiProvider: "anthropic",
|
||||
},
|
||||
taskHistory: [],
|
||||
cwd: "/test/workspace",
|
||||
sendMessageOnEnter: false,
|
||||
})
|
||||
|
||||
const { container } = render(<ChatTextArea {...defaultProps} onSend={onSend} inputValue="Test message" />)
|
||||
|
||||
const textarea = container.querySelector("textarea")!
|
||||
|
||||
// Simulate Enter key press
|
||||
fireEvent.keyDown(textarea, { key: "Enter" })
|
||||
|
||||
// Should not send message
|
||||
expect(onSend).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("should send message on Shift+Enter when sendMessageOnEnter is false", () => {
|
||||
const onSend = vi.fn()
|
||||
;(useExtensionState as ReturnType<typeof vi.fn>).mockReturnValue({
|
||||
filePaths: [],
|
||||
openedTabs: [],
|
||||
apiConfiguration: {
|
||||
apiProvider: "anthropic",
|
||||
},
|
||||
taskHistory: [],
|
||||
cwd: "/test/workspace",
|
||||
sendMessageOnEnter: false,
|
||||
})
|
||||
|
||||
const { container } = render(<ChatTextArea {...defaultProps} onSend={onSend} inputValue="Test message" />)
|
||||
|
||||
const textarea = container.querySelector("textarea")!
|
||||
|
||||
// Simulate Shift+Enter key press
|
||||
fireEvent.keyDown(textarea, { key: "Enter", shiftKey: true })
|
||||
|
||||
expect(onSend).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("should send message on Ctrl+Enter when sendMessageOnEnter is false", () => {
|
||||
const onSend = vi.fn()
|
||||
;(useExtensionState as ReturnType<typeof vi.fn>).mockReturnValue({
|
||||
filePaths: [],
|
||||
openedTabs: [],
|
||||
apiConfiguration: {
|
||||
apiProvider: "anthropic",
|
||||
},
|
||||
taskHistory: [],
|
||||
cwd: "/test/workspace",
|
||||
sendMessageOnEnter: false,
|
||||
})
|
||||
|
||||
const { container } = render(<ChatTextArea {...defaultProps} onSend={onSend} inputValue="Test message" />)
|
||||
|
||||
const textarea = container.querySelector("textarea")!
|
||||
|
||||
// Simulate Ctrl+Enter key press
|
||||
fireEvent.keyDown(textarea, { key: "Enter", ctrlKey: true })
|
||||
|
||||
expect(onSend).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("should create newline on Shift+Enter when sendMessageOnEnter is true", () => {
|
||||
const onSend = vi.fn()
|
||||
;(useExtensionState as ReturnType<typeof vi.fn>).mockReturnValue({
|
||||
filePaths: [],
|
||||
openedTabs: [],
|
||||
apiConfiguration: {
|
||||
apiProvider: "anthropic",
|
||||
},
|
||||
taskHistory: [],
|
||||
cwd: "/test/workspace",
|
||||
sendMessageOnEnter: true,
|
||||
})
|
||||
|
||||
const { container } = render(<ChatTextArea {...defaultProps} onSend={onSend} inputValue="Test message" />)
|
||||
|
||||
const textarea = container.querySelector("textarea")!
|
||||
|
||||
// Simulate Shift+Enter key press
|
||||
fireEvent.keyDown(textarea, { key: "Enter", shiftKey: true })
|
||||
|
||||
// Should not send message
|
||||
expect(onSend).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("should not send message during IME composition regardless of setting", () => {
|
||||
const onSend = vi.fn()
|
||||
;(useExtensionState as ReturnType<typeof vi.fn>).mockReturnValue({
|
||||
filePaths: [],
|
||||
openedTabs: [],
|
||||
apiConfiguration: {
|
||||
apiProvider: "anthropic",
|
||||
},
|
||||
taskHistory: [],
|
||||
cwd: "/test/workspace",
|
||||
sendMessageOnEnter: true,
|
||||
})
|
||||
|
||||
const { container } = render(<ChatTextArea {...defaultProps} onSend={onSend} inputValue="Test message" />)
|
||||
|
||||
const textarea = container.querySelector("textarea")!
|
||||
|
||||
// Create a proper KeyboardEvent with isComposing property
|
||||
const composingEvent = new KeyboardEvent("keydown", {
|
||||
key: "Enter",
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
})
|
||||
// Override the isComposing property
|
||||
Object.defineProperty(composingEvent, "isComposing", {
|
||||
value: true,
|
||||
writable: false,
|
||||
})
|
||||
|
||||
// Dispatch the event directly
|
||||
textarea.dispatchEvent(composingEvent)
|
||||
|
||||
// Should not send message during composition
|
||||
expect(onSend).not.toHaveBeenCalled()
|
||||
|
||||
// Now Enter should work without composition
|
||||
fireEvent.keyDown(textarea, { key: "Enter" })
|
||||
expect(onSend).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("should use default value (true) when sendMessageOnEnter is undefined", () => {
|
||||
const onSend = vi.fn()
|
||||
;(useExtensionState as ReturnType<typeof vi.fn>).mockReturnValue({
|
||||
filePaths: [],
|
||||
openedTabs: [],
|
||||
apiConfiguration: {
|
||||
apiProvider: "anthropic",
|
||||
},
|
||||
taskHistory: [],
|
||||
cwd: "/test/workspace",
|
||||
sendMessageOnEnter: undefined,
|
||||
})
|
||||
|
||||
const { container } = render(<ChatTextArea {...defaultProps} onSend={onSend} inputValue="Test message" />)
|
||||
|
||||
const textarea = container.querySelector("textarea")!
|
||||
|
||||
// Simulate Enter key press
|
||||
fireEvent.keyDown(textarea, { key: "Enter" })
|
||||
|
||||
// Should send message (default behavior)
|
||||
expect(onSend).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("should call onSend even with empty message (actual behavior)", () => {
|
||||
const onSend = vi.fn()
|
||||
;(useExtensionState as ReturnType<typeof vi.fn>).mockReturnValue({
|
||||
filePaths: [],
|
||||
openedTabs: [],
|
||||
apiConfiguration: {
|
||||
apiProvider: "anthropic",
|
||||
},
|
||||
taskHistory: [],
|
||||
cwd: "/test/workspace",
|
||||
sendMessageOnEnter: true,
|
||||
})
|
||||
|
||||
const { container } = render(<ChatTextArea {...defaultProps} onSend={onSend} inputValue="" />)
|
||||
|
||||
const textarea = container.querySelector("textarea")!
|
||||
|
||||
// Simulate Enter key press with empty input
|
||||
fireEvent.keyDown(textarea, { key: "Enter" })
|
||||
|
||||
// The actual implementation calls onSend regardless of empty input
|
||||
// The parent component (ChatView) is responsible for checking if message is empty
|
||||
expect(onSend).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("should call onSend even when sendingDisabled is true (actual behavior)", () => {
|
||||
const onSend = vi.fn()
|
||||
;(useExtensionState as ReturnType<typeof vi.fn>).mockReturnValue({
|
||||
filePaths: [],
|
||||
openedTabs: [],
|
||||
apiConfiguration: {
|
||||
apiProvider: "anthropic",
|
||||
},
|
||||
taskHistory: [],
|
||||
cwd: "/test/workspace",
|
||||
sendMessageOnEnter: true,
|
||||
})
|
||||
|
||||
const { container } = render(
|
||||
<ChatTextArea {...defaultProps} onSend={onSend} inputValue="Test message" sendingDisabled={true} />,
|
||||
)
|
||||
|
||||
const textarea = container.querySelector("textarea")!
|
||||
|
||||
// Simulate Enter key press
|
||||
fireEvent.keyDown(textarea, { key: "Enter" })
|
||||
|
||||
// The actual implementation calls onSend regardless of sendingDisabled
|
||||
// The parent component is responsible for checking if sending is disabled
|
||||
expect(onSend).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -195,6 +195,7 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone, t
|
|||
openRouterImageApiKey,
|
||||
openRouterImageGenerationSelectedModel,
|
||||
reasoningBlockCollapsed,
|
||||
sendMessageOnEnter,
|
||||
} = cachedState
|
||||
|
||||
const apiConfiguration = useMemo(() => cachedState.apiConfiguration ?? {}, [cachedState.apiConfiguration])
|
||||
|
|
@ -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: "setSendMessageOnEnter", bool: sendMessageOnEnter ?? true })
|
||||
vscode.postMessage({ type: "upsertApiConfiguration", text: currentApiConfigName, apiConfiguration })
|
||||
vscode.postMessage({ type: "telemetrySetting", text: telemetrySetting })
|
||||
vscode.postMessage({ type: "profileThresholds", values: profileThresholds })
|
||||
|
|
@ -782,6 +784,7 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone, t
|
|||
{activeTab === "ui" && (
|
||||
<UISettings
|
||||
reasoningBlockCollapsed={reasoningBlockCollapsed ?? true}
|
||||
sendMessageOnEnter={sendMessageOnEnter ?? true}
|
||||
setCachedStateField={setCachedStateField}
|
||||
/>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -11,10 +11,16 @@ import { ExtensionStateContextType } from "@/context/ExtensionStateContext"
|
|||
|
||||
interface UISettingsProps extends HTMLAttributes<HTMLDivElement> {
|
||||
reasoningBlockCollapsed: boolean
|
||||
sendMessageOnEnter: boolean
|
||||
setCachedStateField: SetCachedStateField<keyof ExtensionStateContextType>
|
||||
}
|
||||
|
||||
export const UISettings = ({ reasoningBlockCollapsed, setCachedStateField, ...props }: UISettingsProps) => {
|
||||
export const UISettings = ({
|
||||
reasoningBlockCollapsed,
|
||||
sendMessageOnEnter,
|
||||
setCachedStateField,
|
||||
...props
|
||||
}: UISettingsProps) => {
|
||||
const { t } = useAppTranslation()
|
||||
|
||||
const handleReasoningBlockCollapsedChange = (value: boolean) => {
|
||||
|
|
@ -26,6 +32,15 @@ export const UISettings = ({ reasoningBlockCollapsed, setCachedStateField, ...pr
|
|||
})
|
||||
}
|
||||
|
||||
const handleSendMessageOnEnterChange = (value: boolean) => {
|
||||
setCachedStateField("sendMessageOnEnter", value)
|
||||
|
||||
// Track telemetry event
|
||||
telemetryClient.capture("ui_settings_send_message_on_enter_changed", {
|
||||
enabled: value,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div {...props}>
|
||||
<SectionHeader>
|
||||
|
|
@ -49,6 +64,19 @@ export const UISettings = ({ reasoningBlockCollapsed, setCachedStateField, ...pr
|
|||
{t("settings:ui.collapseThinking.description")}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Send Message on Enter Setting */}
|
||||
<div className="flex flex-col gap-1">
|
||||
<VSCodeCheckbox
|
||||
checked={sendMessageOnEnter}
|
||||
onChange={(e: any) => handleSendMessageOnEnterChange(e.target.checked)}
|
||||
data-testid="send-message-on-enter-checkbox">
|
||||
<span className="font-medium">{t("settings:ui.sendMessageOnEnter.label")}</span>
|
||||
</VSCodeCheckbox>
|
||||
<div className="text-vscode-descriptionForeground text-sm ml-5 mt-1">
|
||||
{t("settings:ui.sendMessageOnEnter.description")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -158,6 +158,8 @@ export interface ExtensionStateContextType extends ExtensionState {
|
|||
setMaxDiagnosticMessages: (value: number) => void
|
||||
includeTaskHistoryInEnhance?: boolean
|
||||
setIncludeTaskHistoryInEnhance: (value: boolean) => void
|
||||
sendMessageOnEnter?: boolean
|
||||
setSendMessageOnEnter: (value: boolean) => void
|
||||
}
|
||||
|
||||
export const ExtensionStateContext = createContext<ExtensionStateContextType | undefined>(undefined)
|
||||
|
|
@ -285,6 +287,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
|
|||
global: {},
|
||||
})
|
||||
const [includeTaskHistoryInEnhance, setIncludeTaskHistoryInEnhance] = useState(true)
|
||||
const [sendMessageOnEnter, setSendMessageOnEnter] = useState(true) // Default to true (current behavior)
|
||||
|
||||
const setListApiConfigMeta = useCallback(
|
||||
(value: ProviderSettingsEntry[]) => setState((prevState) => ({ ...prevState, listApiConfigMeta: value })),
|
||||
|
|
@ -322,6 +325,10 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
|
|||
if ((newState as any).includeTaskHistoryInEnhance !== undefined) {
|
||||
setIncludeTaskHistoryInEnhance((newState as any).includeTaskHistoryInEnhance)
|
||||
}
|
||||
// Update sendMessageOnEnter if present in state message
|
||||
if ((newState as any).sendMessageOnEnter !== undefined) {
|
||||
setSendMessageOnEnter((newState as any).sendMessageOnEnter)
|
||||
}
|
||||
// Handle marketplace data if present in state message
|
||||
if (newState.marketplaceItems !== undefined) {
|
||||
setMarketplaceItems(newState.marketplaceItems)
|
||||
|
|
@ -559,6 +566,8 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
|
|||
},
|
||||
includeTaskHistoryInEnhance,
|
||||
setIncludeTaskHistoryInEnhance,
|
||||
sendMessageOnEnter,
|
||||
setSendMessageOnEnter,
|
||||
}
|
||||
|
||||
return <ExtensionStateContext.Provider value={contextValue}>{children}</ExtensionStateContext.Provider>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
},
|
||||
"sendMessageOnEnter": {
|
||||
"label": "Send message with Enter key",
|
||||
"description": "When enabled, pressing Enter sends messages and Shift+Enter creates new lines. When disabled, Enter creates new lines and Shift/Ctrl+Enter sends messages."
|
||||
}
|
||||
},
|
||||
"prompts": {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue