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('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();
});
});
});
+148 -20
View File
@@ -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
});
}
+48
View File
@@ -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) {
+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 = (() => {
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,
};