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 <artmore@protonmail.com>
This commit is contained in:
Muhammad Zaim
2026-09-05 19:07:28 +03:00
committed by GitHub
co-authored by Bohdan Triapitsyn
parent 7ea24e3c50
commit 2bdd9af90a
31 changed files with 368 additions and 39 deletions
+16 -8
View File
@@ -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<ChatInputProps> = ({
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<ChatInputProps> = ({
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<ChatInputProps> = ({
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}
@@ -218,3 +218,18 @@ on the strength of type-check and unit tests.
Run tests per file (`bun test <path>`): `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.
@@ -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<ComposerEditorHandle, ComposerEdi
const hostRef = React.useRef<HTMLDivElement | null>(null);
const viewRef = React.useRef<EditorView | null>(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<number | null>(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<ComposerEditorHandle, ComposerEdi
const interceptKeys: KeyBinding[] = [{
any: (_view, event) => {
// 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<ComposerEditorHandle, ComposerEdi
viewRef.current = view;
if (store) store.view = view;
// CodeMirror defers Enter on iOS (and Chrome Android): the real
// keydown is captured without running the keymaps, the browser's
// native newline goes through, and the keymaps then run against a
// synthetic keydown `dispatchKey` builds from the key name alone —
// which has NO modifiers. Recording the real shift state here (a
// plain listener, registered after CodeMirror's own, so it runs
// after the deferral decision but before the deferred dispatch)
// lets the deferred Enter be re-presented with Shift+Enter intact
// instead of arriving as a plain Enter that "sends" where Enter
// sends. Without it, Shift+Enter on iOS/Android submits the
// message instead of inserting a newline. The listener lives on
// Record the real modifier state after CodeMirror decides to defer
// the event, before its synthetic dispatch. The state expires if
// CodeMirror never dispatches the replacement, so a later Android
// keyboard Enter cannot inherit an unrelated earlier keydown. The listener lives on
// the kept-alive view's contentDOM, so it stays across mounts and
// keeps feeding the same ref the `interceptKeys` closure reads.
const trackRealEnterShift = (event: KeyboardEvent) => {
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.
@@ -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>): EnterKeyPolicyInput => ({
isMobile: false,
isDesktopExpanded: false,
enterToSend: false,
enterToSendConfigured: false,
shiftKey: false,
ctrlKey: false,
metaKey: false,
...overrides,
});
const enterPolicyCases: Array<[string, Partial<EnterKeyPolicyInput>, 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 });
});
});
@@ -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 });
};
@@ -210,6 +210,7 @@ const ChatSectionContent: React.FC = () => {
'persistDraft',
'inputSpellcheck',
'largeTextPaste',
'enterToSend',
]}
/>
);
@@ -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<OpenChamberVisualSettingsProps>
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<OpenChamberVisualSettingsProps>
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<OpenChamberVisualSettingsProps>
|| 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<OpenChamberVisualSettingsProps>
|| 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<OpenChamberVisualSettingsProps>
/>
))}
</SettingsRadioGroup>
</SettingsControlGroup>
</SettingsControlGroup>
)}
<div className={SETTINGS_OPTION_STACK_CLASS}>
{shouldShow('autoSaveEnabled') && (
@@ -2001,7 +2014,7 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
</SettingsSection>
)}
{(shouldShow('persistDraft') || shouldShow('largeTextPaste') || (!isMobile && shouldShow('inputSpellcheck'))) && (
{(shouldShow('persistDraft') || shouldShow('largeTextPaste') || (!isMobile && shouldShow('inputSpellcheck')) || shouldShow('enterToSend')) && (
<SettingsSection
title={t('settings.openchamber.visual.section.composer')}
settingsItem="chat.composer"
@@ -2044,7 +2057,17 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
/>
))}
</SettingsRadioGroup>
</SettingsControlGroup>
</SettingsControlGroup>
)}
{shouldShow('enterToSend') && (
<SettingsCheckboxRow
checked={enterToSendConfigured ? enterToSend : !isMobile && !isExpandedInput}
onChange={handleEnterToSendChange}
label={t('chat.chatInput.actions.enterToSend')}
info={t('chat.chatInput.actions.enterToSendHint')}
ariaLabel={t('chat.chatInput.actions.enterToSend')}
settingsItem="chat.enter-to-send"
/>
)}
</SettingsSection>
)}
+2
View File
@@ -724,6 +724,8 @@ export interface SettingsPayload {
queueModeEnabled?: boolean;
gitmojiEnabled?: boolean;
inputSpellcheckEnabled?: boolean;
enterToSend?: boolean;
enterToSendConfigured?: boolean;
showOpenCodeUpdateNotifications?: boolean;
openCodeUpdateToastDismissedVersion?: string;
showToolFileIcons?: boolean;
+2
View File
@@ -159,6 +159,8 @@ export type DesktopSettings = {
desktopWindowControlsPosition?: DesktopWindowControlsPosition;
desktopWindowControlsStyle?: DesktopWindowControlsStyle;
inputSpellcheckEnabled?: boolean;
enterToSend?: boolean;
enterToSendConfigured?: boolean;
showOpenCodeUpdateNotifications?: boolean;
agentControlToolEnabled?: boolean;
agentWebToolEnabled?: boolean;
+2
View File
@@ -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',
+2
View File
@@ -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',
+2
View File
@@ -2300,6 +2300,8 @@ export const dict: Record<I18nKey, string> = {
"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",
+2
View File
@@ -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',
+2
View File
@@ -2318,6 +2318,8 @@ export const dict: Record<I18nKey, string> = {
'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': 'フォーカスモードの切り替え',
+2
View File
@@ -2322,6 +2322,8 @@ export const dict: Record<I18nKey, string> = {
'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': '집중 모드 전환',
+2
View File
@@ -1255,6 +1255,8 @@ export const dict: Record<I18nKey, string> = {
'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',
@@ -2300,6 +2300,8 @@ export const dict: Record<I18nKey, string> = {
"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",
+2
View File
@@ -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',
+2
View File
@@ -2300,6 +2300,8 @@ export const dict: Record<I18nKey, string> = {
"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": "Перемкнути режим фокусування",
@@ -2288,6 +2288,8 @@ export const dict: Record<I18nKey, string> = {
'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': '切换专注模式',
@@ -2292,6 +2292,8 @@ export const dict: Record<I18nKey, string> = {
'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': '切換專注模式',
+10
View File
@@ -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'), {
+18
View File
@@ -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;
}
+6
View File
@@ -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',
+14
View File
@@ -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<UIStore>()(
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<UIStore>()(
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<UIStore>()(
projectContextSidebarWidth: state.projectContextSidebarWidth,
inputSpellcheckEnabled: state.inputSpellcheckEnabled,
largeTextPasteBehavior: state.largeTextPasteBehavior,
enterToSend: state.enterToSend,
enterToSendConfigured: state.enterToSendConfigured,
wideChatLayoutEnabled: state.wideChatLayoutEnabled,
codeBlockLineWrap: state.codeBlockLineWrap,
showToolFileIcons: state.showToolFileIcons,
+1
View File
@@ -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.
@@ -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<string, unknown> => {
export const persistSettings = async (changes: Record<string, unknown>, ctx?: BridgeContext): Promise<Record<string, unknown>> => {
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<string>();
@@ -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({});
});
});
+8
View File
@@ -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),
});
@@ -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;
}
@@ -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();