fix(autocomplete): make accept key behavior configurable (#863)

This commit is contained in:
Will Miao
2026-03-28 20:21:23 +08:00
parent e5152108ba
commit dcc7bd33b5
3 changed files with 333 additions and 8 deletions

View File

@@ -69,6 +69,9 @@ describe('AutoComplete widget interactions', () => {
if (key === 'loramanager.autocomplete_append_comma') {
return true;
}
if (key === 'loramanager.autocomplete_accept_key') {
return 'both';
}
if (key === 'loramanager.prompt_tag_autocomplete') {
return true;
}
@@ -210,6 +213,157 @@ describe('AutoComplete widget interactions', () => {
expect(insertSelectionSpy).toHaveBeenCalledWith('example_completion');
});
it('prefers the latest best match when Tab is pressed before debounced suggestions fully refresh', async () => {
caretHelperInstance.getBeforeCursor.mockReturnValue('loop');
const input = document.createElement('textarea');
input.value = 'loop';
input.selectionStart = input.value.length;
input.focus = vi.fn();
input.setSelectionRange = vi.fn();
document.body.append(input);
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
const autoComplete = new AutoComplete(input,'prompt', { showPreview: false, minChars: 1 });
autoComplete.searchType = 'custom_words';
autoComplete.items = [
{ tag_name: 'looking_to_the_side', category: 0, post_count: 1000 },
{ tag_name: 'loop', category: 0, post_count: 500 },
];
autoComplete.currentSearchTerm = 'loo';
autoComplete.selectedIndex = 0;
autoComplete.isVisible = true;
const insertSelectionSpy = vi.spyOn(autoComplete,'insertSelection').mockResolvedValue();
const tabEvent = new KeyboardEvent('keydown', { key: 'Tab', bubbles: true, cancelable: true });
input.dispatchEvent(tabEvent);
expect(tabEvent.defaultPrevented).toBe(true);
expect(autoComplete.selectedIndex).toBe(1);
expect(insertSelectionSpy).toHaveBeenCalledWith('loop');
});
it('accepts the first available suggestion with Tab even if delayed auto-selection has not happened yet', async () => {
caretHelperInstance.getBeforeCursor.mockReturnValue('loop');
const input = document.createElement('textarea');
input.value = 'loop';
input.selectionStart = input.value.length;
input.focus = vi.fn();
input.setSelectionRange = vi.fn();
document.body.append(input);
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
const autoComplete = new AutoComplete(input,'custom_words', { showPreview: false });
autoComplete.items = ['loop'];
autoComplete.selectedIndex = -1;
autoComplete.isVisible = true;
const insertSelectionSpy = vi.spyOn(autoComplete,'insertSelection').mockResolvedValue();
const tabEvent = new KeyboardEvent('keydown', { key: 'Tab', bubbles: true, cancelable: true });
input.dispatchEvent(tabEvent);
expect(tabEvent.defaultPrevented).toBe(true);
expect(autoComplete.selectedIndex).toBe(0);
expect(insertSelectionSpy).toHaveBeenCalledWith('loop');
});
it('only accepts with Tab when autocomplete accept key is set to tab_only', async () => {
settingGetMock.mockImplementation((key) => {
if (key === 'loramanager.autocomplete_append_comma') {
return true;
}
if (key === 'loramanager.autocomplete_accept_key') {
return 'tab_only';
}
if (key === 'loramanager.prompt_tag_autocomplete') {
return true;
}
if (key === 'loramanager.tag_space_replacement') {
return false;
}
return undefined;
});
caretHelperInstance.getBeforeCursor.mockReturnValue('example');
const input = document.createElement('textarea');
input.value = 'example';
input.selectionStart = input.value.length;
input.focus = vi.fn();
input.setSelectionRange = vi.fn();
document.body.append(input);
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
const autoComplete = new AutoComplete(input,'custom_words', { showPreview: false });
autoComplete.items = ['example_completion'];
autoComplete.selectedIndex = 0;
autoComplete.isVisible = true;
const insertSelectionSpy = vi.spyOn(autoComplete,'insertSelection').mockResolvedValue();
const enterEvent = new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true });
input.dispatchEvent(enterEvent);
expect(enterEvent.defaultPrevented).toBe(false);
expect(insertSelectionSpy).not.toHaveBeenCalled();
const tabEvent = new KeyboardEvent('keydown', { key: 'Tab', bubbles: true, cancelable: true });
input.dispatchEvent(tabEvent);
expect(tabEvent.defaultPrevented).toBe(true);
expect(insertSelectionSpy).toHaveBeenCalledWith('example_completion');
});
it('only accepts with Enter when autocomplete accept key is set to enter_only', async () => {
settingGetMock.mockImplementation((key) => {
if (key === 'loramanager.autocomplete_append_comma') {
return true;
}
if (key === 'loramanager.autocomplete_accept_key') {
return 'enter_only';
}
if (key === 'loramanager.prompt_tag_autocomplete') {
return true;
}
if (key === 'loramanager.tag_space_replacement') {
return false;
}
return undefined;
});
caretHelperInstance.getBeforeCursor.mockReturnValue('example');
const input = document.createElement('textarea');
input.value = 'example';
input.selectionStart = input.value.length;
input.focus = vi.fn();
input.setSelectionRange = vi.fn();
document.body.append(input);
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
const autoComplete = new AutoComplete(input,'custom_words', { showPreview: false });
autoComplete.items = ['example_completion'];
autoComplete.selectedIndex = 0;
autoComplete.isVisible = true;
const insertSelectionSpy = vi.spyOn(autoComplete,'insertSelection').mockResolvedValue();
const tabEvent = new KeyboardEvent('keydown', { key: 'Tab', bubbles: true, cancelable: true });
input.dispatchEvent(tabEvent);
expect(tabEvent.defaultPrevented).toBe(false);
expect(insertSelectionSpy).not.toHaveBeenCalled();
const enterEvent = new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true });
input.dispatchEvent(enterEvent);
expect(enterEvent.defaultPrevented).toBe(true);
expect(insertSelectionSpy).toHaveBeenCalledWith('example_completion');
});
it('does not intercept Tab when the dropdown is not visible', async () => {
caretHelperInstance.getBeforeCursor.mockReturnValue('example');

View File

@@ -3,6 +3,7 @@ import { app } from "../../scripts/app.js";
import { TextAreaCaretHelper } from "./textarea_caret_helper.js";
import {
getAutocompleteAppendCommaPreference,
getAutocompleteAcceptKeyPreference,
getPromptTagAutocompletePreference,
getTagSpaceReplacementPreference,
} from "./settings.js";
@@ -121,6 +122,24 @@ function formatAutocompleteInsertion(text = '') {
return getAutocompleteAppendCommaPreference() ? `${trimmed},` : `${trimmed} `;
}
function shouldAcceptAutocompleteKey(key) {
const mode = getAutocompleteAcceptKeyPreference();
if (mode === 'tab_only') {
return key === 'Tab';
}
if (mode === 'enter_only') {
return key === 'Enter';
}
return key === 'Tab' || key === 'Enter';
}
function normalizeAutocompleteMatchText(text = '') {
return text.toLowerCase().replace(/[-_\s']/g, '');
}
const AUTOCOMPLETE_METADATA_VERSION = 1;
function createAutocompleteMetadataBase(textWidgetName = 'text') {
@@ -873,6 +892,92 @@ class AutoComplete {
return { matched: false, isExactMatch: false };
}
_getLiveSearchTermForAcceptance() {
const rawSearchTerm = this.getSearchTerm(this.inputElement.value);
if (this.modelType === 'embeddings') {
const match = rawSearchTerm.match(/^emb:(.*)$/i);
return (match?.[1] || '').trim();
}
if (this.modelType === 'prompt') {
const embeddingMatch = rawSearchTerm.match(/^emb:(.*)$/i);
if (embeddingMatch) {
return (embeddingMatch[1] || '').trim();
}
const commandResult = this._parseCommandInput(rawSearchTerm);
return commandResult.searchTerm ?? rawSearchTerm;
}
return rawSearchTerm;
}
_getPreferredSelectedIndex(searchTerm = '') {
if (!this.items?.length) {
return -1;
}
if (this.showingCommands) {
if (this.selectedIndex >= 0 && this.selectedIndex < this.items.length) {
return this.selectedIndex;
}
return 0;
}
const trimmedSearchTerm = searchTerm.trim();
if (!trimmedSearchTerm) {
if (this.selectedIndex >= 0 && this.selectedIndex < this.items.length) {
return this.selectedIndex;
}
return 0;
}
const searchLower = trimmedSearchTerm.toLowerCase();
const normalizedSearch = normalizeAutocompleteMatchText(trimmedSearchTerm);
let bestIndex = -1;
let bestScore = -Infinity;
this.items.forEach((item, index) => {
const displayText = this._getDisplayText(item);
const textLower = displayText.toLowerCase();
const normalizedText = normalizeAutocompleteMatchText(displayText);
let score = -1;
if (textLower === searchLower) {
score = 5000;
} else if (normalizedText === normalizedSearch) {
score = 4500;
} else if (textLower.startsWith(searchLower)) {
score = 4000;
} else if (normalizedText.startsWith(normalizedSearch)) {
score = 3500;
} else if (textLower.includes(searchLower)) {
score = 3000;
} else if (normalizedText.includes(normalizedSearch)) {
score = 2500;
}
if (score > -1) {
score -= index;
if (score > bestScore) {
bestScore = score;
bestIndex = index;
}
}
});
if (bestIndex !== -1) {
return bestIndex;
}
if (this.selectedIndex >= 0 && this.selectedIndex < this.items.length) {
return this.selectedIndex;
}
return 0;
}
async search(term = '', endpoint = null) {
try {
this.currentSearchTerm = term;
@@ -1135,9 +1240,9 @@ class AutoComplete {
lastChild.style.borderBottom = 'none';
}
// Auto-select first item
// Auto-select immediately so accept keys remain stable.
if (this.items.length > 0) {
setTimeout(() => this.selectItem(0), 100);
this.selectItem(0);
}
// Update virtual scroll height for virtual scrolling mode
@@ -1300,11 +1405,10 @@ class AutoComplete {
}
}
// Auto-select the first item with a small delay
// Auto-select immediately so accept keys do not fall through
// to native focus traversal while the dropdown is visible.
if (this.items.length > 0) {
setTimeout(() => {
this.selectItem(0);
}, 100);
this.selectItem(0);
}
}
@@ -1989,8 +2093,20 @@ class AutoComplete {
case 'Enter':
case 'Tab':
e.preventDefault();
if (!shouldAcceptAutocompleteKey(e.key)) {
break;
}
{
const liveSearchTerm = this._getLiveSearchTermForAcceptance();
const preferredIndex = this._getPreferredSelectedIndex(liveSearchTerm);
if (preferredIndex !== -1 && preferredIndex !== this.selectedIndex) {
this.selectItem(preferredIndex);
}
}
if (this.selectedIndex >= 0 && this.selectedIndex < this.items.length) {
e.preventDefault();
if (this.showingCommands) {
// Insert command
this._insertCommand(this.items[this.selectedIndex].command);

View File

@@ -16,6 +16,12 @@ const PROMPT_TAG_AUTOCOMPLETE_DEFAULT = true;
const AUTOCOMPLETE_APPEND_COMMA_SETTING_ID = "loramanager.autocomplete_append_comma";
const AUTOCOMPLETE_APPEND_COMMA_DEFAULT = true;
const AUTOCOMPLETE_ACCEPT_KEY_SETTING_ID = "loramanager.autocomplete_accept_key";
const AUTOCOMPLETE_ACCEPT_KEY_DEFAULT = "both";
const AUTOCOMPLETE_ACCEPT_KEY_OPTION_BOTH = "Tab or Enter";
const AUTOCOMPLETE_ACCEPT_KEY_OPTION_TAB_ONLY = "Tab only";
const AUTOCOMPLETE_ACCEPT_KEY_OPTION_ENTER_ONLY = "Enter only";
const TAG_SPACE_REPLACEMENT_SETTING_ID = "loramanager.tag_space_replacement";
const TAG_SPACE_REPLACEMENT_DEFAULT = false;
@@ -186,6 +192,41 @@ const getAutocompleteAppendCommaPreference = (() => {
};
})();
const getAutocompleteAcceptKeyPreference = (() => {
let settingsUnavailableLogged = false;
return () => {
const settingManager = app?.extensionManager?.setting;
if (!settingManager || typeof settingManager.get !== "function") {
if (!settingsUnavailableLogged) {
console.warn("LoRA Manager: settings API unavailable, using default autocomplete accept key setting.");
settingsUnavailableLogged = true;
}
return AUTOCOMPLETE_ACCEPT_KEY_DEFAULT;
}
try {
const value = settingManager.get(AUTOCOMPLETE_ACCEPT_KEY_SETTING_ID);
if (value === AUTOCOMPLETE_ACCEPT_KEY_OPTION_TAB_ONLY) {
return "tab_only";
}
if (value === AUTOCOMPLETE_ACCEPT_KEY_OPTION_ENTER_ONLY) {
return "enter_only";
}
if (value === AUTOCOMPLETE_ACCEPT_KEY_OPTION_BOTH || value == null) {
return AUTOCOMPLETE_ACCEPT_KEY_DEFAULT;
}
return value;
} catch (error) {
if (!settingsUnavailableLogged) {
console.warn("LoRA Manager: unable to read autocomplete accept key setting, using default.", error);
settingsUnavailableLogged = true;
}
return AUTOCOMPLETE_ACCEPT_KEY_DEFAULT;
}
};
})();
const getTagSpaceReplacementPreference = (() => {
let settingsUnavailableLogged = false;
@@ -332,7 +373,20 @@ app.registerExtension({
type: "boolean",
defaultValue: AUTOCOMPLETE_APPEND_COMMA_DEFAULT,
tooltip: "When enabled, accepted autocomplete suggestions append ', ' to the inserted text.",
category: ["LoRA Manager", "Autocomplete", "Behavior"],
category: ["LoRA Manager", "Autocomplete", "Append comma"],
},
{
id: AUTOCOMPLETE_ACCEPT_KEY_SETTING_ID,
name: "Autocomplete accept key",
type: "combo",
options: [
AUTOCOMPLETE_ACCEPT_KEY_OPTION_BOTH,
AUTOCOMPLETE_ACCEPT_KEY_OPTION_TAB_ONLY,
AUTOCOMPLETE_ACCEPT_KEY_OPTION_ENTER_ONLY,
],
defaultValue: AUTOCOMPLETE_ACCEPT_KEY_OPTION_BOTH,
tooltip: "Choose which key accepts the selected autocomplete suggestion. Keys not selected here keep their normal textarea behavior.",
category: ["LoRA Manager", "Autocomplete", "Accept key"],
},
{
id: TAG_SPACE_REPLACEMENT_SETTING_ID,
@@ -451,6 +505,7 @@ export {
getWheelSensitivity,
getAutoPathCorrectionPreference,
getAutocompleteAppendCommaPreference,
getAutocompleteAcceptKeyPreference,
getPromptTagAutocompletePreference,
getTagSpaceReplacementPreference,
getUsageStatisticsPreference,