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:
committed by
GitHub
co-authored by
Bohdan Triapitsyn
parent
7ea24e3c50
commit
2bdd9af90a
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -724,6 +724,8 @@ export interface SettingsPayload {
|
||||
queueModeEnabled?: boolean;
|
||||
gitmojiEnabled?: boolean;
|
||||
inputSpellcheckEnabled?: boolean;
|
||||
enterToSend?: boolean;
|
||||
enterToSendConfigured?: boolean;
|
||||
showOpenCodeUpdateNotifications?: boolean;
|
||||
openCodeUpdateToastDismissedVersion?: string;
|
||||
showToolFileIcons?: boolean;
|
||||
|
||||
@@ -159,6 +159,8 @@ export type DesktopSettings = {
|
||||
desktopWindowControlsPosition?: DesktopWindowControlsPosition;
|
||||
desktopWindowControlsStyle?: DesktopWindowControlsStyle;
|
||||
inputSpellcheckEnabled?: boolean;
|
||||
enterToSend?: boolean;
|
||||
enterToSendConfigured?: boolean;
|
||||
showOpenCodeUpdateNotifications?: boolean;
|
||||
agentControlToolEnabled?: boolean;
|
||||
agentWebToolEnabled?: boolean;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': 'フォーカスモードの切り替え',
|
||||
|
||||
@@ -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': '집중 모드 전환',
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': '切換專注模式',
|
||||
|
||||
@@ -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'), {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user