From 2bdd9af90ab0ce124250b79aeb5eba201d0d4c94 Mon Sep 17 00:00:00 2001 From: Muhammad Zaim <146334209+claymor333@users.noreply.github.com> Date: Sun, 6 Sep 2026 00:07:28 +0800 Subject: [PATCH] feat(ui): add composer enter-to-send toggle (#3178) * feat(ui): add composer enter-to-send toggle and native hardware-keyboard detection Replaces the settings-page "Enter sends with a keyboard attached" checkbox with an EnterKeyToggle in the composer footer: plain Enter submits / Shift+Enter inserts a newline when enabled, Shift+Enter submits / Enter inserts a newline when disabled. Ctrl/Cmd+Enter always submits as the soft-keyboard fallback. Persisted as enterToSend. Adds the Android HardwareKeyboardPlugin: scans input devices for an alphabetic physical keyboard (ignoring phantom key/sensor devices), re-answers on config changes/foreground, and confirms attachment from real hardware key events. MainActivity surfaces key events to it before the WebView consumes them. The composer and draft layout start keyboard-aware instead of inferring one focus late; ComposerEditor preserves Enter modifiers through CodeMirror's deferred re-dispatch so the toggle can tell Shift/Ctrl+Enter from plain Enter. Removes the settings search entry and i18n keys for the old checkbox. * refactor(ui): keep enter-to-send branch focused * fix(ui): preserve enter-toggle taps on touch * fix(ui): preserve enter key defaults and move setting * fix(ui): keep enter setting lint-clean * fix(i18n): preserve current Turkish message parity * fix(settings): persist enter-to-send preference * fix(ui): clarify enter-to-send setting * fix(ui): apply enter preference on desktop * fix(ui): match enter setting focus mode default * test(ui): cover enter key policy matrix * fix(ui): harden deferred enter handling * fix(chat): preserve untouched Enter policy and validate settings --------- Co-authored-by: Bohdan Triapitsyn --- packages/ui/src/components/chat/ChatInput.tsx | 24 ++++-- .../components/chat/composer/DOCUMENTATION.md | 15 ++++ .../chat/composer/editor/ComposerEditor.tsx | 59 +++++++------ .../chat/composer/keyboardPolicy.test.ts | 82 +++++++++++++++++++ .../chat/composer/keyboardPolicy.ts | 45 ++++++++++ .../sections/openchamber/OpenChamberPage.tsx | 1 + .../openchamber/OpenChamberVisualSettings.tsx | 33 ++++++-- packages/ui/src/lib/api/types.ts | 2 + packages/ui/src/lib/desktop.ts | 2 + packages/ui/src/lib/i18n/messages/de.ts | 2 + packages/ui/src/lib/i18n/messages/en.ts | 2 + packages/ui/src/lib/i18n/messages/es.ts | 2 + packages/ui/src/lib/i18n/messages/fr.ts | 2 + packages/ui/src/lib/i18n/messages/ja.ts | 2 + packages/ui/src/lib/i18n/messages/ko.ts | 2 + packages/ui/src/lib/i18n/messages/pl.ts | 2 + packages/ui/src/lib/i18n/messages/pt-BR.ts | 2 + packages/ui/src/lib/i18n/messages/tr.ts | 2 + packages/ui/src/lib/i18n/messages/uk.ts | 2 + packages/ui/src/lib/i18n/messages/zh-CN.ts | 2 + packages/ui/src/lib/i18n/messages/zh-TW.ts | 2 + packages/ui/src/lib/persistence.test.ts | 10 +++ packages/ui/src/lib/persistence.ts | 18 ++++ packages/ui/src/lib/settings/search.ts | 6 ++ packages/ui/src/stores/useUIStore.ts | 14 ++++ packages/vscode/src/DOCUMENTATION.md | 1 + .../vscode/src/bridge-settings-runtime.ts | 9 ++ packages/vscode/src/settings-changes.test.js | 19 +++++ packages/vscode/src/settings-changes.ts | 8 ++ .../server/lib/opencode/settings-helpers.js | 6 ++ .../lib/opencode/settings-helpers.test.js | 29 +++++++ 31 files changed, 368 insertions(+), 39 deletions(-) create mode 100644 packages/ui/src/components/chat/composer/keyboardPolicy.test.ts create mode 100644 packages/ui/src/components/chat/composer/keyboardPolicy.ts create mode 100644 packages/vscode/src/settings-changes.test.js create mode 100644 packages/vscode/src/settings-changes.ts diff --git a/packages/ui/src/components/chat/ChatInput.tsx b/packages/ui/src/components/chat/ChatInput.tsx index c106cb7d..2196641b 100644 --- a/packages/ui/src/components/chat/ChatInput.tsx +++ b/packages/ui/src/components/chat/ChatInput.tsx @@ -51,6 +51,7 @@ import { ModelControls } from './ModelControls'; import { parseAgentMentions } from '@/lib/messages/agentMentions'; import { CONTEXT_METADATA_KEY, draftFromContextPayload } from '@/lib/messages/contextParts'; import { ComposerStatusBar } from './ComposerStatusBar'; +import { shouldSubmitEnter } from './composer/keyboardPolicy'; import { PendingChangesBar } from './PendingChangesBar'; import { useChatColumnSession } from './chatColumnSession'; import { useChatSurfaceMode } from './useChatSurfaceMode'; @@ -490,6 +491,8 @@ const ChatInputComponent: React.FC = ({ const agents = getVisibleAgents(); const isMobile = useUIStore((state) => state.isMobile); const hasHardwareKeyboard = useHardwareKeyboard(); + const enterToSend = useUIStore((state) => state.enterToSend); + const enterToSendConfigured = useUIStore((state) => state.enterToSendConfigured); const { enabled: isTabletLayout } = useTabletLayout(); const setImagePreviewOpen = useUIStore((state) => state.setImagePreviewOpen); const inputBarOffset = useUIStore((state) => state.inputBarOffset); @@ -1924,16 +1927,20 @@ const ChatInputComponent: React.FC = ({ return; } - // Handle Enter/Ctrl+Enter based on selected follow-up behavior. On - // mobile, and in desktop focus mode, plain Enter writes a newline and - // only Cmd/Ctrl+Enter sends: both are surfaces for composing long - // prompts, where an accidental send costs more than an extra keypress. - const requiresModifierToSend = isMobile || isDesktopExpanded; - if (e.key === 'Enter' && !e.shiftKey && (!requiresModifierToSend || e.ctrlKey || e.metaKey)) { + // Preserve each surface's existing default until the user changes the + // setting. Once configured, the choice applies consistently everywhere. + const isCtrlEnter = e.ctrlKey || e.metaKey; + if (e.key === 'Enter' && shouldSubmitEnter({ + isMobile, + isDesktopExpanded, + enterToSend, + enterToSendConfigured, + shiftKey: e.shiftKey, + ctrlKey: e.ctrlKey, + metaKey: e.metaKey, + })) { e.preventDefault(); - const isCtrlEnter = e.ctrlKey || e.metaKey; - // Queueing / steering only works when there's an existing busy // session (or an active auto-review run). const canQueue = !isBtwActive && inputMode === 'normal' && hasContent && currentSessionId && (currentSessionPhase !== 'idle' || autoReviewRunning); @@ -3248,6 +3255,7 @@ const ChatInputComponent: React.FC = ({ editable={Boolean(currentSessionId || newSessionDraftOpen)} autoCorrect={composerAutoCorrect({ isMobile })} autoCapitalize={isMobile ? 'sentences' : 'none'} + preserveDeferredEnterShift={!enterToSendConfigured || !isMobile} spellCheck={isMobile || inputSpellcheckEnabled} fillContainer={isComposerExpanded} maxLines={isMobile ? MAX_MOBILE_COMPOSER_LINES : MAX_VISIBLE_COMPOSER_LINES} diff --git a/packages/ui/src/components/chat/composer/DOCUMENTATION.md b/packages/ui/src/components/chat/composer/DOCUMENTATION.md index 98c9377d..26b0a3a9 100644 --- a/packages/ui/src/components/chat/composer/DOCUMENTATION.md +++ b/packages/ui/src/components/chat/composer/DOCUMENTATION.md @@ -218,3 +218,18 @@ on the strength of type-check and unit tests. Run tests per file (`bun test `): `mock.module` is process-global, so suites that install module mocks are order-dependent. + +## Enter preference + +`keyboardPolicy.ts` owns the submission decision. Until the Chat setting is +changed, desktop Enter sends, mobile and focus mode require Ctrl/Cmd+Enter, +and Shift-modified Enter does not send. An explicit choice applies across +shared composers; Ctrl/Cmd+Enter sends in either configured mode. + +CodeMirror's deferred mobile Enter loses modifier information. Untouched +settings restore Shift to keep the original policy. Once configured, with mobile +autocapitalization enabled, the editor cannot distinguish its Shift flag from +an intentional Shift press and does not restore Shift. Consequently, deferred +Shift+Enter can send when Enter-to-send is enabled and cannot serve as the send +shortcut when it is disabled. Ctrl/Cmd+Enter remains the supported modified +send shortcut on this path. diff --git a/packages/ui/src/components/chat/composer/editor/ComposerEditor.tsx b/packages/ui/src/components/chat/composer/editor/ComposerEditor.tsx index 5c4db849..9e2f1f97 100644 --- a/packages/ui/src/components/chat/composer/editor/ComposerEditor.tsx +++ b/packages/ui/src/components/chat/composer/editor/ComposerEditor.tsx @@ -40,6 +40,7 @@ import { replaceWithCaret } from './documentEdits'; import type { ComposerEditorViewStore } from './viewStore'; import { composerEditorTheme, composerSelectionExtension } from './theme'; import { handleComposerHostMouseDown } from './hostMouseDown'; +import { restoreDeferredEnterModifiers } from '../keyboardPolicy'; export interface ComposerSelection { start: number; @@ -97,6 +98,8 @@ export interface ComposerEditorProps { */ autoCorrect?: ComposerAutoCorrect; autoCapitalize?: 'none' | 'sentences'; + /** Retain the untouched key policy before a mobile user opts in. */ + preserveDeferredEnterShift?: boolean; /** Fill the available height instead of growing with the content. */ fillContainer?: boolean; /** Lines of text shown before the editor starts scrolling. */ @@ -175,13 +178,17 @@ export const ComposerEditor = React.forwardRef(null); const viewRef = React.useRef(null); - // The real keydown's shift state for the LAST Enter that reached the - // editor. CodeMirror defers Enter on iOS (and Chrome Android) and - // re-dispatches it as a synthetic keydown built from the key name - // alone, dropping every modifier (see `trackRealEnterShift` and the - // `interceptKeys` handler below); this ref is what lets the deferred - // event still tell Shift+Enter from Enter. - const lastRealEnterShiftRef = React.useRef(false); + // CodeMirror defers Enter on iOS and Chrome Android, then re-dispatches + // a synthetic keydown. Keep modifiers only for that short handoff. + const lastRealEnterModsRef = React.useRef({ shiftKey: false, ctrlKey: false, metaKey: false }); + const deferredEnterModsTimeoutRef = React.useRef(null); + const clearDeferredEnterModifiers = () => { + lastRealEnterModsRef.current = { shiftKey: false, ctrlKey: false, metaKey: false }; + if (deferredEnterModsTimeoutRef.current !== null) { + window.clearTimeout(deferredEnterModsTimeoutRef.current); + deferredEnterModsTimeoutRef.current = null; + } + }; // Callbacks reach the CodeMirror extensions through a ref: the view is // built once and must not be torn down when a handler identity changes, @@ -224,11 +231,11 @@ export const ComposerEditor = React.forwardRef { - // A deferred Enter lost its modifiers in the re-dispatch; - // give the caller's policy (Enter vs Shift+Enter) back the - // shift state it saw on the real keydown. - if (event.key === 'Enter' && isDeferredSyntheticEvent(event) && lastRealEnterShiftRef.current) { - Object.defineProperty(event, 'shiftKey', { value: true }); + // A deferred Enter loses its modifiers in the re-dispatch. + if (event.key === 'Enter' && isDeferredSyntheticEvent(event)) { + const preserveShift = handlersRef.current.preserveDeferredEnterShift !== false; + restoreDeferredEnterModifiers(event, lastRealEnterModsRef.current, { preserveShift }); + clearDeferredEnterModifiers(); } return handlersRef.current.onKeyDown?.(event) ?? false; }, @@ -306,26 +313,26 @@ export const ComposerEditor = React.forwardRef { + const trackRealEnterMods = (event: KeyboardEvent) => { if (event.key !== 'Enter' || isDeferredSyntheticEvent(event)) return; - lastRealEnterShiftRef.current = event.shiftKey; + clearDeferredEnterModifiers(); + lastRealEnterModsRef.current = { + shiftKey: event.shiftKey, + ctrlKey: event.ctrlKey, + metaKey: event.metaKey, + }; + deferredEnterModsTimeoutRef.current = window.setTimeout(clearDeferredEnterModifiers, 500); }; - view.contentDOM.addEventListener('keydown', trackRealEnterShift); + view.contentDOM.addEventListener('keydown', trackRealEnterMods); return () => { + clearDeferredEnterModifiers(); viewRef.current = null; // A stored view is detached, not destroyed: the store owns its // lifetime now, and whoever owns the store ends it. diff --git a/packages/ui/src/components/chat/composer/keyboardPolicy.test.ts b/packages/ui/src/components/chat/composer/keyboardPolicy.test.ts new file mode 100644 index 00000000..3908d8f7 --- /dev/null +++ b/packages/ui/src/components/chat/composer/keyboardPolicy.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, test } from 'bun:test'; + +import { + restoreDeferredEnterModifiers, + shouldSubmitEnter, + type EnterKeyPolicyInput, + type EnterModifierState, +} from './keyboardPolicy'; + +const policy = (overrides: Partial): EnterKeyPolicyInput => ({ + isMobile: false, + isDesktopExpanded: false, + enterToSend: false, + enterToSendConfigured: false, + shiftKey: false, + ctrlKey: false, + metaKey: false, + ...overrides, +}); + +const enterPolicyCases: Array<[string, Partial, boolean]> = [ + ['mobile default Enter inserts a newline', { isMobile: true }, false], + ['desktop default Enter sends', {}, true], + ['desktop focus mode default Enter inserts a newline', { isDesktopExpanded: true }, false], + ['configured enabled Enter sends on mobile', { isMobile: true, enterToSendConfigured: true, enterToSend: true }, true], + ['configured enabled Shift+Enter inserts a newline', { enterToSendConfigured: true, enterToSend: true, shiftKey: true }, false], + ['configured disabled Enter inserts a newline', { enterToSendConfigured: true, enterToSend: false }, false], + ['configured disabled Shift+Enter sends', { enterToSendConfigured: true, enterToSend: false, shiftKey: true }, true], + ['configured Ctrl+Enter always sends', { enterToSendConfigured: true, isMobile: true, isDesktopExpanded: true, shiftKey: true, ctrlKey: true }, true], + ['configured Meta+Enter always sends', { enterToSendConfigured: true, isMobile: true, isDesktopExpanded: true, shiftKey: true, metaKey: true }, true], +]; + +describe('Enter key policy', () => { + for (const surface of [{}, { isMobile: true }, { isDesktopExpanded: true }]) { + for (const modifiers of [{}, { ctrlKey: true }, { metaKey: true }, { ctrlKey: true, metaKey: true }]) { + test(`untouched Shift+Enter does not submit: ${JSON.stringify({ ...surface, ...modifiers })}`, () => { + expect(shouldSubmitEnter(policy({ ...surface, ...modifiers, shiftKey: true }))).toBe(false); + }); + } + } + + for (const [name, overrides, expected] of enterPolicyCases) { + test(name, () => { + expect(shouldSubmitEnter(policy(overrides))).toBe(expected); + }); + } +}); + +const deferredModifierCases: Array<[string, EnterModifierState]> = [ + ['Shift', { shiftKey: true, ctrlKey: false, metaKey: false }], + ['Ctrl', { shiftKey: false, ctrlKey: true, metaKey: false }], + ['Meta', { shiftKey: false, ctrlKey: false, metaKey: true }], + ['Shift+Ctrl+Meta', { shiftKey: true, ctrlKey: true, metaKey: true }], +]; + +describe('deferred Enter modifiers', () => { + for (const modifiers of [{ shiftKey: true, ctrlKey: true, metaKey: false }, { shiftKey: true, ctrlKey: false, metaKey: true }]) { + test(`untouched mobile deferred Shift does not submit: ${JSON.stringify(modifiers)}`, () => { + const event = { shiftKey: false, ctrlKey: false, metaKey: false }; + restoreDeferredEnterModifiers(event, modifiers, { preserveShift: true }); + expect(shouldSubmitEnter(policy({ isMobile: true, ...event }))).toBe(false); + }); + } + + for (const [name, modifiers] of deferredModifierCases) { + test(`preserves ${name}`, () => { + const event = { shiftKey: false, ctrlKey: false, metaKey: false }; + + restoreDeferredEnterModifiers(event, modifiers); + + expect(event).toEqual(modifiers); + }); + } + + test('does not restore iOS auto-capitalization as Shift', () => { + const event = { shiftKey: false, ctrlKey: false, metaKey: false }; + + restoreDeferredEnterModifiers(event, { shiftKey: true, ctrlKey: false, metaKey: false }, { preserveShift: false }); + + expect(event).toEqual({ shiftKey: false, ctrlKey: false, metaKey: false }); + }); +}); diff --git a/packages/ui/src/components/chat/composer/keyboardPolicy.ts b/packages/ui/src/components/chat/composer/keyboardPolicy.ts new file mode 100644 index 00000000..ffd277ea --- /dev/null +++ b/packages/ui/src/components/chat/composer/keyboardPolicy.ts @@ -0,0 +1,45 @@ +export interface EnterKeyPolicyInput { + isMobile: boolean; + isDesktopExpanded: boolean; + enterToSend: boolean; + enterToSendConfigured: boolean; + shiftKey: boolean; + ctrlKey: boolean; + metaKey: boolean; +} + +export const shouldSubmitEnter = (input: EnterKeyPolicyInput): boolean => { + const enterSendsByDefault = !input.isMobile && !input.isDesktopExpanded; + const isCtrlEnter = input.ctrlKey || input.metaKey; + if (!input.enterToSendConfigured) { + return !input.shiftKey && (enterSendsByDefault || isCtrlEnter); + } + const enterSends = input.enterToSend; + const sendsWithEnter = enterSends + ? !input.shiftKey + : input.shiftKey; + + return isCtrlEnter || sendsWithEnter; +}; + +export interface EnterModifierState { + shiftKey: boolean; + ctrlKey: boolean; + metaKey: boolean; +} + +interface DeferredEnterModifierOptions { + preserveShift?: boolean; +} + +export const restoreDeferredEnterModifiers = ( + event: EnterModifierState, + modifiers: EnterModifierState, + options: DeferredEnterModifierOptions = {}, +): void => { + if (options.preserveShift !== false && modifiers.shiftKey) { + Object.defineProperty(event, 'shiftKey', { value: true }); + } + if (modifiers.ctrlKey) Object.defineProperty(event, 'ctrlKey', { value: true }); + if (modifiers.metaKey) Object.defineProperty(event, 'metaKey', { value: true }); +}; diff --git a/packages/ui/src/components/sections/openchamber/OpenChamberPage.tsx b/packages/ui/src/components/sections/openchamber/OpenChamberPage.tsx index d9d7ee93..29f2e7b1 100644 --- a/packages/ui/src/components/sections/openchamber/OpenChamberPage.tsx +++ b/packages/ui/src/components/sections/openchamber/OpenChamberPage.tsx @@ -210,6 +210,7 @@ const ChatSectionContent: React.FC = () => { 'persistDraft', 'inputSpellcheck', 'largeTextPaste', + 'enterToSend', ]} /> ); diff --git a/packages/ui/src/components/sections/openchamber/OpenChamberVisualSettings.tsx b/packages/ui/src/components/sections/openchamber/OpenChamberVisualSettings.tsx index 1a626e74..2d383ea9 100644 --- a/packages/ui/src/components/sections/openchamber/OpenChamberVisualSettings.tsx +++ b/packages/ui/src/components/sections/openchamber/OpenChamberVisualSettings.tsx @@ -283,7 +283,7 @@ const normalizeUserMessageRenderingMode = (mode: unknown): 'markdown' | 'plain' return mode === 'markdown' ? 'markdown' : 'plain'; }; -type VisibleSetting = 'sessionAssist' | 'sessionGoal' | 'theme' | 'windowControlsPosition' | 'pwaInstallName' | 'pwaOrientation' | 'mobileKeyboardMode' | 'timeFormat' | 'weekStart' | 'fontSize' | 'terminalFontSize' | 'terminalShell' | 'terminalLoginShell' | 'editorFontSize' | 'spacing' | 'inputBarOffset' | 'mermaidRendering' | 'userMessageRendering' | 'chatRenderMode' | 'messageTransport' | 'activityRenderMode' | 'collapsibleUserMessages' | 'stickyUserHeader' | 'promptNavigatorEnabled' | 'wideChatLayout' | 'codeBlockLineWrap' | 'splitAssistantMessageActions' | 'subagentReadOnlyBanner' | 'diffLayout' | 'mobileStatusBar' | 'dotfiles' | 'fileViewerPreview' | 'reasoning' | 'showToolFileIcons' | 'showTurnChangedFiles' | 'expandedTools' | 'followUpBehavior' | 'terminalQuickKeys' | 'fileEditorKeymap' | 'persistDraft' | 'inputSpellcheck' | 'largeTextPaste' | 'reportUsage' | 'autoSaveEnabled' | 'sessionTabs'; +type VisibleSetting = 'sessionAssist' | 'sessionGoal' | 'theme' | 'windowControlsPosition' | 'pwaInstallName' | 'pwaOrientation' | 'mobileKeyboardMode' | 'timeFormat' | 'weekStart' | 'fontSize' | 'terminalFontSize' | 'terminalShell' | 'terminalLoginShell' | 'editorFontSize' | 'spacing' | 'inputBarOffset' | 'mermaidRendering' | 'userMessageRendering' | 'chatRenderMode' | 'messageTransport' | 'activityRenderMode' | 'collapsibleUserMessages' | 'stickyUserHeader' | 'promptNavigatorEnabled' | 'wideChatLayout' | 'codeBlockLineWrap' | 'splitAssistantMessageActions' | 'subagentReadOnlyBanner' | 'diffLayout' | 'mobileStatusBar' | 'dotfiles' | 'fileViewerPreview' | 'reasoning' | 'showToolFileIcons' | 'showTurnChangedFiles' | 'expandedTools' | 'followUpBehavior' | 'terminalQuickKeys' | 'fileEditorKeymap' | 'persistDraft' | 'inputSpellcheck' | 'largeTextPaste' | 'enterToSend' | 'reportUsage' | 'autoSaveEnabled' | 'sessionTabs'; const WINDOW_CONTROLS_POSITION_OPTIONS: Array<{ id: DesktopWindowControlsPosition; labelKey: string }> = [ { id: 'left', labelKey: 'settings.openchamber.desktopNetwork.option.windowControlsLeft' }, @@ -380,6 +380,11 @@ export const OpenChamberVisualSettings: React.FC const setInputSpellcheckEnabled = useUIStore(state => state.setInputSpellcheckEnabled); const largeTextPasteBehavior = useUIStore(state => state.largeTextPasteBehavior); const setLargeTextPasteBehavior = useUIStore(state => state.setLargeTextPasteBehavior); + const enterToSend = useUIStore(state => state.enterToSend); + const setEnterToSend = useUIStore(state => state.setEnterToSend); + const enterToSendConfigured = useUIStore(state => state.enterToSendConfigured); + const setEnterToSendConfigured = useUIStore(state => state.setEnterToSendConfigured); + const isExpandedInput = useUIStore(state => state.isExpandedInput); const showToolFileIcons = useUIStore(state => state.showToolFileIcons); const setShowToolFileIcons = useUIStore(state => state.setShowToolFileIcons); const showTurnChangedFiles = useUIStore(state => state.showTurnChangedFiles); @@ -539,6 +544,12 @@ export const OpenChamberVisualSettings: React.FC void updateDesktopSettings({ inputSpellcheckEnabled: enabled }); }, [setInputSpellcheckEnabled]); + const handleEnterToSendChange = React.useCallback((enabled: boolean) => { + setEnterToSend(enabled); + setEnterToSendConfigured(true); + void updateDesktopSettings({ enterToSend: enabled, enterToSendConfigured: true }); + }, [setEnterToSend, setEnterToSendConfigured]); + const handleChatRenderModeChange = React.useCallback((mode: 'sorted' | 'live') => { setChatRenderMode(mode); void updateDesktopSettings({ chatRenderMode: mode }); @@ -660,7 +671,8 @@ export const OpenChamberVisualSettings: React.FC || shouldShow('largeTextPaste') || shouldShow('showToolFileIcons') || shouldShow('expandedTools') - || (!isMobile && shouldShow('inputSpellcheck')); + || (!isMobile && shouldShow('inputSpellcheck')) + || shouldShow('enterToSend'); const showBehaviorDisplaySettings = shouldShow('chatRenderMode') || (shouldShow('activityRenderMode') && chatRenderMode === 'sorted'); const showTransportSection = shouldShow('messageTransport'); @@ -684,6 +696,7 @@ export const OpenChamberVisualSettings: React.FC || shouldShow('showToolFileIcons') || shouldShow('showTurnChangedFiles') || (!isMobile && shouldShow('inputSpellcheck')) + || shouldShow('enterToSend') || shouldShow('reasoning') || shouldShow('expandedTools'); // First behavior section under the page header should not draw a top border on Chat-only; @@ -1457,7 +1470,7 @@ export const OpenChamberVisualSettings: React.FC /> ))} - + )}
{shouldShow('autoSaveEnabled') && ( @@ -2001,7 +2014,7 @@ export const OpenChamberVisualSettings: React.FC )} - {(shouldShow('persistDraft') || shouldShow('largeTextPaste') || (!isMobile && shouldShow('inputSpellcheck'))) && ( + {(shouldShow('persistDraft') || shouldShow('largeTextPaste') || (!isMobile && shouldShow('inputSpellcheck')) || shouldShow('enterToSend')) && ( /> ))} - + + )} + {shouldShow('enterToSend') && ( + )} )} diff --git a/packages/ui/src/lib/api/types.ts b/packages/ui/src/lib/api/types.ts index 7fb80447..422206d5 100644 --- a/packages/ui/src/lib/api/types.ts +++ b/packages/ui/src/lib/api/types.ts @@ -724,6 +724,8 @@ export interface SettingsPayload { queueModeEnabled?: boolean; gitmojiEnabled?: boolean; inputSpellcheckEnabled?: boolean; + enterToSend?: boolean; + enterToSendConfigured?: boolean; showOpenCodeUpdateNotifications?: boolean; openCodeUpdateToastDismissedVersion?: string; showToolFileIcons?: boolean; diff --git a/packages/ui/src/lib/desktop.ts b/packages/ui/src/lib/desktop.ts index 4dea9bef..4555e102 100644 --- a/packages/ui/src/lib/desktop.ts +++ b/packages/ui/src/lib/desktop.ts @@ -159,6 +159,8 @@ export type DesktopSettings = { desktopWindowControlsPosition?: DesktopWindowControlsPosition; desktopWindowControlsStyle?: DesktopWindowControlsStyle; inputSpellcheckEnabled?: boolean; + enterToSend?: boolean; + enterToSendConfigured?: boolean; showOpenCodeUpdateNotifications?: boolean; agentControlToolEnabled?: boolean; agentWebToolEnabled?: boolean; diff --git a/packages/ui/src/lib/i18n/messages/de.ts b/packages/ui/src/lib/i18n/messages/de.ts index cf7ade00..b8381a9e 100644 --- a/packages/ui/src/lib/i18n/messages/de.ts +++ b/packages/ui/src/lib/i18n/messages/de.ts @@ -2105,6 +2105,8 @@ export const dict = { 'chat.chatInput.actions.linkGithubPr': 'GitHub-PR verknüpfen', 'chat.chatInput.actions.modelAgentSettings': 'Modell- und Agenteneinstellungen', 'chat.chatInput.actions.sendMessageAria': 'Nachricht senden', + 'chat.chatInput.actions.enterToSend': 'Enter sendet', + 'chat.chatInput.actions.enterToSendHint': 'Nach der Änderung steuern Enter und Shift+Enter das Verhalten auf jeder Oberfläche. Bis dahin behält jede Oberfläche ihr bestehendes Verhalten bei.', 'chat.chatInput.actions.queueMessageAria': 'Nachricht in die Warteschlange stellen', 'chat.chatInput.actions.stopGeneratingAria': 'Generierung stoppen', 'chat.chatInput.focusMode.toggleAria': 'Fokusmodus umschalten', diff --git a/packages/ui/src/lib/i18n/messages/en.ts b/packages/ui/src/lib/i18n/messages/en.ts index 3de5182c..a365e756 100644 --- a/packages/ui/src/lib/i18n/messages/en.ts +++ b/packages/ui/src/lib/i18n/messages/en.ts @@ -2322,6 +2322,8 @@ export const dict = { 'chat.chatInput.actions.linkGithubPr': 'Link GitHub PR', 'chat.chatInput.actions.modelAgentSettings': 'Model and agent settings', 'chat.chatInput.actions.sendMessageAria': 'Send message', + 'chat.chatInput.actions.enterToSend': 'Enter sends', + 'chat.chatInput.actions.enterToSendHint': 'Once changed, this controls Enter and Shift+Enter on every surface. Until then, each surface keeps its existing behavior.', 'chat.chatInput.actions.queueMessageAria': 'Queue message', 'chat.chatInput.actions.stopGeneratingAria': 'Stop generating', 'chat.chatInput.focusMode.toggleAria': 'Toggle focus mode', diff --git a/packages/ui/src/lib/i18n/messages/es.ts b/packages/ui/src/lib/i18n/messages/es.ts index 622ca275..029c0e90 100644 --- a/packages/ui/src/lib/i18n/messages/es.ts +++ b/packages/ui/src/lib/i18n/messages/es.ts @@ -2300,6 +2300,8 @@ export const dict: Record = { "chat.chatInput.actions.linkGithubPr": "Vincular PR de GitHub", "chat.chatInput.actions.modelAgentSettings": "Configuración del modelo y agente", "chat.chatInput.actions.sendMessageAria": "Enviar mensaje", + 'chat.chatInput.actions.enterToSend': 'Enter envía', + 'chat.chatInput.actions.enterToSendHint': 'Después de cambiarlo, controla Enter y Shift+Enter en todas las superficies. Hasta entonces, cada superficie mantiene su comportamiento actual.', "chat.chatInput.actions.queueMessageAria": "Poner mensaje en cola", "chat.chatInput.actions.stopGeneratingAria": "Detener generación", "chat.chatInput.focusMode.toggleAria": "Activar o desactivar modo de enfoque", diff --git a/packages/ui/src/lib/i18n/messages/fr.ts b/packages/ui/src/lib/i18n/messages/fr.ts index 41b228dc..80bc738f 100644 --- a/packages/ui/src/lib/i18n/messages/fr.ts +++ b/packages/ui/src/lib/i18n/messages/fr.ts @@ -2041,6 +2041,8 @@ export const dict = { 'chat.chatInput.actions.linkGithubPr': 'Lien GitHub PR', 'chat.chatInput.actions.modelAgentSettings': 'Paramètres du modèle et de l\'agent', 'chat.chatInput.actions.sendMessageAria': 'Envoyer un message', + 'chat.chatInput.actions.enterToSend': 'Entrée envoie', + 'chat.chatInput.actions.enterToSendHint': 'Après modification, ce réglage contrôle Entrée et Maj+Entrée sur toutes les surfaces. En attendant, chaque surface conserve son comportement actuel.', 'chat.chatInput.actions.queueMessageAria': 'Message de file d\'attente', 'chat.chatInput.actions.stopGeneratingAria': 'Arrêter de générer', 'chat.chatInput.focusMode.toggleAria': 'Basculer le mode de mise au point', diff --git a/packages/ui/src/lib/i18n/messages/ja.ts b/packages/ui/src/lib/i18n/messages/ja.ts index 388a398a..af07a5e4 100644 --- a/packages/ui/src/lib/i18n/messages/ja.ts +++ b/packages/ui/src/lib/i18n/messages/ja.ts @@ -2318,6 +2318,8 @@ export const dict: Record = { 'chat.chatInput.actions.linkGithubPr': 'GitHub PRをリンク', 'chat.chatInput.actions.modelAgentSettings': 'モデルとエージェント設定', 'chat.chatInput.actions.sendMessageAria': 'メッセージを送信', + 'chat.chatInput.actions.enterToSend': 'Enterで送信', + 'chat.chatInput.actions.enterToSendHint': '変更すると、すべての環境でEnterとShift+Enterの動作を制御します。変更するまでは、各環境の既存の動作が維持されます。', 'chat.chatInput.actions.queueMessageAria': 'メッセージをキュー', 'chat.chatInput.actions.stopGeneratingAria': '生成を停止', 'chat.chatInput.focusMode.toggleAria': 'フォーカスモードの切り替え', diff --git a/packages/ui/src/lib/i18n/messages/ko.ts b/packages/ui/src/lib/i18n/messages/ko.ts index 79cfe10a..0e9206d9 100644 --- a/packages/ui/src/lib/i18n/messages/ko.ts +++ b/packages/ui/src/lib/i18n/messages/ko.ts @@ -2322,6 +2322,8 @@ export const dict: Record = { 'chat.chatInput.actions.linkGithubPr': 'GitHub PR 연결', 'chat.chatInput.actions.modelAgentSettings': '모델 및 에이전트 설정', 'chat.chatInput.actions.sendMessageAria': '보내기 메시지', + 'chat.chatInput.actions.enterToSend': 'Enter로 전송', + 'chat.chatInput.actions.enterToSendHint': '변경하면 모든 환경에서 Enter와 Shift+Enter의 동작을 제어합니다. 변경하기 전에는 각 환경의 기존 동작이 유지됩니다.', 'chat.chatInput.actions.queueMessageAria': '메시지 대기열에 추가', 'chat.chatInput.actions.stopGeneratingAria': '생성 중지', 'chat.chatInput.focusMode.toggleAria': '집중 모드 전환', diff --git a/packages/ui/src/lib/i18n/messages/pl.ts b/packages/ui/src/lib/i18n/messages/pl.ts index 779c5cf6..6af2b343 100644 --- a/packages/ui/src/lib/i18n/messages/pl.ts +++ b/packages/ui/src/lib/i18n/messages/pl.ts @@ -1255,6 +1255,8 @@ export const dict: Record = { 'chat.chatInput.actions.modelAgentSettings': 'Model and agent settings', 'chat.chatInput.actions.queueMessageAria': 'Queue message', 'chat.chatInput.actions.sendMessageAria': 'Send message', + 'chat.chatInput.actions.enterToSend': 'Enter wysyła', + 'chat.chatInput.actions.enterToSendHint': 'Po zmianie ustawienie steruje działaniem klawiszy Enter i Shift+Enter na każdej powierzchni. Do tego czasu każda powierzchnia zachowuje dotychczasowe działanie.', 'chat.chatInput.actions.stopGeneratingAria': 'Stop generating', 'chat.chatInput.branch': 'Gałąź', 'chat.chatInput.draftPicker.projectTitle': 'Projekt', diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.ts b/packages/ui/src/lib/i18n/messages/pt-BR.ts index df8cc2d1..5320a017 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.ts @@ -2300,6 +2300,8 @@ export const dict: Record = { "chat.chatInput.actions.linkGithubPr": "Vincular PR de GitHub", "chat.chatInput.actions.modelAgentSettings": "Configurações de modelo e agente", "chat.chatInput.actions.sendMessageAria": "Enviar mensagem", + 'chat.chatInput.actions.enterToSend': 'Enter envia', + 'chat.chatInput.actions.enterToSendHint': 'Depois de alterada, esta opção controla Enter e Shift+Enter em todas as superfícies. Até lá, cada superfície mantém seu comportamento atual.', "chat.chatInput.actions.queueMessageAria": "Colocar mensagem na fila", "chat.chatInput.actions.stopGeneratingAria": "Parar geração", "chat.chatInput.focusMode.toggleAria": "Ativar ou desativar modo de foco", diff --git a/packages/ui/src/lib/i18n/messages/tr.ts b/packages/ui/src/lib/i18n/messages/tr.ts index 75f8729d..7391bbd4 100644 --- a/packages/ui/src/lib/i18n/messages/tr.ts +++ b/packages/ui/src/lib/i18n/messages/tr.ts @@ -2263,6 +2263,8 @@ export const dict = { 'chat.chatInput.actions.linkGithubPr': 'GitHub PR\'yi bağla', 'chat.chatInput.actions.modelAgentSettings': 'Model ve agent ayarları', 'chat.chatInput.actions.sendMessageAria': 'Mesaj gönder', + 'chat.chatInput.actions.enterToSend': 'Enter gönderir', + 'chat.chatInput.actions.enterToSendHint': 'Değiştirildikten sonra Enter ve Shift+Enter davranışını tüm yüzeylerde kontrol eder. O zamana kadar her yüzey mevcut davranışını korur.', 'chat.chatInput.actions.queueMessageAria': 'Mesajı kuyruğa ekle', 'chat.chatInput.actions.stopGeneratingAria': 'Üretmeyi durdur', 'chat.chatInput.focusMode.toggleAria': 'Odak modunu aç/kapat', diff --git a/packages/ui/src/lib/i18n/messages/uk.ts b/packages/ui/src/lib/i18n/messages/uk.ts index 784acd6a..57e11ecd 100644 --- a/packages/ui/src/lib/i18n/messages/uk.ts +++ b/packages/ui/src/lib/i18n/messages/uk.ts @@ -2300,6 +2300,8 @@ export const dict: Record = { "chat.chatInput.actions.linkGithubPr": "Пов’язати GitHub PR", "chat.chatInput.actions.modelAgentSettings": "Параметри моделі та агента", "chat.chatInput.actions.sendMessageAria": "Надіслати повідомлення", + 'chat.chatInput.actions.enterToSend': 'Enter надсилає', + 'chat.chatInput.actions.enterToSendHint': 'Після зміни цей параметр керує поведінкою Enter і Shift+Enter на всіх поверхнях. До цього кожна поверхня зберігає свою поточну поведінку.', "chat.chatInput.actions.queueMessageAria": "Поставити повідомлення в чергу", "chat.chatInput.actions.stopGeneratingAria": "Припинити генерацію", "chat.chatInput.focusMode.toggleAria": "Перемкнути режим фокусування", diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.ts b/packages/ui/src/lib/i18n/messages/zh-CN.ts index 9df5dd3a..9e7e80a6 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.ts @@ -2288,6 +2288,8 @@ export const dict: Record = { 'chat.chatInput.actions.linkGithubPr': '关联 GitHub PR', 'chat.chatInput.actions.modelAgentSettings': '模型与智能体设置', 'chat.chatInput.actions.sendMessageAria': '发送消息', + 'chat.chatInput.actions.enterToSend': 'Enter 发送', + 'chat.chatInput.actions.enterToSendHint': '更改后,此设置会控制所有界面中的 Enter 和 Shift+Enter。更改前,各界面保持现有行为。', 'chat.chatInput.actions.queueMessageAria': '将消息加入队列', 'chat.chatInput.actions.stopGeneratingAria': '停止生成', 'chat.chatInput.focusMode.toggleAria': '切换专注模式', diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.ts b/packages/ui/src/lib/i18n/messages/zh-TW.ts index fb537bd7..ad82c33e 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.ts @@ -2292,6 +2292,8 @@ export const dict: Record = { 'chat.chatInput.actions.linkGithubPr': '關聯 GitHub PR', 'chat.chatInput.actions.modelAgentSettings': '模型與 Agent 設定', 'chat.chatInput.actions.sendMessageAria': '傳送訊息', + 'chat.chatInput.actions.enterToSend': 'Enter 傳送', + 'chat.chatInput.actions.enterToSendHint': '變更後,此設定會控制所有介面中的 Enter 與 Shift+Enter。變更前,各介面會維持現有行為。', 'chat.chatInput.actions.queueMessageAria': '將訊息加入佇列', 'chat.chatInput.actions.stopGeneratingAria': '停止生成', 'chat.chatInput.focusMode.toggleAria': '切換專注模式', diff --git a/packages/ui/src/lib/persistence.test.ts b/packages/ui/src/lib/persistence.test.ts index e5fba633..b2a07881 100644 --- a/packages/ui/src/lib/persistence.test.ts +++ b/packages/ui/src/lib/persistence.test.ts @@ -261,6 +261,16 @@ describe('updateDesktopSettings', () => { } }); + test('applies enterToSendConfigured when it arrives without enterToSend', async () => { + useUIStore.setState({ enterToSend: false, enterToSendConfigured: false }); + registerSettingsSave(async () => ({ enterToSendConfigured: true })); + + await updateDesktopSettings({ enterToSendConfigured: true }); + + expect(useUIStore.getState().enterToSend).toBe(false); + expect(useUIStore.getState().enterToSendConfigured).toBe(true); + }); + test('reports an error without applying a malformed fallback settings response', async () => { const previousFetch = globalThis.fetch; const fallbackFetch: typeof fetch = async () => new Response(JSON.stringify('ok'), { diff --git a/packages/ui/src/lib/persistence.ts b/packages/ui/src/lib/persistence.ts index 352e24ea..0bc18989 100644 --- a/packages/ui/src/lib/persistence.ts +++ b/packages/ui/src/lib/persistence.ts @@ -574,6 +574,8 @@ const materializeAuthoritativeUiSettings = (settings: DesktopSettings): DesktopS summaryLength: defaults.summaryLength, maxLastMessageLength: defaults.maxLastMessageLength, inputSpellcheckEnabled: defaults.inputSpellcheckEnabled, + enterToSend: defaults.enterToSend, + enterToSendConfigured: defaults.enterToSendConfigured, showOpenCodeUpdateNotifications: defaults.showOpenCodeUpdateNotifications, agentControlToolEnabled: defaults.agentControlToolEnabled, agentWebToolEnabled: defaults.agentWebToolEnabled, @@ -745,6 +747,16 @@ const applyDesktopUiPreferences = (settings: DesktopSettings) => { if (typeof settings.inputSpellcheckEnabled === 'boolean' && settings.inputSpellcheckEnabled !== store.inputSpellcheckEnabled) { store.setInputSpellcheckEnabled(settings.inputSpellcheckEnabled); } + if (settings.enterToSend === true || settings.enterToSend === false) { + if (settings.enterToSend !== store.enterToSend) { + store.setEnterToSend(settings.enterToSend); + } + } + if (settings.enterToSendConfigured === true || settings.enterToSendConfigured === false) { + if (settings.enterToSendConfigured !== store.enterToSendConfigured) { + store.setEnterToSendConfigured(settings.enterToSendConfigured); + } + } if ( typeof settings.showOpenCodeUpdateNotifications === 'boolean' && settings.showOpenCodeUpdateNotifications !== store.showOpenCodeUpdateNotifications @@ -1457,6 +1469,12 @@ const sanitizeWebSettings = (payload: unknown): DesktopSettings | null => { if (typeof candidate.inputSpellcheckEnabled === 'boolean') { result.inputSpellcheckEnabled = candidate.inputSpellcheckEnabled; } + if (candidate.enterToSend === true || candidate.enterToSend === false) { + result.enterToSend = candidate.enterToSend; + } + if (candidate.enterToSendConfigured === true || candidate.enterToSendConfigured === false) { + result.enterToSendConfigured = candidate.enterToSendConfigured; + } if (typeof candidate.showOpenCodeUpdateNotifications === 'boolean') { result.showOpenCodeUpdateNotifications = candidate.showOpenCodeUpdateNotifications; } diff --git a/packages/ui/src/lib/settings/search.ts b/packages/ui/src/lib/settings/search.ts index eaf11603..f446401f 100644 --- a/packages/ui/src/lib/settings/search.ts +++ b/packages/ui/src/lib/settings/search.ts @@ -367,6 +367,12 @@ const SETTINGS_SEARCH_ITEMS: readonly SettingsSearchItem[] = [ descriptionKey: 'settings.openchamber.visual.field.largeTextPasteHint', keywords: ['paste', 'clipboard', 'attachment', 'large', 'text', 'file'], }, + { + id: 'chat.enter-to-send', + page: 'chat', + titleKey: 'chat.chatInput.actions.enterToSend', + keywords: ['enter', 'shift enter', 'send', 'newline'], + }, { id: 'sessions.default-model', page: 'sessions', diff --git a/packages/ui/src/stores/useUIStore.ts b/packages/ui/src/stores/useUIStore.ts index fe3aeeef..4c5ff7cb 100644 --- a/packages/ui/src/stores/useUIStore.ts +++ b/packages/ui/src/stores/useUIStore.ts @@ -943,6 +943,8 @@ interface UIStore { projectContextTab: string; inputSpellcheckEnabled: boolean; largeTextPasteBehavior: LargeTextPasteBehavior; + enterToSend: boolean; + enterToSendConfigured: boolean; wideChatLayoutEnabled: boolean; codeBlockLineWrap: boolean; showToolFileIcons: boolean; @@ -1128,6 +1130,8 @@ interface UIStore { setProjectContextTab: (value: string) => void; setInputSpellcheckEnabled: (value: boolean) => void; setLargeTextPasteBehavior: (value: LargeTextPasteBehavior) => void; + setEnterToSend: (value: boolean) => void; + setEnterToSendConfigured: (value: boolean) => void; setWideChatLayoutEnabled: (value: boolean) => void; setCodeBlockLineWrap: (value: boolean) => void; setShowToolFileIcons: (value: boolean) => void; @@ -1298,6 +1302,8 @@ export const useUIStore = create()( projectContextTab: 'notes', inputSpellcheckEnabled: false, largeTextPasteBehavior: DEFAULT_LARGE_TEXT_PASTE_BEHAVIOR, + enterToSend: false, + enterToSendConfigured: false, wideChatLayoutEnabled: false, codeBlockLineWrap: true, showToolFileIcons: true, @@ -2599,6 +2605,12 @@ export const useUIStore = create()( setLargeTextPasteBehavior: (value) => { set({ largeTextPasteBehavior: normalizeLargeTextPasteBehavior(value) }); }, + setEnterToSend: (value) => { + set({ enterToSend: value }); + }, + setEnterToSendConfigured: (value) => { + set({ enterToSendConfigured: value }); + }, setWideChatLayoutEnabled: (value) => { set({ wideChatLayoutEnabled: value }); }, @@ -3026,6 +3038,8 @@ export const useUIStore = create()( projectContextSidebarWidth: state.projectContextSidebarWidth, inputSpellcheckEnabled: state.inputSpellcheckEnabled, largeTextPasteBehavior: state.largeTextPasteBehavior, + enterToSend: state.enterToSend, + enterToSendConfigured: state.enterToSendConfigured, wideChatLayoutEnabled: state.wideChatLayoutEnabled, codeBlockLineWrap: state.codeBlockLineWrap, showToolFileIcons: state.showToolFileIcons, diff --git a/packages/vscode/src/DOCUMENTATION.md b/packages/vscode/src/DOCUMENTATION.md index fa988275..29b36777 100644 --- a/packages/vscode/src/DOCUMENTATION.md +++ b/packages/vscode/src/DOCUMENTATION.md @@ -66,6 +66,7 @@ The webview build emits each worker as one self-contained file. VS Code webviews - `bridge-settings-runtime.ts` - Settings read/write and OpenCode skills discovery via API for bridge consumers. + - `settings-changes.ts` validates Enter preferences independently before writes; invalid values are omitted without dropping unrelated changes. - `bridge-system-runtime.ts` - System/editor/provider/quota/notification/update-check message handlers. diff --git a/packages/vscode/src/bridge-settings-runtime.ts b/packages/vscode/src/bridge-settings-runtime.ts index d2b01669..85a5b438 100644 --- a/packages/vscode/src/bridge-settings-runtime.ts +++ b/packages/vscode/src/bridge-settings-runtime.ts @@ -5,6 +5,7 @@ import * as path from 'path'; import * as vscode from 'vscode'; import { BUILT_IN_SKILL_LOCATION, type DiscoveredSkill, type SkillScope, type SkillSource } from './opencodeConfig'; import type { BridgeContext } from './bridge'; +import { enterSettingsSchema } from './settings-changes'; const SETTINGS_KEY = 'openchamber.settings'; const OPENCHAMBER_SHARED_SETTINGS_PATH = path.join(os.homedir(), '.config', 'openchamber', 'settings.json'); @@ -292,6 +293,14 @@ export const readSettings = (ctx?: BridgeContext): Record => { export const persistSettings = async (changes: Record, ctx?: BridgeContext): Promise> => { const current = readSettings(ctx); const restChanges = stripDerived({ ...(changes || {}) }); + const enterSettings = enterSettingsSchema.parse(restChanges); + for (const key of ['enterToSend', 'enterToSendConfigured'] as const) { + if (enterSettings[key] === undefined) { + delete restChanges[key]; + } else { + restChanges[key] = enterSettings[key]; + } + } const keysToClear = new Set(); diff --git a/packages/vscode/src/settings-changes.test.js b/packages/vscode/src/settings-changes.test.js new file mode 100644 index 00000000..03a09a8d --- /dev/null +++ b/packages/vscode/src/settings-changes.test.js @@ -0,0 +1,19 @@ +import { describe, expect, test } from 'bun:test'; +import { enterSettingsSchema } from './settings-changes'; + +describe('VS Code Enter settings validation', () => { + test('preserves both explicit choices', () => { + expect(enterSettingsSchema.parse({ enterToSend: true, enterToSendConfigured: false })) + .toEqual({ enterToSend: true, enterToSendConfigured: false }); + expect(enterSettingsSchema.parse({ enterToSend: false, enterToSendConfigured: true })) + .toEqual({ enterToSend: false, enterToSendConfigured: true }); + }); + + test('omits invalid fields independently', () => { + expect(enterSettingsSchema.parse({ enterToSend: 'true', enterToSendConfigured: true })) + .toEqual({ enterToSend: undefined, enterToSendConfigured: true }); + expect(enterSettingsSchema.parse({ enterToSend: false, enterToSendConfigured: 1 })) + .toEqual({ enterToSend: false, enterToSendConfigured: undefined }); + expect(enterSettingsSchema.parse({})).toEqual({}); + }); +}); diff --git a/packages/vscode/src/settings-changes.ts b/packages/vscode/src/settings-changes.ts new file mode 100644 index 00000000..f157d5fc --- /dev/null +++ b/packages/vscode/src/settings-changes.ts @@ -0,0 +1,8 @@ +import { z } from 'zod'; + +// Invalid fields are omitted independently so one malformed preference cannot +// discard the other preference or any unrelated settings in the same write. +export const enterSettingsSchema = z.object({ + enterToSend: z.boolean().optional().catch(undefined), + enterToSendConfigured: z.boolean().optional().catch(undefined), +}); diff --git a/packages/web/server/lib/opencode/settings-helpers.js b/packages/web/server/lib/opencode/settings-helpers.js index 58861943..a514320f 100644 --- a/packages/web/server/lib/opencode/settings-helpers.js +++ b/packages/web/server/lib/opencode/settings-helpers.js @@ -519,6 +519,12 @@ export const createSettingsHelpers = (dependencies) => { if (typeof candidate.inputSpellcheckEnabled === 'boolean') { result.inputSpellcheckEnabled = candidate.inputSpellcheckEnabled; } + if (candidate.enterToSend === true || candidate.enterToSend === false) { + result.enterToSend = candidate.enterToSend; + } + if (candidate.enterToSendConfigured === true || candidate.enterToSendConfigured === false) { + result.enterToSendConfigured = candidate.enterToSendConfigured; + } if (typeof candidate.showOpenCodeUpdateNotifications === 'boolean') { result.showOpenCodeUpdateNotifications = candidate.showOpenCodeUpdateNotifications; } diff --git a/packages/web/server/lib/opencode/settings-helpers.test.js b/packages/web/server/lib/opencode/settings-helpers.test.js index 88a7d3e4..f03d69eb 100644 --- a/packages/web/server/lib/opencode/settings-helpers.test.js +++ b/packages/web/server/lib/opencode/settings-helpers.test.js @@ -66,6 +66,14 @@ describe('settings helpers', () => { expect(helpers.sanitizeSettingsUpdate({ draftStartersVisible: 'false' })).toEqual({}); }); + it('sanitizes both Enter settings independently', () => { + const helpers = createTestHelpers(); + + expect(helpers.sanitizeSettingsUpdate({ enterToSend: true })).toEqual({ enterToSend: true }); + expect(helpers.sanitizeSettingsUpdate({ enterToSendConfigured: false })).toEqual({ enterToSendConfigured: false }); + expect(helpers.sanitizeSettingsUpdate({ enterToSend: 'true', enterToSendConfigured: 1 })).toEqual({}); + }); + it('sanitizes shared sidebar display preferences', () => { const helpers = createTestHelpers(); @@ -325,6 +333,27 @@ describe('settings helpers', () => { }); }); + it('accepts and rejects invalid Enter-to-send preference values', () => { + const helpers = createTestHelpers(); + + expect(helpers.sanitizeSettingsUpdate({ enterToSend: true, enterToSendConfigured: true })).toEqual({ + enterToSend: true, + enterToSendConfigured: true, + }); + expect(helpers.sanitizeSettingsUpdate({ enterToSend: false, enterToSendConfigured: true })).toEqual({ + enterToSend: false, + enterToSendConfigured: true, + }); + expect(helpers.sanitizeSettingsUpdate({ enterToSend: false, enterToSendConfigured: false })).toEqual({ + enterToSend: false, + enterToSendConfigured: false, + }); + expect(helpers.sanitizeSettingsUpdate({ enterToSend: 'true' })).toEqual({}); + expect(helpers.sanitizeSettingsUpdate({ enterToSend: 1 })).toEqual({}); + expect(helpers.sanitizeSettingsUpdate({ enterToSendConfigured: 'true' })).toEqual({}); + expect(helpers.sanitizeSettingsUpdate({ enterToSendConfigured: 1 })).toEqual({}); + }); + it('accepts dismissed OpenCode update toast version as a persisted shared setting', () => { const helpers = createTestHelpers();