fix(autocomplete): support slash commands after non-ASCII prefix and multiple slashes (#3924)

This commit is contained in:
Rod Boev
2026-06-14 14:54:18 -04:00
committed by nesquena-hermes
parent db9803946f
commit fcaab02e93
3 changed files with 170 additions and 8 deletions
+3 -2
View File
@@ -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();
}
+33 -6
View File
@@ -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<text.length;i++){
if(text[i]==='/'){
// Start of line = token-initial
if(i===0) return i;
// After whitespace = token-initial
if(/\s/.test(text[i-1])){
// Exclude ~/ path tokens
if(i+1<text.length&&text[i+1]==='~') continue;
return i;
}
}
}
return -1;
}
function _parseSlashAutocomplete(text){
if(!text.startsWith('/')||text.indexOf('\n')!==-1) return null;
const raw=text.slice(1);
const slashIdx=_activeSlashCommandOffset(text);
if(slashIdx<0) return null;
const raw=text.slice(slashIdx+1);
const hasSpace=/\s/.test(raw);
const parts=raw.split(/\s+/);
const cmdName=(parts[0]||'').toLowerCase();
@@ -1621,7 +1644,7 @@ async function getBundleCommandMetadata(name){
function refreshSlashCommandDropdown(){
const ta=$('msg');if(!ta)return;
const text=ta.value||'';
if(!text.startsWith('/')||text.indexOf('\n')!==-1){hideCmdDropdown();return;}
if(text.indexOf('\n')!==-1||_activeSlashCommandOffset(text)<0){hideCmdDropdown();return;}
getSlashAutocompleteMatches(text).then(matches=>{
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;
@@ -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)"
)