diff --git a/tests/frontend/components/autocomplete.behavior.test.js b/tests/frontend/components/autocomplete.behavior.test.js index d799c11a..e3da890a 100644 --- a/tests/frontend/components/autocomplete.behavior.test.js +++ b/tests/frontend/components/autocomplete.behavior.test.js @@ -2032,4 +2032,122 @@ describe('AutoComplete widget interactions', () => { expect(calledUrl).toContain('folder=Flux.1+D%2Fstyle'); expect(calledUrl).toContain('recursive=true'); }); + + describe('discoverability hints', () => { + beforeEach(() => { + localStorage.clear(); + }); + + const typeSlashCommand = async () => { + const input = document.createElement('textarea'); + input.value = '/'; + input.selectionStart = 1; + document.body.append(input); + + caretHelperInstance.getBeforeCursor.mockReturnValue('/'); + + const { AutoComplete } = await import(AUTOCOMPLETE_MODULE); + const autoComplete = new AutoComplete(input, 'prompt', { showPreview: false, minChars: 1 }); + + input.dispatchEvent(new Event('input', { bubbles: true })); + return autoComplete; + }; + + it('shows the current autocomplete state below the slash command list', async () => { + const autoComplete = await typeSlashCommand(); + + const footer = autoComplete.dropdown.querySelector('.lm-autocomplete-command-footer'); + expect(footer).not.toBeNull(); + expect(footer.textContent).toContain('/noautocomplete to disable'); + }); + + it('shows how to re-enable autocomplete in the footer when it is off', async () => { + settingGetMock.mockImplementation((key) => { + if (key === 'loramanager.prompt_tag_autocomplete') { + return false; + } + return undefined; + }); + + const autoComplete = await typeSlashCommand(); + + const footer = autoComplete.dropdown.querySelector('.lm-autocomplete-command-footer'); + expect(footer).not.toBeNull(); + expect(footer.textContent).toContain('/autocomplete to enable'); + }); + + it('stays silent when typing with tag autocomplete disabled', async () => { + settingGetMock.mockImplementation((key) => { + if (key === 'loramanager.prompt_tag_autocomplete') { + return false; + } + if (key === 'loramanager.autocomplete_accept_key') { + return 'both'; + } + return undefined; + }); + + const input = document.createElement('textarea'); + input.value = 'hello'; + input.selectionStart = 5; + document.body.append(input); + + caretHelperInstance.getBeforeCursor.mockReturnValue('hello'); + + const { AutoComplete } = await import(AUTOCOMPLETE_MODULE); + const autoComplete = new AutoComplete(input, 'prompt', { showPreview: false, minChars: 1 }); + + input.dispatchEvent(new Event('input', { bubbles: true })); + + expect(autoComplete.isVisible).toBe(false); + expect(fetchApiMock).not.toHaveBeenCalled(); + }); + + it('shows a dismissible first-run hint on tag suggestions and remembers dismissal', async () => { + vi.useFakeTimers(); + + fetchApiMock.mockResolvedValue({ + json: () => Promise.resolve({ + success: true, + words: [{ tag_name: '1girl', category: 4, post_count: 500000 }], + }), + }); + + caretHelperInstance.getBeforeCursor.mockReturnValue('1gi'); + + const triggerSearch = async () => { + const input = document.createElement('textarea'); + input.value = '1gi'; + input.selectionStart = 3; + document.body.append(input); + + const { AutoComplete } = await import(AUTOCOMPLETE_MODULE); + const autoComplete = new AutoComplete(input, 'prompt', { + debounceDelay: 0, + showPreview: false, + minChars: 1, + }); + + input.dispatchEvent(new Event('input', { bubbles: true })); + await vi.runAllTimersAsync(); + await Promise.resolve(); + return autoComplete; + }; + + const autoComplete = await triggerSearch(); + + const hint = autoComplete.dropdown.querySelector('.lm-autocomplete-first-run-hint'); + expect(hint).not.toBeNull(); + expect(hint.textContent).toContain('/noautocomplete'); + + hint.querySelector('button').click(); + + expect(autoComplete.dropdown.querySelector('.lm-autocomplete-first-run-hint')).toBeNull(); + expect(localStorage.getItem('lm:autocomplete-disable-tip-dismissed')).toBe('1'); + + // A fresh instance no longer shows the hint once dismissed + const autoComplete2 = await triggerSearch(); + expect(autoComplete2.dropdown.querySelector('.lm-autocomplete-first-run-hint')).toBeNull(); + }); + }); }); diff --git a/web/comfyui/autocomplete.js b/web/comfyui/autocomplete.js index 1c1800bb..90de6639 100644 --- a/web/comfyui/autocomplete.js +++ b/web/comfyui/autocomplete.js @@ -17,9 +17,13 @@ import { getLoraActiveFiltersAutocompletePreference, getPromptTagAutocompletePreference, getTagSpaceReplacementPreference, + setLoraManagerSettingValue, } from "./settings.js"; import { showToast } from "./utils.js"; +// localStorage key for the one-time "how to disable" hint in the dropdown +const FIRST_RUN_HINT_DISMISSED_KEY = 'lm:autocomplete-disable-tip-dismissed'; + // Command definitions for category filtering const TAG_COMMANDS = { '/character': { categories: [4, 11], label: 'Character' }, @@ -37,14 +41,14 @@ const TAG_COMMANDS = { type: 'toggle_setting', settingId: 'loramanager.prompt_tag_autocomplete', value: true, - label: 'Autocomplete: ON', + label: 'Turn autocomplete ON', condition: () => !getPromptTagAutocompletePreference() }, '/noautocomplete': { type: 'toggle_setting', settingId: 'loramanager.prompt_tag_autocomplete', value: false, - label: 'Autocomplete: OFF', + label: 'Turn autocomplete OFF', condition: () => getPromptTagAutocompletePreference() }, }; @@ -55,7 +59,7 @@ const LORAS_COMMANDS = { type: 'toggle_setting', settingId: 'loramanager.lora_active_filters_autocomplete', value: true, - label: 'Active Filters: ON', + label: 'Turn active filters search ON', feedbackSummary: 'Active Filters Search: ON', feedbackDetail: 'LoRA autocomplete now searches within the active filters of the LoRA Manager page.', condition: () => !getLoraActiveFiltersAutocompletePreference() @@ -64,7 +68,7 @@ const LORAS_COMMANDS = { type: 'toggle_setting', settingId: 'loramanager.lora_active_filters_autocomplete', value: false, - label: 'Active Filters: OFF', + label: 'Turn active filters search OFF', feedbackSummary: 'Active Filters Search: OFF', feedbackDetail: 'LoRA autocomplete searches the full library again.', condition: () => getLoraActiveFiltersAutocompletePreference() @@ -72,8 +76,7 @@ const LORAS_COMMANDS = { }; // Category display information -const CATEGORY_INFO = { - 0: { bg: 'rgba(0, 155, 230, 0.2)', text: '#4bb4ff', label: 'General' }, +const CATEGORY_INFO = { 0: { bg: 'rgba(0, 155, 230, 0.2)', text: '#4bb4ff', label: 'General' }, 1: { bg: 'rgba(255, 138, 139, 0.2)', text: '#ffc3c3', label: 'Artist' }, 3: { bg: 'rgba(199, 151, 255, 0.2)', text: '#ddc9fb', label: 'Copyright' }, 4: { bg: 'rgba(53, 198, 74, 0.2)', text: '#93e49a', label: 'Character' }, @@ -471,6 +474,10 @@ class AutoComplete { this.searchType = null; this.suppressAutocompleteOnce = false; + // Discoverability hints state + this.commandListFooter = null; // State hint shown below the slash command list + this.firstRunHint = null; // One-time "how to disable" bar inside the dropdown + // Virtual scrolling state this.virtualScrollOffset = 0; this.hasMoreItems = true; @@ -829,7 +836,9 @@ class AutoComplete { searchTerm = rawSearchTerm; this.searchType = 'custom_words'; } else { - // No command and setting disabled - no autocomplete for direct typing + // No command and setting disabled - no autocomplete for direct typing. + // Re-enable discovery is covered by the command-list footer, + // the node context menu and the settings tooltip. this.hide(); return; } @@ -1707,13 +1716,132 @@ class AutoComplete { this.selectItem(0); } } - + + // State hint below the command list (e.g. how to toggle autocomplete) + this._renderCommandListFooter(); + // Update virtual scroll height for virtual scrolling mode if (this.contentContainer) { this.updateVirtualScrollHeight(); } } + /** + * Render a state hint below the slash command list so the autocomplete + * toggle commands explain themselves. Only applies to prompt nodes. + */ + _renderCommandListFooter() { + this._removeCommandListFooter(); + + if (this.modelType !== 'prompt') { + return; + } + + const enabled = getPromptTagAutocompletePreference(); + const footer = document.createElement('div'); + footer.className = 'lm-autocomplete-command-footer'; + footer.textContent = enabled + ? 'Tag autocomplete is ON — /noautocomplete to disable' + : 'Tag autocomplete is OFF — /autocomplete to enable'; + footer.style.cssText = ` + padding: 6px 12px; + font-size: 11px; + color: rgba(226, 232, 240, 0.5); + border-top: 1px solid rgba(226, 232, 240, 0.1); + white-space: nowrap; + `; + // Keep focus in the textarea when the hint is clicked + footer.addEventListener('mousedown', (e) => e.preventDefault()); + + this.dropdown.appendChild(footer); + this.commandListFooter = footer; + } + + _removeCommandListFooter() { + if (this.commandListFooter) { + this.commandListFooter.remove(); + this.commandListFooter = null; + } + } + + /** + * Show a one-time, dismissible hint inside the dropdown telling users how + * to disable tag autocomplete. Dismissal is persisted in localStorage. + */ + _maybeShowFirstRunHint() { + if (this.firstRunHint) { + return; + } + if (this.modelType !== 'prompt' + || this.showingCommands + || this.searchType !== 'custom_words' + || this.activeCommand) { + return; + } + + let dismissed = false; + try { + dismissed = localStorage.getItem(FIRST_RUN_HINT_DISMISSED_KEY) === '1'; + } catch (e) { + // localStorage unavailable - fall through and show the hint + } + if (dismissed) { + return; + } + + const hint = document.createElement('div'); + hint.className = 'lm-autocomplete-first-run-hint'; + hint.style.cssText = ` + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 6px 12px; + font-size: 11px; + color: rgba(226, 232, 240, 0.6); + border-bottom: 1px solid rgba(226, 232, 240, 0.1); + `; + + const text = document.createElement('span'); + text.textContent = 'Tip: type /noautocomplete to turn off these suggestions'; + + const closeBtn = document.createElement('button'); + closeBtn.type = 'button'; + closeBtn.textContent = '×'; + closeBtn.title = 'Dismiss'; + closeBtn.style.cssText = ` + background: none; + border: none; + color: rgba(226, 232, 240, 0.5); + cursor: pointer; + font-size: 14px; + line-height: 1; + padding: 0 2px; + `; + closeBtn.addEventListener('click', () => { + try { + localStorage.setItem(FIRST_RUN_HINT_DISMISSED_KEY, '1'); + } catch (e) { + } + this._removeFirstRunHint(); + }); + + hint.appendChild(text); + hint.appendChild(closeBtn); + // Keep focus in the textarea when interacting with the hint + hint.addEventListener('mousedown', (e) => e.preventDefault()); + + this.dropdown.insertBefore(hint, this.dropdown.firstChild); + this.firstRunHint = hint; + } + + _removeFirstRunHint() { + if (this.firstRunHint) { + this.firstRunHint.remove(); + this.firstRunHint = null; + } + } + /** * Insert a command into the input * @param {string} command - The command to insert (e.g., "/character") @@ -1745,6 +1873,9 @@ class AutoComplete { this.selectedIndex = -1; this.hasManualSelection = false; + // Command-list state hints do not belong to regular search results + this._removeCommandListFooter(); + // Reset virtual scroll state this.virtualScrollOffset = 0; this.currentPage = 0; @@ -2447,6 +2578,7 @@ class AutoComplete { return; } + this._maybeShowFirstRunHint(); // For virtual scrolling, render items first so positionAtCursor can measure width correctly if (this.options.enableVirtualScroll && this.contentContainer) { this.dropdown.style.display = 'block'; @@ -2515,6 +2647,10 @@ class AutoComplete { this.selectedIndex = -1; this.hasManualSelection = false; this.showingCommands = false; + + // Remove discoverability hints attached to the dropdown + this._removeCommandListFooter(); + this._removeFirstRunHint(); // Clear items to prevent stale data from being displayed // when autocomplete is shown again @@ -2854,20 +2990,12 @@ class AutoComplete { const { settingId, value } = command; try { - // Use ComfyUI's setting API to update global setting - const settingManager = app?.extensionManager?.setting; - if (settingManager && typeof settingManager.set === 'function') { - await settingManager.set(settingId, value); + const success = await setLoraManagerSettingValue(settingId, value); + if (success) { this._showToggleFeedback(command, value); this._clearCurrentToken(); } else { - // Fallback: use legacy settings API - const setting = app.ui.settings.settingsById?.[settingId]; - if (setting) { - app.ui.settings.setSettingValue(settingId, value); - this._showToggleFeedback(command, value); - this._clearCurrentToken(); - } + throw new Error('settings API unavailable'); } } catch (error) { console.error('[Lora Manager] Failed to toggle setting:', error); @@ -2893,7 +3021,7 @@ class AutoComplete { summary: command.feedbackSummary || (enabled ? 'Autocomplete Enabled' : 'Autocomplete Disabled'), detail: command.feedbackDetail || (enabled ? 'Tag autocomplete is now ON. Type to see suggestions.' - : 'Tag autocomplete is now OFF. Use /autocomplete to re-enable.'), + : 'Tag autocomplete is now OFF. Use /autocomplete or the node right-click menu to re-enable.'), life: 3000 }); } diff --git a/web/comfyui/prompt_dynamic_inputs.js b/web/comfyui/prompt_dynamic_inputs.js index 1519be63..4b606bda 100644 --- a/web/comfyui/prompt_dynamic_inputs.js +++ b/web/comfyui/prompt_dynamic_inputs.js @@ -1,4 +1,10 @@ import { app } from "../../scripts/app.js"; +import { + PROMPT_TAG_AUTOCOMPLETE_SETTING_ID, + getPromptTagAutocompletePreference, + setLoraManagerSettingValue, +} from "./settings.js"; +import { showToast } from "./utils.js"; /** * Extension for PromptLM node to support dynamic trigger_words inputs. @@ -93,6 +99,48 @@ app.registerExtension({ return onConnectionsChange?.apply?.(this, arguments); }; + + // Expose the tag autocomplete toggle in the node's right-click menu so + // users can discover the switch where the behavior actually happens, + // instead of only via slash commands or the global settings dialog. + const getExtraMenuOptions = nodeType.prototype.getExtraMenuOptions; + nodeType.prototype.getExtraMenuOptions = function(_, options) { + getExtraMenuOptions?.apply?.(this, arguments); + + options.push(null); + + const autocompleteEnabled = getPromptTagAutocompletePreference(); + options.push({ + content: autocompleteEnabled + ? "Tag Autocomplete: ON (/noautocomplete to disable)" + : "Tag Autocomplete: OFF (/autocomplete to enable)", + callback: async () => { + const newValue = !autocompleteEnabled; + try { + const success = await setLoraManagerSettingValue(PROMPT_TAG_AUTOCOMPLETE_SETTING_ID, newValue); + if (!success) { + throw new Error("settings API unavailable"); + } + showToast({ + severity: newValue ? 'success' : 'secondary', + summary: newValue ? 'Autocomplete Enabled' : 'Autocomplete Disabled', + detail: newValue + ? 'Tag autocomplete is now ON. Type to see suggestions.' + : 'Tag autocomplete is now OFF. Type /autocomplete in the prompt field to re-enable.', + life: 3000 + }); + } catch (error) { + console.error('[Lora Manager] Failed to toggle setting:', error); + showToast({ + severity: 'error', + summary: 'Error', + detail: 'Failed to toggle autocomplete setting', + life: 3000 + }); + } + } + }); + }; }, nodeCreated(node, app) { diff --git a/web/comfyui/settings.js b/web/comfyui/settings.js index dc5742b0..1d723d58 100644 --- a/web/comfyui/settings.js +++ b/web/comfyui/settings.js @@ -172,6 +172,26 @@ const getPromptTagAutocompletePreference = (() => { }; })(); +/** + * Persist a LoRA Manager setting through ComfyUI's setting API. + * Returns true when the setting was written successfully. + */ +const setLoraManagerSettingValue = async (settingId, value) => { + const settingManager = app?.extensionManager?.setting; + if (settingManager && typeof settingManager.set === "function") { + await settingManager.set(settingId, value); + return true; + } + + const setting = app?.ui?.settings?.settingsById?.[settingId]; + if (setting) { + app.ui.settings.setSettingValue(settingId, value); + return true; + } + + return false; +}; + const getAutocompleteAppendCommaPreference = (() => { let settingsUnavailableLogged = false; @@ -422,7 +442,7 @@ app.registerExtension({ name: "Enable Tag Autocomplete in Prompt Nodes", type: "boolean", defaultValue: PROMPT_TAG_AUTOCOMPLETE_DEFAULT, - tooltip: "When enabled, typing will trigger tag autocomplete suggestions. Commands (e.g., /character, /artist) always work regardless of this setting.", + tooltip: "When enabled, typing in a Prompt (LoraManager) node triggers tag autocomplete suggestions. You can also toggle it by typing /autocomplete or /noautocomplete in the node, or from the node's right-click menu. Slash commands (e.g., /character, /artist) always work regardless of this setting.", category: ["LoRA Manager", "Autocomplete", "Prompt"], }, { @@ -576,6 +596,7 @@ app.registerExtension({ // ============================================================================ export { + PROMPT_TAG_AUTOCOMPLETE_SETTING_ID, getWheelSensitivity, getAutoPathCorrectionPreference, getAutocompleteAppendCommaPreference, @@ -587,4 +608,5 @@ export { getNewTabTemplatePreference, getStrengthStepPreference, getLoraActiveFiltersAutocompletePreference, + setLoraManagerSettingValue, };