/** * MacroBrowser - Dynamic documentation browser for macros. * Similar to SlashCommandBrowser but for the macro system. */ import { MacroRegistry, MacroCategory } from './engine/MacroRegistry.js'; import { performFuzzySearch } from '../power-user.js'; /** @typedef {import('./engine/MacroRegistry.js').MacroDefinition} MacroDefinition */ /** @typedef {import('./engine/MacroRegistry.js').MacroValueType} MacroValueType */ /** * Category display names and order for documentation. * @type {Record} */ const CATEGORY_CONFIG = { [MacroCategory.NAMES]: { label: 'Names & Participants', order: 1 }, [MacroCategory.UTILITY]: { label: 'Utilities', order: 2 }, [MacroCategory.RANDOM]: { label: 'Randomization', order: 3 }, [MacroCategory.TIME]: { label: 'Date & Time', order: 4 }, [MacroCategory.VARIABLE]: { label: 'Variables', order: 5 }, [MacroCategory.STATE]: { label: 'Runtime State', order: 6 }, [MacroCategory.CHARACTER]: { label: 'Character Card & Persona Fields', order: 7 }, [MacroCategory.CHAT]: { label: 'Chat History & Messages', order: 8 }, [MacroCategory.PROMPTS]: { label: 'Prompt Templates', order: 9 }, [MacroCategory.MISC]: { label: 'Miscellaneous', order: 10 }, }; /** * MacroBrowser class for displaying searchable macro documentation. */ export class MacroBrowser { /** @type {Map} */ macrosByCategory = new Map(); /** @type {HTMLElement} */ dom; /** @type {HTMLInputElement} */ searchInput; /** @type {HTMLElement} */ detailsPanel; /** @type {Map} */ itemMap = new Map(); /** @type {boolean} */ isSorted = false; /** * Groups macros by category in registration order. * Excludes hidden aliases from the list. */ #loadMacros() { this.macrosByCategory.clear(); // Exclude hidden aliases - they won't show in the list const allMacros = MacroRegistry.getAllMacros({ excludeHiddenAliases: true }); for (const macro of allMacros) { const category = macro.category || MacroCategory.MISC; if (!this.macrosByCategory.has(category)) { this.macrosByCategory.set(category, []); } this.macrosByCategory.get(category).push(macro); } } /** * Sorts macros within each category alphabetically. */ #sortMacros() { for (const [, macros] of this.macrosByCategory) { macros.sort((a, b) => a.name.localeCompare(b.name)); } } /** * Gets categories sorted by their configured order. * @returns {string[]} */ #getSortedCategories() { return Array.from(this.macrosByCategory.keys()) .sort((a, b) => getCategoryConfig(a).order - getCategoryConfig(b).order); } /** * Renders the browser into a parent element. * @param {HTMLElement} parent * @returns {HTMLElement} */ renderInto(parent) { this.#loadMacros(); const root = document.createElement('div'); root.classList.add('macroBrowser'); this.dom = root; // Search bar and sort button const toolbar = document.createElement('div'); toolbar.classList.add('macro-toolbar'); const searchLabel = document.createElement('label'); searchLabel.classList.add('macro-search-label'); searchLabel.textContent = 'Search: '; const searchInput = document.createElement('input'); searchInput.type = 'search'; searchInput.classList.add('macro-search-input', 'text_pole'); searchInput.placeholder = 'Search macros by name or description...'; searchInput.addEventListener('input', () => this.#handleSearch(searchInput.value)); this.searchInput = searchInput; searchLabel.appendChild(searchInput); toolbar.appendChild(searchLabel); const sortBtn = document.createElement('button'); sortBtn.classList.add('macro-sort-btn', 'menu_button'); sortBtn.innerHTML = ' Sort A-Z'; sortBtn.title = 'Sort macros alphabetically within each category'; sortBtn.addEventListener('click', () => this.#toggleSort()); toolbar.appendChild(sortBtn); root.appendChild(toolbar); // Container for list and details const container = document.createElement('div'); container.classList.add('macro-container'); // Macro list const listPanel = document.createElement('div'); listPanel.classList.add('macro-list-panel'); this.#renderList(listPanel); container.appendChild(listPanel); // Details panel const detailsPanel = document.createElement('div'); detailsPanel.classList.add('macro-details-panel'); detailsPanel.innerHTML = '
Select a macro to view details
'; this.detailsPanel = detailsPanel; container.appendChild(detailsPanel); root.appendChild(container); parent.appendChild(root); return root; } /** * Renders the macro list grouped by category. * @param {HTMLElement} listPanel */ #renderList(listPanel) { listPanel.innerHTML = ''; this.itemMap.clear(); for (const category of this.#getSortedCategories()) { const macros = this.macrosByCategory.get(category); if (!macros || macros.length === 0) continue; // Category header const categoryHeader = document.createElement('div'); categoryHeader.classList.add('macro-category-header'); categoryHeader.textContent = getCategoryConfig(category).label; categoryHeader.dataset.category = category; listPanel.appendChild(categoryHeader); // Macro items for (const macro of macros) { const item = renderMacroItem(macro); item.addEventListener('click', () => this.#showDetails(macro, item)); this.itemMap.set(macro.name, item); listPanel.appendChild(item); } } } /** * Shows details for a selected macro. * @param {MacroDefinition} macro * @param {HTMLElement} item */ #showDetails(macro, item) { // Clear previous selection this.dom.querySelectorAll('.macro-item.selected').forEach(el => el.classList.remove('selected')); item.classList.add('selected'); // Render details this.detailsPanel.innerHTML = ''; this.detailsPanel.appendChild(renderMacroDetails(macro)); } /** * Handles search input using fuzzy search. * @param {string} query */ #handleSearch(query) { query = query.trim(); // Clear details on search this.detailsPanel.innerHTML = '
Select a macro to view details
'; this.dom.querySelectorAll('.macro-item.selected').forEach(el => el.classList.remove('selected')); // If empty query, show all if (!query) { for (const item of this.itemMap.values()) { item.classList.remove('isFiltered'); } this.dom.querySelectorAll('.macro-category-header').forEach(h => h.classList.remove('isFiltered')); return; } // Trim query of braces, as we don't have them in the macro names of the search definitions query = query.replace(/[{}]/g, ''); // Build searchable data array from all macros const allMacros = MacroRegistry.getAllMacros(); const searchData = allMacros.map(macro => ({ name: macro.name, aliases: macro.aliases?.map(a => a.alias).join(' '), description: macro.description || '', category: getCategoryConfig(macro.category).label, argNames: macro.unnamedArgDefs.map(d => d.name).join(' '), argDescriptions: macro.unnamedArgDefs.map(d => d.description || '').join(' '), })); // Fuzzy search with weighted keys const keys = [ { name: 'name', weight: 10 }, { name: 'aliases', weight: 1 }, // No need to rank those high, if they are important (visible) they have their own entry { name: 'description', weight: 5 }, { name: 'category', weight: 3 }, { name: 'argNames', weight: 2 }, { name: 'argDescriptions', weight: 1 }, ]; const results = performFuzzySearch('macro-browser', searchData, keys, query); const matchedNames = new Set(results.map(r => r.item.name)); // Filter items based on fuzzy results for (const [name, item] of this.itemMap) { item.classList.toggle('isFiltered', !matchedNames.has(name)); } // Hide empty category headers this.dom.querySelectorAll('.macro-category-header').forEach(header => { if (!(header instanceof HTMLElement)) return; const category = header.dataset.category; const hasVisible = Array.from(this.itemMap.values()) .filter(item => item.dataset.macroName) .some(item => { const macro = MacroRegistry.getMacro(item.dataset.macroName); return macro?.category === category && !item.classList.contains('isFiltered'); }); header.classList.toggle('isFiltered', !hasVisible); }); } /** * Toggles alphabetical sorting. */ #toggleSort() { this.isSorted = !this.isSorted; if (this.isSorted) { this.#sortMacros(); } else { this.#loadMacros(); // Reload to restore registration order } const listPanel = this.dom.querySelector('.macro-list-panel'); if (!(listPanel instanceof HTMLElement)) return; this.#renderList(listPanel); // Re-apply current search filter if (this.searchInput?.value) { this.#handleSearch(this.searchInput.value); } // Update button state const sortBtn = this.dom.querySelector('.macro-sort-btn'); sortBtn?.classList.toggle('active', this.isSorted); } /** * Handles keyboard shortcuts. * @param {KeyboardEvent} evt */ #handleKeyDown(evt) { if (!evt.shiftKey && !evt.altKey && evt.ctrlKey && evt.key.toLowerCase() === 'f') { if (!this.dom.closest('body')) return; if (this.dom.closest('.mes') && !this.dom.closest('.last_mes')) return; evt.preventDefault(); evt.stopPropagation(); evt.stopImmediatePropagation(); this.searchInput?.focus(); } } } /** * Gets the macro help content. * If experimental_macro_engine is enabled, returns a placeholder for the browser. * Otherwise returns the static template content. * * @returns {string} HTML string for help content */ export function getMacrosHelp() { // Return a placeholder that will be replaced with the browser return '
Loading macro documentation...
'; } /** * Gets display config for a category. * @param {string} category * @returns {{ label: string, order: number }} */ function getCategoryConfig(category) { return CATEGORY_CONFIG[category] ?? { label: category, order: 100 }; } /** * Formats a macro signature with its arguments. * Uses displayOverride if available, otherwise auto-generates from args. * Optional args are shown in [brackets]. * @param {MacroDefinition} macro * @returns {string} */ export function formatMacroSignature(macro) { // Use displayOverride if provided if (macro.displayOverride) { return macro.displayOverride; } const parts = [macro.name]; // Add all unnamed args (required + optional) for (let i = 0; i < macro.unnamedArgDefs.length; i++) { const argDef = macro.unnamedArgDefs[i]; const argName = argDef?.sampleValue || argDef?.name || `arg${i + 1}`; // Wrap optional args in brackets parts.push(argDef?.optional ? `[${argName}]` : argName); } // Add list args indicator if (macro.list) { const hasMin = macro.list.min > 0; const hasMax = macro.list.max !== null; if (hasMin && hasMax && macro.list.min === macro.list.max) { // Fixed number of list items for (let i = 0; i < macro.list.min; i++) { parts.push(`item${i + 1}`); } } else { // Variable list parts.push('item1', 'item2', '...'); } } return `{{${parts.join('::')}}}`; } /** * Creates a DOM element for a macro's source indicator (extension/third-party icons). * @param {MacroDefinition} macro * @returns {HTMLElement} */ export function createSourceIndicator(macro) { const src = document.createElement('span'); src.classList.add('macro-source', 'fa-solid'); if (macro.source.isExtension) { src.classList.add('isExtension', 'fa-cubes'); src.classList.add(macro.source.isThirdParty ? 'isThirdParty' : 'isCore'); } else { src.classList.add('isCore', 'fa-star-of-life'); } const titleParts = [ macro.source.isExtension ? 'Extension' : 'Core', macro.source.isThirdParty ? 'Third Party' : (macro.source.isExtension ? 'Built-in' : null), macro.source.name, ].filter(Boolean); src.title = titleParts.join('\n'); return src; } /** * Creates a DOM element for alias indicator icon. * @param {MacroDefinition} macro * @returns {HTMLElement|null} */ export function createAliasIndicator(macro) { if (!macro.aliasOf) return null; const icon = document.createElement('span'); icon.classList.add('macro-alias-indicator', 'fa-solid', 'fa-arrow-turn-up'); icon.title = `Alias of {{${macro.aliasOf}}}`; return icon; } /** * Creates a type badge element. Supports single type or array of types. * @param {MacroValueType|MacroValueType[]} type - Single type or array of accepted types. * @returns {HTMLElement} */ export function createTypeBadge(type) { const badge = document.createElement('span'); badge.classList.add('macro-arg-type'); if (Array.isArray(type)) { badge.textContent = type.join(' | '); badge.title = `Accepts: ${type.join(', ')}`; } else { badge.textContent = type; } return badge; } /** * Renders a single macro item for the list. * Order: [signature] [description (shrinks)] [alias icon?] [source icon] * @param {MacroDefinition} macro * @returns {HTMLElement} */ function renderMacroItem(macro) { const item = document.createElement('div'); item.classList.add('macro-item'); if (macro.aliasOf) item.classList.add('isAlias'); item.dataset.macroName = macro.name; // Signature (fixed width, truncates if too long) const signature = document.createElement('code'); signature.classList.add('macro-signature'); signature.textContent = formatMacroSignature(macro); item.appendChild(signature); // Description preview (shrinks to fit, truncates) const desc = document.createElement('span'); desc.classList.add('macro-desc-preview'); desc.textContent = macro.description || ''; item.appendChild(desc); // Alias indicator (if this is an alias entry) const aliasIcon = createAliasIndicator(macro); if (aliasIcon) item.appendChild(aliasIcon); // Source indicator (fixed, stays at right edge) item.appendChild(createSourceIndicator(macro)); return item; } /** * Renders detailed information for a macro. * Can optionally highlight the current argument being typed. * @param {MacroDefinition} macro * @param {Object} [options] * @param {number} [options.currentArgIndex=-1] - Index of argument to highlight (-1 for none). * @param {boolean} [options.showCategory=true] - Whether to show category badge. * @returns {HTMLElement} */ export function renderMacroDetails(macro, options = {}) { const { currentArgIndex = -1, showCategory = true } = options; const details = document.createElement('div'); details.classList.add('macro-details'); // Header with name and source const header = document.createElement('div'); header.classList.add('macro-details-header'); const nameEl = document.createElement('code'); nameEl.classList.add('macro-details-name'); nameEl.textContent = formatMacroSignature(macro); header.appendChild(nameEl); header.appendChild(createSourceIndicator(macro)); details.appendChild(header); // Category badge (optional) if (showCategory) { const categoryBadge = document.createElement('span'); categoryBadge.classList.add('macro-category-badge'); categoryBadge.textContent = getCategoryConfig(macro.category).label; details.appendChild(categoryBadge); } // If this is an alias, show what it's an alias of if (macro.aliasOf) { const aliasOfSection = document.createElement('div'); aliasOfSection.classList.add('macro-alias-of'); aliasOfSection.innerHTML = ` Alias of {{${macro.aliasOf}}}`; details.appendChild(aliasOfSection); } // Description const descSection = document.createElement('div'); descSection.classList.add('macro-details-section'); const descLabel = document.createElement('div'); descLabel.classList.add('macro-details-label'); descLabel.textContent = 'Description'; descSection.appendChild(descLabel); const descText = document.createElement('div'); descText.classList.add('macro-details-text'); descText.textContent = macro.description || ''; descSection.appendChild(descText); details.appendChild(descSection); // Arguments section (if any) if (macro.unnamedArgDefs.length > 0 || macro.list) { const argsSection = document.createElement('div'); argsSection.classList.add('macro-details-section'); const argsLabel = document.createElement('div'); argsLabel.classList.add('macro-details-label'); argsLabel.textContent = 'Arguments'; argsSection.appendChild(argsLabel); const argsList = document.createElement('ul'); argsList.classList.add('macro-args-list'); // Unnamed args (required + optional) for (let i = 0; i < macro.unnamedArgDefs.length; i++) { const argDef = macro.unnamedArgDefs[i]; const argItem = document.createElement('li'); argItem.classList.add('macro-arg-item'); if (argDef?.optional) argItem.classList.add('isOptional'); if (currentArgIndex === i) argItem.classList.add('current'); const argName = document.createElement('code'); argName.classList.add('macro-arg-name'); argName.textContent = argDef?.name || `arg${i + 1}`; argItem.appendChild(argName); argItem.appendChild(createTypeBadge(argDef.type ?? 'string')); const argRequiredLabel = document.createElement('span'); argRequiredLabel.classList.add(argDef?.optional ? 'macro-arg-optional' : 'macro-arg-required'); if (argDef?.optional && argDef.defaultValue !== undefined) { argRequiredLabel.textContent = `(optional, default: ${argDef.defaultValue === '' ? '' : argDef.defaultValue})`; } else { argRequiredLabel.textContent = argDef?.optional ? '(optional)' : '(required)'; } argItem.appendChild(argRequiredLabel); if (argDef?.description) { const argDesc = document.createElement('span'); argDesc.classList.add('macro-arg-desc'); argDesc.textContent = ` — ${argDef.description}`; argItem.appendChild(argDesc); } if (argDef?.sampleValue) { const sample = document.createElement('span'); sample.classList.add('macro-arg-sample'); sample.textContent = ` (e.g. ${argDef.sampleValue})`; argItem.appendChild(sample); } argsList.appendChild(argItem); } // List args if (macro.list) { const listItem = document.createElement('li'); listItem.classList.add('macro-arg-item', 'macro-arg-list'); if (currentArgIndex >= macro.maxArgs) listItem.classList.add('current'); const listName = document.createElement('code'); listName.classList.add('macro-arg-name'); listName.textContent = 'item1::item2::...'; listItem.appendChild(listName); const listInfo = document.createElement('span'); listInfo.classList.add('macro-arg-list-info'); const minMax = []; if (macro.list.min > 0) minMax.push(`min: ${macro.list.min}`); if (macro.list.max !== null) minMax.push(`max: ${macro.list.max}`); if (minMax.length > 0) { listInfo.textContent = ` (list, ${minMax.join(', ')})`; } else { listInfo.textContent = ' (variable-length list)'; } listItem.appendChild(listInfo); argsList.appendChild(listItem); } argsSection.appendChild(argsList); details.appendChild(argsSection); } // Returns section (always show - at minimum shows the type) { const returnsSection = document.createElement('div'); returnsSection.classList.add('macro-details-section'); const returnsLabel = document.createElement('div'); returnsLabel.classList.add('macro-details-label'); returnsLabel.textContent = 'Returns'; returnsSection.appendChild(returnsLabel); const returnsContent = document.createElement('div'); returnsContent.classList.add('macro-returns-content'); // Add return type badge const returnTypeBadge = createTypeBadge(macro.returnType); returnsContent.appendChild(returnTypeBadge); // Add description text if provided if (macro.returns) { const returnsText = document.createElement('span'); returnsText.classList.add('macro-details-text'); returnsText.textContent = macro.returns; returnsContent.appendChild(returnsText); } returnsSection.appendChild(returnsContent); details.appendChild(returnsSection); } // Example usage section (if any) if (macro.exampleUsage && macro.exampleUsage.length > 0) { const exampleSection = document.createElement('div'); exampleSection.classList.add('macro-details-section'); const exampleLabel = document.createElement('div'); exampleLabel.classList.add('macro-details-label'); exampleLabel.textContent = 'Example Usage'; exampleSection.appendChild(exampleLabel); const exampleList = document.createElement('ul'); exampleList.classList.add('macro-example-list'); for (const example of macro.exampleUsage) { const li = document.createElement('li'); const code = document.createElement('code'); code.textContent = example; li.appendChild(code); exampleList.appendChild(li); } exampleSection.appendChild(exampleList); details.appendChild(exampleSection); } // Aliases section (if this macro has aliases) if (macro.aliases && macro.aliases.length > 0) { const aliasSection = document.createElement('div'); aliasSection.classList.add('macro-details-section'); const aliasLabel = document.createElement('div'); aliasLabel.classList.add('macro-details-label'); aliasLabel.textContent = 'Aliases'; aliasSection.appendChild(aliasLabel); const aliasList = document.createElement('ul'); aliasList.classList.add('macro-alias-list'); for (const { alias, visible } of macro.aliases) { const li = document.createElement('li'); li.classList.add('macro-alias-item'); if (!visible) li.classList.add('isHidden'); const code = document.createElement('code'); code.textContent = `{{${alias}}}`; li.appendChild(code); if (!visible) { const hiddenBadge = document.createElement('span'); hiddenBadge.classList.add('macro-alias-hidden-badge'); hiddenBadge.textContent = '(deprecated)'; hiddenBadge.title = 'This alias is deprecated and will not be shown in documentation or autocomplete'; li.appendChild(hiddenBadge); } aliasList.appendChild(li); } aliasSection.appendChild(aliasList); details.appendChild(aliasSection); } return details; }