feat(autocomplete): support Tab accept and configurable suffix behavior (#863)

This commit is contained in:
Will Miao
2026-03-28 19:18:23 +08:00
parent a82f89d14a
commit 1ed5eef985
7 changed files with 633 additions and 95 deletions

View File

@@ -15,6 +15,8 @@ const {
})); }));
const fetchApiMock = vi.fn(); const fetchApiMock = vi.fn();
const settingGetMock = vi.fn();
const settingSetMock = vi.fn();
const caretHelperInstance = { const caretHelperInstance = {
getBeforeCursor: vi.fn(() => ''), getBeforeCursor: vi.fn(() => ''),
getCursorOffset: vi.fn(() => ({ left: 0, top: 0 })), getCursorOffset: vi.fn(() => ({ left: 0, top: 0 })),
@@ -37,6 +39,12 @@ vi.mock(APP_MODULE, () => ({
canvas: { canvas: {
ds: { scale: 1 }, ds: { scale: 1 },
}, },
extensionManager: {
setting: {
get: settingGetMock,
set: settingSetMock,
},
},
registerExtension: vi.fn(), registerExtension: vi.fn(),
}, },
})); }));
@@ -55,6 +63,20 @@ describe('AutoComplete widget interactions', () => {
document.head.querySelectorAll('style').forEach((styleEl) => styleEl.remove()); document.head.querySelectorAll('style').forEach((styleEl) => styleEl.remove());
Element.prototype.scrollIntoView = vi.fn(); Element.prototype.scrollIntoView = vi.fn();
fetchApiMock.mockReset(); fetchApiMock.mockReset();
settingGetMock.mockReset();
settingSetMock.mockReset();
settingGetMock.mockImplementation((key) => {
if (key === 'loramanager.autocomplete_append_comma') {
return true;
}
if (key === 'loramanager.prompt_tag_autocomplete') {
return true;
}
if (key === 'loramanager.tag_space_replacement') {
return false;
}
return undefined;
});
caretHelperInstance.getBeforeCursor.mockReset(); caretHelperInstance.getBeforeCursor.mockReset();
caretHelperInstance.getCursorOffset.mockReset(); caretHelperInstance.getCursorOffset.mockReset();
caretHelperInstance.getBeforeCursor.mockReturnValue(''); caretHelperInstance.getBeforeCursor.mockReturnValue('');
@@ -138,6 +160,81 @@ describe('AutoComplete widget interactions', () => {
expect(input.setSelectionRange).toHaveBeenCalled(); expect(input.setSelectionRange).toHaveBeenCalled();
}); });
it('accepts the selected suggestion with Tab', async () => {
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(true);
expect(insertSelectionSpy).toHaveBeenCalledWith('example_completion');
});
it('accepts the selected suggestion with Enter', async () => {
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(true);
expect(insertSelectionSpy).toHaveBeenCalledWith('example_completion');
});
it('does not intercept Tab when the dropdown is not visible', async () => {
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 = false;
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();
});
it('highlights multiple include tokens while ignoring excluded ones', async () => { it('highlights multiple include tokens while ignoring excluded ones', async () => {
const input = document.createElement('textarea'); const input = document.createElement('textarea');
document.body.append(input); document.body.append(input);
@@ -656,6 +753,248 @@ describe('AutoComplete widget interactions', () => {
expect(input.value).toBe('blue_hair,'); expect(input.value).toBe('blue_hair,');
}); });
it('omits the trailing comma when the append comma setting is disabled', async () => {
settingGetMock.mockImplementation((key) => {
if (key === 'loramanager.autocomplete_append_comma') {
return false;
}
if (key === 'loramanager.prompt_tag_autocomplete') {
return true;
}
if (key === 'loramanager.tag_space_replacement') {
return false;
}
return undefined;
});
const mockTags = [
{ tag_name: 'blue_hair', category: 0, post_count: 45000 },
];
caretHelperInstance.getBeforeCursor.mockReturnValue('blue hair');
caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 });
const input = document.createElement('textarea');
input.value = 'blue hair';
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', {
debounceDelay: 0,
showPreview: false,
minChars: 1,
});
autoComplete.searchType = 'custom_words';
autoComplete.activeCommand = null;
autoComplete.items = mockTags;
autoComplete.selectedIndex = 0;
autoComplete.currentSearchTerm = 'blue hair';
await autoComplete.insertSelection('blue_hair');
expect(input.value).toBe('blue_hair ');
});
it('uses persisted autocomplete metadata as the next search start when comma append is disabled', async () => {
vi.useFakeTimers();
settingGetMock.mockImplementation((key) => {
if (key === 'loramanager.autocomplete_append_comma') {
return false;
}
if (key === 'loramanager.prompt_tag_autocomplete') {
return true;
}
if (key === 'loramanager.tag_space_replacement') {
return false;
}
return undefined;
});
fetchApiMock.mockResolvedValue({
json: () => Promise.resolve({ success: true, words: [{ tag_name: 'cat_ears', category: 0, post_count: 1234 }] }),
});
caretHelperInstance.getBeforeCursor.mockReturnValue('1girl cat');
caretHelperInstance.getCursorOffset.mockReturnValue({ left: 15, top: 25 });
const input = document.createElement('textarea');
input.value = '1girl cat';
input.selectionStart = input.value.length;
input.focus = vi.fn();
input.setSelectionRange = vi.fn();
input._autocompleteMetadataWidget = {
value: {
version: 1,
textWidgetName: 'text',
lastAccepted: {
start: 0,
end: 6,
insertedText: '1girl ',
textSnapshot: '1girl ',
},
},
};
document.body.append(input);
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
const autoComplete = new AutoComplete(input,'prompt', {
debounceDelay: 0,
showPreview: false,
minChars: 1,
});
expect(autoComplete.getSearchTerm(input.value)).toBe('cat');
input.dispatchEvent(new Event('input', { bubbles: true }));
await vi.runAllTimersAsync();
await Promise.resolve();
expect(fetchApiMock).toHaveBeenCalledWith('/lm/custom-words/search?enriched=true&search=cat&limit=100');
});
it('invalidates stale autocomplete metadata and falls back to delimiter-based matching', async () => {
settingGetMock.mockImplementation((key) => {
if (key === 'loramanager.autocomplete_append_comma') {
return false;
}
if (key === 'loramanager.prompt_tag_autocomplete') {
return true;
}
if (key === 'loramanager.tag_space_replacement') {
return false;
}
return undefined;
});
caretHelperInstance.getBeforeCursor.mockReturnValue('1boy cat');
const metadataWidget = {
value: {
version: 1,
textWidgetName: 'text',
lastAccepted: {
start: 0,
end: 6,
insertedText: '1girl ',
textSnapshot: '1girl ',
},
},
};
const input = document.createElement('textarea');
input.value = '1boy cat';
input.selectionStart = input.value.length;
input._autocompleteMetadataWidget = metadataWidget;
document.body.append(input);
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
const autoComplete = new AutoComplete(input,'prompt', {
debounceDelay: 0,
showPreview: false,
minChars: 1,
});
expect(autoComplete.getSearchTerm(input.value)).toBe('1boy cat');
expect(metadataWidget.value.lastAccepted).toBeUndefined();
});
it('does not duplicate the first character when accepting a suggestion after a trailing space', async () => {
settingGetMock.mockImplementation((key) => {
if (key === 'loramanager.autocomplete_append_comma') {
return false;
}
if (key === 'loramanager.prompt_tag_autocomplete') {
return true;
}
if (key === 'loramanager.tag_space_replacement') {
return false;
}
return undefined;
});
const mockTags = [
{ tag_name: '1girl', category: 4, post_count: 500000 },
];
caretHelperInstance.getBeforeCursor.mockReturnValue('1girl ');
const input = document.createElement('textarea');
input.value = '1girl ';
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', {
debounceDelay: 0,
showPreview: false,
minChars: 1,
});
autoComplete.searchType = 'custom_words';
autoComplete.activeCommand = null;
autoComplete.items = mockTags;
autoComplete.selectedIndex = 0;
await autoComplete.insertSelection('1girl');
expect(input.value).toBe('1girl ');
});
it('omits the trailing comma for LoRA insertions when the setting is disabled', async () => {
settingGetMock.mockImplementation((key) => {
if (key === 'loramanager.autocomplete_append_comma') {
return false;
}
if (key === 'loramanager.prompt_tag_autocomplete') {
return true;
}
if (key === 'loramanager.tag_space_replacement') {
return false;
}
return undefined;
});
fetchApiMock.mockImplementation((url) => {
if (url.includes('usage-tips-by-path')) {
return Promise.resolve({
ok: true,
json: () => Promise.resolve({
success: true,
usage_tips: JSON.stringify({ strength: '1.2' }),
}),
});
}
return Promise.resolve({
json: () => Promise.resolve({ success: true, relative_paths: ['models/example.safetensors'] }),
});
});
caretHelperInstance.getBeforeCursor.mockReturnValue('alpha, example');
const input = document.createElement('textarea');
input.value = 'alpha, 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,'loras', { debounceDelay: 0, showPreview: false });
await autoComplete.insertSelection('models/example.safetensors');
expect(input.value).toContain('<lora:example:1.2>');
expect(input.value).not.toContain('<lora:example:1.2>,');
});
it('replaces entire phrase when selected tag ends with underscore version of search term (suffix match)', async () => { it('replaces entire phrase when selected tag ends with underscore version of search term (suffix match)', async () => {
const mockTags = [ const mockTags = [
{ tag_name: 'looking_to_the_side', category: 0, post_count: 1234 }, { tag_name: 'looking_to_the_side', category: 0, post_count: 1234 },

View File

@@ -36,6 +36,8 @@ export interface AutocompleteTextWidgetInterface {
inputEl?: HTMLTextAreaElement inputEl?: HTMLTextAreaElement
callback?: (v: string) => void callback?: (v: string) => void
onSetValue?: (v: string) => void onSetValue?: (v: string) => void
metadataWidget?: { value?: unknown }
name?: string
} }
const props = defineProps<{ const props = defineProps<{
@@ -171,6 +173,9 @@ onMounted(() => {
// Register textarea reference with widget // Register textarea reference with widget
if (textareaRef.value) { if (textareaRef.value) {
props.widget.inputEl = textareaRef.value props.widget.inputEl = textareaRef.value
;(textareaRef.value as any)._autocompleteHostWidget = props.widget
;(textareaRef.value as any)._autocompleteMetadataWidget = props.widget.metadataWidget
;(textareaRef.value as any)._autocompleteTextWidgetName = props.widget.name ?? 'text'
// Also store on the container element for cloned widgets (subgraph promotion) // Also store on the container element for cloned widgets (subgraph promotion)
// When widgets are promoted to subgraph nodes, the cloned widget shares the same // When widgets are promoted to subgraph nodes, the cloned widget shares the same
@@ -208,6 +213,9 @@ onUnmounted(() => {
// Remove external value change event listener // Remove external value change event listener
if (textareaRef.value) { if (textareaRef.value) {
delete (textareaRef.value as any)._autocompleteHostWidget
delete (textareaRef.value as any)._autocompleteMetadataWidget
delete (textareaRef.value as any)._autocompleteTextWidgetName
textareaRef.value.removeEventListener('lora-manager:autocomplete-value-changed', onExternalValueChange as EventListener) textareaRef.value.removeEventListener('lora-manager:autocomplete-value-changed', onExternalValueChange as EventListener)
} }

View File

@@ -411,6 +411,7 @@ function createAutocompleteTextWidgetFactory(
modelType: 'loras' | 'embeddings' | 'prompt', modelType: 'loras' | 'embeddings' | 'prompt',
inputOptions: { placeholder?: string } = {} inputOptions: { placeholder?: string } = {}
) { ) {
const metadataWidgetName = `__lm_autocomplete_meta_${widgetName}`
const container = document.createElement('div') const container = document.createElement('div')
container.id = `autocomplete-text-widget-${node.id}-${widgetName}` container.id = `autocomplete-text-widget-${node.id}-${widgetName}`
container.style.width = '100%' container.style.width = '100%'
@@ -427,6 +428,15 @@ function createAutocompleteTextWidgetFactory(
const widgetElementRef = { inputEl: undefined as HTMLTextAreaElement | undefined } const widgetElementRef = { inputEl: undefined as HTMLTextAreaElement | undefined }
;(container as any).__widgetInputEl = widgetElementRef ;(container as any).__widgetInputEl = widgetElementRef
const metadataWidget = node.addWidget('text', metadataWidgetName, {
version: 1,
textWidgetName: widgetName
})
metadataWidget.type = 'LORA_MANAGER_AUTOCOMPLETE_METADATA'
metadataWidget.hidden = true
metadataWidget.computeSize = () => [0, -4]
metadataWidget.serializeValue = () => metadataWidget.value
const widget = node.addDOMWidget( const widget = node.addDOMWidget(
widgetName, widgetName,
`AUTOCOMPLETE_TEXT_${modelType.toUpperCase()}`, `AUTOCOMPLETE_TEXT_${modelType.toUpperCase()}`,
@@ -463,6 +473,7 @@ function createAutocompleteTextWidgetFactory(
}) })
} }
) )
widget.metadataWidget = metadataWidget
// Get spellcheck setting from ComfyUI settings (default: false) // Get spellcheck setting from ComfyUI settings (default: false)
const spellcheck = app.ui?.settings?.getSettingValue?.('Comfy.TextareaWidget.Spellcheck') ?? false const spellcheck = app.ui?.settings?.getSettingValue?.('Comfy.TextareaWidget.Spellcheck') ?? false

View File

@@ -1,7 +1,11 @@
import { api } from "../../scripts/api.js"; import { api } from "../../scripts/api.js";
import { app } from "../../scripts/app.js"; import { app } from "../../scripts/app.js";
import { TextAreaCaretHelper } from "./textarea_caret_helper.js"; import { TextAreaCaretHelper } from "./textarea_caret_helper.js";
import { getPromptTagAutocompletePreference, getTagSpaceReplacementPreference } from "./settings.js"; import {
getAutocompleteAppendCommaPreference,
getPromptTagAutocompletePreference,
getTagSpaceReplacementPreference,
} from "./settings.js";
import { showToast } from "./utils.js"; import { showToast } from "./utils.js";
// Command definitions for category filtering // Command definitions for category filtering
@@ -108,6 +112,24 @@ function parseSearchTokens(term = '') {
return { include, exclude }; return { include, exclude };
} }
function formatAutocompleteInsertion(text = '') {
const trimmed = typeof text === 'string' ? text.trim() : '';
if (!trimmed) {
return '';
}
return getAutocompleteAppendCommaPreference() ? `${trimmed},` : `${trimmed} `;
}
const AUTOCOMPLETE_METADATA_VERSION = 1;
function createAutocompleteMetadataBase(textWidgetName = 'text') {
return {
version: AUTOCOMPLETE_METADATA_VERSION,
textWidgetName,
};
}
function createDefaultBehavior(modelType) { function createDefaultBehavior(modelType) {
return { return {
enablePreview: false, enablePreview: false,
@@ -116,7 +138,7 @@ function createDefaultBehavior(modelType) {
if (!trimmed) { if (!trimmed) {
return ''; return '';
} }
return `${trimmed}, `; return formatAutocompleteInsertion(trimmed);
}, },
}; };
} }
@@ -185,9 +207,9 @@ const MODEL_BEHAVIORS = {
} }
if (clipStrength !== null) { if (clipStrength !== null) {
return `<lora:${fileName}:${strength}:${clipStrength}>, `; return formatAutocompleteInsertion(`<lora:${fileName}:${strength}:${clipStrength}>`);
} }
return `<lora:${fileName}:${strength}>, `; return formatAutocompleteInsertion(`<lora:${fileName}:${strength}>`);
} }
}, },
embeddings: { embeddings: {
@@ -202,13 +224,13 @@ const MODEL_BEHAVIORS = {
const { directories, fileName } = splitRelativePath(relativePath); const { directories, fileName } = splitRelativePath(relativePath);
const trimmedName = removeGeneralExtension(fileName); const trimmedName = removeGeneralExtension(fileName);
const folder = directories.length ? `${directories.join('/')}/` : ''; const folder = directories.length ? `${directories.join('/')}/` : '';
return `embedding:${folder}${trimmedName}, `; return formatAutocompleteInsertion(`embedding:${folder}${trimmedName}`);
}, },
}, },
custom_words: { custom_words: {
enablePreview: false, enablePreview: false,
async getInsertText(_instance, relativePath) { async getInsertText(_instance, relativePath) {
return `${relativePath}, `; return formatAutocompleteInsertion(relativePath);
}, },
}, },
prompt: { prompt: {
@@ -245,7 +267,7 @@ const MODEL_BEHAVIORS = {
const { directories, fileName } = splitRelativePath(relativePath); const { directories, fileName } = splitRelativePath(relativePath);
const trimmedName = removeGeneralExtension(fileName); const trimmedName = removeGeneralExtension(fileName);
const folder = directories.length ? `${directories.join('/')}/` : ''; const folder = directories.length ? `${directories.join('/')}/` : '';
return `embedding:${folder}${trimmedName}, `; return formatAutocompleteInsertion(`embedding:${folder}${trimmedName}`);
} else { } else {
let tagText = relativePath; let tagText = relativePath;
@@ -253,7 +275,7 @@ const MODEL_BEHAVIORS = {
tagText = tagText.replace(/_/g, ' '); tagText = tagText.replace(/_/g, ' ');
} }
return `${tagText}, `; return formatAutocompleteInsertion(tagText);
} }
}, },
}, },
@@ -620,18 +642,130 @@ class AutoComplete {
} }
getSearchTerm(value) { getSearchTerm(value) {
// Use helper to get text before cursor for more accurate positioning return this.getActiveSearchRange(value).text;
const beforeCursor = this.helper.getBeforeCursor();
if (!beforeCursor) {
return '';
} }
// Split on comma and '>' delimiters only (do not split on spaces) getActiveSearchRange(value = null) {
const segments = beforeCursor.split(/[,\>]+/); const currentValue = typeof value === 'string' ? value : this.inputElement.value;
const caretPos = this.getCaretPosition();
const beforeCursor = this.helper.getBeforeCursor() ?? currentValue.substring(0, caretPos);
let start = this._getHardBoundaryStart(beforeCursor);
// Return the last non-empty segment as search term if (!getAutocompleteAppendCommaPreference()) {
const lastSegment = segments[segments.length - 1] || ''; const persistedBoundaryEnd = this._getPersistedBoundaryEnd(currentValue, caretPos);
return lastSegment.trim(); if (persistedBoundaryEnd !== null && persistedBoundaryEnd > start) {
start = persistedBoundaryEnd;
}
}
const rawText = beforeCursor.substring(start);
const text = rawText.trim();
const leadingWhitespaceLength = rawText.length - rawText.trimStart().length;
const trimmedStart = start + leadingWhitespaceLength;
return {
start,
trimmedStart,
end: caretPos,
beforeCursor,
rawText,
text,
};
}
_getHardBoundaryStart(beforeCursor = '') {
const lastComma = beforeCursor.lastIndexOf(',');
const lastAngle = beforeCursor.lastIndexOf('>');
return Math.max(lastComma, lastAngle) + 1;
}
_getMetadataWidget() {
return this.inputElement?._autocompleteMetadataWidget
?? this.inputElement?._autocompleteHostWidget?.metadataWidget
?? null;
}
_getMetadataBase() {
return createAutocompleteMetadataBase(this.inputElement?._autocompleteTextWidgetName ?? 'text');
}
_getAutocompleteMetadata() {
const metadataWidget = this._getMetadataWidget();
const value = metadataWidget?.value;
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return this._getMetadataBase();
}
return {
...this._getMetadataBase(),
...value,
};
}
_setAutocompleteMetadata(metadata = {}) {
const metadataWidget = this._getMetadataWidget();
if (!metadataWidget) {
return;
}
metadataWidget.value = {
...this._getMetadataBase(),
...metadata,
};
}
_clearLastAcceptedBoundary() {
const metadataWidget = this._getMetadataWidget();
if (!metadataWidget) {
return;
}
const metadata = this._getAutocompleteMetadata();
delete metadata.lastAccepted;
metadataWidget.value = metadata;
}
_storeLastAcceptedBoundary(boundary) {
this._setAutocompleteMetadata({ lastAccepted: boundary });
}
_getPersistedBoundaryEnd(currentValue, caretPos) {
const metadata = this._getAutocompleteMetadata();
const boundary = metadata?.lastAccepted;
if (!boundary || typeof boundary !== 'object') {
return null;
}
const { start, end, insertedText, textSnapshot } = boundary;
if (!Number.isInteger(start) || !Number.isInteger(end) || start < 0 || end < start) {
this._clearLastAcceptedBoundary();
return null;
}
if (end > currentValue.length || end > caretPos) {
this._clearLastAcceptedBoundary();
return null;
}
if (typeof insertedText !== 'string' || insertedText.length === 0) {
this._clearLastAcceptedBoundary();
return null;
}
if (currentValue.slice(start, end) !== insertedText) {
this._clearLastAcceptedBoundary();
return null;
}
if (typeof textSnapshot !== 'string' || currentValue.slice(0, end) !== textSnapshot) {
this._clearLastAcceptedBoundary();
return null;
}
return end;
} }
/** /**
@@ -1017,25 +1151,12 @@ class AutoComplete {
*/ */
_insertCommand(command) { _insertCommand(command) {
const currentValue = this.inputElement.value; const currentValue = this.inputElement.value;
const caretPos = this.getCaretPosition(); const activeRange = this.getActiveSearchRange(currentValue);
const commandStartPos = activeRange.trimmedStart;
// Find the start of the current command being typed
const beforeCursor = currentValue.substring(0, caretPos);
const segments = beforeCursor.split(/[,\>]+/);
const lastSegment = segments[segments.length - 1] || '';
let commandStartPos = caretPos - lastSegment.length;
// Preserve leading space if the last segment starts with a space
// This handles cases like "1girl, /character" where we want to keep the space
// after the comma instead of replacing it
if (lastSegment.length > 0 && lastSegment[0] === ' ') {
// Move start position past the leading space to preserve it
commandStartPos = commandStartPos + 1;
}
// Insert command with trailing space // Insert command with trailing space
const insertText = command + ' '; const insertText = command + ' ';
const newValue = currentValue.substring(0, commandStartPos) + insertText + currentValue.substring(caretPos); const newValue = currentValue.substring(0, commandStartPos) + insertText + currentValue.substring(activeRange.end);
const newCaretPos = commandStartPos + insertText.length; const newCaretPos = commandStartPos + insertText.length;
this.inputElement.value = newValue; this.inputElement.value = newValue;
@@ -1866,6 +1987,7 @@ class AutoComplete {
break; break;
case 'Enter': case 'Enter':
case 'Tab':
e.preventDefault(); e.preventDefault();
if (this.selectedIndex >= 0 && this.selectedIndex < this.items.length) { if (this.selectedIndex >= 0 && this.selectedIndex < this.items.length) {
if (this.showingCommands) { if (this.showingCommands) {
@@ -1897,11 +2019,10 @@ class AutoComplete {
} }
const currentValue = this.inputElement.value; const currentValue = this.inputElement.value;
const caretPos = this.getCaretPosition(); const activeRange = this.getActiveSearchRange(currentValue);
const caretPos = activeRange.end;
// Use getSearchTerm to get the current search term before cursor const fullSearchTerm = activeRange.text;
const beforeCursor = currentValue.substring(0, caretPos); let replaceStartPos = activeRange.trimmedStart;
const fullSearchTerm = this.getSearchTerm(beforeCursor);
// For regular tag autocomplete (no command), only replace the last space-separated token // For regular tag autocomplete (no command), only replace the last space-separated token
// This allows "hello 1gi" + selecting "1girl" to become "hello 1girl, " // This allows "hello 1gi" + selecting "1girl" to become "hello 1girl, "
@@ -1934,18 +2055,26 @@ class AutoComplete {
underscoreVersion === selectedTagLower underscoreVersion === selectedTagLower
)) { )) {
searchTerm = fullSearchTerm; searchTerm = fullSearchTerm;
replaceStartPos = activeRange.trimmedStart;
} else { } else {
searchTerm = this._getLastSpaceToken(fullSearchTerm); searchTerm = this._getLastSpaceToken(fullSearchTerm);
replaceStartPos = searchTerm === fullSearchTerm
? activeRange.trimmedStart
: caretPos - searchTerm.length;
} }
} }
const searchStartPos = caretPos - searchTerm.length;
// Only replace the search term, not everything after the last comma // Only replace the search term, not everything after the last comma
const newValue = currentValue.substring(0, searchStartPos) + insertText + currentValue.substring(caretPos); const newValue = currentValue.substring(0, replaceStartPos) + insertText + currentValue.substring(caretPos);
const newCaretPos = searchStartPos + insertText.length; const newCaretPos = replaceStartPos + insertText.length;
this.inputElement.value = newValue; this.inputElement.value = newValue;
this._storeLastAcceptedBoundary({
start: replaceStartPos,
end: newCaretPos,
insertedText: insertText,
textSnapshot: newValue.substring(0, newCaretPos),
});
// Trigger input event to notify about the change // Trigger input event to notify about the change
const event = new Event('input', { bubbles: true }); const event = new Event('input', { bubbles: true });
@@ -1974,7 +2103,7 @@ class AutoComplete {
if (!trimmed) { if (!trimmed) {
return ''; return '';
} }
return `${trimmed}, `; return formatAutocompleteInsertion(trimmed);
} }
/** /**
@@ -2053,12 +2182,9 @@ class AutoComplete {
*/ */
_clearCurrentToken() { _clearCurrentToken() {
const currentValue = this.inputElement.value; const currentValue = this.inputElement.value;
const caretPos = this.inputElement.selectionStart; const activeRange = this.getActiveSearchRange(currentValue);
const caretPos = activeRange.end;
// Find the command text before cursor const lastSegment = activeRange.rawText;
const beforeCursor = currentValue.substring(0, caretPos);
const segments = beforeCursor.split(/[,\>]+/);
const lastSegment = segments[segments.length - 1] || '';
// Find the command start position, preserving leading spaces // Find the command start position, preserving leading spaces
// lastSegment includes leading spaces (e.g., " /ac"), find where command actually starts // lastSegment includes leading spaces (e.g., " /ac"), find where command actually starts
@@ -2067,7 +2193,7 @@ class AutoComplete {
// commandMatch[1] is leading spaces, commandMatch[2] is the command // commandMatch[1] is leading spaces, commandMatch[2] is the command
const leadingSpaces = commandMatch[1].length; const leadingSpaces = commandMatch[1].length;
// Keep the spaces by starting after them // Keep the spaces by starting after them
const commandStartPos = caretPos - lastSegment.length + leadingSpaces; const commandStartPos = activeRange.start + leadingSpaces;
// Skip trailing spaces when deleting // Skip trailing spaces when deleting
let endPos = caretPos; let endPos = caretPos;
@@ -2089,7 +2215,7 @@ class AutoComplete {
this.inputElement.setSelectionRange(newCaretPos, newCaretPos); this.inputElement.setSelectionRange(newCaretPos, newCaretPos);
} else { } else {
// Fallback: delete the whole last segment (original behavior) // Fallback: delete the whole last segment (original behavior)
const commandStartPos = caretPos - lastSegment.length; const commandStartPos = activeRange.start;
let endPos = caretPos; let endPos = caretPos;
while (endPos < currentValue.length && currentValue[endPos] === ' ') { while (endPos < currentValue.length && currentValue[endPos] === ' ') {

View File

@@ -13,6 +13,9 @@ const AUTO_PATH_CORRECTION_DEFAULT = true;
const PROMPT_TAG_AUTOCOMPLETE_SETTING_ID = "loramanager.prompt_tag_autocomplete"; const PROMPT_TAG_AUTOCOMPLETE_SETTING_ID = "loramanager.prompt_tag_autocomplete";
const PROMPT_TAG_AUTOCOMPLETE_DEFAULT = true; const PROMPT_TAG_AUTOCOMPLETE_DEFAULT = true;
const AUTOCOMPLETE_APPEND_COMMA_SETTING_ID = "loramanager.autocomplete_append_comma";
const AUTOCOMPLETE_APPEND_COMMA_DEFAULT = true;
const TAG_SPACE_REPLACEMENT_SETTING_ID = "loramanager.tag_space_replacement"; const TAG_SPACE_REPLACEMENT_SETTING_ID = "loramanager.tag_space_replacement";
const TAG_SPACE_REPLACEMENT_DEFAULT = false; const TAG_SPACE_REPLACEMENT_DEFAULT = false;
@@ -157,6 +160,32 @@ const getPromptTagAutocompletePreference = (() => {
}; };
})(); })();
const getAutocompleteAppendCommaPreference = (() => {
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 append comma setting.");
settingsUnavailableLogged = true;
}
return AUTOCOMPLETE_APPEND_COMMA_DEFAULT;
}
try {
const value = settingManager.get(AUTOCOMPLETE_APPEND_COMMA_SETTING_ID);
return value ?? AUTOCOMPLETE_APPEND_COMMA_DEFAULT;
} catch (error) {
if (!settingsUnavailableLogged) {
console.warn("LoRA Manager: unable to read append comma setting, using default.", error);
settingsUnavailableLogged = true;
}
return AUTOCOMPLETE_APPEND_COMMA_DEFAULT;
}
};
})();
const getTagSpaceReplacementPreference = (() => { const getTagSpaceReplacementPreference = (() => {
let settingsUnavailableLogged = false; let settingsUnavailableLogged = false;
@@ -297,6 +326,14 @@ app.registerExtension({
tooltip: "When enabled, typing will trigger tag autocomplete suggestions. Commands (e.g., /character, /artist) always work regardless of this setting.", tooltip: "When enabled, typing will trigger tag autocomplete suggestions. Commands (e.g., /character, /artist) always work regardless of this setting.",
category: ["LoRA Manager", "Autocomplete", "Prompt"], category: ["LoRA Manager", "Autocomplete", "Prompt"],
}, },
{
id: AUTOCOMPLETE_APPEND_COMMA_SETTING_ID,
name: "Append comma after autocomplete",
type: "boolean",
defaultValue: AUTOCOMPLETE_APPEND_COMMA_DEFAULT,
tooltip: "When enabled, accepted autocomplete suggestions append ', ' to the inserted text.",
category: ["LoRA Manager", "Autocomplete", "Behavior"],
},
{ {
id: TAG_SPACE_REPLACEMENT_SETTING_ID, id: TAG_SPACE_REPLACEMENT_SETTING_ID,
name: "Replace underscores with spaces in tags", name: "Replace underscores with spaces in tags",
@@ -413,6 +450,7 @@ app.registerExtension({
export { export {
getWheelSensitivity, getWheelSensitivity,
getAutoPathCorrectionPreference, getAutoPathCorrectionPreference,
getAutocompleteAppendCommaPreference,
getPromptTagAutocompletePreference, getPromptTagAutocompletePreference,
getTagSpaceReplacementPreference, getTagSpaceReplacementPreference,
getUsageStatisticsPreference, getUsageStatisticsPreference,

View File

@@ -2118,14 +2118,14 @@ to { transform: rotate(360deg);
padding: 20px 0; padding: 20px 0;
} }
.autocomplete-text-widget[data-v-b3b00fdd] { .autocomplete-text-widget[data-v-918e2bc5] {
background: transparent; background: transparent;
height: 100%; height: 100%;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
box-sizing: border-box; box-sizing: border-box;
} }
.input-wrapper[data-v-b3b00fdd] { .input-wrapper[data-v-918e2bc5] {
position: relative; position: relative;
flex: 1; flex: 1;
display: flex; display: flex;
@@ -2133,7 +2133,7 @@ to { transform: rotate(360deg);
} }
/* Canvas mode styles (default) - matches built-in comfy-multiline-input */ /* Canvas mode styles (default) - matches built-in comfy-multiline-input */
.text-input[data-v-b3b00fdd] { .text-input[data-v-918e2bc5] {
flex: 1; flex: 1;
width: 100%; width: 100%;
background-color: var(--comfy-input-bg, #222); background-color: var(--comfy-input-bg, #222);
@@ -2150,7 +2150,7 @@ to { transform: rotate(360deg);
} }
/* Vue DOM mode styles - matches built-in p-textarea in Vue DOM mode */ /* Vue DOM mode styles - matches built-in p-textarea in Vue DOM mode */
.text-input.vue-dom-mode[data-v-b3b00fdd] { .text-input.vue-dom-mode[data-v-918e2bc5] {
background-color: var(--color-charcoal-400, #313235); background-color: var(--color-charcoal-400, #313235);
color: #fff; color: #fff;
padding: 8px 12px 30px 12px; /* Reserve bottom space for clear button */ padding: 8px 12px 30px 12px; /* Reserve bottom space for clear button */
@@ -2159,12 +2159,12 @@ to { transform: rotate(360deg);
font-size: 12px; font-size: 12px;
font-family: inherit; font-family: inherit;
} }
.text-input[data-v-b3b00fdd]:focus { .text-input[data-v-918e2bc5]:focus {
outline: none; outline: none;
} }
/* Clear button styles */ /* Clear button styles */
.clear-button[data-v-b3b00fdd] { .clear-button[data-v-918e2bc5] {
position: absolute; position: absolute;
right: 6px; right: 6px;
bottom: 6px; /* Changed from top to bottom */ bottom: 6px; /* Changed from top to bottom */
@@ -2187,31 +2187,31 @@ to { transform: rotate(360deg);
} }
/* Show clear button when hovering over input wrapper */ /* Show clear button when hovering over input wrapper */
.input-wrapper:hover .clear-button[data-v-b3b00fdd] { .input-wrapper:hover .clear-button[data-v-918e2bc5] {
opacity: 0.7; opacity: 0.7;
pointer-events: auto; pointer-events: auto;
} }
.clear-button[data-v-b3b00fdd]:hover { .clear-button[data-v-918e2bc5]:hover {
opacity: 1; opacity: 1;
background: rgba(255, 100, 100, 0.8); background: rgba(255, 100, 100, 0.8);
} }
.clear-button svg[data-v-b3b00fdd] { .clear-button svg[data-v-918e2bc5] {
width: 12px; width: 12px;
height: 12px; height: 12px;
} }
/* Vue DOM mode adjustments for clear button */ /* Vue DOM mode adjustments for clear button */
.text-input.vue-dom-mode ~ .clear-button[data-v-b3b00fdd] { .text-input.vue-dom-mode ~ .clear-button[data-v-918e2bc5] {
right: 8px; right: 8px;
bottom: 10px; /* Changed from top to bottom, adjusted for Vue DOM padding */ bottom: 10px; /* Changed from top to bottom, adjusted for Vue DOM padding */
width: 20px; width: 20px;
height: 20px; height: 20px;
background: rgba(107, 114, 128, 0.6); background: rgba(107, 114, 128, 0.6);
} }
.text-input.vue-dom-mode ~ .clear-button[data-v-b3b00fdd]:hover { .text-input.vue-dom-mode ~ .clear-button[data-v-918e2bc5]:hover {
background: oklch(62% 0.18 25); background: oklch(62% 0.18 25);
} }
.text-input.vue-dom-mode ~ .clear-button svg[data-v-b3b00fdd] { .text-input.vue-dom-mode ~ .clear-button svg[data-v-918e2bc5] {
width: 14px; width: 14px;
height: 14px; height: 14px;
}`)); }`));
@@ -14679,6 +14679,9 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
onMounted(() => { onMounted(() => {
if (textareaRef.value) { if (textareaRef.value) {
props.widget.inputEl = textareaRef.value; props.widget.inputEl = textareaRef.value;
textareaRef.value._autocompleteHostWidget = props.widget;
textareaRef.value._autocompleteMetadataWidget = props.widget.metadataWidget;
textareaRef.value._autocompleteTextWidgetName = props.widget.name ?? "text";
const container = textareaRef.value.closest('[id^="autocomplete-text-widget-"]'); const container = textareaRef.value.closest('[id^="autocomplete-text-widget-"]');
if (container && container.__widgetInputEl) { if (container && container.__widgetInputEl) {
container.__widgetInputEl.inputEl = textareaRef.value; container.__widgetInputEl.inputEl = textareaRef.value;
@@ -14697,6 +14700,9 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
props.widget.inputEl = void 0; props.widget.inputEl = void 0;
} }
if (textareaRef.value) { if (textareaRef.value) {
delete textareaRef.value._autocompleteHostWidget;
delete textareaRef.value._autocompleteMetadataWidget;
delete textareaRef.value._autocompleteTextWidgetName;
textareaRef.value.removeEventListener("lora-manager:autocomplete-value-changed", onExternalValueChange); textareaRef.value.removeEventListener("lora-manager:autocomplete-value-changed", onExternalValueChange);
} }
if (props.widget) { if (props.widget) {
@@ -14748,7 +14754,7 @@ const _sfc_main = /* @__PURE__ */ defineComponent({
}; };
} }
}); });
const AutocompleteTextWidget = /* @__PURE__ */ _export_sfc(_sfc_main, [["__scopeId", "data-v-b3b00fdd"]]); const AutocompleteTextWidget = /* @__PURE__ */ _export_sfc(_sfc_main, [["__scopeId", "data-v-918e2bc5"]]);
const LORA_PROVIDER_NODE_TYPES$1 = [ const LORA_PROVIDER_NODE_TYPES$1 = [
"Lora Stacker (LoraManager)", "Lora Stacker (LoraManager)",
"Lora Randomizer (LoraManager)", "Lora Randomizer (LoraManager)",
@@ -15325,6 +15331,7 @@ if ((_a = app$1.ui) == null ? void 0 : _a.settings) {
} }
function createAutocompleteTextWidgetFactory(node, widgetName, modelType, inputOptions = {}) { function createAutocompleteTextWidgetFactory(node, widgetName, modelType, inputOptions = {}) {
var _a2, _b, _c; var _a2, _b, _c;
const metadataWidgetName = `__lm_autocomplete_meta_${widgetName}`;
const container = document.createElement("div"); const container = document.createElement("div");
container.id = `autocomplete-text-widget-${node.id}-${widgetName}`; container.id = `autocomplete-text-widget-${node.id}-${widgetName}`;
container.style.width = "100%"; container.style.width = "100%";
@@ -15335,6 +15342,14 @@ function createAutocompleteTextWidgetFactory(node, widgetName, modelType, inputO
forwardMiddleMouseToCanvas(container); forwardMiddleMouseToCanvas(container);
const widgetElementRef = { inputEl: void 0 }; const widgetElementRef = { inputEl: void 0 };
container.__widgetInputEl = widgetElementRef; container.__widgetInputEl = widgetElementRef;
const metadataWidget = node.addWidget("text", metadataWidgetName, {
version: 1,
textWidgetName: widgetName
});
metadataWidget.type = "LORA_MANAGER_AUTOCOMPLETE_METADATA";
metadataWidget.hidden = true;
metadataWidget.computeSize = () => [0, -4];
metadataWidget.serializeValue = () => metadataWidget.value;
const widget = node.addDOMWidget( const widget = node.addDOMWidget(
widgetName, widgetName,
`AUTOCOMPLETE_TEXT_${modelType.toUpperCase()}`, `AUTOCOMPLETE_TEXT_${modelType.toUpperCase()}`,
@@ -15369,6 +15384,7 @@ function createAutocompleteTextWidgetFactory(node, widgetName, modelType, inputO
} }
} }
); );
widget.metadataWidget = metadataWidget;
const spellcheck = ((_c = (_b = (_a2 = app$1.ui) == null ? void 0 : _a2.settings) == null ? void 0 : _b.getSettingValue) == null ? void 0 : _c.call(_b, "Comfy.TextareaWidget.Spellcheck")) ?? false; const spellcheck = ((_c = (_b = (_a2 = app$1.ui) == null ? void 0 : _a2.settings) == null ? void 0 : _b.getSettingValue) == null ? void 0 : _c.call(_b, "Comfy.TextareaWidget.Spellcheck")) ?? false;
const vueApp = createApp(AutocompleteTextWidget, { const vueApp = createApp(AutocompleteTextWidget, {
widget, widget,

File diff suppressed because one or more lines are too long