throwing shit at the wall for /: autocomplete

This commit is contained in:
LenAnderson
2024-04-12 21:43:27 -04:00
parent 18f7b29536
commit 2e1a7b5811
8 changed files with 152 additions and 44 deletions

View File

@ -2393,7 +2393,7 @@ async function processCommands(message) {
}
const previousText = String($('#send_textarea').val());
const result = await executeSlashCommands(message);
const result = await executeSlashCommands(message, true, null, true);
if (!result || typeof result !== 'object') {
return false;

View File

@ -1757,7 +1757,7 @@ function modelCallback(_, model) {
* @param {SlashCommandScope} scope The scope to be used when executing the commands.
* @returns {Promise<SlashCommandClosureResult>}
*/
async function executeSlashCommands(text, handleParserErrors = true, scope = null) {
async function executeSlashCommands(text, handleParserErrors = true, scope = null, handleExecutionErrors = false) {
if (!text) {
return null;
}
@ -1780,13 +1780,26 @@ async function executeSlashCommands(text, handleParserErrors = true, scope = nul
'SlashCommandParserError',
{ escapeHtml:false, timeOut: 10000, onclick:()=>callPopup(toast, 'text') },
);
return;
const result = new SlashCommandClosureResult();
result.interrupt = true;
return result;
} else {
throw e;
}
}
try {
return await closure.execute();
} catch (e) {
if (handleExecutionErrors) {
toastr.error(e.message);
const result = new SlashCommandClosureResult();
result.interrupt = true;
return result;
} else {
throw e;
}
}
}
/**
@ -1794,7 +1807,7 @@ async function executeSlashCommands(text, handleParserErrors = true, scope = nul
* @param {HTMLTextAreaElement} textarea The textarea to receive autocomplete
* @param {Boolean} isFloating Whether to show the auto complete as a floating window (e.g., large QR editor)
*/
export function setSlashCommandAutoComplete(textarea, isFloating = false) {
export async function setSlashCommandAutoComplete(textarea, isFloating = false) {
const dom = document.createElement('ul'); {
dom.classList.add('slashCommandAutoComplete');
}
@ -1803,13 +1816,16 @@ export function setSlashCommandAutoComplete(textarea, isFloating = false) {
let selectedItem = null;
let isActive = false;
let text;
/**@type {SlashCommandExecutor}*/
let executor;
let clone;
let startQuote;
let endQuote;
const hide = () => {
dom?.remove();
isActive = false;
};
const show = (isInput = false, isForced = false) => {
const show = async(isInput = false, isForced = false) => {
//TODO check if isInput and isForced are both required
text = textarea.value;
// only show with textarea in focus
@ -1819,16 +1835,47 @@ export function setSlashCommandAutoComplete(textarea, isFloating = false) {
// request parser to get command executor (potentially "incomplete", i.e. not an actual existing command) for
// cursor position
executor = parser.getCommandAt(text, textarea.selectionStart);
let slashCommand = executor?.name?.toLowerCase() ?? '';
const parserResult = parser.getCommandAt(text, textarea.selectionStart);
let run;
let options;
if (Array.isArray(parserResult)) {
executor = null;
run = parserResult[0];
options = parserResult[1];
startQuote = text[run.start - 2] == '"';
endQuote = startQuote && text[run.start - 2 + run.value.length + 1] == '"';
try {
const qrApi = (await import('./extensions/quick-reply/index.js')).quickReplyApi;
options.push(...qrApi.listSets().map(set=>qrApi.listQuickReplies(set).map(qr=>`${set}.${qr}`)).flat());
} catch { /* empty */ }
} else {
executor = parserResult;
}
let slashCommand = run ? run.value?.toLowerCase() : executor?.name?.toLowerCase() ?? '';
// do autocomplete if triggered by a user input and we either don't have an executor or the cursor is at the end
// of the name part of the command
isReplacable = isInput && (!executor ? true : textarea.selectionStart == executor.start - 2 + executor.name.length + 1);
if (options) {
isReplacable = isInput && (!run.value ? true : textarea.selectionStart == run.start - 2 + run.value.length + (startQuote ? 1 : 0));
} else {
isReplacable = isInput && (!executor ? true : textarea.selectionStart == executor.start - 2 + executor.name.length);
}
// if forced (ctrl+space) or user input and cursor is in the middle of the name part (not at the end)
if ((isForced || isInput) && executor && textarea.selectionStart > executor.start - 2 && textarea.selectionStart <= executor.start - 2 + executor.name.length + 1) {
slashCommand = slashCommand.slice(0, textarea.selectionStart - (executor.start - 2) - 1);
if ((isForced || isInput)
&& (
((executor) && textarea.selectionStart >= executor.start - 2 && textarea.selectionStart <= executor.start - 2 + executor.name.length)
||
((run) && textarea.selectionStart >= run.start - 2 && textarea.selectionStart <= run.start - 2 + run.value.length + (startQuote ? 1 : 0))
)
){
if (run) {
slashCommand = slashCommand.slice(0, textarea.selectionStart - (run.start - 2) - (startQuote ? 1 : 0));
run.value = slashCommand;
run.end = run.start + slashCommand.length;
} else {
slashCommand = slashCommand.slice(0, textarea.selectionStart - (executor.start - 2));
executor.name = slashCommand;
executor.end = executor.start + slashCommand.length;
}
isReplacable = true;
}
@ -1874,14 +1921,14 @@ export function setSlashCommandAutoComplete(textarea, isFloating = false) {
if (a.score.longestConsecutive < b.score.longestConsecutive) return 1;
return a.name.localeCompare(b.name);
};
const buildHelpStringName = (name) => {
const buildHelpStringName = (name, noSlash=false) => {
switch (matchType) {
case 'strict': {
return `<span class="monospace">/<span class="matched">${name.slice(0, slashCommand.length)}</span>${name.slice(slashCommand.length)}</span> `;
return `<span class="monospace">${noSlash?'':'/'}<span class="matched">${name.slice(0, slashCommand.length)}</span>${name.slice(slashCommand.length)}</span> `;
}
case 'includes': {
const start = name.toLowerCase().search(slashCommand);
return `<span class="monospace">/${name.slice(0, start)}<span class="matched">${name.slice(start, start + slashCommand.length)}</span>${name.slice(start + slashCommand.length)}</span> `;
return `<span class="monospace">${noSlash?'':'/'}${name.slice(0, start)}<span class="matched">${name.slice(start, start + slashCommand.length)}</span>${name.slice(start + slashCommand.length)}</span> `;
}
case 'fuzzy': {
const matched = name.replace(fuzzyRegex, (_, ...parts)=>{
@ -1897,21 +1944,29 @@ export function setSlashCommandAutoComplete(textarea, isFloating = false) {
return it;
}).join('');
});
return `<span class="monospace">/${matched}</span> `;
return `<span class="monospace">${noSlash?'':'/'}${matched}</span> `;
}
}
};
// don't show if no executor found, i.e. cursor's area is not a command
if (!executor) return hide();
if (!executor && !options) return hide();
else {
const helpStrings = Object
.keys(parser.commands) // Get all slash commands
.filter(it => executor.name == '' || isReplacable ? matchers[matchType](it) : it.toLowerCase() == slashCommand) // Filter by the input
const helpStrings = (options ?? Object.keys(parser.commands)) // Get all slash commands
.filter(it => run?.value == '' || executor?.name == '' || isReplacable ? matchers[matchType](it) : it.toLowerCase() == slashCommand) // Filter by the input
;
result = helpStrings
.filter((it,idx)=>[idx, -1].includes(helpStrings.indexOf(parser.commands[it].name.toLowerCase()))) // remove duplicates
.filter((it,idx)=>options || [idx, -1].includes(helpStrings.indexOf(parser.commands[it].name.toLowerCase()))) // remove duplicates
.map(name => {
if (options) {
return {
name: name,
label: `<span class="type monospace">${name.includes('.')?'QR':'𝑥'}</span> ${buildHelpStringName(name, true)}`,
value: name.includes(' ') || startQuote || endQuote ? `"${name}"` : `${name}`,
score: matchType == 'fuzzy' ? fuzzyScore(name) : null,
li: null,
};
}
const cmd = parser.commands[name];
let aliases = '';
if (cmd.aliases?.length > 0) {
@ -1926,7 +1981,7 @@ export function setSlashCommandAutoComplete(textarea, isFloating = false) {
return {
name: name,
label: `${buildHelpStringName(name)}${cmd.helpString}${aliases}`,
value: `/${name}`,
value: `${name}`,
score: matchType == 'fuzzy' ? fuzzyScore(name) : null,
li: null,
};
@ -1941,16 +1996,30 @@ export function setSlashCommandAutoComplete(textarea, isFloating = false) {
return hide();
}
// otherwise add "no match" notice
if (options) {
result.push({
name: '',
label: `No matching commands for "/${slashCommand}"`,
value:'',
label: slashCommand.length ?
`No matching variables in scope for "${slashCommand}"`
: 'No variables in scope.',
value: null,
score: null,
li: null,
});
} else if (result.length == 1 && result[0].value == `/${executor.name}`) {
} else {
result.push({
name: '',
label: `No matching commands for "/${slashCommand}"`,
value: null,
score: null,
li: null,
});
}
} else if (result.length == 1 && ((executor && result[0].value == `/${executor.name}`) || (options && result[0].value == run.value))) {
// only one result that is exactly the current value? just show hint, no autocomplete
isReplacable = false;
} else if (!isReplacable && result.length > 1) {
return hide();
}
// render autocomplete list
@ -2001,6 +2070,9 @@ export function setSlashCommandAutoComplete(textarea, isFloating = false) {
updatePosition();
document.body.append(dom);
isActive = true;
if (options) {
executor = {start:run.start, name:`${slashCommand}`};
}
};
const updatePosition = () => {
if (isFloating) {
@ -2074,8 +2146,8 @@ export function setSlashCommandAutoComplete(textarea, isFloating = false) {
};
let pointerup = Promise.resolve();
const select = async() => {
if (isReplacable) {
textarea.value = `${text.slice(0, executor.start - 2)}${selectedItem.value}${text.slice(executor.start - 2 + executor.name.length + 1)}`;
if (isReplacable && selectedItem.value !== null) {
textarea.value = `${text.slice(0, executor.start - 2)}${selectedItem.value}${text.slice(executor.start - 2 + executor.name.length + (startQuote ? 1 : 0) + (endQuote ? 1 : 0))}`;
await pointerup;
textarea.focus();
textarea.selectionStart = executor.start - 2 + selectedItem.value.length;

View File

@ -8,9 +8,9 @@ export class SlashCommandClosure {
/**@type {SlashCommandScope}*/ scope;
/**@type {Boolean}*/ executeNow = false;
// @ts-ignore
/**@type {Map<string,string|SlashCommandClosure>}*/ arguments = {};
/**@type {Object.<string,string|SlashCommandClosure>}*/ arguments = {};
// @ts-ignore
/**@type {Map<string,string|SlashCommandClosure>}*/ providedArguments = {};
/**@type {Object.<string,string|SlashCommandClosure>}*/ providedArguments = {};
/**@type {SlashCommandExecutor[]}*/ executorList = [];
/**@type {String}*/ keptText;

View File

@ -1,5 +1,5 @@
export class SlashCommandClosureExecutor {
/**@type {String}*/ name = '';
// @ts-ignore
/**@type {Map<string,string|SlashCommandClosure>}*/ providedArguments = {};
/**@type {Object.<string,string|SlashCommandClosure>}*/ providedArguments = {};
}

View File

@ -10,7 +10,7 @@ export class SlashCommandExecutor {
/**@type {String}*/ name = '';
/**@type {SlashCommand}*/ command;
// @ts-ignore
/**@type {Map<String,String|SlashCommandClosure>}*/ args = {};
/**@type {Object.<string,String|SlashCommandClosure>}*/ args = {};
/**@type {String|SlashCommandClosure|(String|SlashCommandClosure)[]}*/ value;
constructor(start) {

View File

@ -18,6 +18,7 @@ export class SlashCommandParser {
/**@type {SlashCommandScope}*/ scope;
/**@type {SlashCommandExecutor[]}*/ commandIndex;
/**@type {SlashCommandScope[]}*/ scopeIndex;
get ahead() {
return this.text.slice(this.index + 1);
@ -167,6 +168,12 @@ export class SlashCommandParser {
<li>This will remove the first message in chat, send a system message that starts with 'Hello,', and then ask the AI to continue the message.</li></ul>`;
}
/**
*
* @param {*} text
* @param {*} index
* @returns {SlashCommandExecutor|String[]}
*/
getCommandAt(text, index) {
try {
this.parse(text, false);
@ -180,7 +187,8 @@ export class SlashCommandParser {
.slice(-1)[0]
?? null
;
if (executor && executor.name == ':') return null;
const scope = this.scopeIndex[this.commandIndex.indexOf(executor)];
if (executor && executor.name == ':') return [executor, scope?.allVariableNames];
return executor;
}
@ -205,6 +213,7 @@ export class SlashCommandParser {
this.index = 0;
this.scope = null;
this.commandIndex = [];
this.scopeIndex = [];
const closure = this.parseClosure();
closure.keptText = this.keptText;
return closure;
@ -226,6 +235,7 @@ export class SlashCommandParser {
while (this.testNamedArgument()) {
const arg = this.parseNamedArgument();
closure.arguments[arg.key] = arg.value;
this.scope.variableNames.push(arg.key);
this.discardWhitespace();
}
while (!this.testClosureEnd()) {
@ -269,12 +279,13 @@ export class SlashCommandParser {
return this.testCommandEnd();
}
parseRunShorthand() {
const start = this.index;
const start = this.index + 2;
const cmd = new SlashCommandExecutor(start);
cmd.name = ':';
cmd.value = '';
cmd.command = this.commands[cmd.name];
cmd.command = this.commands['run'];
this.commandIndex.push(cmd);
this.scopeIndex.push(this.scope.getCopy());
this.take(2); //discard "/:"
if (this.testQuotedValue()) cmd.value = this.parseQuotedValue();
else cmd.value = this.parseValue();
@ -303,9 +314,10 @@ export class SlashCommandParser {
return this.testClosureEnd() || this.endOfText || (this.char == '|' && this.behind.slice(-1) != '\\');
}
parseCommand() {
const start = this.index;
const start = this.index + 1;
const cmd = new SlashCommandExecutor(start);
this.commandIndex.push(cmd);
this.scopeIndex.push(this.scope.getCopy());
this.take(); // discard "/"
while (!/\s/.test(this.char) && !this.testCommandEnd()) cmd.name += this.take(); // take chars until whitespace or end
this.discardWhitespace();
@ -319,6 +331,15 @@ export class SlashCommandParser {
this.discardWhitespace();
if (this.testUnnamedArgument()) {
cmd.value = this.parseUnnamedArgument();
if (cmd.name == 'let') {
if (Array.isArray(cmd.value)) {
if (typeof cmd.value[0] == 'string') {
this.scope.variableNames.push(cmd.value[0]);
}
} else if (typeof cmd.value == 'string') {
this.scope.variableNames.push(cmd.value.split(/\s+/)[0]);
}
}
}
if (this.testCommandEnd()) {
cmd.end = this.index;
@ -380,7 +401,7 @@ export class SlashCommandParser {
if (listValues.length == 1) return listValues[0];
return listValues;
}
return value.trim();
return value.trim().replace(/\\([\s{:])/g, '$1');
}
testQuotedValue() {

View File

@ -1,8 +1,13 @@
export class SlashCommandScope {
/**@type {String[]}*/ variableNames = [];
get allVariableNames() {
const names = [...this.variableNames, ...(this.parent?.allVariableNames ?? [])];
return names.filter((it,idx)=>idx == names.indexOf(it));
}
// @ts-ignore
/**@type {Map<String, Object>}*/ variables = {};
/**@type {Object.<string, Object>}*/ variables = {};
// @ts-ignore
/**@type {Map<String, Object>}*/ macros = {};
/**@type {Object.<string, Object>}*/ macros = {};
get macroList() {
return [...Object.keys(this.macros).map(key=>({ key, value:this.macros[key] })), ...(this.parent?.macroList ?? [])];
}
@ -22,6 +27,7 @@ export class SlashCommandScope {
getCopy() {
const scope = new SlashCommandScope(this.parent);
scope.variableNames = [...this.variableNames];
scope.variables = Object.assign({}, this.variables);
scope.macros = this.macros;
scope.#pipe = this.#pipe;

View File

@ -1129,6 +1129,15 @@ select {
background-color: var(--ac-color-selected-background);
color: var(--ac-color-selected-text);
}
> .type {
display: inline-block;
width: 2.75em;
font-size: 0.8em;
text-align: center;
opacity: 0.6;
&:before { content: "["; }
&:after { content: "]"; }
}
.matched {
background-color: var(--ac-color-matched-background);
color: var(--ac-color-matched-text);