mirror of
https://github.com/nesquena/hermes-webui.git
synced 2026-07-16 20:50:18 +00:00
fix(autocomplete): support slash commands after non-ASCII prefix and multiple slashes (#3924)
This commit is contained in:
committed by
nesquena-hermes
parent
db9803946f
commit
fcaab02e93
+3
-2
@@ -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
@@ -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)"
|
||||
)
|
||||
Reference in New Issue
Block a user