diff --git a/api/config.py b/api/config.py
index 647010ca..5f85c595 100644
--- a/api/config.py
+++ b/api/config.py
@@ -7792,7 +7792,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
@@ -8022,7 +8022,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 f3040de2..79695372 100644
--- a/static/boot.js
+++ b/static/boot.js
@@ -1737,6 +1737,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;
@@ -1750,7 +1753,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;}
@@ -1758,7 +1762,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 dd8ed616..0057db11 100644
--- a/static/index.html
+++ b/static/index.html
@@ -1153,6 +1153,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"})