diff --git a/api/config.py b/api/config.py index b5c2130e..792a30b6 100644 --- a/api/config.py +++ b/api/config.py @@ -7791,7 +7791,7 @@ def _get_session_agent_lock(session_id: str) -> threading.Lock: _SETTINGS_DEFAULTS = { "default_workspace": str(DEFAULT_WORKSPACE), "onboarding_completed": False, - "send_key": "enter", # 'enter' or 'ctrl+enter' + "send_key": "enter", # 'enter', 'ctrl+enter', or 'shift+enter' "show_token_usage": False, # show input/output token badge below assistant messages "show_quota_chip": False, # show ambient provider quota chip in composer footer (default off; wide desktop only when enabled, see style.css @media) "show_conversation_outline": False, # show opt-in desktop jump-to-question outline panel @@ -8019,7 +8019,7 @@ _SETTINGS_ALLOWED_KEYS = set(_SETTINGS_DEFAULTS.keys()) - { "simplified_tool_calling", } _SETTINGS_ENUM_VALUES = { - "send_key": {"enter", "ctrl+enter"}, + "send_key": {"enter", "ctrl+enter", "shift+enter"}, "sidebar_density": {"compact", "detailed"}, "font_size": {"small", "default", "large", "xlarge"}, "auto_title_refresh_every": {"0", "5", "10", "20"}, diff --git a/static/boot.js b/static/boot.js index a1b9074c..df8a95a0 100644 --- a/static/boot.js +++ b/static/boot.js @@ -1697,6 +1697,9 @@ $('msg').addEventListener('keydown',e=>{ if(e.key==='Escape'){e.preventDefault();e.stopPropagation();hideCmdDropdown();return;} if(e.key==='Enter'&&!e.shiftKey){ if(_isImeEnter(e)){return;} + if(window._sendKey==='shift+enter'){ + return; + } e.preventDefault(); selectCmdDropdownItem(); return; @@ -1710,7 +1713,8 @@ $('msg').addEventListener('keydown',e=>{ // doesn't consistently reduce vv.height by >120px. The pointer media query // pair is a sufficient and more reliable signal for "software keyboard only". // Hardware keyboards on tablets are covered by _hasFinePointerCoexisting. - // The 'ctrl+enter' setting also uses this behavior (Enter = newline). + // The 'ctrl+enter' and 'shift+enter' settings also use this behavior + // (plain Enter = newline). // Users can override in Settings by explicitly choosing 'enter' mode. if(e.key==='Enter'){ if(_isImeEnter(e)){return;} @@ -1718,7 +1722,9 @@ $('msg').addEventListener('keydown',e=>{ const _mobileDefault=matchMedia('(pointer:coarse)').matches &&!_hasFinePointerCoexisting() &&window._sendKey==='enter'; - if(window._sendKey==='ctrl+enter'||_mobileDefault){ + if(window._sendKey==='shift+enter'){ + if(e.shiftKey){e.preventDefault();send();} + } else if(window._sendKey==='ctrl+enter'||_mobileDefault){ if(isNumpadEnter||e.ctrlKey||e.metaKey){e.preventDefault();send();} } else { if(!e.shiftKey){e.preventDefault();send();} diff --git a/static/index.html b/static/index.html index 249e0cd5..50383dac 100644 --- a/static/index.html +++ b/static/index.html @@ -1146,6 +1146,7 @@
diff --git a/tests/test_shift_enter_send_key.py b/tests/test_shift_enter_send_key.py new file mode 100644 index 00000000..bb2ab188 --- /dev/null +++ b/tests/test_shift_enter_send_key.py @@ -0,0 +1,54 @@ +"""Keyboard contract for the Shift+Enter send-key preference.""" + +from pathlib import Path + +REPO = Path(__file__).resolve().parents[1] +BOOT_JS = (REPO / "static" / "boot.js").read_text(encoding="utf-8") +INDEX_HTML = (REPO / "static" / "index.html").read_text(encoding="utf-8") +CONFIG_PY = (REPO / "api" / "config.py").read_text(encoding="utf-8") + + +def _listener_body() -> str: + signature = "$('msg').addEventListener('keydown',e=>" + start = BOOT_JS.index(signature) + brace = BOOT_JS.index("{", start) + depth = 0 + for idx in range(brace, len(BOOT_JS)): + char = BOOT_JS[idx] + if char == "{": + depth += 1 + elif char == "}": + depth -= 1 + if depth == 0: + return BOOT_JS[start : idx + 1] + raise AssertionError("could not extract composer keydown listener") + + +def test_settings_expose_shift_enter_send_key_option(): + assert '' in INDEX_HTML + assert '"send_key": {"enter", "ctrl+enter", "shift+enter"}' in CONFIG_PY + + +def test_shift_enter_mode_sends_only_with_shift_enter(): + handler = _listener_body() + shift_branch = handler.split("if(window._sendKey==='shift+enter')", 1)[1] + shift_branch = shift_branch.split("} else if(window._sendKey==='ctrl+enter'||_mobileDefault)", 1)[0] + assert "if(e.shiftKey){e.preventDefault();send();}" in shift_branch + assert "if(!e.shiftKey){e.preventDefault();send();}" not in shift_branch + + +def test_shift_enter_mode_leaves_plain_enter_to_textarea_when_autocomplete_open(): + handler = _listener_body() + dropdown_enter = handler[ + handler.index("if(e.key==='Enter'&&!e.shiftKey)") : + handler.index("// Send key: respect user preference.") + ] + assert "if(window._sendKey==='shift+enter')" in dropdown_enter + guarded = dropdown_enter.split("if(window._sendKey==='shift+enter')", 1)[1].split("e.preventDefault();", 1)[0] + assert "return;" in guarded + + +def test_existing_enter_and_ctrl_enter_modes_stay_available(): + handler = _listener_body() + assert "if(!e.shiftKey){e.preventDefault();send();}" in handler + assert "if(isNumpadEnter||e.ctrlKey||e.metaKey){e.preventDefault();send();}" in handler diff --git a/tests/test_sprint17.py b/tests/test_sprint17.py index 9188f34a..e59548e6 100644 --- a/tests/test_sprint17.py +++ b/tests/test_sprint17.py @@ -47,6 +47,12 @@ def test_settings_save_send_key(): # Verify it persisted data, _ = get("/api/settings") assert data["send_key"] == "ctrl+enter" + # Save shift+enter + _, status = post("/api/settings", {"send_key": "shift+enter"}) + assert status == 200 + # Verify it persisted + data, _ = get("/api/settings") + assert data["send_key"] == "shift+enter" finally: # Always restore default post("/api/settings", {"send_key": "enter"})