diff --git a/static/boot.js b/static/boot.js index 720c73f0a..df0132cd1 100644 --- a/static/boot.js +++ b/static/boot.js @@ -1352,14 +1352,15 @@ $('msg').addEventListener('input',()=>{ _saveComposerDraft(sid, $('msg').value, S.pendingFiles ? [...S.pendingFiles] : []); } const text=$('msg').value; - if(text.startsWith('/')&&text.indexOf('\n')===-1){ + const _slashIdx=typeof _activeSlashCommandOffset==='function'?_activeSlashCommandOffset(text):-1; + if(_slashIdx>=0&&text.indexOf('\n')===-1){ if(typeof getSlashAutocompleteMatches==='function'){ getSlashAutocompleteMatches(text).then(matches=>{ if(($('msg').value||'')!==text) return; if(matches.length)showCmdDropdown(matches); else hideCmdDropdown(); }); }else{ - const prefix=text.slice(1); + const prefix=text.slice(_slashIdx+1); const matches=getMatchingCommands(prefix); if(matches.length)showCmdDropdown(matches); else hideCmdDropdown(); } diff --git a/static/commands.js b/static/commands.js index d62efd414..2cd0c6adc 100644 --- a/static/commands.js +++ b/static/commands.js @@ -319,9 +319,32 @@ async function resolveBundleCommand(text,_meta){ }); } +function _activeSlashCommandOffset(text){ + // Find the offset of the slash that begins the active command token. + // A command token starts with / at the beginning of the line or after + // whitespace. Excludes ~/ path tokens and mid-word slashes (URLs, + // provider/model IDs like openrouter/deepseek). + if(!text||text.indexOf('\n')!==-1) return -1; + // Scan for / at a token-initial position + for(let i=0;i{ if(($('msg').value||'')!==text) return; if(matches.length)showCmdDropdown(matches);else hideCmdDropdown(); @@ -1690,9 +1713,13 @@ function showCmdDropdown(matches){ hideCmdDropdown(); return; } - const nextValue=isSubArg?('/'+c.parent+' '+c.value):('/'+c.name+(c.arg?' ':'')); - $('msg').value=nextValue; - $('msg').focus(); + const _ta=$('msg'); + const _cur=String(_ta&&_ta.value||''); + const _slashIdx=_activeSlashCommandOffset(_cur); + const _prefix=_slashIdx>=0?_cur.slice(0,_slashIdx):''; + const nextValue=_prefix+(isSubArg?('/'+c.parent+' '+c.value):('/'+c.name+(c.arg?' ':''))); + if(_ta)_ta.value=nextValue; + if(_ta)_ta.focus(); if(!isSubArg&&c.source!=='skill'&&c.source!=='bundle'&&nextValue.endsWith(' ')&&typeof getSlashAutocompleteMatches==='function'){ getSlashAutocompleteMatches(nextValue).then(matches=>{ if(($('msg').value||'')!==nextValue) return; diff --git a/tests/test_issue3924_cjk_slash_autocomplete.py b/tests/test_issue3924_cjk_slash_autocomplete.py new file mode 100644 index 000000000..3ce6aa225 --- /dev/null +++ b/tests/test_issue3924_cjk_slash_autocomplete.py @@ -0,0 +1,134 @@ +"""Tests for #3924 — CJK text before / should not block slash autocomplete. + +The autocomplete trigger previously gated on text.startsWith('/') which +fails when CJK (or any non-/ text) precedes the slash. The fix introduces +_activeSlashCommandOffset() which finds the / at a token-initial position +(start of line or after whitespace), excluding ~/ path tokens and mid-word +slashes (URLs, provider/model IDs). +""" +import os +import re + + +_SRC = os.path.join(os.path.dirname(__file__), "..") + + +def _read(name): + return open(os.path.join(_SRC, name), encoding="utf-8").read() + + +class TestActiveSlashCommandOffset: + """_activeSlashCommandOffset must exist and use token-initial logic.""" + + def test_helper_exists(self): + js = _read("static/commands.js") + assert "function _activeSlashCommandOffset(text)" in js + + def test_helper_checks_token_initial_position(self): + js = _read("static/commands.js") + m = re.search( + r'function _activeSlashCommandOffset\(text\)\{(.*?)\n\}', + js, re.DOTALL, + ) + assert m, "_activeSlashCommandOffset not found" + fn = m.group(0) + # Must check for start-of-line (i===0) or after whitespace + assert "i===0" in fn or "i === 0" in fn, ( + "_activeSlashCommandOffset must check start-of-line position" + ) + assert "text[i-1]" in fn, ( + "_activeSlashCommandOffset must check character before /" + ) + + def test_helper_excludes_path_tokens(self): + js = _read("static/commands.js") + m = re.search( + r'function _activeSlashCommandOffset\(text\)\{(.*?)\n\}', + js, re.DOTALL, + ) + assert m + fn = m.group(0) + # Must exclude ~/ path tokens + assert "~" in fn, ( + "_activeSlashCommandOffset must exclude ~/ path tokens" + ) + + +class TestParseSlashAutocompleteUsesHelper: + """_parseSlashAutocomplete must delegate to _activeSlashCommandOffset.""" + + def test_delegates_to_helper(self): + js = _read("static/commands.js") + m = re.search( + r'function _parseSlashAutocomplete\(text\)\{(.*?)\n\}', + js, re.DOTALL, + ) + assert m, "_parseSlashAutocomplete not found" + fn = m.group(0) + assert "_activeSlashCommandOffset" in fn, ( + "_parseSlashAutocomplete must use _activeSlashCommandOffset" + ) + + def test_does_not_use_startswith(self): + js = _read("static/commands.js") + m = re.search( + r'function _parseSlashAutocomplete\(text\)\{(.*?)\n\}', + js, re.DOTALL, + ) + assert m + fn = m.group(0) + assert "startsWith('/')" not in fn, ( + "_parseSlashAutocomplete must not gate on startsWith('/')" + ) + + +class TestRefreshSlashCommandDropdownUsesHelper: + """refreshSlashCommandDropdown must delegate to _activeSlashCommandOffset.""" + + def test_delegates_to_helper(self): + js = _read("static/commands.js") + m = re.search( + r'function refreshSlashCommandDropdown\(\)\{(.*?)\n\}', + js, re.DOTALL, + ) + assert m, "refreshSlashCommandDropdown not found" + fn = m.group(0) + assert "_activeSlashCommandOffset" in fn, ( + "refreshSlashCommandDropdown must use _activeSlashCommandOffset" + ) + + +class TestBootJsSlashTrigger: + """boot.js input handler must use _activeSlashCommandOffset.""" + + def test_boot_uses_helper(self): + js = _read("static/boot.js") + assert "_activeSlashCommandOffset" in js, ( + "boot.js must use _activeSlashCommandOffset for the slash " + "autocomplete trigger (#3924)" + ) + + def test_boot_falls_back_to_path_autocomplete(self): + js = _read("static/boot.js") + # The path autocomplete else-if must still be present + assert "getComposerPathAutocompleteMatches" in js, ( + "boot.js must preserve path autocomplete fallback" + ) + + +class TestSelectionHandlerPreservesPrefix: + """When an autocomplete item is selected, prefix text before the active + slash must be preserved rather than discarded.""" + + def test_selection_uses_helper(self): + js = _read("static/commands.js") + m = re.search( + r'function showCmdDropdown\(matches\)\{(.*?)\nfunction hideCmdDropdown', + js, re.DOTALL, + ) + assert m, "showCmdDropdown not found" + fn = m.group(0) + assert "_activeSlashCommandOffset" in fn, ( + "showCmdDropdown selection handler must use _activeSlashCommandOffset " + "to preserve prefix text before the slash (#3924)" + )