feat(ui): improve tag autocomplete toggle discoverability in prompt nodes

- Add Tag Autocomplete ON/OFF entry to the Prompt (LoraManager) node
  right-click menu, cross-referencing the slash commands
- Show the current autocomplete state (/autocomplete or /noautocomplete
  hint) below the slash command list
- Show a one-time dismissible tip in the suggestion dropdown on first use
- Clarify toggle command labels (Turn autocomplete ON/OFF) and cross-link
  all three entry points in the settings tooltip
- Share the setting write path via setLoraManagerSettingValue()
This commit is contained in:
Will Miao
2026-08-24 12:21:31 +08:00
parent 40f922b0e8
commit 074d1f2e51
4 changed files with 337 additions and 21 deletions
@@ -2032,4 +2032,122 @@ describe('AutoComplete widget interactions', () => {
expect(calledUrl).toContain('folder=Flux.1+D%2Fstyle'); expect(calledUrl).toContain('folder=Flux.1+D%2Fstyle');
expect(calledUrl).toContain('recursive=true'); 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();
});
});
}); });
+147 -19
View File
@@ -17,9 +17,13 @@ import {
getLoraActiveFiltersAutocompletePreference, getLoraActiveFiltersAutocompletePreference,
getPromptTagAutocompletePreference, getPromptTagAutocompletePreference,
getTagSpaceReplacementPreference, getTagSpaceReplacementPreference,
setLoraManagerSettingValue,
} from "./settings.js"; } from "./settings.js";
import { showToast } from "./utils.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 // Command definitions for category filtering
const TAG_COMMANDS = { const TAG_COMMANDS = {
'/character': { categories: [4, 11], label: 'Character' }, '/character': { categories: [4, 11], label: 'Character' },
@@ -37,14 +41,14 @@ const TAG_COMMANDS = {
type: 'toggle_setting', type: 'toggle_setting',
settingId: 'loramanager.prompt_tag_autocomplete', settingId: 'loramanager.prompt_tag_autocomplete',
value: true, value: true,
label: 'Autocomplete: ON', label: 'Turn autocomplete ON',
condition: () => !getPromptTagAutocompletePreference() condition: () => !getPromptTagAutocompletePreference()
}, },
'/noautocomplete': { '/noautocomplete': {
type: 'toggle_setting', type: 'toggle_setting',
settingId: 'loramanager.prompt_tag_autocomplete', settingId: 'loramanager.prompt_tag_autocomplete',
value: false, value: false,
label: 'Autocomplete: OFF', label: 'Turn autocomplete OFF',
condition: () => getPromptTagAutocompletePreference() condition: () => getPromptTagAutocompletePreference()
}, },
}; };
@@ -55,7 +59,7 @@ const LORAS_COMMANDS = {
type: 'toggle_setting', type: 'toggle_setting',
settingId: 'loramanager.lora_active_filters_autocomplete', settingId: 'loramanager.lora_active_filters_autocomplete',
value: true, value: true,
label: 'Active Filters: ON', label: 'Turn active filters search ON',
feedbackSummary: 'Active Filters Search: ON', feedbackSummary: 'Active Filters Search: ON',
feedbackDetail: 'LoRA autocomplete now searches within the active filters of the LoRA Manager page.', feedbackDetail: 'LoRA autocomplete now searches within the active filters of the LoRA Manager page.',
condition: () => !getLoraActiveFiltersAutocompletePreference() condition: () => !getLoraActiveFiltersAutocompletePreference()
@@ -64,7 +68,7 @@ const LORAS_COMMANDS = {
type: 'toggle_setting', type: 'toggle_setting',
settingId: 'loramanager.lora_active_filters_autocomplete', settingId: 'loramanager.lora_active_filters_autocomplete',
value: false, value: false,
label: 'Active Filters: OFF', label: 'Turn active filters search OFF',
feedbackSummary: 'Active Filters Search: OFF', feedbackSummary: 'Active Filters Search: OFF',
feedbackDetail: 'LoRA autocomplete searches the full library again.', feedbackDetail: 'LoRA autocomplete searches the full library again.',
condition: () => getLoraActiveFiltersAutocompletePreference() condition: () => getLoraActiveFiltersAutocompletePreference()
@@ -72,8 +76,7 @@ const LORAS_COMMANDS = {
}; };
// Category display information // Category display information
const CATEGORY_INFO = { const CATEGORY_INFO = { 0: { bg: 'rgba(0, 155, 230, 0.2)', text: '#4bb4ff', label: 'General' },
0: { bg: 'rgba(0, 155, 230, 0.2)', text: '#4bb4ff', label: 'General' },
1: { bg: 'rgba(255, 138, 139, 0.2)', text: '#ffc3c3', label: 'Artist' }, 1: { bg: 'rgba(255, 138, 139, 0.2)', text: '#ffc3c3', label: 'Artist' },
3: { bg: 'rgba(199, 151, 255, 0.2)', text: '#ddc9fb', label: 'Copyright' }, 3: { bg: 'rgba(199, 151, 255, 0.2)', text: '#ddc9fb', label: 'Copyright' },
4: { bg: 'rgba(53, 198, 74, 0.2)', text: '#93e49a', label: 'Character' }, 4: { bg: 'rgba(53, 198, 74, 0.2)', text: '#93e49a', label: 'Character' },
@@ -471,6 +474,10 @@ class AutoComplete {
this.searchType = null; this.searchType = null;
this.suppressAutocompleteOnce = false; 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 // Virtual scrolling state
this.virtualScrollOffset = 0; this.virtualScrollOffset = 0;
this.hasMoreItems = true; this.hasMoreItems = true;
@@ -829,7 +836,9 @@ class AutoComplete {
searchTerm = rawSearchTerm; searchTerm = rawSearchTerm;
this.searchType = 'custom_words'; this.searchType = 'custom_words';
} else { } 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(); this.hide();
return; return;
} }
@@ -1708,12 +1717,131 @@ class AutoComplete {
} }
} }
// State hint below the command list (e.g. how to toggle autocomplete)
this._renderCommandListFooter();
// Update virtual scroll height for virtual scrolling mode // Update virtual scroll height for virtual scrolling mode
if (this.contentContainer) { if (this.contentContainer) {
this.updateVirtualScrollHeight(); 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 * Insert a command into the input
* @param {string} command - The command to insert (e.g., "/character") * @param {string} command - The command to insert (e.g., "/character")
@@ -1745,6 +1873,9 @@ class AutoComplete {
this.selectedIndex = -1; this.selectedIndex = -1;
this.hasManualSelection = false; this.hasManualSelection = false;
// Command-list state hints do not belong to regular search results
this._removeCommandListFooter();
// Reset virtual scroll state // Reset virtual scroll state
this.virtualScrollOffset = 0; this.virtualScrollOffset = 0;
this.currentPage = 0; this.currentPage = 0;
@@ -2447,6 +2578,7 @@ class AutoComplete {
return; return;
} }
this._maybeShowFirstRunHint();
// For virtual scrolling, render items first so positionAtCursor can measure width correctly // For virtual scrolling, render items first so positionAtCursor can measure width correctly
if (this.options.enableVirtualScroll && this.contentContainer) { if (this.options.enableVirtualScroll && this.contentContainer) {
this.dropdown.style.display = 'block'; this.dropdown.style.display = 'block';
@@ -2516,6 +2648,10 @@ class AutoComplete {
this.hasManualSelection = false; this.hasManualSelection = false;
this.showingCommands = false; this.showingCommands = false;
// Remove discoverability hints attached to the dropdown
this._removeCommandListFooter();
this._removeFirstRunHint();
// Clear items to prevent stale data from being displayed // Clear items to prevent stale data from being displayed
// when autocomplete is shown again // when autocomplete is shown again
this.items = []; this.items = [];
@@ -2854,20 +2990,12 @@ class AutoComplete {
const { settingId, value } = command; const { settingId, value } = command;
try { try {
// Use ComfyUI's setting API to update global setting const success = await setLoraManagerSettingValue(settingId, value);
const settingManager = app?.extensionManager?.setting; if (success) {
if (settingManager && typeof settingManager.set === 'function') {
await settingManager.set(settingId, value);
this._showToggleFeedback(command, value); this._showToggleFeedback(command, value);
this._clearCurrentToken(); this._clearCurrentToken();
} else { } else {
// Fallback: use legacy settings API throw new Error('settings API unavailable');
const setting = app.ui.settings.settingsById?.[settingId];
if (setting) {
app.ui.settings.setSettingValue(settingId, value);
this._showToggleFeedback(command, value);
this._clearCurrentToken();
}
} }
} catch (error) { } catch (error) {
console.error('[Lora Manager] Failed to toggle setting:', error); console.error('[Lora Manager] Failed to toggle setting:', error);
@@ -2893,7 +3021,7 @@ class AutoComplete {
summary: command.feedbackSummary || (enabled ? 'Autocomplete Enabled' : 'Autocomplete Disabled'), summary: command.feedbackSummary || (enabled ? 'Autocomplete Enabled' : 'Autocomplete Disabled'),
detail: command.feedbackDetail || (enabled detail: command.feedbackDetail || (enabled
? 'Tag autocomplete is now ON. Type to see suggestions.' ? '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 life: 3000
}); });
} }
+48
View File
@@ -1,4 +1,10 @@
import { app } from "../../scripts/app.js"; 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. * Extension for PromptLM node to support dynamic trigger_words inputs.
@@ -93,6 +99,48 @@ app.registerExtension({
return onConnectionsChange?.apply?.(this, arguments); 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) { nodeCreated(node, app) {
+23 -1
View File
@@ -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 = (() => { const getAutocompleteAppendCommaPreference = (() => {
let settingsUnavailableLogged = false; let settingsUnavailableLogged = false;
@@ -422,7 +442,7 @@ app.registerExtension({
name: "Enable Tag Autocomplete in Prompt Nodes", name: "Enable Tag Autocomplete in Prompt Nodes",
type: "boolean", type: "boolean",
defaultValue: PROMPT_TAG_AUTOCOMPLETE_DEFAULT, 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"], category: ["LoRA Manager", "Autocomplete", "Prompt"],
}, },
{ {
@@ -576,6 +596,7 @@ app.registerExtension({
// ============================================================================ // ============================================================================
export { export {
PROMPT_TAG_AUTOCOMPLETE_SETTING_ID,
getWheelSensitivity, getWheelSensitivity,
getAutoPathCorrectionPreference, getAutoPathCorrectionPreference,
getAutocompleteAppendCommaPreference, getAutocompleteAppendCommaPreference,
@@ -587,4 +608,5 @@ export {
getNewTabTemplatePreference, getNewTabTemplatePreference,
getStrengthStepPreference, getStrengthStepPreference,
getLoraActiveFiltersAutocompletePreference, getLoraActiveFiltersAutocompletePreference,
setLoraManagerSettingValue,
}; };